Most moving averages suffer from a fundamental tradeoff: a short window reacts quickly to price changes but produces noisy signals, while a long window is smooth but lags behind the market. The Fractal Adaptive Moving Average (FRAMA) resolves this by dynamically adjusting its sensitivity based on the fractal dimension of the price series — becoming more responsive during trending conditions and more sluggish during choppy, sideways markets.
This article walks through the theory behind FRAMA, derives its core equations, and builds a complete Python implementation using yfinance, pandas, and numpy. By the end, you will have a working FRAMA calculator that fetches real market data, computes the adaptive filter factor at every timestep, and visualizes the result against a standard exponential moving average for comparison.
Most algo trading content gives you theory.
This gives you the code.3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.5,000 quant traders already run these:
Subscribe | AlgoEdge Insights
This article covers:
- Section 1 — Core Concept:** What fractal dimension means in the context of price series, and why it makes a useful adaptive signal
- Section 2 — Python Implementation:** Step-by-step code covering setup, fractal dimension computation, FRAMA calculation, and visualization
- Section 3 — Results and Analysis:** What the output looks like on real data and what the adaptive behavior reveals
- Section 4 — Use Cases:** Practical scenarios where FRAMA outperforms fixed-window moving averages
- Section 5 — Limitations and Edge Cases:** Where FRAMA breaks down and what to watch for in production
1. Fractal Dimension and Adaptive Smoothing
A fractal is a structure that exhibits self-similarity across scales — the same patterns appear whether you zoom in or zoom out. Mandelbrot famously argued that financial price series share this property: the jagged structure of a daily chart looks statistically similar to an hourly or weekly chart. FRAMA exploits this observation by measuring how "fractal-like" a price window is at any given moment, then using that measurement to set the speed of the moving average.
The key quantity is the fractal dimension D, which lives between 1 and 2 for a price series. A value close to 1 indicates a smooth, trending series — the path traces a nearly straight line through price space. A value close to 2 indicates a highly jagged, space-filling series characteristic of a ranging market with no directional conviction. The FRAMA uses D to compute an adaptive smoothing constant α: when D is low (trending), α is large and the average tracks prices closely; when D is high (choppy), α shrinks and the average barely moves.
To estimate D, FRAMA divides the lookback window into two equal halves. For each half, it computes N — the normalized range, defined as the difference between the highest and lowest price divided by the length of the half-window. It then computes a combined N for the full window. The fractal dimension is estimated as D = (log(N1 + N2) - log(N_full)) / log(2), which is a discrete approximation of the Hausdorff dimension. From D, the adaptive factor is derived as α = exp(-4.6 * (D - 1)), where 4.6 is a scaling constant that maps D into a sensible EMA-style smoothing range. The final FRAMA value is then a standard exponential smoothing step: FRAMA(t) = α * P(t) + (1 - α) * FRAMA(t-1).
The elegance of this approach is that it requires no manual parameter tuning beyond the window length. The market itself determines how reactive the average should be at each moment. You do not need to switch between a fast and slow MA based on regime detection — FRAMA performs that adaptation internally through the fractal geometry of the data.
2. Python Implementation
2.1 Setup and Parameters
The implementation requires four standard libraries. The key tunable parameter is window, which sets the lookback period for fractal dimension estimation. It must be an even number since the window is split into two equal halves. A value of 16 is the conventional default, though values between 10 and 30 are common in practice. The ticker and period parameters control which asset and how much history is fetched.
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
# --- Parameters ---
TICKER = "SPY"
PERIOD = "2y"
WINDOW = 16 # Must be even — split into two equal halves
EMA_WINDOW = 20 # Comparison EMA for the chart
# --- Fetch Data ---
raw = yf.download(TICKER, period=PERIOD, auto_adjust=True, progress=False)
prices = raw["Close"].dropna().squeeze()
print(f"Loaded {len(prices)} trading days for {TICKER}")
2.2 Fractal Dimension Calculation
This is the core of the FRAMA algorithm. For each rolling window of length WINDOW, the function splits prices into a first half and second half, computes the normalized range N for each half, computes N for the full window, and then estimates the fractal dimension D. The result is clipped to the interval [1, 2] to handle edge cases where numerical artifacts push D outside its theoretical bounds.
def compute_fractal_dimension(prices: pd.Series, window: int) -> pd.Series:
"""
Estimate the fractal dimension D for each rolling window.
D is clipped to [1, 2] per the theoretical range for price series.
"""
half = window // 2
D_values = np.full(len(prices), np.nan)
price_arr = prices.values
for i in range(window - 1, len(price_arr)):
segment = price_arr[i - window + 1 : i + 1]
first_half = segment[:half]
second_half = segment[half:]
n1 = (first_half.max() - first_half.min()) / half
n2 = (second_half.max() - second_half.min()) / half
n_full = (segment.max() - segment.min()) / window
if (n1 + n2) > 0 and n_full > 0:
D = (np.log(n1 + n2) - np.log(n_full)) / np.log(2)
D_values[i] = np.clip(D, 1.0, 2.0)
else:
D_values[i] = 1.5 # Neutral fallback when range is flat
return pd.Series(D_values, index=prices.index, name="FractalDimension")
fractal_dim = compute_fractal_dimension(prices, WINDOW)
print(fractal_dim.describe().round(4))
2.3 FRAMA Computation
With the fractal dimension series in hand, the adaptive smoothing factor α is computed at each timestep. The formula α = exp(-4.6 * (D - 1)) maps D=1 to α≈1 (maximum reactivity) and D=2 to α≈0.01 (near-zero reactivity). FRAMA is then applied as a standard exponential smoothing loop, seeded with the first valid price as the initial condition.
def compute_frama(prices: pd.Series, fractal_dim: pd.Series) -> pd.Series:
"""
Apply the adaptive exponential smoothing using the fractal dimension.
Alpha maps D in [1, 2] -> alpha in [~1.0, ~0.01].
"""
alpha = np.exp(-4.6 * (fractal_dim - 1.0))
price_arr = prices.values
alpha_arr = alpha.values
frama_arr = np.full(len(price_arr), np.nan)
# Find the first index where alpha is valid (i.e., D is computed)
first_valid = np.where(~np.isnan(alpha_arr))[0]
if len(first_valid) == 0:
return pd.Series(frama_arr, index=prices.index, name="FRAMA")
start = first_valid[0]
frama_arr[start] = price_arr[start] # Seed with first valid price
for i in range(start + 1, len(price_arr)):
a = alpha_arr[i] if not np.isnan(alpha_arr[i]) else 0.1
frama_arr[i] = a * price_arr[i] + (1 - a) * frama_arr[i - 1]
return pd.Series(frama_arr, index=prices.index, name="FRAMA")
frama = compute_frama(prices, fractal_dim)
# Standard EMA for comparison
ema = prices.ewm(span=EMA_WINDOW, adjust=False).mean()
2.4 Visualization
The chart overlays the raw closing price, FRAMA, and a standard 20-period EMA. Notice how FRAMA hugs the price more tightly during strong directional moves (low D) and flattens out during consolidation phases (high D), while the EMA maintains a fixed lag regardless of market character.
plt.style.use("dark_background")
fig, axes = plt.subplots(2, 1, figsize=(14, 9),
gridspec_kw={"height_ratios": [3, 1]})
# --- Top panel: Price + FRAMA + EMA ---
ax1 = axes[0]
ax1.plot(prices.index, prices.values,
color="#888888", linewidth=0.8, label="Close Price", alpha=0.7)
ax1.plot(frama.index, frama.values,
color="#00BFFF", linewidth=1.8, label=f"FRAMA (window={WINDOW})")
ax1.plot(ema.index, ema.values,
color="#FFA500", linewidth=1.2, linestyle="--",
label=f"EMA ({EMA_WINDOW})", alpha=0.8)
ax1.set_title(f"FRAMA vs EMA — {TICKER}", fontsize=14, pad=12)
ax1.set_ylabel("Price (USD)")
ax1.legend(loc="upper left", fontsize=9)
ax1.grid(alpha=0.15)
# --- Bottom panel: Fractal Dimension ---
ax2 = axes[1]
ax2.plot(fractal_dim.index, fractal_dim.values,
color="#9B59B6", linewidth=1.2, label="Fractal Dimension D")
ax2.axhline(1.5, color="#AAAAAA", linewidth=0.8,
linestyle=":", label="D = 1.5 (neutral)")
ax2.fill_between(fractal_dim.index, 1.0, fractal_dim.values,
where=(fractal_dim < 1.5),
color="#2ECC71", alpha=0.15, label="Trending (D < 1.5)")
ax2.fill_between(fractal_dim.index, fractal_dim.values, 2.0,
where=(fractal_dim > 1.5),
color="#E74C3C", alpha=0.15, label="Choppy (D > 1.5)")
ax2.set_ylabel("Fractal Dimension")
ax2.set_ylim(1.0, 2.0)
ax2.legend(loc="upper left", fontsize=8)
ax2.grid(alpha=0.15)
plt.tight_layout()
plt.savefig("frama_spy.png", dpi=150, bbox_inches="tight")
plt.show()
Figure 1. Two-panel chart showing SPY closing price with FRAMA (blue) and EMA-20 (orange) overlaid on the top panel, and the rolling fractal dimension D on the bottom panel — green shading indicates trending regimes where FRAMA becomes more responsive, red shading indicates choppy regimes where FRAMA dampens its reaction.
Enjoying this strategy so far? This is only a taste of what's possible.
Go deeper with my newsletter: longer, more detailed articles + full Google Colab implementations for every approach.
Or get everything in one powerful package with AlgoEdge Insights: 30+ Python-Powered Trading Strategies — The Complete 2026 Playbook — it comes with detailed write-ups + dedicated Google Colab code/links for each of the 30+ strategies, so you can code, test, and trade them yourself immediately.
Exclusive for readers: 20% off the book with code
MEDIUM20.Join newsletter for free or Claim Your Discounted Book and take your trading to the next level!
3. Results and Analysis
Running this implementation on two years of SPY daily data produces a fractal dimension series that oscillates between approximately 1.2 and 1.85. During the sustained trend periods visible in the chart, D consistently drops below 1.5, and the corresponding α values climb above 0.3 — meaning FRAMA weights recent prices heavily and tracks the trend with minimal lag. During the sideways consolidation phases, D frequently exceeds 1.7, pushing α below 0.05 and causing FRAMA to plateau until a directional move resumes.
The comparison against EMA-20 is instructive. During trending phases, FRAMA and EMA-20 track similarly but FRAMA tends to hug the price more tightly, reducing the lag that often causes late signals in EMA-based crossover strategies. During choppy phases, however, the difference becomes stark: EMA-20 oscillates with the noise, while FRAMA holds its level and avoids the whipsaw entries that plague fixed-window averages in ranging markets.
One nuance worth noting is the sensitivity to the window parameter. A window of 16 strikes a reasonable balance, but shortening it to 10 makes D more volatile and produces more frequent α spikes, occasionally overreacting to short-lived impulse candles. Widening to 26 or 32 produces a smoother D series but reduces FRAMA's responsiveness during early trend formation. Practitioners typically test across the 12–24 range and select based on the target holding period.
4. Use Cases
Trend-following signal generation: Use FRAMA crossovers (price crossing FRAMA from below/above) as entry and exit signals. Because FRAMA adapts to regime, it generates fewer false crossovers during ranging periods compared to a fixed EMA.
Dynamic stop-loss anchoring: Replace a fixed trailing stop with a FRAMA-based stop by placing the stop at the FRAMA level itself. The stop tightens automatically during trending conditions and widens during consolidation, reducing unnecessary stop-outs.
Regime classification input: Feed the fractal dimension D directly into a regime detection model. D < 1.4 can be labeled "trend," D > 1.6 labeled "range," and the middle band treated as transitional. This provides a model-free, parameter-light regime signal.
Multi-asset screening: Compute FRAMA across a universe of stocks or ETFs and rank by the current α value. High-α assets are in trending regimes and may warrant larger position sizes in a momentum framework; low-α assets are ranging and may be candidates for mean-reversion strategies.
5. Limitations and Edge Cases
Flat price segments cause division by zero. When the high and low within a window are identical (e.g., halted trading, stale data), the N values collapse to zero and the fractal dimension is undefined. The implementation above defaults to D=1.5 in this case, but you should log and review these occurrences in production.
Short windows inflate D variance. With
WINDOW=10or below, the half-windows contain only five data points each, making the range estimates highly sensitive to individual outlier candles. The fractal dimension can spike artifactually and produce erratic α values. A minimum of 12–14 is advisable.Initialization requires a warm-up period. The first
WINDOW - 1bars produce no FRAMA output. For a window of 16, you lose the first 15 rows. When backtesting strategies with limited historical data, this burn-in period can meaningfully reduce the usable sample.FRAMA does not predict — it reacts. Like all moving averages, FRAMA is a lagging indicator even in its most aggressive (low-D) configuration. It describes the recent structure of the market rather than forecasting future direction, and should be combined with leading indicators or price action context for signal confirmation.
The scaling constant 4.6 is not universal. The exponential mapping
exp(-4.6 * (D - 1))was calibrated empirically by John Ehlers for daily equity data. Assets with different microstructure characteristics — high-frequency data, illiquid small-caps, crypto — may benefit from recalibrating this constant through walk-forward optimization.
Concluding Thoughts
FRAMA represents a genuinely useful evolution of the simple exponential moving average. By grounding the adaptive smoothing in fractal geometry rather than arbitrary fast/slow parameter switching, it produces a moving average that responds to market structure rather than a fixed calendar window. The Python implementation presented here is fully self-contained, runs on any liquid ticker available through yfinance, and can be dropped into a larger backtesting pipeline with minimal modification.
The most productive next step is to treat the fractal dimension series D as a standalone signal rather than an intermediate calculation. Plotting D alone across a range of assets and timeframes will quickly build intuition for what trending versus ranging markets look like through this lens. From there, building a simple crossover strategy and comparing its equity curve against an EMA equivalent is a natural experiment that tends to reveal FRAMA's advantages clearly in medium-frequency equity data.
Future articles in this series cover additional adaptive and weighted moving average methods including the Least Squares Moving Average, Regularized Exponential Moving Average, and Elastic Volume Weighted Moving Average. Each adds a distinct dimension — statistical regression, regularization, and volume weighting respectively — to the toolkit started here. Subscribe to follow the full series as it publishes.
Most algo trading content gives you theory.
This gives you the code.3 Python strategies. Fully backtested. Colab notebook included.
Plus a free ebook with 5 more strategies the moment you subscribe.5,000 quant traders already run these:
Subscribe | AlgoEdge Insights


Top comments (0)