Skip to main content

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