Skip to main content

Minimal backtesting for Polymarket/Kalshi prediction markets via Dome API

Project description

Backtest Service

A backtesting framework for Polymarket and Kalshi prediction markets built around Dome API. Just swap your import from "from dome_api_sdk import DomeClient" to "from emulo import DomeBacktestClient" and set the start and end times to go from a live algorithm to a backtest.

Quick Start

from emulo import DomeBacktestClient
from datetime import datetime

# Initialize backtest client
dome = DomeBacktestClient({
    "api_key": "your-dome-api-key",
    "start_time": int(datetime(2024, 11, 1).timestamp()),
    "end_time": int(datetime(2024, 11, 2).timestamp()),
    "initial_cash": 10000,
    "verbose": True,  # See progress in real-time
})

# Define your strategy
async def my_strategy(dome):
    # Get open markets
    markets = await dome.polymarket.markets.get_markets({
        "status": "open",
        "limit": 10
    })
    
    # Check prices and trade
    for market in markets.markets:
        price_data = await dome.polymarket.markets.get_market_price({
            "token_id": market.side_a.id
        })
        
        if price_data.price < 0.5 and dome.portfolio.cash > 100:
            # Create order (matches Dome API format)
            order = await dome.polymarket.markets.create_order(
                token_id=market.side_a.id,
                side="buy",
                size="100000",
                price=str(price_data.price),
                order_type="FOK"
            )

# Run backtest
result = await dome.run(my_strategy)
print(f"Return: {result.total_return_pct:.2f}%")
print(f"Final Value: ${result.final_value:.2f}")

Requirements

The dome-api-sdk dependency will be installed automatically when you install this package.

Installation

git clone https://github.com/tweidv/emulo-backtest.git
cd emulo-backtest
pip install -e .

Environment Variables

Create a .env file in the project root and add your Dome API key:

DOME_API_KEY=your-dome-api-key-here

The API key will be automatically loaded from the DOME_API_KEY environment variable if not provided in the config. Get your API key from domeapi.io.

How It Works

The framework simulates trading in the past by maintaining an internal simulation clock (at_time) that tracks the current backtest timestamp. Every API call automatically injects this timestamp, ensuring you only see data that existed at that point in time. The framework:

  1. Time Simulation: Maintains a simulation clock starting at start_time that advances by step seconds each tick
  2. Historical Data Injection: Every API call automatically uses the current simulation time, ensuring you only see markets, prices, and orderbooks that existed at that timestamp
  3. Lookahead Prevention: Market resolution status (winning_side, was_resolved) is filtered to None if the market wasn't resolved yet, preventing you from using future information
  4. Portfolio Simulation: Tracks cash, positions, fees, and interest exactly as they would have occurred in real trading

Your strategy code stays the same — just swap DomeClient for DomeBacktestClient and add time bounds.

Example: Converting Live Code to Backtest

Live Code:

from dome_api_sdk import DomeClient

dome = DomeClient({"api_key": "..."})
markets = await dome.polymarket.markets.get_markets({"status": "open"})

Backtest Code:

from emulo import DomeBacktestClient
from datetime import datetime

dome = DomeBacktestClient({
    "api_key": "...",
    "start_time": int(datetime(2024, 11, 1).timestamp()),
    "end_time": int(datetime(2024, 11, 2).timestamp()),
})
markets = await dome.polymarket.markets.get_markets({"status": "open"})
# Same API!

Configuration

Basic Configuration

dome = DomeBacktestClient({
    "api_key": "your-api-key",           # Optional if DOME_API_KEY env var is set
    "start_time": 1729800000,            # Required: Unix timestamp
    "end_time": 1729886400,              # Required: Unix timestamp
    "step": 3600,                         # Optional: seconds between ticks (default: 3600, minimum: 1)
    "initial_cash": 10000,                # Optional: starting capital (default: 10000)
    "enable_fees": True,                  # Optional: transaction fees (default: True)
    "enable_interest": False,             # Optional: Kalshi interest (default: False)
    "rate_limit_tier": "free",            # Optional: "free", "dev", or "enterprise" (default: "free")
    "verbose": False,                     # Optional: enable progress output (default: False)
    "log_level": "INFO",                  # Optional: logging detail level (default: "INFO")
})
  • What is a tick? A tick is one execution of your strategy function at a specific timestamp. The simulation clock advances forward by step seconds after each tick, and your strategy runs again at the new timestamp.

Verbose Mode and Logging

The verbose and log_level parameters control how much information is displayed during a backtest:

Verbose Mode (verbose)

When verbose=True, the framework displays:

  • Tick Progress: Shows the current tick number, timestamp, portfolio cash, total value, and number of positions at the start of each tick
  • API Calls: Lists all API calls made during the backtest (controlled by log_level)

Log Level (log_level)

The log_level parameter controls the detail of API call logging when verbose=True:

  • "DEBUG": Shows all API calls and their responses/results. Use this when you want to see exactly what data is being returned.
  • "INFO": Shows API calls but not their responses. Use this for a cleaner output that still shows what your strategy is doing.
  • "WARNING" / "ERROR": Suppresses API call logging (reserved for actual warnings/errors).

Example with verbose=True and log_level="DEBUG":

dome = DomeBacktestClient({
    "api_key": "...",
    "start_time": ...,
    "end_time": ...,
    "verbose": True,      # Enable progress output
    "log_level": "DEBUG", # Show API calls AND responses
})

# Output:
# [Tick 1/56] 2024-11-01 00:00:00 | Cash: $10,000.00 | Value: $10,000.00 | Positions: 0
#   [API] 00:00:00 polymarket.PolymarketMarketsNamespace.get_markets({'status': 'open'})
#     -> 50 markets
#   [API] 00:00:01 polymarket.PolymarketMarketsNamespace.get_market_price({'token_id': '...'})
#     -> price=0.65

Example with verbose=True and log_level="INFO" (default):

dome = DomeBacktestClient({
    "verbose": True,
    "log_level": "INFO",  # Or omit since INFO is default
})

# Output:
# [Tick 1/56] 2024-11-01 00:00:00 | Cash: $10,000.00 | Value: $10,000.00 | Positions: 0
#   [API] 00:00:00 polymarket.PolymarketMarketsNamespace.get_markets({'status': 'open'})
#   [API] 00:00:01 polymarket.PolymarketMarketsNamespace.get_market_price({'token_id': '...'})
# (API responses not shown)

Recommendation: Use verbose=True with log_level="INFO" for development and debugging. Switch to log_level="DEBUG" when you need to inspect API responses in detail. Set verbose=False for production runs where you only care about the final results.

API Reference

Dome API SDK Methods

All Dome SDK methods are supported with automatic historical time filtering. Timestamps are automatically capped at backtest time to prevent lookahead bias.

Polymarket:

  • dome.polymarket.markets.get_markets(params)
  • dome.polymarket.markets.get_market_price(params)
  • dome.polymarket.markets.get_candlesticks(params)
  • dome.polymarket.markets.get_orderbooks(params) - Historical data from Oct 14, 2025
  • dome.polymarket.markets.create_order(...) - Supports FOK, FAK, GTC, GTD order types
  • dome.polymarket.orders.get_orders(params)
  • dome.polymarket.wallet.get_wallet(params)
  • dome.polymarket.wallet.get_wallet_pnl(params)
  • dome.polymarket.activity.get_activity(params)
  • dome.polymarket.websocket.subscribe(request, on_event) - Simulate WebSocket order events
  • dome.polymarket.websocket.unsubscribe(subscription_id) - Unsubscribe from events

Kalshi:

  • dome.kalshi.markets.get_markets(params)
  • dome.kalshi.orderbooks.get_orderbooks(params) - Historical data from Oct 29, 2025
  • dome.kalshi.trades.get_trades(params)

Matching Markets:

  • dome.matching_markets.get_matching_markets(params)
  • dome.matching_markets.get_matching_markets_by_sport(params)

Crypto Prices:

  • dome.crypto_prices.binance.get_binance_prices(params)
  • dome.crypto_prices.chainlink.get_chainlink_prices(params)

Backtest-Specific Methods

Client Methods:

  • dome.run(strategy_fn) - Run backtest with your strategy function
  • dome.portfolio - Access portfolio state

Portfolio Methods:

  • dome.portfolio.cash - Available cash
  • dome.portfolio.positions - Dict[token_id, quantity]
  • dome.portfolio.get_position(token_id) - Get Position object with avg_price, cost_basis
  • dome.portfolio.get_position_pnl(token_id, current_price) - Calculate unrealized P&L

Native SDK Clients

For compatibility with py-clob-client (Polymarket) and kalshi SDK (Kalshi):

PolymarketBacktestClient:

  • client.create_order(...) - Matches py-clob-client API (side: "YES"/"NO", order_type: "MARKET"/"FOK"/"GTC"/"GTD")
  • client.portfolio - Access portfolio state

KalshiBacktestClient:

  • client.create_order(...) - Matches kalshi SDK API (side: "yes"/"no", action: "buy"/"sell", order_type: "limit"/"market")
  • client.get_positions() - Get current positions
  • client.portfolio - Access portfolio state

Trading

Strategy Function Signature

Your strategy can be a function or class instance:

# Function strategy
async def strategy(dome):
    cash = dome.portfolio.cash
    # ... trading logic

# Class-based strategy (state persists between ticks)
class MyStrategy:
    def __init__(self):
        self.price_history = []
    
    async def execute(self, dome):
        price = await dome.polymarket.markets.get_market_price(...)
        self.price_history.append(price.price)
        # ... trading logic

strategy = MyStrategy()
result = await dome.run(strategy)

Method detection: override -> __call__ -> execute -> run. Auto-detects sync/async. Use method="custom" to specify explicitly.

Creating Orders

Create orders using Dome API format to match production:

async def strategy(dome):
    # Get market price
    price_data = await dome.polymarket.markets.get_market_price({
        "token_id": "0x123..."
    })
    
    # Create order (matches Dome API router.placeOrder())
    order = await dome.polymarket.markets.create_order(
        token_id="0x123...",
        side="buy",           # "buy" or "sell"
        size="1000000000",    # Order size as string
        price="0.65",         # Limit price as string (0-1)
        order_type="GTC"      # "FOK", "FAK", "GTC", or "GTD"
    )
    
    # Check order status
    if order["status"] == "matched":
        print(f"Order filled at {order['fill_price']}")

Order Types:

  • "FOK" (Fill Or Kill): Must fill completely or reject immediately
  • "FAK" (Fill And Kill): Fill what you can at limit price, cancel remainder
  • "GTC" (Good Till Cancel): Stays on book until filled or cancelled (pending orders are automatically checked each tick against historical prices)
  • "GTD" (Good Till Date): Expires at specified expiration_time_seconds

Order Status:

  • "matched" - Order was filled
  • "pending" - Order is on the book waiting to fill
  • "rejected" - Order was rejected (e.g., insufficient liquidity)
  • "cancelled" - Order was cancelled
  • "expired" - Order expired (GTD orders)

Native SDK Compatibility

You can also use the native SDK clients for compatibility with py-clob-client (Polymarket) or kalshi SDK (Kalshi):

from emulo.native import PolymarketBacktestClient, KalshiBacktestClient

# Polymarket - matches py-clob-client API
polymarket = PolymarketBacktestClient({
    "dome_api_key": "...",
    "start_time": ...,
    "end_time": ...,
})
order = await polymarket.create_order(
    token_id="0x123...",
    side="YES",  # py-clob-client format
    size="1000000000",
    price="0.65",
    order_type="GTC"
)

# Kalshi - matches kalshi SDK API
kalshi = KalshiBacktestClient({
    "dome_api_key": "...",
    "start_time": ...,
    "end_time": ...,
})
order = await kalshi.create_order(
    ticker="KXNFLGAME-...",
    side="yes",
    action="buy",
    count=100,
    order_type="limit",
    yes_price=75
)

Position Tracking

dome.portfolio.cash                    # Available cash
dome.portfolio.positions               # Dict[token_id, quantity]
dome.portfolio.get_position(token_id)  # Get Position object with avg_price, cost_basis
dome.portfolio.get_position_pnl(token_id, current_price)  # Calculate unrealized P&L

Reading Data from Dome API

Market Discovery

Discover markets dynamically during backtests without lookahead bias:

async def strategy(dome):
    # Get markets that existed at backtest time
    response = await dome.polymarket.markets.get_markets({
        "status": "open",
        "min_volume": 100000,
        "limit": 10,
    })
    
    for market in response.markets:
        print(market.title)              # Market title
        print(market.historical_status)   # "open" at backtest time
        print(market.was_resolved)        # False if not resolved yet
        print(market.winning_side)        # None if not resolved (prevents lookahead!)
        print(market.side_a.id)          # Token ID for trading

Key Features:

  • Markets with start_time > backtest_time are excluded (didn't exist yet)
  • historical_status reflects status at backtest time, not current
  • winning_side is None if market wasn't resolved (prevents lookahead bias)

Results

After running a backtest:

result = await dome.run(my_strategy)

# Performance metrics
print(f"Initial Cash: ${result.initial_cash:,.2f}")
print(f"Final Value: ${result.final_value:,.2f}")
print(f"Total Return: {result.total_return_pct:+.2f}%")
print(f"Net Return (after fees): {result.net_return_after_fees_pct:+.2f}%")

# Trading activity
print(f"Total Trades: {len(result.trades)}")
print(f"Total Fees: ${result.total_fees_paid:.2f}")

# Equity curve
for timestamp, value in result.equity_curve:
    print(f"{timestamp}: ${value:.2f}")

Transaction Fees

Fees are enabled by default for realistic backtests.

Polymarket

  • Global Platform: No fees
  • US Market (QCEX): 0.01% taker fee

Kalshi

Dynamic fees based on contract price: 0.07 × contracts × price × (1 - price)

Disable fees:

dome = DomeBacktestClient({
    "enable_fees": False,  # Disable fees
    ...
})

Kalshi Interest (Optional)

Kalshi offers 4% APY on cash and positions. Enable with:

dome = DomeBacktestClient({
    "enable_interest": True,
    "interest_apy": 0.04,  # 4% APY
    ...
})

Interest accrues daily on cash balances and position values.

BacktestResult

result.initial_cash              # Starting capital
result.final_value               # Final portfolio value
result.total_return_pct          # Return percentage
result.trades                    # List of all trades
result.equity_curve              # [(timestamp, value), ...]
result.total_fees_paid           # Total fees
result.total_interest_earned     # Total interest (Kalshi)
result.net_return_after_fees_pct # Net return after fees

Rate Limiting

The service includes built-in rate limiting that automatically enforces Dome API tier limits. Rate limit errors are automatically retried with exponential backoff.

Configuration

Rate limiting is configured via the rate_limit_tier parameter (or rateLimitTier for camelCase):

dome = DomeBacktestClient({
    "api_key": "...",
    "start_time": ...,
    "end_time": ...,
    "rate_limit_tier": "free",  # "free", "dev", or "enterprise"
})

Available Tiers:

Tier Queries Per Second Queries Per 10 Seconds
Free (default) 1 10
Dev 100 500
Enterprise Custom Custom

Custom Limits (Enterprise)

For Enterprise tier or custom limits, specify qps and per_10s:

dome = DomeBacktestClient({
    "api_key": "...",
    "start_time": ...,
    "end_time": ...,
    "rate_limit_tier": "enterprise",
    "rate_limit_qps": 200,          # Custom QPS limit
    "rate_limit_per_10s": 1000,      # Custom per-10-second limit
})

The rate limiter uses a sliding window approach to track both per-second and per-10-second limits, ensuring compliance with Dome API rate limits across all tiers.

WebSocket Event Simulation

Implemented! Simulate WebSocket events for copy trading strategies that monitor wallet addresses and replicate trades. Events are pre-fetched at backtest start and replayed chronologically as the backtest clock advances.

Usage

async def strategy(dome):
    # Subscribe to order events from specific users (matches Dome SDK API)
    subscription_id = await dome.polymarket.websocket.subscribe(
        users=["0x6031b6eed1c97e853c6e0f03ad3ce3529351f96d"],
        on_event=lambda event: handle_order_event(dome, event)
    )
    
    # Events are automatically emitted as clock advances
    # Your on_event callback will be called for each matching order

async def handle_order_event(dome, event):
    if event.type == "event":
        order_data = event.data
        # React to order event - e.g., copy the trade
        if order_data["side"] == "BUY":
            await dome.polymarket.markets.create_order(
                token_id=order_data["token_id"],
                side="buy",
                size=str(order_data["shares"]),
                price=str(order_data["price"]),
                order_type="FOK"
            )

Supported Filters

  • Users: Track orders from specific wallet addresses

    subscription_id = await dome.polymarket.websocket.subscribe(
        users=["0x123...", "0x456..."],
        on_event=handle_event
    )
    
  • Condition IDs: Track orders for specific market conditions

    subscription_id = await dome.polymarket.websocket.subscribe(
        condition_ids=["0xabc...", "0xdef..."],
        on_event=handle_event
    )
    
  • Market Slugs: Track orders in specific markets

    subscription_id = await dome.polymarket.websocket.subscribe(
        market_slugs=["market-slug-1", "market-slug-2"],
        on_event=handle_event
    )
    

Additional Methods

  • update(subscription_id, users=..., condition_ids=..., market_slugs=...) - Update subscription filters
  • unsubscribe(subscription_id) - Unsubscribe from events
  • get_active_subscriptions() - Get all active subscriptions
  • connect() / disconnect() - Connection management (no-op for backtesting)
  • Context manager support: async with dome.polymarket.websocket: ...

Limitations

Important Limitations:

  1. Wildcard subscriptions not supported: The users: ["*"] wildcard filter is not supported because the orders API requires explicit filters. You must specify individual wallet addresses.

  2. Multiple filter types: If you need to track multiple users/condition_ids/market_slugs, the implementation makes multiple API calls (one per filter value) and merges the results. This is efficient but may be slower for very large filter lists.

  3. Pre-fetching: All matching orders are fetched at subscription time. For large time ranges or very active wallets, this may require significant API calls and memory.

  4. Time range: Events are fetched for a 1-year window around the backtest start time. Events outside this window won't be included.

How It Works

  1. Pre-fetch: When you subscribe, all matching orders are fetched from the orders API
  2. Sort: Events are sorted chronologically by timestamp
  3. Replay: As the backtest clock advances, events are emitted when their timestamp matches the current backtest time
  4. No lookahead: Events are only emitted up to the current backtest time, preventing lookahead bias

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

emulo_backtest-0.1.0.tar.gz (58.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

emulo_backtest-0.1.0-py3-none-any.whl (69.9 kB view details)

Uploaded Python 3

File details

Details for the file emulo_backtest-0.1.0.tar.gz.

File metadata

  • Download URL: emulo_backtest-0.1.0.tar.gz
  • Upload date:
  • Size: 58.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.5

File hashes

Hashes for emulo_backtest-0.1.0.tar.gz
Algorithm Hash digest
SHA256 aab4a2a055cf60eddffc56ee981516e95ca6fb0c3f25b5e0d6b2ce349587437e
MD5 a31ec00490df28419491f4a967f75efd
BLAKE2b-256 90f0dc75cb4288ae50e754c51d49f6ddc338e9c4b3b06a3603656a6c98f7ac81

See more details on using hashes here.

File details

Details for the file emulo_backtest-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: emulo_backtest-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 69.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.5

File hashes

Hashes for emulo_backtest-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 438448a369cba64016b98d3b60c0303e79406ee84c360232160afbe961322191
MD5 937541b78e041e2a00803c3507510b74
BLAKE2b-256 c8a149e83fdb312436f8e4156e58a13badf263ebda9b90bdfc8e4f8ce357d77f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page