Skip to content

tweidv/emulo-backtest

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages