Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/joicodev/polymarket-bot/llms.txt

Use this file to discover all available pages before exploring further.

Overview

The prediction engine combines three mathematical models in logit space for mathematically sound probability adjustments that naturally stay within (0, 1):
  1. Black-Scholes Binary Option - Base probability using distance to strike, volatility, and time remaining
  2. EWMA Volatility - Exponentially weighted moving average of realized volatility (λ=0.94)
  3. Logit-Space Combination - Momentum and mean reversion adjustments in log-odds space
All probability adjustments happen in logit space (log-odds) to ensure outputs remain valid probabilities without requiring manual clamping.

Black-Scholes Binary Call Probability

Mathematical Foundation

The probability that BTC closes above the strike price is modeled as a binary call option using the Black-Scholes framework:
P(BTC > Strike) = N(d₂)

d₂ = [ln(S₀/K) - (σ²/2)×T] / (σ × √T)

where:
  S₀ = current BTC price
  K  = strike price (target)
  σ  = volatility (per-second)
  T  = time remaining (seconds)
  N  = cumulative standard normal distribution

Implementation

From src/engine/probability.js:44-71:
export function binaryCallProbability({
  currentPrice,
  strikePrice,
  volatility,
  timeRemainingSeconds,
  riskFreeRate = 0,
}) {
  // At expiry: deterministic outcome
  if (timeRemainingSeconds <= 0) {
    return currentPrice > strikePrice ? 1.0 : 0.0
  }

  // Guard against degenerate inputs
  if (volatility <= 0 || currentPrice <= 0 || strikePrice <= 0) {
    return 0.5
  }

  const T = timeRemainingSeconds
  const sqrtT = Math.sqrt(T)

  // d2 = [ln(S/K) + (r - sigma^2/2) * T] / (sigma * sqrt(T))
  const d2 =
    (Math.log(currentPrice / strikePrice) +
      (riskFreeRate - (volatility * volatility) / 2) * T) /
    (volatility * sqrtT)

  return normalCDF(d2)
}

Normal CDF Approximation

The engine uses the Abramowitz and Stegun polynomial approximation for N(x), achieving accuracy within 1.5e-7:
export function normalCDF(x) {
  const a1 = 0.254829592
  const a2 = -0.284496736
  const a3 = 1.421413741
  const a4 = -1.453152027
  const a5 = 1.061405429
  const p = 0.3275911

  const sign = x < 0 ? -1 : 1
  const absX = Math.abs(x) / Math.sqrt(2)
  const t = 1.0 / (1.0 + p * absX)
  const y = 1.0 - ((((a5*t + a4)*t + a3)*t + a2)*t + a1)*t * Math.exp(-absX*absX)

  return 0.5 * (1.0 + sign * y)
}
The Abramowitz-Stegun approximation is:
  • Dependency-free (no external math libraries)
  • Fast (polynomial evaluation, no iterative methods)
  • Accurate (max error 1.5e-7, sufficient for probability forecasting)
  • Deterministic (identical results across platforms)

Example Calculation

// Scenario: BTC at $64,232, strike $64,355, 176 seconds remaining, σ=0.00012/sec
const prob = binaryCallProbability({
  currentPrice: 64232,
  strikePrice: 64355,
  volatility: 0.00012,
  timeRemainingSeconds: 176
})

// Step-by-step:
// ln(64232/64355) = -0.001912
// σ² = 0.00012² = 1.44e-8
// T = 176 seconds
// d₂ = [-0.001912 - (1.44e-8/2)×176] / (0.00012×√176)
//    = [-0.001912 - 1.27e-6] / 0.001593
//    = -0.001913 / 0.001593
//    = -1.201
// N(-1.201) ≈ 0.115

// Result: 11.5% probability of closing above strike

EWMA Volatility Estimation

Why EWMA?

Exponentially Weighted Moving Average (EWMA) gives more weight to recent price movements while maintaining a continuous estimate of volatility. Key properties:
  • Responsive - React quickly to regime changes (crashes, pumps)
  • Smooth - Avoid noise from single outlier ticks
  • Stationary - Converge to stable values in calm markets
  • Per-second units - Matches Black-Scholes time convention

Formula

σ²ₜ = λ × σ²ₜ₋₁ + (1-λ) × (r²ᵢ / Δtᵢ)

where:
  λ = 0.94 (decay factor, higher = more historical weight)
  r = ln(Pₜ / Pₜ₋₁) (log return between consecutive ticks)
  Δt = time delta in seconds
  r²/Δt = variance per second
With λ=0.94, the half-life of a volatility shock is approximately 11 ticks (~11 seconds).

Implementation

From src/engine/volatility.js:28-69:
update(price, timestamp) {
  this._tickCount++

  if (this._lastPrice === null) {
    this._lastPrice = price
    this._lastTimestamp = timestamp
    return 0
  }

  // Log return between consecutive ticks
  const r = Math.log(price / this._lastPrice)

  // Time delta in seconds (guard against zero)
  const dt = Math.max((timestamp - this._lastTimestamp) / 1000, 0.001)

  // r²/dt gives variance per second
  const r2PerSec = (r * r) / dt

  if (!this._initialized) {
    this._variance = r2PerSec  // Seed with first observation
    this._initialized = true
  } else {
    // EWMA update
    this._variance = this._lambda * this._variance + (1 - this._lambda) * r2PerSec
  }

  this._lastPrice = price
  this._lastTimestamp = timestamp

  const sigma = Math.sqrt(this._variance)
  this._sigmaHistory.push(sigma)
  if (this._sigmaHistory.length > 100) {
    this._sigmaHistory.shift()
  }

  return sigma
}
Volatility is computed in per-second units, not annualized. A typical value is σ ≈ 0.00012/sec. To convert to annualized volatility: σ_annual = σ_per_sec × √(365.25 × 86400) ≈ σ_per_sec × 5615.

Anomalous Regime Detection

The engine tracks the mean of the last 100 σ values. If current volatility exceeds 2× meanSigma, the abstention system blocks trading to avoid unpredictable regime shifts.
getMeanSigma() {
  if (this._sigmaHistory.length === 0) return 0
  const sum = this._sigmaHistory.reduce((a, b) => a + b, 0)
  return sum / this._sigmaHistory.length
}
This prevents trading during:
  • Flash crashes
  • Major news events (Fed announcements, etc.)
  • Oracle malfunctions
  • Market manipulation attacks

Momentum Analysis

Rate of Change (ROC)

Momentum is measured as the weighted combination of three ROC windows:
ROC = 0.5 × ROC₁₀ₛ + 0.3 × ROC₃₀ₛ + 0.2 × ROC₆₀ₛ

ROCₙ = (Pₙₒw - Pₙ_ₛₑcₒₙdₛ_ₐgₒ) / Pₙ_ₛₑcₒₙdₛ_ₐgₒ
Short-term momentum (10s) gets the highest weight, capturing immediate directional bias.

Implementation

From src/engine/momentum.js:35-52:
getMomentum() {
  if (this._buffer.length === 0) {
    return { roc60s: 0, roc30s: 0, roc10s: 0, combined: 0 }
  }

  const currentTick = this._buffer[this._buffer.length - 1]
  const currentPrice = currentTick.price
  const now = currentTick.timestamp

  const roc60s = this._rocForWindow(now, currentPrice, 60)
  const roc30s = this._rocForWindow(now, currentPrice, 30)
  const roc10s = this._rocForWindow(now, currentPrice, 10)

  // Weighted combination
  const combined = 0.5 * roc10s + 0.3 * roc30s + 0.2 * roc60s

  return { roc60s, roc30s, roc10s, combined }
}

Mean Reversion Signal

When price deviates more than 0.3% from the 2-minute SMA, a reversion signal fires:
getMeanReversion() {
  const currentPrice = this._buffer[this._buffer.length - 1].price
  const now = this._buffer[this._buffer.length - 1].timestamp
  const cutoff = now - 120_000  // 2 minutes

  // Compute SMA over last 2 minutes
  let sum = 0, count = 0
  for (let i = this._buffer.length - 1; i >= 0; i--) {
    if (this._buffer[i].timestamp >= cutoff) {
      sum += this._buffer[i].price
      count++
    } else break
  }

  const sma2m = sum / count
  const deviation = (currentPrice - sma2m) / sma2m

  let signal = 0
  if (Math.abs(deviation) > 0.003) {
    signal = -deviation  // Reversion opposes deviation direction
  }

  return { sma2m, currentPrice, deviation, signal }
}
Reversion signal is negative when price is elevated (predicts pullback) and positive when price is depressed (predicts bounce).

Logit-Space Combination

Why Logit Space?

Probabilities live in (0, 1), making direct addition problematic:
  • Adding 0.6 + 0.5 = 1.1 (invalid probability)
  • Adjustments compound incorrectly near boundaries
Logit space (log-odds) maps (0,1) → (-∞, +∞), allowing linear adjustments:
logit(p) = ln(p / (1-p))
sigmoid(z) = 1 / (1 + e^(-z))

Transformation Properties

ProbabilityLogitInterpretation
0.01-4.60Very unlikely
0.10-2.20Unlikely
0.500.00Neutral
0.90+2.20Likely
0.99+4.60Very likely
Adding/subtracting in logit space = multiplying odds ratios.

Implementation

From src/engine/predictor.js:25-35:
function logit(p) {
  const safe = clamp(p, 1e-7, 1 - 1e-7)
  return Math.log(safe / (1 - safe))
}

function sigmoid(z) {
  return 1 / (1 + Math.exp(-z))
}

Final Prediction Formula

From src/engine/predictor.js:122-133:
const baseProb = binaryCallProbability({...})  // Black-Scholes
const { combined: momentumFactor } = this._momentum.getMomentum()
const { signal: reversionFactor } = this._momentum.getMeanReversion()

// Near-expiry guard: skip adjustments when <= 5 seconds remain
if (timeRemainingSeconds <= 5) {
  finalProb = baseProb
} else {
  // Combine in logit space
  const logitBase = logit(baseProb)
  const logitAdj = logitBase
    + 150 * momentumFactor      // config.engine.prediction.logitMomentumWeight
    + 80 * reversionFactor      // config.engine.prediction.logitReversionWeight
  finalProb = sigmoid(logitAdj)
}

Weight Tuning

Momentum Weight = 150
  • A 1% price move (ROC ≈ 0.01) shifts log-odds by 0.01 × 150 = 1.5
  • At p=0.5 (logit=0), this moves probability from 0.50 → 0.82
  • At p=0.7 (logit=0.85), this moves probability from 0.70 → 0.90
Reversion Weight = 80
  • A 0.5% deviation (signal ≈ -0.005) shifts log-odds by -0.005 × 80 = -0.4
  • At p=0.5, this moves probability from 0.50 → 0.40
  • Provides counterbalance to momentum during overextensions
Changing these weights requires re-calibration. Higher weights make the model more reactive but less stable. Lower weights reduce signal but improve calibration.

Platt Calibration

After 200+ predictions, the engine applies Platt scaling - a logistic regression that learns to recalibrate raw probabilities:
p_calibrated = sigmoid(A × logit(p_raw) + B)
This corrects for systematic biases:
  • Overconfidence - Model predicts 90% but wins only 75% of the time
  • Underconfidence - Model predicts 60% but wins 80% of the time
  • Direction bias - Model performs better on UP vs DOWN predictions
From src/engine/predictor.js:144-153:
// Auto-activates at 200+ samples
if (this._scaler.canFit()) {
  if (!this._scaler.getStats().fitted) {
    this._scaler.fit()
  }
  finalProb = this._scaler.calibrate(finalProb)
  finalProb = clamp(finalProb, 0.01, 0.99)
  calibrated = true
}

Near-Expiry Guard

When 5 or fewer seconds remain, the engine disables momentum and reversion adjustments:
if (timeRemainingSeconds <= config.engine.prediction.nearExpiryGuardSec) {
  finalProb = baseProb  // Pure Black-Scholes only
}
This prevents gaming the system with late-stage volatility spikes or momentum surges that won’t have time to resolve.

Complete Example

// ── Setup ──
const engine = new PredictionEngine()

// ── Feed 50+ price ticks ──
for (const tick of priceTicks) {
  engine.feedTick({ timestamp: tick.time, price: tick.price })
}

// ── Generate prediction ──
const result = engine.predict({
  currentPrice: 64232,
  strikePrice: 64355,
  timeRemainingSeconds: 176
})

/*
Internal calculations:

1. EWMA Volatility
   σ = 0.000120 per-second

2. Black-Scholes Base Probability
   d₂ = [ln(64232/64355) - (0.000120²/2)×176] / (0.000120×√176)
      = -1.201
   N(d₂) = 0.115  (11.5% chance of UP)

3. Momentum Signal
   ROC₁₀ₛ = -0.0008  (price down 0.08% over 10s)
   ROC₃₀ₛ = -0.0012  (price down 0.12% over 30s)
   ROC₆₀ₛ = -0.0015  (price down 0.15% over 60s)
   combined = 0.5×(-0.0008) + 0.3×(-0.0012) + 0.2×(-0.0015)
            = -0.001

4. Mean Reversion Signal
   SMA₂ₘ = 64250
   deviation = (64232 - 64250) / 64250 = -0.00028
   |deviation| < 0.003 → signal = 0 (no reversion)

5. Logit-Space Combination
   logitBase = logit(0.115) = ln(0.115 / 0.885) = -2.041
   logitAdj = -2.041 + 150×(-0.001) + 80×0
            = -2.041 - 0.15
            = -2.191
   finalProb = sigmoid(-2.191) = 0.101  (10.1% chance of UP)

6. Platt Calibration (if trained)
   Assume A=1.05, B=-0.02
   p_cal = sigmoid(1.05×logit(0.101) - 0.02)
         = sigmoid(1.05×(-2.191) - 0.02)
         = sigmoid(-2.320)
         = 0.089  (8.9% final probability)
*/

console.log(result)
// {
//   probability: 0.089,
//   direction: 'DOWN',
//   volatility: 0.000120,
//   momentum: -0.001,
//   reversion: 0,
//   calibrated: true
// }

Next Steps

Data Sources

Learn how price feeds and market data are ingested

Abstention System

Understand when and why the system refuses to trade

Build docs developers (and LLMs) love