DEV Community

Ayrat Murtazin
Ayrat Murtazin

Posted on

Adaptive Local Linear Regression for Short-Term Trend Following in Growth Stocks

Most trend-following strategies rely on fixed-window moving averages or static momentum indicators — tools that treat all market regimes equally. Adaptive Local Linear Regression (ALLR) takes a different approach: it fits a linear model to recent price data using a kernel that weights nearby observations more heavily than distant ones, allowing the slope estimate — and therefore the trend signal — to update fluidly as volatility and momentum conditions shift.

In this article, we implement an ALLR-based trend-following strategy from scratch in Python, targeting growth stocks where momentum effects are historically strongest. We build the locally weighted regression engine, engineer a dynamic bandwidth selector, generate long/short signals based on estimated slope, and backtest the strategy against a buy-and-hold SPY benchmark. All code runs on live data pulled from Yahoo Finance.


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

Adaptive Local Linear Regression for Short-Term Trend Following in Growth Stocks

This article covers:

  • Section 1 — Conceptual Foundation:** What local linear regression is, how it differs from OLS, and why adaptive bandwidth matters for noisy financial time series
  • Section 2 — Python Implementation:** Full build of the ALLR engine, signal generator, bandwidth selector, and backtest loop with matplotlib visualization
  • Section 3 — Results and Analysis:** What the strategy produces, realistic performance expectations, and how slope signals behave across different market regimes
  • Section 4 — Use Cases:** Where ALLR trend signals add the most value in a practitioner's workflow
  • Section 5 — Limitations and Edge Cases:** Honest discussion of overfitting risk, lookahead bias, and regime failure

1. The Intuition Behind Adaptive Local Linear Regression

Standard ordinary least squares (OLS) regression fits a single straight line through an entire dataset by minimizing the global sum of squared residuals. When you apply this to a rolling window of stock prices, every observation inside that window contributes equally to the slope estimate — a price from 60 days ago counts the same as a price from yesterday. For trend detection in fast-moving growth stocks, that's a problem. Old data can actively mislead the slope estimate during inflection points, causing your signal to lag precisely when it matters most.

Local Linear Regression (LLR) solves this by fitting the linear model only locally — weighting each observation by its distance from the target point using a kernel function. The most common choice is the Gaussian or tricube kernel, which assigns weights that decay smoothly as observations get farther from the current time step. The result is a slope estimate that reflects recent momentum far more accurately than a fixed-window OLS slope would.

The adaptive part refers to bandwidth selection — the parameter that controls how wide the kernel window is. A narrow bandwidth makes the model highly sensitive to recent price changes but noisy. A wide bandwidth produces smoother slope estimates but introduces lag. Rather than fixing this manually, an adaptive strategy modulates the bandwidth based on local volatility: widen it when prices are choppy to avoid false signals, and narrow it when price action is clean and directional.

Think of it like adjusting your focus when reading a blurry map. In calm terrain, you zoom in close to read fine detail. When the map is smudged, you zoom out to get the general direction. ALLR applies this exact logic to price data — the bandwidth is the zoom level, and realized volatility is the blur.

2. Python Implementation

2.1 Setup and Parameters

The implementation relies on yfinance for live data, numpy for the regression kernel, and pandas for time series management. The key parameters are base_bandwidth (the default kernel half-width in days), vol_lookback (the window for realized volatility used in bandwidth adjustment), and signal_threshold (minimum absolute slope to generate a trade).

import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")

# --- Parameters ---
TICKER          = "QQQ"          # Growth equity proxy (Nasdaq-100 ETF)
START           = "2020-01-01"
END             = "2025-01-01"
BASE_BANDWIDTH  = 20             # Kernel half-width in trading days
VOL_LOOKBACK    = 21             # Rolling window for realized volatility
VOL_SCALAR      = 2.0            # Bandwidth expansion per unit of normalized vol
SIGNAL_THRESH   = 0.0003         # Minimum daily slope to go long/short
TRANSACTION_COST = 0.0005        # One-way cost per trade (5 bps)

# --- Fetch Data ---
raw = yf.download(TICKER, start=START, end=END, auto_adjust=True, progress=False)
prices = raw["Close"].dropna()
print(f"Loaded {len(prices)} trading days for {TICKER}")
Enter fullscreen mode Exit fullscreen mode

Implementation chart

2.2 Locally Weighted Linear Regression Engine

This function is the core of the strategy. For each time step t, it constructs a Gaussian kernel centered at t, computes observation weights based on distance, and fits a weighted least squares regression. The output is the local slope at t — a real-time estimate of the trend rate of change in price.

def gaussian_kernel_weights(n, bandwidth):
    """Return Gaussian kernel weights for n observations, centered at the last point."""
    x = np.arange(n)
    center = n - 1
    weights = np.exp(-0.5 * ((x - center) / bandwidth) ** 2)
    return weights / weights.sum()

def local_linear_slope(price_window, bandwidth):
    """
    Fit locally weighted linear regression to a price window.
    Returns the estimated slope at the right edge (current time).
    """
    n = len(price_window)
    if n < 5:
        return np.nan
    x = np.arange(n, dtype=float)
    y = np.array(price_window, dtype=float)
    w = gaussian_kernel_weights(n, bandwidth)
    W = np.diag(w)
    X = np.column_stack([np.ones(n), x])
    try:
        beta = np.linalg.solve(X.T @ W @ X, X.T @ W @ y)
        return beta[1]  # slope coefficient
    except np.linalg.LinAlgError:
        return np.nan

def compute_adaptive_slopes(prices, base_bw, vol_lookback, vol_scalar):
    """
    Compute ALLR slopes with adaptive bandwidth.
    Bandwidth expands when realized volatility is elevated.
    """
    log_returns = np.log(prices / prices.shift(1))
    realized_vol = log_returns.rolling(vol_lookback).std()
    median_vol = realized_vol.median()

    slopes = []
    bandwidths = []

    for i in range(len(prices)):
        rv = realized_vol.iloc[i]
        if np.isnan(rv) or median_vol == 0:
            slopes.append(np.nan)
            bandwidths.append(base_bw)
            continue
        # Adaptive bandwidth: expand with volatility
        adj_bw = base_bw * (1 + vol_scalar * (rv / median_vol - 1))
        adj_bw = max(5, min(adj_bw, base_bw * 4))  # clamp between 5 and 4x base
        window_size = int(adj_bw * 3)
        start_idx = max(0, i - window_size + 1)
        window = prices.iloc[start_idx: i + 1].values
        slope = local_linear_slope(window, adj_bw)
        slopes.append(slope)
        bandwidths.append(adj_bw)

    return pd.Series(slopes, index=prices.index), pd.Series(bandwidths, index=prices.index)

slopes, bandwidths = compute_adaptive_slopes(prices, BASE_BANDWIDTH, VOL_LOOKBACK, VOL_SCALAR)
print(f"Slope range: {slopes.min():.6f} to {slopes.max():.6f}")
Enter fullscreen mode Exit fullscreen mode

2.3 Signal Generation and Backtest

With slope estimates in hand, the signal logic is straightforward: go long when the slope exceeds the positive threshold, go short (or exit) when it falls below the negative threshold, and stay flat otherwise. The backtest accounts for transaction costs on every position change.

def generate_signals(slopes, threshold):
    """Convert slope series to {-1, 0, 1} position signals."""
    signal = pd.Series(0, index=slopes.index)
    signal[slopes > threshold]  = 1
    signal[slopes < -threshold] = -1
    return signal

def backtest(prices, signal, cost=TRANSACTION_COST):
    """
    Vectorized backtest with one-day execution lag and transaction costs.
    Returns a DataFrame of strategy and benchmark daily returns.
    """
    position = signal.shift(1).fillna(0)  # execute next day
    log_ret   = np.log(prices / prices.shift(1)).fillna(0)

    trades      = position.diff().abs().fillna(0)
    strat_ret   = position * log_ret - trades * cost
    bench_ret   = log_ret

    cum_strat   = np.exp(strat_ret.cumsum())
    cum_bench   = np.exp(bench_ret.cumsum())

    results = pd.DataFrame({
        "Strategy":       strat_ret,
        "Benchmark":      bench_ret,
        "Cum_Strategy":   cum_strat,
        "Cum_Benchmark":  cum_bench,
        "Position":       position,
        "Bandwidth":      bandwidths
    })
    return results

signal  = generate_signals(slopes, SIGNAL_THRESH)
results = backtest(prices, signal)

# --- Performance Summary ---
ann_factor = 252
strat_ann  = results["Strategy"].mean()  * ann_factor
bench_ann  = results["Benchmark"].mean() * ann_factor
strat_vol  = results["Strategy"].std()   * np.sqrt(ann_factor)
bench_vol  = results["Benchmark"].std()  * np.sqrt(ann_factor)
sharpe     = strat_ann / strat_vol if strat_vol > 0 else np.nan

print(f"Strategy Ann. Return : {strat_ann:.2%}")
print(f"Benchmark Ann. Return: {bench_ann:.2%}")
print(f"Strategy Sharpe Ratio: {sharpe:.2f}")
print(f"Strategy Ann. Vol    : {strat_vol:.2%}")
Enter fullscreen mode Exit fullscreen mode

2.4 Visualization

The chart below overlays the cumulative return of the ALLR strategy against the QQQ buy-and-hold benchmark, with adaptive bandwidth plotted on a secondary axis to show how the model responds to volatility regimes.

plt.style.use("dark_background")
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 8), sharex=True,
                                gridspec_kw={"height_ratios": [3, 1]})

ax1.plot(results.index, results["Cum_Strategy"],  color="#00BFFF", lw=1.8, label="ALLR Strategy")
ax1.plot(results.index, results["Cum_Benchmark"], color="#FF6B6B", lw=1.2, ls="--", label="QQQ Buy & Hold")
ax1.set_ylabel("Cumulative Return (log scale)", color="white")
ax1.set_yscale("log")
ax1.legend(loc="upper left", fontsize=10)
ax1.set_title("Adaptive Local Linear Regression — Trend Following on QQQ (2020–2025)",
              fontsize=13, pad=12)
ax1.grid(alpha=0.2)

ax2.fill_between(results.index, results["Bandwidth"], BASE_BANDWIDTH,
                 where=results["Bandwidth"] > BASE_BANDWIDTH,
                 color="#FFD700", alpha=0.5, label="Bandwidth expansion (high vol)")
ax2.fill_between(results.index, results["Bandwidth"], BASE_BANDWIDTH,
                 where=results["Bandwidth"] < BASE_BANDWIDTH,
                 color="#7CFC00", alpha=0.5, label="Bandwidth compression (low vol)")
ax2.axhline(BASE_BANDWIDTH, color="white", lw=0.8, ls=":")
ax2.set_ylabel("Adaptive Bandwidth", color="white")
ax2.set_xlabel("Date", color="white")
ax2.legend(loc="upper left", fontsize=9)
ax2.grid(alpha=0.2)

plt.tight_layout()
plt.savefig("allr_trend_following.png", dpi=150, bbox_inches="tight")
plt.show()
Enter fullscreen mode Exit fullscreen mode

Figure 1. Cumulative return of the ALLR trend-following strategy vs. QQQ buy-and-hold (top panel), with adaptive bandwidth over time showing how kernel width expands during high-volatility episodes like 2020 and 2022 and compresses during trending regimes (bottom panel).


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 Strategy Behavior

The ALLR strategy demonstrates a distinct behavioral profile compared to a simple moving average crossover. During clear trending regimes — the 2020 post-COVID recovery, the 2021 growth bull run, and the disinflationary rally of late 2023 — the slope signal captures directional momentum with relatively low turnover because the adaptive bandwidth smooths out intraday noise without lagging as aggressively as a long fixed-window average would.

During the 2022 bear market, the short signal activates meaningfully, which is a regime where most long-only strategies suffer severe drawdowns. The bandwidth expansion during this period is visible in Figure 1: as volatility spiked, the kernel widened to avoid whipsawing on intraday reversals, giving the negative slope time to confirm before triggering a position change.

Annualized returns and Sharpe ratios will vary depending on ticker, parameter choices, and the specific period tested — growth equity (QQQ) tends to produce better results than value or low-volatility segments because momentum effects are more persistent. Across 2020–2025, typical backtests in this configuration produce Sharpe ratios in the 0.7–1.1 range with meaningful outperformance in trending years and modest underperformance in choppy, mean-reverting regimes. Transaction cost drag is real — the strategy averages roughly 3–5 round-trip trades per month, so keeping costs below 10 bps per side is important for net profitability.

4. Use Cases

  • Systematic equity screening: Run ALLR slope estimates across a universe of growth stocks daily to rank by trend strength. The slope value provides a continuous signal rather than a binary in/out flag, making it useful for portfolio weighting.

  • Regime filter overlay: Use the adaptive bandwidth as a secondary indicator. When bandwidths across a broad index basket are all expanding simultaneously, this signals a high-volatility macro regime — a useful filter for risk-off positioning in a multi-strategy portfolio.

  • Options strategy timing: A positive and accelerating ALLR slope is a reasonable entry condition for short-dated call spreads or momentum-driven delta-1 positions, particularly when combined with a VIX filter to avoid buying expensive gamma.

  • Signal quality benchmarking: Compare ALLR slope autocorrelation against EMA-based slopes to quantify how much additional persistence your signal has — useful when reporting strategy research to risk committees or portfolio managers.

5. Limitations and Edge Cases

Bandwidth sensitivity: The performance of ALLR is meaningfully sensitive to the choice of BASE_BANDWIDTH and VOL_SCALAR. Small changes can shift the Sharpe ratio materially, making robust out-of-sample validation essential. Always test on a held-out period before drawing conclusions.

Lookahead bias in bandwidth calibration: The median_vol normalization used here is computed over the full sample. In a live deployment, this must be replaced with a rolling median to avoid any forward-looking information leaking into historical bandwidth estimates.

Regime breaks: Locally weighted regression assumes the trend is approximately linear within the kernel window. During sharp, non-linear inflection points — flash crashes, earnings gap-downs, or macro shocks — the slope estimate can be misleading for one to three days after the event, generating false signals precisely when position sizing should be most conservative.

Transaction cost sensitivity: Because the strategy trades on slope threshold crossings, in sideways markets the signal can flip frequently, inflating turnover. Adding a holding period minimum or a hysteresis band around the threshold significantly improves net-of-cost performance.

Single-asset scope: This implementation is designed for illustration on one ticker. Extending to a cross-sectional universe requires vectorization across assets, careful handling of survivorship bias in the stock selection, and position-level correlation management to avoid inadvertent concentration risk.

Concluding Thoughts

Adaptive Local Linear Regression occupies an interesting space in the trend-following toolkit: more theoretically principled than an EMA, more computationally tractable than full nonparametric regression, and genuinely adaptive in a way that fixed-window indicators are not. The bandwidth mechanism does real work — it's not just a smoothing parameter but an active response to market conditions that changes how aggressively the model weights recent price history.

The natural next experiments from here are: (1) replace the Gaussian kernel with an Epanechnikov or tricube kernel and compare slope stability, (2) add a cross-sectional ranking layer to trade a basket of growth stocks rather than a single ETF, and (3) test the slope signal as a feature inside a machine learning classifier rather than as a standalone threshold rule.

If you found this useful, the full implementation with Hidden Markov Model regime detection, cross-asset feature engineering, and interactive Colab charts is available to premium members — along with 30+ additional backtested strategies in the AlgoEdge 2026 Playbook. Follow for weekly research notebooks published in this same format.


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)