DEV Community

Ayrat Murtazin
Ayrat Murtazin

Posted on

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

Most trend-following systems apply a fixed lookback window — a 20-day moving average, a 50-day exponential smoother — uniformly across all market conditions. The problem is that volatility regimes change, momentum cycles compress and expand, and a static window that works in trending markets bleeds badly during choppy ones. Adaptive local linear regression addresses this by dynamically adjusting the bandwidth of the regression kernel based on local price behavior, fitting the trend estimate to the data rather than forcing the data into a predetermined frame.

In this article, we implement an adaptive local linear regression trend-following strategy applied specifically to growth stocks — securities where momentum is structurally more persistent but also more violently mean-reverting when it breaks. We will build the full signal pipeline from scratch: kernel-weighted regression, bandwidth selection via local volatility scaling, entry and exit logic, and a vectorized backtest with performance metrics. Everything 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 Growth Stock Trend-Following in Python

This article covers:

  • Section 1 — Core Concept:** What local linear regression is, how adaptive bandwidth works, and why growth stocks are the right testing ground
  • Section 2 — Python Implementation:** Full pipeline from data fetch through signal generation, backtest, and visualization (Sections 2.1–2.4)
  • Section 3 — Results and Analysis:** What the strategy produces, how to read the performance output, and realistic expectations
  • Section 4 — Use Cases:** Where this technique fits in a broader quant workflow
  • Section 5 — Limitations and Edge Cases:** Where the model breaks down and what to watch for

1. Local Linear Regression as an Adaptive Trend Filter

A simple moving average treats every observation in its window equally — the price from 19 days ago carries the same weight as yesterday's close. A linear regression over a rolling window is slightly better: it fits a slope through recent prices, giving you both a trend level and a rate of change. But both approaches share the same structural flaw — the window is fixed, and the market does not care about your window.

Local linear regression (LLR) solves this with a kernel function. Instead of equal weights, each observation receives a weight that decays with distance from the current point. The most common choice is a Gaussian or Epanechnikov kernel. The fitted value at any point is the intercept of a weighted least-squares regression, where nearby observations dominate and distant ones fade. The key parameter is bandwidth — the width of the kernel, which controls how local or global the fit is.

Adaptive bandwidth takes this one step further. Rather than setting bandwidth to a fixed constant, you tie it to a local measure of variability — typically realized volatility over a short rolling window. In high-volatility regimes the bandwidth widens, smoothing over noise. In low-volatility, trending regimes it narrows, making the filter more responsive. The result is a trend estimate that breathes with the market rather than fighting it. Conceptually, it is similar to how a human trader instinctively ignores small fluctuations during a choppy consolidation but pays close attention to each tick when price is breaking out cleanly.

Growth stocks amplify every characteristic that makes this technique interesting. Their trends are steeper, their volatility is higher, and their regime changes are sharper. A fixed-window system applied to a basket of high-beta growth names will over-smooth the entries in trending phases and over-trade the noise in consolidation. Adaptive LLR is structurally well-suited to this behavior — the bandwidth automatically compresses when a stock is trending cleanly and expands when it enters a volatile, directionless phase.

2. Python Implementation

2.1 Setup and Parameters

The strategy has four key parameters. BANDWIDTH_BASE sets the baseline kernel width in days. VOL_LOOKBACK controls the rolling window for computing local realized volatility used in the adaptive scaling. VOL_SCALE determines how aggressively bandwidth responds to volatility changes — higher values produce wider smoothing in noisy regimes. SIGNAL_THRESHOLD is the minimum normalized slope required to trigger a long entry.

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

# --- Strategy Parameters ---
TICKERS        = ["QQQ", "ARKK", "SOXX", "IGV", "WCLD"]  # growth proxies
START_DATE     = "2020-01-01"
END_DATE       = "2024-12-31"
BANDWIDTH_BASE = 15       # baseline kernel bandwidth (days)
VOL_LOOKBACK   = 21       # rolling window for realized vol estimate
VOL_SCALE      = 2.0      # sensitivity of bandwidth to local volatility
SIGNAL_THRESHOLD = 0.003  # minimum daily slope (normalized) to go long
TRANSACTION_COST = 0.001  # one-way cost per trade (0.1%)
Enter fullscreen mode Exit fullscreen mode

Implementation chart

2.2 Adaptive Local Linear Regression Signal Engine

This function computes the LLR fitted value and slope at every point in a price series. For each index position, it builds a Gaussian kernel centered at that point with a bandwidth scaled by local volatility — wider when the recent vol is high, narrower when conditions are calm. It then solves a weighted least-squares problem to extract the local slope, which becomes the raw trend signal.

def adaptive_llr_signals(prices: pd.Series,
                         bandwidth_base: int = BANDWIDTH_BASE,
                         vol_lookback: int = VOL_LOOKBACK,
                         vol_scale: float = VOL_SCALE) -> pd.DataFrame:
    """
    Compute adaptive local linear regression fitted values and slopes.
    Returns a DataFrame with columns: ['fitted', 'slope', 'bandwidth'].
    """
    log_prices = np.log(prices.values)
    n = len(log_prices)
    t = np.arange(n, dtype=float)

    # Realized volatility (rolling std of log returns)
    log_returns = np.diff(log_prices, prepend=log_prices[0])
    vol_series  = (pd.Series(log_returns)
                   .rolling(vol_lookback, min_periods=5)
                   .std()
                   .fillna(method="bfill")
                   .values)

    # Normalize vol to get a bandwidth multiplier centered at 1.0
    vol_mean      = vol_series.mean()
    vol_norm      = vol_series / (vol_mean + 1e-10)
    bandwidths    = bandwidth_base * (1 + vol_scale * (vol_norm - 1))
    bandwidths    = np.clip(bandwidths, bandwidth_base * 0.3,
                            bandwidth_base * 3.0)

    fitted    = np.zeros(n)
    slopes    = np.zeros(n)

    for i in range(n):
        h   = bandwidths[i]
        u   = (t - i) / h
        w   = np.exp(-0.5 * u ** 2)        # Gaussian kernel weights
        w  /= w.sum()

        W   = np.diag(w)
        X   = np.column_stack([np.ones(n), t - i])   # [intercept, time]
        XtW = X.T @ W
        try:
            coeffs, _, _, _ = lstsq(XtW @ X, XtW @ log_prices)
            fitted[i]  = coeffs[0]
            slopes[i]  = coeffs[1]          # daily log-return slope
        except Exception:
            fitted[i]  = log_prices[i]
            slopes[i]  = 0.0

    return pd.DataFrame({
        "fitted":    fitted,
        "slope":     slopes,
        "bandwidth": bandwidths
    }, index=prices.index)
Enter fullscreen mode Exit fullscreen mode

2.3 Backtest Engine with Signal-to-Position Logic

The backtest converts slopes into binary positions. A long signal fires when the normalized slope exceeds SIGNAL_THRESHOLD. Positions are held until the slope falls below zero — no partial sizing, no leverage. Transaction costs are applied on every position change. We compute cumulative returns, maximum drawdown, and a Sharpe ratio for each ticker.

def run_backtest(ticker: str) -> dict:
    raw  = yf.download(ticker, start=START_DATE, end=END_DATE,
                       auto_adjust=True, progress=False)
    px   = raw["Close"].squeeze().dropna()

    sig_df   = adaptive_llr_signals(px)
    slope    = sig_df["slope"]

    # Binary long/flat position
    position = (slope > SIGNAL_THRESHOLD).astype(float)
    position = position.shift(1).fillna(0)   # avoid look-ahead

    daily_ret      = px.pct_change().fillna(0)
    trade_flag     = position.diff().abs().fillna(0)
    strategy_ret   = position * daily_ret - trade_flag * TRANSACTION_COST

    cum_strategy   = (1 + strategy_ret).cumprod()
    cum_bh         = (1 + daily_ret).cumprod()

    # Sharpe (annualized, risk-free = 0 for simplicity)
    sharpe = (strategy_ret.mean() / strategy_ret.std()) * np.sqrt(252)

    # Max drawdown
    roll_max   = cum_strategy.cummax()
    drawdown   = (cum_strategy - roll_max) / roll_max
    max_dd     = drawdown.min()

    return {
        "ticker":       ticker,
        "prices":       px,
        "signals":      sig_df,
        "position":     position,
        "strat_ret":    cum_strategy,
        "bh_ret":       cum_bh,
        "sharpe":       sharpe,
        "max_dd":       max_dd,
        "total_return": cum_strategy.iloc[-1] - 1
    }

results = {t: run_backtest(t) for t in TICKERS}
Enter fullscreen mode Exit fullscreen mode

2.4 Visualization

The chart overlays the raw price, the adaptive LLR fitted curve, and shaded long-signal regions for the selected ticker. The second panel plots the local slope with a dashed threshold line, making it easy to see exactly when and why positions are entered and exited.

def plot_strategy(result: dict, ticker: str):
    px     = result["prices"]
    fitted = np.exp(result["signals"]["fitted"])
    slope  = result["signals"]["slope"]
    pos    = result["position"]

    fig, axes = plt.subplots(3, 1, figsize=(14, 10),
                              gridspec_kw={"height_ratios": [3, 1.5, 1.5]})
    plt.style.use("dark_background")
    fig.patch.set_facecolor("#0d0d0d")
    for ax in axes:
        ax.set_facecolor("#0d0d0d")

    # Panel 1 — Price and fitted trend
    axes[0].plot(px.index, px.values, color="#4a90d9", lw=1.0,
                 alpha=0.7, label="Price")
    axes[0].plot(px.index, fitted, color="#f5a623", lw=1.8,
                 label="Adaptive LLR Fit")
    axes[0].fill_between(px.index, px.values.min(), px.values,
                         where=(pos > 0), alpha=0.12,
                         color="#7ed321", label="Long Signal")
    axes[0].set_title(f"{ticker} — Adaptive LLR Trend Filter",
                      color="white", fontsize=13)
    axes[0].legend(loc="upper left", fontsize=9)
    axes[0].set_ylabel("Price (USD)", color="white")

    # Panel 2 — Local slope
    axes[1].plot(slope.index, slope.values, color="#bd10e0", lw=1.2)
    axes[1].axhline(SIGNAL_THRESHOLD, color="#f5a623", ls="--",
                    lw=0.9, label=f"Entry threshold ({SIGNAL_THRESHOLD})")
    axes[1].axhline(0, color="white", ls=":", lw=0.6, alpha=0.5)
    axes[1].set_ylabel("LLR Slope", color="white")
    axes[1].legend(loc="upper left", fontsize=9)

    # Panel 3 — Cumulative returns
    axes[2].plot(result["strat_ret"].index, result["strat_ret"].values,
                 color="#7ed321", lw=1.5, label="Strategy")
    axes[2].plot(result["bh_ret"].index, result["bh_ret"].values,
                 color="#4a90d9", lw=1.0, alpha=0.7, label="Buy & Hold")
    axes[2].set_ylabel("Cum. Return", color="white")
    axes[2].legend(loc="upper left", fontsize=9)

    for ax in axes:
        ax.tick_params(colors="white")
        for spine in ax.spines.values():
            spine.set_edgecolor("#333333")

    plt.tight_layout()
    plt.savefig(f"{ticker}_adaptive_llr.png", dpi=150,
                bbox_inches="tight", facecolor="#0d0d0d")
    plt.show()

plot_strategy(results["QQQ"], "QQQ")

# Print summary table
print(f"\n{'Ticker':<8}{'Total Return':>14}{'Sharpe':>10}{'Max DD':>10}")
print("-" * 44)
for t, r in results.items():
    print(f"{t:<8}{r['total_return']:>13.1%}{r['sharpe']:>10.2f}"
          f"{r['max_dd']:>10.1%}")
Enter fullscreen mode Exit fullscreen mode

Figure 1. QQQ price overlaid with the adaptive LLR trend estimate (orange), long signal regions shaded in green, local slope in the middle panel, and cumulative strategy vs. buy-and-hold in the bottom panel — the slope panel reveals the exact entry and exit logic at each regime transition.


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 Analysis

Running this pipeline across QQQ, ARKK, SOXX, IGV, and WCLD over 2020–2024 produces a clear bifurcation. On trending instruments — QQQ and SOXX in particular — the adaptive bandwidth consistently compresses during clean directional moves, making the slope signal responsive enough to catch momentum legs early. On high-volatility names like ARKK, the bandwidth widens during the 2021–2022 drawdown cycle, which correctly suppresses false entries during the violent whipsaws of that period.

The Sharpe ratio advantage over a naive buy-and-hold varies by ticker but tends to be most pronounced on the semiconductor and software ETFs, where sector momentum has historically been autocorrelated over 10–30 day windows. The strategy is not designed to beat buy-and-hold on total return in a persistent bull run — it will miss portions of extended uptrends during re-entry delays. What it does provide is a materially better return-per-unit-of-risk profile and significantly smaller maximum drawdown, because the flat position during negative slope regimes keeps the portfolio out of the worst decline phases.

The slope threshold parameter (SIGNAL_THRESHOLD = 0.003) is the most sensitive tuning variable. Set it too low and the strategy over-trades sideways markets. Set it too high and it misses the early phase of genuine breakouts. A practical calibration approach is to run the backtest across a grid of threshold values and select the one that maximizes Sharpe on an out-of-sample validation window — never on the full in-sample period.

4. Use Cases

  • Portfolio overlay signal: The LLR slope can serve as a regime filter on top of an existing long-only equity strategy — reduce gross exposure when slope turns negative across a basket of growth names, increase it when slope is positive and rising.

  • Sector rotation timing: Apply the pipeline to sector ETFs (XLK, SOXX, IGV, WCLD) and rotate capital toward whichever sector shows the strongest positive slope, rebalancing weekly.

  • Volatility-adjusted position sizing: Rather than using slope as a binary on/off signal, normalize it by the current bandwidth value to get a continuous signal strength score that can drive fractional position sizing.

  • Feature for ML models: The LLR slope and bandwidth time series are high-quality engineered features for gradient boosting or LSTM models predicting short-term forward returns on growth stocks, capturing both trend direction and regime confidence in two numbers.

5. Limitations and Edge Cases

Computational cost scales quadratically. The naive implementation loops over every index point and solves a full weighted least-squares problem. For a 1,000-day series it runs in seconds; for tick-level data or very large universes, you need either an approximate kernel solution or a fast Cython/Numba rewrite of the inner loop.

Bandwidth calibration is regime-dependent. The BANDWIDTH_BASE and VOL_SCALE parameters were not optimized — they were set by intuition and light experimentation. A parameter sweep on in-sample data is required before live deployment, and the resulting parameters should be validated on a held-out period before trusting the Sharpe numbers.

Survivorship bias in the ticker selection. QQQ, SOXX, and IGV are diversified ETFs that have survived and grown. Applying this same methodology to individual growth stocks introduces survivorship bias unless you source point-in-time constituent data.

Gap risk on binary signals. The strategy enters at the next open after a signal fires. Overnight gaps in growth stocks — particularly around earnings — can produce fill prices far from the theoretical entry. Realistic slippage should be modeled as at least 2–3x the TRANSACTION_COST constant used here.

No short-side implementation. The current design is long/flat only. Growth stocks in downtrends can trend persistently on the short side, and the slope signal would technically support a short entry. Adding shorting introduces borrow costs, margin requirements, and behavioral risk that are not captured in this backtest.

Concluding Thoughts

Adaptive local linear regression offers a principled, mathematically grounded alternative to fixed-window momentum indicators. By tying the kernel bandwidth to local realized volatility, the filter naturally becomes more conservative in noisy regimes and more aggressive in trending ones — exactly the behavior a discretionary trader tries to replicate manually. The slope output is clean, interpretable, and directly tradeable as both a binary signal and a continuous feature.

The most productive next experiments are: replacing the Gaussian kernel with an Epanechnikov kernel for better edge behavior, adding a second signal layer based on the second derivative of the fitted curve (acceleration), and testing the pipeline on a larger universe of individual growth stocks with proper point-in-time data. Each of those extensions builds directly on the framework coded here.

If you found this useful, the full annotated Colab notebook — with interactive charts, parameter sweep grid, and out-of-sample validation — is available to premium subscribers at AlgoEdge Insights. New strategy notebooks are published weekly, covering everything from volatility surface modeling to cross-sectional momentum systems.


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)