Skip to content

Latency Arbitrage Deep Dive

import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from ‘@astrojs/starlight/components’;

This guide provides a comprehensive deep dive into the latency arbitrage strategy, covering:

  • How trades are evaluated and executed
  • The mathematical models behind probability calculation
  • Kelly criterion position sizing
  • Complete worked examples with real numbers

The latency arbitrage strategy exploits the speed difference between centralized exchanges (Binance) and prediction markets (Polymarket). When BTC moves on Binance, it takes time for Polymarket prices to adjust—this creates a window where we can calculate the “correct” probability and trade against stale prices.

sequenceDiagram
    participant Binance
    participant Bot
    participant Polymarket
    
    Binance->>Bot: BTC price update ($91,620)
    Note over Bot: Calculate theoretical<br/>probability (42.8%)
    Bot->>Polymarket: Request orderbook
    Polymarket-->>Bot: YES ask: $0.42, NO ask: $0.60
    Note over Bot: Market implies 42%<br/>Theo says 42.8%<br/>Edge: 0.8% - too small
    
    Binance->>Bot: BTC price jumps ($91,850)
    Note over Bot: Recalculate<br/>probability (51.2%)
    Note over Bot: Edge now 9.2%!<br/>Trade triggered
    Bot->>Polymarket: Buy YES @ $0.42
    Polymarket-->>Bot: Filled

Every tick of the strategy evaluates whether to trade. Here’s the complete decision tree:

flowchart TD
    Start([Market Update]) --> Parse{Parse Market Slug}
    Parse -->|Failed| Skip1[Skip: Cannot parse]
    Parse -->|Success| Asset{Asset Mapping?}
    
    Asset -->|Unknown asset| Skip2[Skip: No asset mapping]
    Asset -->|BTC/ETH/SOL| Price{Reference Price<br/>Available?}
    
    Price -->|No| Skip3[Skip: No Binance price]
    Price -->|Yes| Vol{Volatility ≥ 10%?}
    
    Vol -->|No| Skip4[Skip: Low volatility]
    Vol -->|Yes| Cooldown{Cooldown<br/>Elapsed?}
    
    Cooldown -->|No| Skip5[Skip: In cooldown]
    Cooldown -->|Yes| Calc[Calculate Edge]
    
    Calc --> Edge{Net Edge ><br/>Threshold?}
    Edge -->|No| Skip6[Skip: Insufficient edge]
    Edge -->|Yes| Bounds{Price in<br/>Bounds?}
    
    Bounds -->|No| Skip7[Skip: Price out of bounds]
    Bounds -->|Yes| Position{Position<br/>Under Limit?}
    
    Position -->|No| Skip8[Skip: Max position reached]
    Position -->|Yes| Size[Calculate Kelly Size]
    
    Size --> Execute[Execute Trade]
    
    style Execute fill:#10b981,stroke:#059669,color:#fff
    style Skip1 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip2 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip3 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip4 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip5 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip6 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip7 fill:#6b7280,stroke:#4b5563,color:#fff
    style Skip8 fill:#6b7280,stroke:#4b5563,color:#fff

All seven conditions must be true for a trade to execute:

#ConditionCheckConfig Parameter
1Market ParseableSlug contains recognized asset (bitcoin, btc, ethereum, eth, solana, sol)assetMapping
2Reference Price AvailableLive Binance price exists for the assetBinance WebSocket
3Sufficient VolatilityAnnualized volatility ≥ minimum thresholdminVolatility (default: 10%)
4Cooldown ElapsedTime since last trade on this market ≥ cooldowncooldownMs (default: 3000ms)
5Net Edge > ThresholdnetEdge > edgeThreshold + modelUncertaintyedgeThreshold (default: 2.5%)
6Price In Bounds0.01 < ask price < 0.99Hardcoded in strategy
7Position Under LimitCurrent position < maximum allowedmaxPositionSize (default: 50)

Probability Model: Black-Scholes for Binary Options

Section titled “Probability Model: Black-Scholes for Binary Options”

The strategy uses a simplified Black-Scholes model to calculate the theoretical probability that a binary option will expire in-the-money.

For a binary option asking “Will BTC be above strike K at expiry T?”:

d = [ln(S/K) - σ²T/2] / (σ√T)
P(above) = Φ(d)

Where:

  • S = Current spot price (from Binance)
  • K = Strike price (parsed from market slug)
  • σ = Annualized volatility (calculated from price history)
  • T = Time to expiry (in years)
  • Φ = Standard normal cumulative distribution function
```typescript // From src/strategies/latency-arb/math.ts export function computeTheoreticalProbability( currentPrice: number, strikePrice: number, direction: "above" | "below", volatility: number, timeToExpiryMs: number, ): number { if (timeToExpiryMs <= 0) { // At expiry: deterministic outcome if (direction === "above") { return currentPrice > strikePrice ? 1 : 0; } return currentPrice < strikePrice ? 1 : 0; }

const timeYears = timeToExpiryMs / (365.25 * 24 * 60 * 60 * 1000); const sqrtT = Math.sqrt(timeYears);

// d2 from Black-Scholes with zero drift (μ=0 for short-term crypto) const d = (Math.log(currentPrice / strikePrice) - 0.5 * volatility * volatility * timeYears) / (volatility * sqrtT);

const prob = normalCDF(d); return direction === “above” ? prob : 1 - prob; }

</TabItem>
<TabItem label="Math Explanation">
**Why d₂ instead of d₁?**
In the standard Black-Scholes formula for options pricing:
- d₁ is used for delta hedging
- d₂ is the probability measure under the risk-neutral distribution
For binary (digital) options, we care about the *probability* of finishing in-the-money, which is Φ(d₂).
**Why zero drift?**
For short-term crypto markets (minutes to hours), expected drift is negligible compared to volatility. Setting μ=0 simplifies the formula and is empirically accurate for our timeframes.
</TabItem>
</Tabs>
### Volatility Calculation
Historical volatility is calculated from a sliding window of Binance prices:
```typescript
// From src/strategies/latency-arb/math.ts
export function computeVolatility(
priceHistory: { ts: number; price: number }[],
windowMs: number,
nowMs: number,
): number | null {
const recent = priceHistory.filter((p) => p.ts >= nowMs - windowMs);
if (recent.length < 2) return null;
// Calculate log returns
const returns: number[] = [];
for (let i = 1; i < recent.length; i++) {
const prev = recent[i - 1]!;
const curr = recent[i]!;
if (prev.price > 0) {
returns.push(Math.log(curr.price / prev.price));
}
}
if (returns.length < 2) return null;
// Standard deviation of returns
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) =>
sum + (r - mean) ** 2, 0) / returns.length;
const stdDev = Math.sqrt(variance);
// Annualize based on observation frequency
const avgIntervalMs = windowMs / recent.length;
const intervalsPerYear = (365.25 * 24 * 60 * 60 * 1000) / avgIntervalMs;
const annualizedVol = stdDev * Math.sqrt(intervalsPerYear);
// Clamp to reasonable range
return Math.max(0.1, Math.min(annualizedVol, 3.0));
}

Edge is the difference between our theoretical probability and the market’s implied probability, minus fees.

flowchart LR
    subgraph "Input"
        Theo["Theoretical Prob<br/>(from Black-Scholes)"]
        YesAsk["YES Ask Price<br/>(Polymarket)"]
        NoAsk["NO Ask Price<br/>(Polymarket)"]
    end
    
    subgraph "Edge Calculation"
        YesEdge["YES Edge =<br/>Theo - YesAsk"]
        NoEdge["NO Edge =<br/>(1 - Theo) - NoAsk"]
        Gross["Gross Edge =<br/>max(YesEdge, NoEdge)"]
        Net["Net Edge =<br/>Gross - Fees"]
    end
    
    subgraph "Decision"
        Thresh["Net Edge ><br/>Threshold?"]
        Trade["Trade!"]
        Skip["Skip"]
    end
    
    Theo --> YesEdge
    YesAsk --> YesEdge
    Theo --> NoEdge
    NoAsk --> NoEdge
    YesEdge --> Gross
    NoEdge --> Gross
    Gross --> Net
    Net --> Thresh
    Thresh -->|Yes| Trade
    Thresh -->|No| Skip
    
    style Trade fill:#10b981,stroke:#059669,color:#fff
    style Skip fill:#6b7280,stroke:#4b5563,color:#fff
// Round-trip fees = entry fee + exit fee
const takerFeeDecimal = params.takerFeeBps / 10000; // 100 bps = 1%
const roundTripFees = takerFeeDecimal * 2; // 2% round trip
const netEdge = Math.abs(edge) - roundTripFees;

The Black-Scholes model has known limitations. The strategy dynamically increases the edge threshold in scenarios where the model is less reliable:

ConditionBufferRationale
Base uncertainty+2% alwaysNo model is perfect
Deep ITM/OTM strikes+0-5%Log-normal assumptions break down at extremes
Tail probabilities (<5% or >95%)+0-5%Model least accurate at extremes
Long-dated options (>7 days)+1%More time = more uncertainty
// From src/strategies/latency-arb/math.ts
export function computeModelUncertainty(
theoreticalProb: number,
currentPrice: number,
strikePrice: number,
volatility: number,
timeToExpiryMs: number,
baseEdgeThreshold: number,
): ModelUncertainty {
const BASE_UNCERTAINTY = 0.02; // Always 2%
// Moneyness: how far is spot from strike?
const logMoneyness = Math.abs(Math.log(currentPrice / strikePrice));
const moneynessAdjustment = Math.min(logMoneyness * 0.1, 0.05);
// Tail probability adjustment
let tailAdjustment = 0;
if (theoreticalProb < 0.1 || theoreticalProb > 0.9) {
const distanceFromExtreme = Math.min(theoreticalProb, 1 - theoreticalProb);
tailAdjustment = 0.03 * (1 - distanceFromExtreme / 0.1);
}
if (theoreticalProb < 0.05 || theoreticalProb > 0.95) {
tailAdjustment += 0.02; // Extra buffer for extreme tails
}
// Time adjustment for long-dated options
const timeYears = timeToExpiryMs / (365.25 * 24 * 60 * 60 * 1000);
const timeAdjustment = timeYears > 7 / 365 ? 0.01 : 0;
const totalUncertainty = BASE_UNCERTAINTY + moneynessAdjustment +
tailAdjustment + timeAdjustment;
return {
adjustedEdgeThreshold: baseEdgeThreshold + totalUncertainty,
// ... other fields
};
}

The Kelly criterion determines optimal bet sizing to maximize long-term growth while avoiding ruin.

For a binary bet with:

  • p = Probability of winning (our theoretical probability)
  • q = Probability of losing (1 - p)
  • b = Payout odds (win amount per dollar risked)
Kelly Fraction = (p × b - q) / b

For Polymarket binary options, the payout odds are derived from the price:

b = (1 / price) - 1

If you buy YES at $0.40, you win $1 if correct and lose $0.40 if wrong:

  • Win: $1.00 - $0.40 = $0.60 profit
  • Lose: $0.40 loss
  • Odds: b = 0.60 / 0.40 = 1.5 (same as (1/0.40) - 1)
// From src/strategies/latency-arb/kelly.ts
export function computeKellySize(
input: KellyInput,
params: KellyParams,
): number {
const { theoreticalProb, marketPrice, edge } = input;
const { bankroll, fraction, minSize, maxSize,
uncertaintyDiscount = 0, correlatedPositions = 0 } = params;
if (edge <= 0 || bankroll <= 0) return 0;
const p = theoreticalProb;
const q = 1 - p;
const b = 1 / marketPrice - 1; // Payout odds
if (b <= 0) return 0;
// Standard Kelly formula
const kellyFraction = (p * b - q) / b;
if (kellyFraction <= 0) return 0;
// Apply fraction (e.g., 0.25 for quarter-Kelly)
let adjustedKelly = kellyFraction * fraction;
// Uncertainty discount for model risk
if (uncertaintyDiscount > 0) {
adjustedKelly *= (1 - uncertaintyDiscount);
}
// Reduce sizing when holding correlated positions
if (correlatedPositions > 1) {
adjustedKelly /= Math.sqrt(correlatedPositions);
}
// Convert to dollar amount and then to contracts
const dollarSize = bankroll * adjustedKelly;
const contractSize = Math.floor(dollarSize / marketPrice);
return Math.max(minSize, Math.min(maxSize, contractSize));
}

Full Kelly betting is mathematically optimal but practically volatile. The strategy uses quarter-Kelly (fraction: 0.25) for several reasons:

  1. Parameter uncertainty: Our probability estimate isn’t perfect
  2. Volatility reduction: Quarter-Kelly has ~1/4 the variance of full Kelly
  3. Psychological sustainability: Smaller drawdowns are easier to stomach
  4. Edge degradation: Our edge might shrink as we trade
xychart-beta
    title "Kelly Fraction vs Expected Growth vs Volatility"
    x-axis [0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5]
    y-axis "Relative Performance" 0 --> 1.2
    line "Expected Growth" [0, 0.75, 0.94, 0.99, 1.0, 0.94, 0.75]
    line "Volatility" [0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5]

Let’s walk through a complete example with real numbers.

- **Question**: "Will Bitcoin be above $92,000 on Jan 12?" - **Current BTC Price** (Binance): $91,620 - **Strike Price**: $92,000 - **Time to Expiry**: 4 hours (0.000456 years) - **Volatility**: 45% annualized (from 5-min lookback window) - **YES Ask**: $0.42 - **NO Ask**: $0.60
const parsed = parseStrike("bitcoin-above-92000-jan-12");
// Result: { asset: "BTC", strikePrice: 92000, direction: "above" }

Condition 1 passed: Market slug contains “bitcoin”, maps to “BTC”

const refPrice = snapshot.referencePrices.find(r => r.symbol === "BTC");
// Result: { symbol: "BTC", price: 91620, tsMs: 1736694000000 }

Condition 2 passed: Binance price available

With 5 minutes of price history and ~300 price samples:

const volatility = computeVolatility(history, 300000, nowMs);
// Result: 0.45 (45% annualized)

Condition 3 passed: 45% > 10% minimum

const lastTrade = state.lastTradeTs.get(marketId) ?? 0;
const elapsed = nowMs - lastTrade;
// Result: 5000ms since last trade

Condition 4 passed: 5000ms > 3000ms cooldown

const timeToExpiry = 4 * 60 * 60 * 1000; // 4 hours in ms
const theo = computeTheoreticalProbability(91620, 92000, "above", 0.45, timeToExpiry);

Manual calculation:

T = 4h / (365.25 × 24h) = 0.000456 years
√T = 0.0214
d = [ln(91620/92000) - 0.45² × 0.000456 / 2] / (0.45 × 0.0214)
d = [ln(0.99587) - 0.000046] / 0.00963
d = [-0.00414 - 0.000046] / 0.00963
d = -0.435
P(above) = Φ(-0.435) = 0.332 (33.2%)

Result: Theoretical probability = 33.2%

const yesEdge = 0.332 - 0.42; // -8.8% (YES overpriced)
const noEdge = 0.668 - 0.60; // +6.8% (NO underpriced)
const grossEdge = 0.068; // Best edge is on NO side
const roundTripFees = 0.02; // 2% (1% entry + 1% exit)
const netEdge = 0.068 - 0.02 = 0.048; // 4.8%
const uncertainty = computeModelUncertainty(0.332, 91620, 92000, 0.45, timeToExpiry, 0.025);

Components:

  • Base uncertainty: +2%
  • Moneyness: ln(91620/92000) = -0.004, adjustment = 0.04% (negligible)
  • Tail: 33.2% is not extreme, no adjustment
  • Time: 4 hours < 7 days, no adjustment

Effective threshold: 2.5% + 2% = 4.5%

const shouldTrade = netEdge > effectiveThreshold;
// 4.8% > 4.5% → TRUE

Condition 5 passed: 4.8% net edge > 4.5% threshold

const noAsk = 0.60;
const inBounds = noAsk > 0.01 && noAsk < 0.99;
// TRUE

Condition 6 passed: $0.60 is within bounds

const currentDownSize = position?.downSize ?? 0; // 0
const underLimit = currentDownSize < 50;
// TRUE

Condition 7 passed: No existing position

const kellyInput = {
theoreticalProb: 0.668, // NO probability
marketPrice: 0.60, // NO ask
edge: 0.048
};
const kellyParams = {
bankroll: 10000,
fraction: 0.25, // Quarter-Kelly
minSize: 5,
maxSize: 50
};
const size = computeKellySizeForNo(kellyInput, kellyParams);

Manual calculation:

p = 0.668 (probability NO wins)
q = 0.332
b = 1/0.60 - 1 = 0.667 (payout odds)
Kelly = (p × b - q) / b
Kelly = (0.668 × 0.667 - 0.332) / 0.667
Kelly = (0.445 - 0.332) / 0.667
Kelly = 0.169 (16.9%)
Quarter-Kelly = 0.169 × 0.25 = 0.0423 (4.23%)
Dollar size = $10,000 × 0.0423 = $423
Contract size = floor($423 / $0.60) = 705 contracts
Clamped to max = min(705, 50) = 50 contracts

Result: Buy 50 NO contracts at $0.60

intents.push({
type: "PlaceOrder",
marketId: market.marketId,
token: "DOWN", // NO side
side: "BUY",
price: 0.60,
size: 50,
reason: "latency_arb: theo=0.332 < mkt=0.400, edge=6.8%, netEdge=4.8%, vol=45%, size=50",
orderType: "FAK" // Fill-and-kill
});
- NO contracts pay $1 each - Revenue: 50 × $1.00 = $50 - Cost: 50 × $0.60 = $30 - Profit: $20 (minus fees) - NO contracts expire worthless - Loss: 50 × $0.60 = $30

With 66.8% theoretical probability of winning, expected value:

EV = 0.668 × $20 - 0.332 × $30 = $13.36 - $9.96 = +$3.40 per trade

Using the same market, but now BTC is at $91,900 (closer to strike):

const theo = computeTheoreticalProbability(91900, 92000, "above", 0.45, timeToExpiry);
// Result: 0.421 (42.1%)
const yesEdge = 0.421 - 0.42 = 0.001; // 0.1%
const noEdge = 0.579 - 0.60 = -0.021; // -2.1% (NO overpriced!)
const grossEdge = 0.001;
const netEdge = 0.001 - 0.02 = -0.019; // Negative after fees!

Condition 5 failed: -1.9% net edge < 4.5% threshold

Result: No trade. The market is fairly priced after accounting for fees.

Here’s the complete latency arbitrage configuration from configs/paper-realistic.yaml:

strategies:
enabled: [latency_arb]
latency_arb:
# Minimum net edge to trigger a trade (after fees)
edgeThreshold: 0.025 # 2.5%
# Maximum position per market
maxPositionSize: 50
# Minimum time between trades on same market
cooldownMs: 3000 # 3 seconds
# Lookback window for volatility calculation
volatilityWindowMs: 300000 # 5 minutes
# Skip trading if volatility below this threshold
minVolatility: 0.10 # 10% annualized
# Fee assumption (basis points)
takerFeeBps: 100 # 1%
# Asset name patterns to symbol mapping
assetMapping:
bitcoin: BTC
btc: BTC
ethereum: ETH
eth: ETH
solana: SOL
sol: SOL
# Kelly criterion position sizing
kelly:
enabled: true
fraction: 0.25 # Quarter-Kelly
minSize: 5 # Minimum contracts
maxSize: 250 # Maximum contracts
bankroll: 10000 # Starting capital for sizing
1. **Edge must exceed fees**: With 2% round-trip fees, you need at least 2%+ gross edge to have any expected profit.
  1. Model uncertainty is additive: The effective threshold is edgeThreshold + modelUncertainty, typically 4.5-7% depending on market conditions.

  2. Kelly sizing prevents ruin: Quarter-Kelly means risking ~4% of bankroll per trade even on high-edge opportunities.

  3. Volatility is critical: Without volatility, probability calculations are meaningless. The strategy won’t trade in low-vol environments.

  4. Cooldowns prevent overtrading: 3-second cooldown per market prevents chasing the same opportunity multiple times.