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.
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}")- Python 3.9 or higher
- Dome API key (get one here)
The dome-api-sdk dependency will be installed automatically when you install this package.
git clone https://github.com/tweidv/emulo-backtest.git
cd emulo-backtest
pip install -e .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.
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:
- Time Simulation: Maintains a simulation clock starting at
start_timethat advances bystepseconds each tick - 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
- Lookahead Prevention: Market resolution status (
winning_side,was_resolved) is filtered toNoneif the market wasn't resolved yet, preventing you from using future information - 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.
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!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
stepseconds after each tick, and your strategy runs again at the new timestamp.
The verbose and log_level parameters control how much information is displayed during a backtest:
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)
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.65Example 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.
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, 2025dome.polymarket.markets.create_order(...)- Supports FOK, FAK, GTC, GTD order typesdome.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 eventsdome.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, 2025dome.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)
Client Methods:
dome.run(strategy_fn)- Run backtest with your strategy functiondome.portfolio- Access portfolio state
Portfolio Methods:
dome.portfolio.cash- Available cashdome.portfolio.positions- Dict[token_id, quantity]dome.portfolio.get_position(token_id)- Get Position object with avg_price, cost_basisdome.portfolio.get_position_pnl(token_id, current_price)- Calculate unrealized P&L
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 positionsclient.portfolio- Access portfolio state
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.
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 specifiedexpiration_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)
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
)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&LDiscover 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 tradingKey Features:
- Markets with
start_time > backtest_timeare excluded (didn't exist yet) historical_statusreflects status at backtest time, not currentwinning_sideisNoneif market wasn't resolved (prevents lookahead bias)
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}")Fees are enabled by default for realistic backtests.
- Global Platform: No fees
- US Market (QCEX): 0.01% taker fee
Dynamic fees based on contract price: 0.07 × contracts × price × (1 - price)
Disable fees:
dome = DomeBacktestClient({
"enable_fees": False, # Disable fees
...
})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.
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 feesThe service includes built-in rate limiting that automatically enforces Dome API tier limits. Rate limit errors are automatically retried with exponential backoff.
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 |
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.
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.
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"
)-
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 )
update(subscription_id, users=..., condition_ids=..., market_slugs=...)- Update subscription filtersunsubscribe(subscription_id)- Unsubscribe from eventsget_active_subscriptions()- Get all active subscriptionsconnect()/disconnect()- Connection management (no-op for backtesting)- Context manager support:
async with dome.polymarket.websocket: ...
Important Limitations:
-
Wildcard subscriptions not supported: The
users: ["*"]wildcard filter is not supported because the orders API requires explicit filters. You must specify individual wallet addresses. -
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.
-
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.
-
Time range: Events are fetched for a 1-year window around the backtest start time. Events outside this window won't be included.
- Pre-fetch: When you subscribe, all matching orders are fetched from the orders API
- Sort: Events are sorted chronologically by timestamp
- Replay: As the backtest clock advances, events are emitted when their timestamp matches the current backtest time
- No lookahead: Events are only emitted up to the current backtest time, preventing lookahead bias