Buy-and-hold Polymarket YES contracts sized by fractional Kelly.
Timeframe
1h
Direction
Long Only
Stoploss
-100.0%
Trailing Stop
No
ROI
N/A
Interface Version
3
Startup Candles
N/A
Indicators
0
freqtrade/freqtrade-strategies
freqtrade/freqtrade-strategies
freqtrade/freqtrade-strategies
this is an example class, implementing a PSAR based trailing stop loss you are supposed to take the `custom_stoploss()` and `populate_indicators()` parts and adapt it to your own strategy
freqtrade/freqtrade-strategies
"""Dual-model Polymarket portfolio strategy for PortfolioBench.
Behaviour
---------
* Timeframe: 1 hour.
* Decision rule: at each hourly candle, the model computes a fair value for
each active contract. A YES position is entered when
``ml_edge > MIN_EDGE`` and no position is already open for that pair.
* Sizing: fractional Kelly (``KELLY_FRACTION = 0.15``) applied to free capital.
* Hold rule: once entered, a position is held until the contract expires.
There is no stop loss, no ROI exit, and no mid-trade rebalancing.
* Settlement: at the expiry candle the trade is force-closed via
``custom_exit()`` at the known binary settlement price (0.999 for YES, 0.001
for NO) via ``custom_exit_price()``.
Data dependencies
-----------------
* Synthetic contract feather files in ``user_data/data/polymarket_ml/``,
produced by ``polymarket.data_builder.build_all_feathers()``.
* Per-contract event probability CSVs in ``user_data/data/polymarket_ml/``,
one file per contract named ``{pair}-event_probs.csv``,
produced by ``scripts/prepare_event_model.py`` (calls
``polymarket.data_builder.build_event_predictions()``).
* Contract metadata JSONL in
``user_data/data/polymarket_contracts/jan20.jsonl``.
All paths are resolved relative to the freqtrade ``user_data`` directory
stored in ``self.config["user_data_path"]`` when available, falling back to
a path relative to the project root.
Settlement price convention
----------------------------
Freqtrade's ``PRICE_FLOOR`` for Polymarket is 0.001 and ``PRICE_CEIL`` is
0.999. Returning exactly 0.0 or 1.0 may be clamped by the exchange layer.
We return 0.999 / 0.001 to stay within valid bounds.
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional
import pandas as pd
from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy
from alpha.EventProbAlpha import EventProbAlpha
from polymarket.contracts import ContractMetadata, load_contracts
logger = logging.getLogger(__name__)
# Settlement price constants — must stay within Polymarket exchange bounds
SETTLE_YES = 0.999
SETTLE_NO = 0.001
UTC = UTC
class DualModelPolymarketPortfolio(IStrategy):
"""Buy-and-hold Polymarket YES contracts sized by fractional Kelly.
See module docstring for full description.
"""
INTERFACE_VERSION = 3
timeframe = "1h"
startup_candle_count: int = 0 # Predictions are precomputed; no warmup needed.
# Hold until expiry — suppress all automatic exit mechanisms.
stoploss = -1.0
minimal_roi = {}
trailing_stop = False
use_exit_signal = True # We need custom_exit() to fire.
exit_profit_only = False
# No rebalancing: buy once, hold to expiry.
position_adjustment_enable = False
can_short = False
# Strategy parameters
MIN_EDGE: float = 0.04 # Minimum model edge to open a position.
KELLY_FRACTION: float = 0.15 # Fractional Kelly multiplier.
MAX_ALLOC: float = 0.08 # Cap on any single contract allocation.
# Per-expiry exposure cap: max fraction of total portfolio on one settlement date.
MAX_EXPIRY_ALLOC: float = 0.10
# ---------------------------------------------------------------------------
# Initialisation helpers
# ---------------------------------------------------------------------------
def _resolve_data_root(self) -> Path:
"""Return the absolute path to ``user_data/``."""
if hasattr(self, "config") and "user_data_path" in self.config:
return Path(self.config["user_data_path"])
# Fallback: resolve relative to this file's location (…/user_data/strategies/)
return Path(__file__).resolve().parents[1]
def _load_contracts_registry(self) -> dict[str, ContractMetadata]:
"""Load contract metadata and return a dict keyed by pair_yes."""
data_root = self._resolve_data_root()
jsonl_path = data_root / "data" / "polymarket_contracts" / "jan20.jsonl"
contracts = load_contracts(jsonl_path)
return {c.pair_yes: c for c in contracts}
def _get_registry(self) -> dict[str, ContractMetadata]:
if not hasattr(self, "_contract_registry"):
self._contract_registry = self._load_contracts_registry()
return self._contract_registry
def _load_event_probs(self, pair: str) -> pd.DataFrame | None:
"""Load per-contract event probability CSV produced by build_event_predictions.
Returns a DataFrame indexed by UTC timestamps with a ``fair_value`` column,
or ``None`` if the file does not exist.
"""
data_root = self._resolve_data_root()
filename = pair.replace("/", "_") + "-event_probs.csv"
csv_path = data_root / "data" / "polymarket_ml" / filename
if not csv_path.exists():
return None
df = pd.read_csv(str(csv_path), parse_dates=["dt_utc"])
df = df.set_index("dt_utc")
if df.index.tzinfo is None:
df.index = df.index.tz_localize("UTC")
return df
def _get_event_probs(self, pair: str) -> pd.DataFrame | None:
"""Lazy-load per-contract event probabilities (cached per pair)."""
if not hasattr(self, "_event_probs_cache"):
self._event_probs_cache: dict[str, pd.DataFrame | None] = {}
if pair not in self._event_probs_cache:
self._event_probs_cache[pair] = self._load_event_probs(pair)
return self._event_probs_cache[pair]
# ---------------------------------------------------------------------------
# IStrategy interface
# ---------------------------------------------------------------------------
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
pair = metadata["pair"]
registry = self._get_registry()
if pair not in registry:
logger.warning("Pair %s not found in contract registry — skipping ML alpha.", pair)
dataframe["ml_fair_value"] = float("nan")
dataframe["ml_edge"] = 0.0
dataframe["ml_kelly_alloc"] = 0.0
dataframe["ml_h_remaining"] = 0.0
return dataframe
contract = registry[pair]
# --- Direct event-probability model ---
event_probs = self._get_event_probs(pair)
if event_probs is None:
logger.warning(
"No event_probs CSV found for %s — run scripts/prepare_event_model.py first.",
pair,
)
dataframe["ml_fair_value"] = float("nan")
dataframe["ml_edge"] = 0.0
dataframe["ml_kelly_alloc"] = 0.0
dataframe["ml_h_remaining"] = 0.0
return dataframe
alpha_meta = {
"pair": pair,
"expiry_utc": contract.end_date_utc,
"event_probs_df": event_probs,
"kelly_fraction": self.KELLY_FRACTION,
"min_edge": self.MIN_EDGE,
"max_alloc": self.MAX_ALLOC,
}
return EventProbAlpha(dataframe, alpha_meta).process()
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
# Enter when there is a positive Kelly allocation above MIN_EDGE.
entry_condition = (
(dataframe["ml_kelly_alloc"] > 0.001)
& (dataframe["ml_edge"] >= self.MIN_EDGE)
& (dataframe["ml_h_remaining"] > 1.0) # Don't enter on the expiry candle.
& (dataframe["close"] > 0.01)
& (dataframe["close"] < 0.99)
)
dataframe.loc[entry_condition, "enter_long"] = 1
# Encode model probability into enter_tag for reporting.
dataframe["enter_tag"] = dataframe.apply(
lambda row: json.dumps(
{
"model_prob": round(float(row.get("ml_fair_value", 0.0)), 4),
"edge": round(float(row.get("ml_edge", 0.0)), 4),
"kelly": round(float(row.get("ml_kelly_alloc", 0.0)), 4),
}
)
if row.get("enter_long") == 1
else "",
axis=1,
)
return dataframe
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
# Expiry-based exit is handled in custom_exit().
# Do not set any exit signals here.
dataframe["exit_long"] = 0
return dataframe
# ---------------------------------------------------------------------------
# Custom exit: settlement at expiry
# ---------------------------------------------------------------------------
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> str | bool | None:
"""Trigger settlement exit when the current candle reaches contract expiry."""
registry = self._get_registry()
if pair not in registry:
return None
contract = registry[pair]
expiry_ts = pd.Timestamp(contract.end_date_utc, tz="UTC")
# current_time from freqtrade backtesting is the candle open timestamp.
ct = current_time if current_time.tzinfo else current_time.replace(tzinfo=UTC)
if ct >= expiry_ts:
return "settlement"
return None
def custom_exit_price(
self,
pair: str,
trade: Trade,
current_time: datetime,
proposed_rate: float,
current_profit: float,
exit_tag: str | None,
**kwargs,
) -> float:
"""Return the binary settlement price when exiting at contract expiry."""
if exit_tag != "settlement":
return proposed_rate
registry = self._get_registry()
if pair not in registry:
return proposed_rate
contract = registry[pair]
return SETTLE_YES if contract.settlement == 1.0 else SETTLE_NO
# ---------------------------------------------------------------------------
# Position sizing: fractional Kelly
# ---------------------------------------------------------------------------
def custom_stake_amount(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_stake: float,
min_stake: Optional[float],
max_stake: float,
leverage: float,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> float:
if not self.dp:
return proposed_stake
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return proposed_stake
last = dataframe.iloc[-1]
kelly_alloc = float(last.get("ml_kelly_alloc", 0.0))
if kelly_alloc <= 0:
return 0.0
# Free capital available for this trade.
if self.wallets:
free_capital = float(self.wallets.get_free(self.config["stake_currency"]))
else:
free_capital = float(self.config.get("dry_run_wallet", 10000))
stake = kelly_alloc * free_capital
# Hard per-expiry cap: total notional on one settlement date must not
# exceed MAX_EXPIRY_ALLOC × total_portfolio. We approximate total
# portfolio as free_capital (this is conservative — open positions
# are not double-counted).
registry = self._get_registry()
if pair in registry:
target_expiry = registry[pair].end_date_utc
open_trades = Trade.get_trades_proxy(is_open=True)
expiry_notional = sum(
t.open_rate * t.amount
for t in open_trades
if t.pair in registry
and registry[t.pair].end_date_utc == target_expiry
)
expiry_cap = self.MAX_EXPIRY_ALLOC * free_capital
remaining_cap = max(0.0, expiry_cap - expiry_notional)
stake = min(stake, remaining_cap)
# Respect freqtrade's min/max stake bounds.
if min_stake is not None and stake < min_stake:
return 0.0 # Skip rather than exceed the cap by rounding up.
return min(stake, max_stake)
# ---------------------------------------------------------------------------
# Confirm entry: reject if no ML edge
# ---------------------------------------------------------------------------
def confirm_trade_entry(
self,
pair: str,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
current_time: datetime,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> bool:
registry = self._get_registry()
if pair not in registry:
logger.warning("confirm_trade_entry: %s not in registry — rejecting.", pair)
return False
contract = registry[pair]
expiry_ts = pd.Timestamp(contract.end_date_utc, tz="UTC")
ct = current_time if current_time.tzinfo else current_time.replace(tzinfo=UTC)
if ct >= expiry_ts:
logger.info("confirm_trade_entry: %s already expired — rejecting.", pair)
return False
# One position per expiry: reject if we already hold any contract
# with the same settlement date. Prevents stacking correlated bets
# (e.g. 88K YES + 90K YES + 92K YES on the same BTC expiry).
# Trade.get_trades_proxy works in both live and backtesting mode.
open_trades = Trade.get_trades_proxy(is_open=True)
for trade in open_trades:
if trade.pair == pair:
continue
if trade.pair in registry:
other_expiry = registry[trade.pair].end_date_utc
if other_expiry == contract.end_date_utc:
logger.info(
"confirm_trade_entry: already holding %s with same expiry %s "
"— rejecting %s to prevent correlated-bet stacking.",
trade.pair, contract.end_date_utc, pair,
)
return False
return True