Skip to main content
A complete workflow showing how a quantitative trading desk would take a prediction market strategy from idea to production using Horizon. This covers every step: market discovery, probability modeling, backtesting, calibration validation, risk configuration, multi-exchange deployment, execution algorithms, position protection, and production monitoring.

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:
  1. Find liquid BTC prediction markets on Polymarket and Kalshi
  2. Build a Black-Scholes binary model for fair value
  3. Backtest the strategy on historical data
  4. Validate calibration before going live
  5. Deploy across both exchanges with bracket orders and TWAP execution
  6. 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:
MetricThresholdWhy
Sharpe > 1.0Risk-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.3Wins meaningfully outweigh losses after fees
Brier Score under 0.25Model forecasts better than a coin flip
If any metric fails, go back to step 2 and adjust the model.

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"]
Suggested panels:
PanelQuery
PnL (realized + unrealized)horizon_engine_realized_pnl + horizon_engine_unrealized_pnl
Model edgehorizon_current_edge
Implied volhorizon_implied_vol
Tick raterate(horizon_ticks_total[1m])
Model latency p95histogram_quantile(0.95, horizon_model_latency_seconds_bucket)
Position counthorizon_engine_position_count
Kill switchhorizon_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:
StepWhatHorizon Feature
1Find marketsdiscover_markets()
2Build modelPipeline functions
3Backtesthz.backtest() with Brier score
4CalibrationCalibrationTracker
5Risk confighz.Risk() with drawdown, position, notional limits
6Deploy livehz.run() multi-exchange with netting
6Position protectionadd_stop_loss(), add_take_profit() brackets
6MonitoringPrometheus metrics, Telegram alerts
7Large ordersTWAP, Iceberg execution
8Post-tradeCalibration report, bias analysis