The Setup
You run a small quantitative fund trading prediction markets. Your thesis: BTC-related prediction markets are systematically mispriced because retail participants overweight recent price action. You want to:- Find liquid BTC prediction markets on Polymarket and Kalshi
- Build a Black-Scholes binary model for fair value
- Backtest the strategy on historical data
- Validate calibration before going live
- Deploy across both exchanges with bracket orders and TWAP execution
- Monitor everything with Prometheus metrics, Telegram alerts, and calibration tracking
Step 1: Market Discovery
Scan both exchanges for active BTC markets with sufficient volume."""Step 1: Find tradeable markets."""
from horizon.discovery import discover_markets
# Find BTC markets on Polymarket with >$10k volume
poly_markets = discover_markets(
exchange="polymarket",
query="btc",
min_volume=10_000,
limit=10,
)
print(f"Found {len(poly_markets)} Polymarket markets:")
for m in poly_markets:
print(f" {m.slug} | active={m.active} | token={m.yes_token_id}")
# Find BTC markets on Kalshi
kalshi_markets = discover_markets(
exchange="kalshi",
query="KXBTC",
limit=10,
)
print(f"\nFound {len(kalshi_markets)} Kalshi markets:")
for m in kalshi_markets:
print(f" {m.ticker} | active={m.active}")
# Pick our targets
POLY_MARKET = "will-btc-hit-100k-by-end-of-2025"
KALSHI_MARKET = "KXBTC-25DEC31"
Step 2: Build the Model
The pricing model uses Black-Scholes for binary options with a toxicity-adjusted spread. This is the same code that will run in backtesting and production."""Step 2: Probability model and quoting logic."""
import math
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
# ----- Model functions -----
def black_scholes_binary(spot: float, strike: float, vol: float, tte: float) -> float:
"""Price a binary option using Black-Scholes. Returns P(spot >= strike at expiry)."""
if tte <= 0:
return 1.0 if spot >= strike else 0.0
d2 = (math.log(spot / strike) - 0.5 * vol ** 2 * tte) / (vol * math.sqrt(tte))
return 0.5 * (1.0 + math.erf(d2 / math.sqrt(2)))
def estimate_vol(prices: list[float], window: int = 20) -> float:
"""Annualized realized vol from a price series."""
if len(prices) < window + 1:
return 0.60 # default
returns = [math.log(prices[i] / prices[i - 1]) for i in range(-window, 0)]
variance = sum(r ** 2 for r in returns) / len(returns)
daily_vol = math.sqrt(variance)
return daily_vol * math.sqrt(365)
# ----- Pipeline functions -----
# Rolling price buffer for vol estimation
_price_history: list[float] = []
STRIKE = 100_000
BASE_SPREAD = 0.02
GAMMA = 0.15 # inventory penalty
MIN_EDGE = 0.02 # minimum 2 cents of edge to quote
BANKROLL = 50_000.0
KELLY_FRACTION = 0.25 # quarter-Kelly
def fair_value(ctx: hz.Context) -> float:
"""Compute fair value using Black-Scholes binary pricing on BTC spot."""
btc = ctx.feeds.get("btc", hz.context.FeedData())
spot = btc.price if btc.price > 0 else 97_000
# Track price history for vol estimation
_price_history.append(spot)
if len(_price_history) > 500:
_price_history.pop(0)
vol = estimate_vol(_price_history)
tte = ctx.params.get("days_to_expiry", 30) / 365.0
fair = black_scholes_binary(spot, STRIKE, vol, tte)
return fair
def toxicity(ctx: hz.Context) -> float:
"""Spread-based toxicity proxy from the BTC feed."""
btc = ctx.feeds.get("btc", hz.context.FeedData())
if btc.bid <= 0 or btc.ask <= 0:
return 0.5
mid = (btc.bid + btc.ask) / 2.0
if mid <= 0:
return 0.5
return min(1.0, max(0.0, (btc.ask - btc.bid) / mid * 100.0))
def quoter(ctx: hz.Context, fair: float, tox: float) -> list[hz.Quote]:
"""GLFT spread with Kelly sizing. Only quotes when edge exceeds minimum."""
book = ctx.feeds.get("book", hz.context.FeedData())
market_mid = book.price if book.price > 0 else 0.50
edge = abs(fair - market_mid)
if edge < MIN_EDGE:
return [] # not enough edge
# GLFT spread: base + inventory penalty + toxicity adjustment
inv = ctx.inventory.net_for_market(ctx.market.id) if ctx.market else 0.0
spread = BASE_SPREAD + GAMMA * abs(inv) * 0.001 + tox * 0.04
# Kelly-based sizing
size = hz.liquidity_adjusted_kelly(
prob=fair,
market_price=market_mid,
bankroll=BANKROLL,
fraction=KELLY_FRACTION,
available_liquidity=200.0,
max_size=50.0,
)
size = max(1.0, size) # floor at 1
return hz.quotes(fair, spread, size=size)
PIPELINE = [fair_value, toxicity, quoter]
Step 3: Backtest
Run the model on historical data. Validate Sharpe, drawdown, and Brier score before committing capital."""Step 3: Backtest the strategy on historical data."""
import horizon as hz
# In production, load real data from CSV or a database.
# Here we generate synthetic data that mimics BTC price action around $100k.
import random
random.seed(42)
btc_price = 97_000.0
market_price = 0.45
market_data = []
for i in range(5000):
ts = 1700000000.0 + i * 60 # 1-minute bars, ~3.5 days
# BTC random walk with drift
btc_price *= math.exp(random.gauss(0.0001, 0.003))
btc_price = max(80_000, min(120_000, btc_price))
# Market price loosely tracks BTC proximity to $100k
target = black_scholes_binary(btc_price, 100_000, 0.60, 30 / 365)
market_price += (target - market_price) * 0.05 + random.gauss(0, 0.005)
market_price = max(0.05, min(0.95, market_price))
market_data.append({
"timestamp": ts,
"price": round(market_price, 4),
"bid": round(market_price - 0.015, 4),
"ask": round(market_price + 0.015, 4),
})
# Run the backtest
result = hz.backtest(
name="btc_binary_mm",
markets=["btc-100k"],
data=market_data,
pipeline=PIPELINE,
risk=hz.Risk(
max_position=200,
max_notional=10_000,
max_drawdown_pct=8,
max_order_size=50,
),
params={"days_to_expiry": 30},
initial_capital=50_000.0,
paper_fee_rate=0.001,
outcomes={"btc-100k": 1.0}, # assume BTC did hit $100k
)
# Analyze results
print(result.summary())
print()
m = result.metrics
print(f"Sharpe Ratio: {m.sharpe_ratio:.3f}")
print(f"Sortino Ratio: {m.sortino_ratio:.3f}")
print(f"Max Drawdown: ${m.max_drawdown:.2f} ({m.max_drawdown_pct:.2%})")
print(f"Win Rate: {m.win_rate:.1f}%")
print(f"Profit Factor: {m.profit_factor:.2f}")
print(f"Total Trades: {m.total_trades}")
print(f"Brier Score: {m.brier_score:.4f}")
print(f"Total Fees: ${m.total_fees:.2f}")
# Export for further analysis
result.to_csv("equity_curve.csv", what="equity")
result.to_csv("trade_log.csv", what="trades")
Go/No-Go Checklist
Before deploying live, verify:| Metric | Threshold | Why |
|---|---|---|
| Sharpe > 1.0 | Risk-adjusted return justifies capital allocation | |
| Max Drawdown under 5% | Survivable loss given fund risk limits | |
| Win Rate > 50% | Edge is real, not from a few lucky trades | |
| Profit Factor > 1.3 | Wins meaningfully outweigh losses after fees | |
| Brier Score under 0.25 | Model forecasts better than a coin flip |
Step 4: Calibration Validation
Track your model’s probability accuracy over time. This runs alongside the backtest results."""Step 4: Validate calibration with historical outcomes."""
from horizon.calibration import CalibrationTracker
tracker = CalibrationTracker("calibration.db")
# Record predictions from historical markets where you know the outcome.
# In production, you'd pull these from your prediction database.
historical = [
("btc-above-80k-jan", 0.92, True),
("btc-above-90k-jan", 0.78, True),
("btc-above-100k-jan", 0.45, False),
("btc-above-110k-jan", 0.15, False),
("btc-above-80k-feb", 0.95, True),
("btc-above-90k-feb", 0.82, True),
("btc-above-100k-feb", 0.52, True),
("btc-above-110k-feb", 0.22, False),
("eth-above-4k-jan", 0.60, True),
("eth-above-5k-jan", 0.30, False),
("fed-rate-cut-jan", 0.70, True),
("fed-rate-cut-feb", 0.55, False),
]
for market_id, prob, outcome in historical:
tracker.log_prediction(market_id, prob)
tracker.resolve_market(market_id, outcome)
# Generate report
report = tracker.report(n_bins=5)
print(f"Brier Score: {report.brier_score:.4f}")
print(f"Log Loss: {report.log_loss:.4f}")
print(f"Overconfidence: {report.overconfidence:+.4f}")
print(f"Predictions: {report.n_predictions}")
print(f"Resolved: {report.n_resolved}")
print("\nCalibration Curve:")
print(f"{'Bin':>12} | {'Predicted':>10} | {'Actual':>8} | {'Count':>6}")
print("-" * 50)
for b in report.buckets:
print(f" [{b.bin_start:.1f}, {b.bin_end:.1f}) | {b.predicted_mean:10.3f} | {b.actual_frequency:8.3f} | {b.count:6d}")
# Use calibration to adjust live predictions
adjusted = tracker.suggest_adjustment(0.65)
print(f"\nRaw model says 0.65, calibration-adjusted: {adjusted:.4f}")
tracker.close()
Step 5: Configure Risk
Set up risk parameters based on backtest results and fund constraints."""Step 5: Risk configuration for production."""
import horizon as hz
# Risk config tuned from backtest analysis
RISK = hz.Risk(
max_position=200, # max contracts per market
max_notional=15_000, # max total portfolio value ($15k)
max_drawdown_pct=5, # kill switch at 5% daily drawdown
max_order_size=50, # never submit more than 50 in one order
rate_limit=30, # 30 orders/sec sustained
rate_burst=100, # burst capacity
)
Step 6: Deploy Live (Multi-Exchange)
Deploy across Polymarket and Kalshi simultaneously with netting, bracket orders, and Prometheus monitoring."""Step 6: Full production deployment."""
import os
import time
import math
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
from horizon import (
MetricsCollector,
MetricsServer,
AlertManager,
AlertType,
AlertLevel,
TelegramChannel,
LogChannel,
update_engine_metrics,
)
from horizon.calibration import CalibrationTracker
from horizon.algos import TWAP
# ============================================================
# Configuration
# ============================================================
POLY_MARKET = "will-btc-hit-100k-by-end-of-2025"
KALSHI_MARKET = "KXBTC-25DEC31"
STRIKE = 100_000
BANKROLL = 50_000.0
KELLY_FRACTION = 0.25
BASE_SPREAD = 0.02
GAMMA = 0.15
MIN_EDGE = 0.02
DAYS_TO_EXPIRY = 30
# ============================================================
# Monitoring setup
# ============================================================
# Prometheus metrics
collector = MetricsCollector()
server = MetricsServer(collector, port=9090)
server.start()
tick_counter = collector.counter("horizon_ticks_total")
edge_gauge = collector.gauge("horizon_current_edge")
vol_gauge = collector.gauge("horizon_implied_vol")
model_latency = collector.histogram("horizon_model_latency_seconds")
# Telegram alerts for the trading desk
alerts = AlertManager(channels=[
TelegramChannel(
bot_token=os.environ.get("TELEGRAM_BOT_TOKEN", ""),
chat_id=os.environ.get("TELEGRAM_CHAT_ID", ""),
),
LogChannel(),
])
# Calibration tracker (persistent across restarts)
calibration = CalibrationTracker("calibration_prod.db")
# ============================================================
# Pipeline functions (same model as backtest)
# ============================================================
_price_history: list[float] = []
_bracket_set: set[str] = set() # markets with active brackets
_twap_algo = None
def black_scholes_binary(spot, strike, vol, tte):
if tte <= 0:
return 1.0 if spot >= strike else 0.0
d2 = (math.log(spot / strike) - 0.5 * vol ** 2 * tte) / (vol * math.sqrt(tte))
return 0.5 * (1.0 + math.erf(d2 / math.sqrt(2)))
def estimate_vol(prices, window=20):
if len(prices) < window + 1:
return 0.60
returns = [math.log(prices[i] / prices[i - 1]) for i in range(-window, 0)]
variance = sum(r ** 2 for r in returns) / len(returns)
return math.sqrt(variance) * math.sqrt(365)
def fair_value(ctx: hz.Context) -> float:
"""Black-Scholes binary pricing with calibration adjustment."""
start = time.time()
btc = ctx.feeds.get("btc", hz.context.FeedData())
spot = btc.price if btc.price > 0 else 97_000
_price_history.append(spot)
if len(_price_history) > 500:
_price_history.pop(0)
vol = estimate_vol(_price_history)
vol_gauge.set(vol)
tte = ctx.params.get("days_to_expiry", DAYS_TO_EXPIRY) / 365.0
fair = black_scholes_binary(spot, STRIKE, vol, tte)
# Apply calibration adjustment from historical data
fair = calibration.suggest_adjustment(fair)
# Log prediction for future calibration analysis
if ctx.market:
calibration.log_prediction(ctx.market.id, fair)
model_latency.observe(time.time() - start)
return fair
def toxicity(ctx: hz.Context) -> float:
btc = ctx.feeds.get("btc", hz.context.FeedData())
if btc.bid <= 0 or btc.ask <= 0:
return 0.5
mid = (btc.bid + btc.ask) / 2.0
if mid <= 0:
return 0.5
return min(1.0, max(0.0, (btc.ask - btc.bid) / mid * 100.0))
def quoter(ctx: hz.Context, fair: float, tox: float) -> list[hz.Quote]:
"""GLFT spread + Kelly sizing. Reports edge to Prometheus."""
tick_counter.inc()
book = ctx.feeds.get("book", hz.context.FeedData())
market_mid = book.price if book.price > 0 else 0.50
edge = abs(fair - market_mid)
edge_gauge.set(edge)
if edge < MIN_EDGE:
return []
inv = ctx.inventory.net_for_market(ctx.market.id) if ctx.market else 0.0
spread = BASE_SPREAD + GAMMA * abs(inv) * 0.001 + tox * 0.04
size = hz.liquidity_adjusted_kelly(
prob=fair,
market_price=market_mid,
bankroll=BANKROLL,
fraction=KELLY_FRACTION,
available_liquidity=200.0,
max_size=50.0,
)
size = max(1.0, size)
return hz.quotes(fair, spread, size=size)
def risk_manager(ctx: hz.Context) -> None:
"""
Post-quote risk overlay:
- Update Prometheus engine metrics
- Send alerts on fills, kill switch, large positions
- Add bracket orders (SL/TP) after first fill
"""
engine = ctx.params.get("engine")
if engine is None:
return
update_engine_metrics(collector, engine)
status = engine.status()
# Kill switch alert
if status.kill_switch_active:
alerts.alert(
AlertType.RISK_TRIGGER,
f"Kill switch activated: {status.kill_switch_reason}",
)
return
# Large position alert
positions = engine.positions()
for pos in positions:
if pos.size > 150:
alerts.alert(
AlertType.RISK_TRIGGER,
f"Large position: {pos.market_id} {pos.side} {pos.size:.0f} contracts",
)
# Fill alerts
for fill in engine.recent_fills()[-5:]:
alerts.alert(
AlertType.FILL,
f"Fill: {fill.order_side} {fill.size:.0f} {fill.side} @ {fill.price:.4f} on {fill.market_id}",
)
# Bracket orders: add SL/TP after first fill on a market
if ctx.market and ctx.market.id not in _bracket_set:
market_pos = [p for p in positions if p.market_id == ctx.market.id]
if market_pos and market_pos[0].size >= 5:
pos = market_pos[0]
# Stop-loss 15 cents below entry
sl_price = max(0.05, pos.avg_entry_price - 0.15)
engine.add_stop_loss(
market_id=ctx.market.id,
side=pos.side,
order_side=OrderSide.Sell,
size=pos.size,
trigger_price=sl_price,
)
# Take-profit 20 cents above entry
tp_price = min(0.95, pos.avg_entry_price + 0.20)
engine.add_take_profit(
market_id=ctx.market.id,
side=pos.side,
order_side=OrderSide.Sell,
size=pos.size,
trigger_price=tp_price,
trigger_pnl=pos.size * 0.10, # or $0.10/contract profit
)
_bracket_set.add(ctx.market.id)
alerts.alert(
AlertType.CUSTOM,
f"Bracket set on {ctx.market.id}: SL@{sl_price:.2f}, TP@{tp_price:.2f}",
)
# ============================================================
# Launch
# ============================================================
try:
hz.run(
name="btc_binary_desk",
exchanges=[
hz.Polymarket(
private_key=os.environ["POLYMARKET_PRIVATE_KEY"],
api_key=os.environ.get("POLYMARKET_API_KEY"),
api_secret=os.environ.get("POLYMARKET_API_SECRET"),
api_passphrase=os.environ.get("POLYMARKET_API_PASSPHRASE"),
),
hz.Kalshi(
api_key=os.environ["KALSHI_API_KEY"],
),
],
markets=[POLY_MARKET, KALSHI_MARKET],
feeds={
"btc": hz.BinanceWS("btcusdt"),
"book": hz.PolymarketBook(POLY_MARKET),
"kalshi_book": hz.KalshiBook(KALSHI_MARKET),
},
pipeline=[fair_value, toxicity, quoter, risk_manager],
risk=RISK,
params={
"days_to_expiry": DAYS_TO_EXPIRY,
},
interval=0.5,
mode="live",
dashboard=True,
netting_pairs=[
(POLY_MARKET, KALSHI_MARKET),
],
)
finally:
server.stop()
calibration.close()
alerts.alert(
AlertType.LIFECYCLE,
"Strategy shutdown complete.",
)
Step 7: Large Order Execution
When your model identifies a strong signal and you want to build a large position quickly without moving the market, use TWAP or Iceberg:"""Step 7: Execute a large order with TWAP."""
import time
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
from horizon.algos import TWAP, Iceberg
# Assuming engine is already running from step 6.
# In practice, you'd grab the engine reference from inside a pipeline function.
engine = hz.Engine()
# TWAP: accumulate 500 contracts over 10 minutes in 20 slices
request = OrderRequest(
market_id="will-btc-hit-100k-by-end-of-2025",
side=Side.Yes,
order_side=OrderSide.Buy,
price=0.55,
size=500.0,
)
twap = TWAP(engine, duration_secs=600, num_slices=20)
twap.start(request)
while not twap.is_complete:
engine.tick("will-btc-hit-100k-by-end-of-2025", 0.55)
twap.on_tick(current_price=0.55, timestamp=time.time())
time.sleep(1.0)
print(f"TWAP complete: {twap.total_filled:.0f} contracts filled")
print(f"Child orders: {len(twap.child_order_ids)}")
# For thin books, use Iceberg to hide total size
iceberg_request = OrderRequest(
market_id="will-btc-hit-100k-by-end-of-2025",
side=Side.Yes,
order_side=OrderSide.Buy,
price=0.55,
size=300.0,
)
iceberg = Iceberg(engine, show_size=15.0)
iceberg.start(iceberg_request)
while not iceberg.is_complete:
engine.tick("will-btc-hit-100k-by-end-of-2025", 0.55)
iceberg.on_tick(current_price=0.55, timestamp=time.time())
time.sleep(0.5)
print(f"Iceberg complete: {iceberg.total_filled:.0f} contracts filled")
Step 8: Post-Trade Analysis
After the market resolves, record the outcome and evaluate your model’s performance."""Step 8: Post-trade analysis and calibration update."""
from horizon.calibration import CalibrationTracker
tracker = CalibrationTracker("calibration_prod.db")
# Market resolved: BTC did hit $100k
tracker.resolve_market("will-btc-hit-100k-by-end-of-2025", True)
tracker.resolve_market("KXBTC-25DEC31", True)
# Full calibration report
report = tracker.report()
print(f"Overall Brier Score: {report.brier_score:.4f}")
print(f"Total Predictions: {report.n_predictions}")
print(f"Total Resolved: {report.n_resolved}")
print(f"Overconfidence: {report.overconfidence:+.4f}")
# Review calibration curve for systematic bias
for b in report.buckets:
bias = b.predicted_mean - b.actual_frequency
flag = " << OVERCONFIDENT" if bias > 0.10 else ""
flag = " << UNDERCONFIDENT" if bias < -0.10 else flag
print(f" [{b.bin_start:.1f}-{b.bin_end:.1f}] pred={b.predicted_mean:.2f} actual={b.actual_frequency:.2f} n={b.count}{flag}")
tracker.close()
Infrastructure
Environment Variables
# Polymarket
export POLYMARKET_PRIVATE_KEY="0x..."
export POLYMARKET_API_KEY="..."
export POLYMARKET_API_SECRET="..."
export POLYMARKET_API_PASSPHRASE="..."
# Kalshi
export KALSHI_API_KEY="..."
# Alerts
export TELEGRAM_BOT_TOKEN="..."
export TELEGRAM_CHAT_ID="..."
# Logging
export HORIZON_LOG=info
Grafana Dashboard
With the MetricsServer running on port 9090, add it to Prometheus and build panels:# prometheus.yml
scrape_configs:
- job_name: "horizon_btc_desk"
scrape_interval: 15s
static_configs:
- targets: ["localhost:9090"]
| Panel | Query |
|---|---|
| PnL (realized + unrealized) | horizon_engine_realized_pnl + horizon_engine_unrealized_pnl |
| Model edge | horizon_current_edge |
| Implied vol | horizon_implied_vol |
| Tick rate | rate(horizon_ticks_total[1m]) |
| Model latency p95 | histogram_quantile(0.95, horizon_model_latency_seconds_bucket) |
| Position count | horizon_engine_position_count |
| Kill switch | horizon_engine_kill_switch |
Running
# Paper mode first (always)
python btc_desk.py
# Paper mode with dashboard
python -m horizon run btc_desk.py --dashboard
# Live mode (after paper validation)
python -m horizon run btc_desk.py --mode=live --dashboard
# Check historical fills
python -m horizon fills --db ./btc_binary_desk.db --limit 100
# Check positions
python -m horizon positions --db ./btc_binary_desk.db
Summary
This workflow covers every stage a trading desk needs:| Step | What | Horizon Feature |
|---|---|---|
| 1 | Find markets | discover_markets() |
| 2 | Build model | Pipeline functions |
| 3 | Backtest | hz.backtest() with Brier score |
| 4 | Calibration | CalibrationTracker |
| 5 | Risk config | hz.Risk() with drawdown, position, notional limits |
| 6 | Deploy live | hz.run() multi-exchange with netting |
| 6 | Position protection | add_stop_loss(), add_take_profit() brackets |
| 6 | Monitoring | Prometheus metrics, Telegram alerts |
| 7 | Large orders | TWAP, Iceberg execution |
| 8 | Post-trade | Calibration report, bias analysis |