Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat> Co-authored-by: Warp <agent@warp.dev>
11 KiB
EMA: Exponential Moving Average
"The SMA drops an old price, the average jumps, the signal fires, the market does something unhelpful. The EMA exists because someone finally asked: what if old data just... mattered less?"
The Exponential Moving Average is the reference standard for trend-following indicators. Unlike the SMA, which treats data from 10 days ago with the same reverence as data from 10 seconds ago (a touching but mathematically questionable form of loyalty), the EMA applies exponentially decaying weights to older prices. The result: faster reaction to new information without the "drop-off effect" that makes SMA users twitch nervously around window boundaries. Simple, well-understood, computationally cheap. The indicator equivalent of a reliable sedan: not glamorous, but it starts every morning.
Historical Context
The EMA entered financial analysis to solve a specific problem with the SMA: window discontinuity. Picture a 20-day SMA cruising along smoothly. Then an outlier price from exactly 20 days ago drops out of the window. The average jumps. The signal fires. The position opens. The market, with characteristic indifference, moves the other way.
This "drop-off effect" made the SMA behave like a meticulously organized filing cabinet that occasionally explodes. By using a recursive formula, the EMA includes all past data in its calculation, with weights diminishing exponentially toward zero. No drop-off, no discontinuity. This makes it an Infinite Impulse Response (IIR) filter in signal processing terminology: the impulse response never fully reaches zero, but it gets small enough that even the most pedantic quant can be persuaded to ignore it.
Architecture & Physics
The EMA is controlled by a single parameter: the smoothing factor \alpha.
\alpha = \frac{2}{N + 1}
where N is the "period" (a human-friendly proxy for decay rate).
| Period | Alpha | Half-life (bars) | Behavior |
|---|---|---|---|
| 5 | 0.333 | ~2.4 | Very responsive, noisy |
| 10 | 0.182 | ~4.4 | Fast, some noise |
| 20 | 0.095 | ~8.7 | Balanced |
| 50 | 0.039 | ~21.8 | Smooth, significant lag |
| 100 | 0.020 | ~43.7 | Very smooth, very laggy |
The half-life formula: t_{1/2} = \frac{\ln(2)}{\ln(1/(1-\alpha))} \approx \frac{N-1}{2}
Warmup Compensation
Standard EMA implementations start at zero (or seed with the first price) and take approximately 3N bars to converge within 5% of the true value. During warmup, the output is biased.
QuanTAlib implements a mathematical compensator that corrects for initialization bias:
E_t = (1 - \alpha)^t
\text{Corrected}_t = \frac{\text{Raw}_t}{1 - E_t}
This produces statistically valid output from bar one. The first 14 bars of a 10-period EMA will differ from TA-Lib. TA-Lib uses an approximation (the technical term is "good enough for most purposes, which is precisely the problem"). QuanTAlib uses the mathematically correct value.
Mathematical Foundation
Recursive Formula
\text{EMA}_t = \alpha \cdot P_t + (1 - \alpha) \cdot \text{EMA}_{t-1}
Rewritten for fused multiply-add optimization:
\text{EMA}_t = \text{FMA}(\text{EMA}_{t-1}, \text{decay}, \alpha \cdot P_t)
where \text{decay} = 1 - \alpha.
Transfer Function
In z-domain:
H(z) = \frac{\alpha}{1 - (1-\alpha) z^{-1}}
This is a first-order IIR low-pass filter with cutoff frequency determined by \alpha.
Frequency Response
The -3dB cutoff frequency:
f_c = \frac{\alpha}{2\pi} \cdot f_s
For a 20-period EMA on daily data: f_c \approx 0.015 cycles/day, or roughly a 67-day period.
Performance Profile
Operation Count (Streaming Mode)
| Operation | Count | Cost (cycles) | Subtotal |
|---|---|---|---|
| FMA | 1 | 4 | 4 |
| MUL | 1 | 3 | 3 |
| Total (post-warmup) | 2 | — | ~7 cycles |
During warmup (first ~3N bars), additional operations for bias compensation:
| Operation | Count | Cost (cycles) | Subtotal |
|---|---|---|---|
| MUL | 1 | 3 | 3 |
| SUB | 1 | 1 | 1 |
| DIV | 1 | 15 | 15 |
| CMP | 2 | 1 | 2 |
| Warmup overhead | 5 | — | ~21 cycles |
Total during warmup: ~28 cycles/bar. Post-warmup: ~7 cycles/bar.
SIMD Analysis
EMA is inherently recursive: each value depends on the previous. SIMD parallelization across bars is not possible. The recursive dependency chain cannot be vectorized.
Available optimizations:
| Technique | Benefit |
|---|---|
| FMA instruction | ~2 cycles saved vs MUL+ADD |
| Loop unrolling (4×) | Reduced branch overhead |
| Unsafe memory access | Eliminated bounds checking |
Benchmark Results
Test environment: Apple M4, .NET 10.0, AdvSIMD, 500,000 bars.
| Metric | Value | Notes |
|---|---|---|
| Span throughput | 381 μs / 500K bars | 0.76 ns/bar |
| Streaming throughput | ~2 ns/bar | Single Update() call |
| Allocations (hot path) | 0 bytes | Verified via BenchmarkDotNet |
| Complexity | O(1) | Per-bar |
| State size | 32 bytes | Two doubles + flags |
Comparative Performance
| Library | Time (500K bars) | Allocated | Relative |
|---|---|---|---|
| QuanTAlib (Span) | 381 μs | 0 B | baseline |
| Tulip | 353 μs | 0 B | 0.93× |
| TA-Lib | 357 μs | 34 B | 0.94× |
| Skender | 10,635 μs | 23.6 MB | 27.9× slower |
QuanTAlib matches C-based libraries (Tulip, TA-Lib) in throughput while providing bias-corrected results and zero allocations.
Quality Metrics
| Metric | Score | Notes |
|---|---|---|
| Accuracy | 8/10 | Reliable trend tracking |
| Timeliness | 7/10 | Lag of ~N/2 bars |
| Overshoot | 8/10 | Minimal on reversals |
| Smoothness | 7/10 | Good noise rejection |
Validation
Validated against external libraries in Ema.Validation.Tests.cs. Tests run against 5,000 bars with tolerance of 1e-9.
| Library | Batch | Streaming | Span | Notes |
|---|---|---|---|---|
| TA-Lib | ✅ | ✅ | ✅ | Matches after warmup (TA-Lib lacks compensator) |
| Skender | ✅ | ✅ | ✅ | Matches GetEma() |
| Tulip | ✅ | ✅ | ✅ | Matches ema indicator |
| Ooples | ✅ | — | — | Matches CalculateExponentialMovingAverage() |
Run validation:
dotnet test --filter "FullyQualifiedName~EmaValidation"
Common Pitfalls
-
Warmup Divergence: QuanTAlib uses bias compensation. Other libraries approximate. The first
Nbars will differ. After ~3N bars, all libraries converge. Skip the first 3N bars when comparing cross-library results. -
Alpha vs. Period Confusion:
Ema(10)uses\alpha = 0.182.Ema(0.1)uses\alpha = 0.1, equivalent to period ~19. The constructors accept both formats. They are not equivalent. -
Lag Expectations: A 20-period EMA lags approximately 10 bars behind price. The EMA reduces lag versus SMA but does not eliminate it. Zero-lag filters exist (JMA, Ehlers) but introduce their own complications. There is no free lunch, only differently priced lunches.
-
Period-Timeframe Mismatch: An EMA(5) on hourly bars has a half-life of ~2.5 hours. Minor fluctuations become signals. The trading system interprets every coffee break as a trend reversal. Match period length to timeframe and expected signal duration.
-
Bar Correction Handling: When processing live ticks within the same bar, use
Update(value, isNew: false). UseisNew: true(default) only when a new bar opens. Incorrect usage causes the EMA to advance N times faster than intended. -
Cross-Library Comparison Window: When validating against TA-Lib or Tulip, compare only bars after index 3N. Earlier bars will differ due to warmup handling differences.
Usage Examples
// Streaming: one bar at a time
var ema = new Ema(20);
foreach (var bar in liveStream)
{
var result = ema.Update(new TValue(bar.Time, bar.Close));
Console.WriteLine($"EMA: {result.Value:F2}");
}
// Alpha-based construction (signal processing convention)
var fastEma = new Ema(0.2); // α=0.2, roughly period 9
// Batch processing with Span (zero allocation)
double[] prices = LoadHistoricalData();
double[] emaValues = new double[prices.Length];
Ema.Batch(prices.AsSpan(), emaValues.AsSpan(), period: 20);
// Batch processing with TSeries
var series = new TSeries();
// ... populate series ...
var results = Ema.Batch(series, period: 20);
// Event-driven chaining
var source = new TSeries();
var ema20 = new Ema(source, 20);
var ema50 = new Ema(source, 50);
source.Add(new TValue(DateTime.UtcNow, 100.0)); // Both EMAs update
// Pre-load with historical data
var ema = new Ema(20);
ema.Prime(historicalPrices); // Ready for live data
Implementation Notes
State Structure
private record struct State(double Ema, double E, bool IsHot, bool IsCompensated, int TickCount);
| Field | Size | Purpose |
|---|---|---|
Ema |
8 bytes | Running exponential average |
E |
8 bytes | Compensator factor (1-\alpha)^n |
IsHot |
1 byte | Warmup complete flag |
IsCompensated |
1 byte | True when E < 1e-10 |
TickCount |
4 bytes | Bars processed |
Total state: ~32 bytes per instance. No buffers required regardless of period.
FMA Optimization
The core update uses Math.FusedMultiplyAdd for single-instruction precision:
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * input);
This computes Ema * decay + alpha * input with a single rounding operation instead of two.
Loop Unrolling
Batch processing unrolls by 4 to reduce branch overhead:
for (; i < unrollEnd; i += 4)
{
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * Unsafe.Add(ref srcRef, i));
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * Unsafe.Add(ref srcRef, i + 1));
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * Unsafe.Add(ref srcRef, i + 2));
state.Ema = Math.FusedMultiplyAdd(state.Ema, decay, alpha * Unsafe.Add(ref srcRef, i + 3));
}
Bar Correction
The _state / _p_state pattern enables correction of the current bar:
if (isNew)
{
_p_state = _state;
_p_lastValidValue = _lastValidValue;
}
else
{
_state = _p_state;
_lastValidValue = _p_lastValidValue;
}
Memory Summary
| Component | Size |
|---|---|
| State struct | ~32 bytes |
| Instance fields | ~48 bytes |
| Total per instance | ~80 bytes |
| Additional buffers | 0 bytes |
References
- Hunter, J. S. (1986). "The Exponentially Weighted Moving Average." Journal of Quality Technology, 18(4), 203-210.
- Roberts, S. W. (1959). "Control Chart Tests Based on Geometric Moving Averages." Technometrics, 1(3), 239-250.
- Ehlers, J. F. (2001). Rocket Science for Traders. John Wiley & Sons. Chapter 3: Smoothing. (The title oversells it slightly, but the content is solid.)