Skip to main content

Overview

The EWMA (Exponentially Weighted Moving Average) volatility estimator produces a real-time, per-second volatility estimate from streaming price ticks. It is the core input to the Black-Scholes probability model.

Mathematical Foundation

Volatility Definition

Volatility (σ) is the standard deviation of log returns: rt=ln(StSt1)r_t = \ln\left(\frac{S_t}{S_{t-1}}\right) σ=Var(r)\sigma = \sqrt{\text{Var}(r)}

EWMA Variance Recursion

The EWMA estimator updates variance incrementally with each new tick: σt2=λσt12+(1λ)rt2Δt\sigma_t^2 = \lambda \cdot \sigma_{t-1}^2 + (1 - \lambda) \cdot \frac{r_t^2}{\Delta t} Where:
  • λ = decay factor ∈ (0, 1) (default: 0.94)
  • r_t = log return between consecutive ticks
  • Δt = time delta between ticks (in seconds)
  • r_t² / Δt = normalized squared return (variance per second)
Decay Factor λ = 0.94: This gives approximately 94% weight to the previous variance and 6% weight to the new observation. Higher λ → smoother, slower adaptation. Lower λ → more reactive, noisier.

Per-Second Normalization

Crucial detail: we divide r² by Δt to express variance in per-second units: variance per second=r2Δt\text{variance per second} = \frac{r^2}{\Delta t} This ensures σ is directly compatible with the Black-Scholes formula, which expects time in seconds.

Implementation

Constructor

volatility.js
export class EWMAVolatility {
  constructor({ lambda = 0.94 } = {}) {
    this._lambda = lambda
    this._lastPrice = null
    this._variance = 0
    this._initialized = false
    this._lastTimestamp = null
    this._tickCount = 0
    this._sigmaHistory = []     // Circular buffer (last 100 values)
    this._sigmaHistoryMax = 100
  }
}

Update Algorithm

volatility.js
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/negative dt)
  const dt = this._lastTimestamp !== null
    ? Math.max((timestamp - this._lastTimestamp) / 1000, 0.001)
    : 1

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

  if (!this._initialized) {
    // Seed variance with first normalized squared return
    this._variance = r2PerSec
    this._initialized = true
  } else {
    // EWMA update: σ²_t = λ·σ²_{t-1} + (1-λ)·(r²/dt)
    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 > this._sigmaHistoryMax) {
    this._sigmaHistory.shift()
  }

  return sigma
}

Getters

volatility.js
getVolatility() {
  if (!this._initialized) {
    return 0
  }
  return Math.sqrt(this._variance)
}

getTickCount() {
  return this._tickCount
}

getMeanSigma() {
  if (this._sigmaHistory.length === 0) return 0
  const sum = this._sigmaHistory.reduce((a, b) => a + b, 0)
  return sum / this._sigmaHistory.length
}
getMeanSigma(): Used by the anomalous regime detector. If current volatility exceeds 3× the mean of the last 100 values, the engine abstains from predicting.

Edge Cases

1. First Tick

On the first tick, no return can be computed. The estimator stores the price and returns 0:
if (this._lastPrice === null) {
  this._lastPrice = price
  this._lastTimestamp = timestamp
  return 0
}

2. Second Tick (Initialization)

The first return seeds the variance:
if (!this._initialized) {
  this._variance = r2PerSec
  this._initialized = true
}

3. Zero Time Delta

If two ticks share the same timestamp (or arrive out of order), dt is clamped to 1ms:
const dt = Math.max((timestamp - this._lastTimestamp) / 1000, 0.001)
Time Delta Guard: Extremely small or zero Δt would cause r²/Δt to explode. The 1ms floor prevents division-by-near-zero while still being negligible for typical tick intervals (100ms - 1s).

Intuition

Why Exponential Weighting?

Simple moving average (SMA) gives equal weight to all observations in the window: σSMA2=1Ni=1Nri2\sigma_{\text{SMA}}^2 = \frac{1}{N} \sum_{i=1}^N r_i^2 EWMA gives exponentially decaying weight to older observations: σEWMA2=(1λ)i=0λirti2\sigma_{\text{EWMA}}^2 = (1-\lambda) \sum_{i=0}^\infty \lambda^i r_{t-i}^2 Benefits:
  • Memory efficiency: O(1) state (no tick buffer required)
  • Recency bias: Recent volatility matters more than ancient history
  • Continuous adaptation: No window boundary effects

Effective Window Length

The “effective” lookback period for λ = 0.94: Neff=21λ1=20.06132 ticksN_{\text{eff}} = \frac{2}{1-\lambda} - 1 = \frac{2}{0.06} - 1 \approx 32 \text{ ticks} This means the estimator “forgets” 95% of a tick’s influence after ~32 subsequent ticks.

Usage Example

import { EWMAVolatility } from './volatility.js'

const estimator = new EWMAVolatility({ lambda: 0.94 })

// Stream price ticks
const ticks = [
  { timestamp: 1000, price: 0.500 },
  { timestamp: 2000, price: 0.502 },
  { timestamp: 3000, price: 0.498 },
  { timestamp: 4000, price: 0.503 },
  // ...
]

for (const { timestamp, price } of ticks) {
  const sigma = estimator.update(price, timestamp)
  console.log(`t=${timestamp}, σ=${sigma.toFixed(6)}`)
}

// After processing ticks, get current volatility
const currentVol = estimator.getVolatility()
console.log(`Current per-second volatility: ${currentVol}`)

// Check if enough data exists
if (estimator.getTickCount() >= 5) {
  console.log('Estimator warmed up, ready for prediction')
}

// Detect anomalous regime
const meanSigma = estimator.getMeanSigma()
if (currentVol > 3 * meanSigma) {
  console.warn('Volatility spike detected — abstain from prediction')
}

Integration with Engine

The prediction engine feeds each tick into the volatility estimator:
predictor.js
feedTick({ timestamp, price }) {
  this._volatility.update(price, timestamp)
  this._momentum.addTick({ timestamp, price })
}
Before computing a prediction, it checks for sufficient data:
predictor.js
const sigma = this._volatility.getVolatility()

if (sigma === 0 || this._volatility.getTickCount() < abstentionCfg.minTicks) {
  return { abstained: true, reason: 'insufficient_data', volatility: sigma }
}

Time Scale Conversion

Why Per-Second?

The Black-Scholes formula expects time remaining in seconds and volatility in matching units: d2=ln(S/K)+(rσ22)TσTd_2 = \frac{\ln(S/K) + \left(r - \frac{\sigma^2}{2}\right) T}{\sigma \sqrt{T}} If T is in seconds, σ must be per-second volatility.

Alternative Time Units

If you prefer annualized volatility (common in finance): σannual=σper-second×31536000\sigma_{\text{annual}} = \sigma_{\text{per-second}} \times \sqrt{31536000} Where 31,536,000 = seconds per year. For hourly volatility: σhourly=σper-second×3600\sigma_{\text{hourly}} = \sigma_{\text{per-second}} \times \sqrt{3600}
Unit Consistency: The engine uses per-second units throughout. If you modify the time scale, you must adjust both the volatility estimator and the Black-Scholes inputs.

Calibration

Choosing λ

The decay factor controls the trade-off between stability and responsiveness:
λEffective WindowBehavior
0.90~19 ticksVery reactive, noisy
0.94~32 ticksDefault — balanced
0.97~65 ticksSmooth, slow adaptation
0.99~199 ticksVery stable, insensitive to shocks
Empirical Tuning: λ = 0.94 was selected through backtesting on Polymarket tick data. For markets with different microstructure (e.g., high-frequency vs. low-frequency ticks), you may need to re-calibrate.

Warm-Up Period

The estimator requires a minimum number of ticks before producing reliable estimates. The engine uses:
minTicks: 5  // config.engine.abstention.minTicks
This ensures at least 4 returns have been observed before the first prediction.

State Management

Reset

volatility.js
reset() {
  this._lastPrice = null
  this._variance = 0
  this._initialized = false
  this._lastTimestamp = null
  this._tickCount = 0
  this._sigmaHistory = []
}
When to reset: Only reset volatility on market close or context switch. The engine’s resetMomentum() method preserves volatility state across interval boundaries, allowing the EWMA to accumulate long-term regime information.

Advanced Topics

GARCH vs. EWMA

GARCH (Generalized Autoregressive Conditional Heteroskedasticity) is a more sophisticated volatility model: σt2=ω+αrt12+βσt12\sigma_t^2 = \omega + \alpha r_{t-1}^2 + \beta \sigma_{t-1}^2 EWMA is a special case of GARCH(1,1) with:
  • ω = 0 (no long-term mean reversion)
  • α = 1 - λ
  • β = λ
GARCH offers mean-reversion to a long-run variance level, but requires more complex parameter estimation. EWMA is simpler and suffices for short-lived prediction markets.

RiskMetrics™ Standard

The λ = 0.94 default comes from J.P. Morgan’s RiskMetrics™ methodology (1996), which popularized EWMA for daily returns. For intraday tick data, the optimal λ may differ.

Realized Volatility

An alternative to EWMA is realized volatility (sum of squared returns over a fixed window): σRV2=i=1Nri2\sigma_{\text{RV}}^2 = \sum_{i=1}^N r_i^2 This is unbiased but requires storing all returns in the window (O(N) memory vs. O(1) for EWMA).

Performance Characteristics

  • Time Complexity: O(1) per update
  • Space Complexity: O(1) core state + O(100) history buffer
  • Numerical Stability: Log returns prevent overflow; variance is always non-negative

References

  • J.P. Morgan (1996). RiskMetrics™ Technical Document (4th ed.).
  • Tsay, R. S. (2010). Analysis of Financial Time Series (3rd ed.). Wiley.
  • Engle, R. F. (1982). “Autoregressive Conditional Heteroscedasticity with Estimates of the Variance of United Kingdom Inflation.” Econometrica, 50(4), 987-1007.

Next Steps

Black-Scholes Model

How volatility feeds into the probability calculation

Prediction Engine

Abstention logic using volatility thresholds

Build docs developers (and LLMs) love