TradingView strategy research

Residual momentum: factor regression before ranking

Residual momentum ranks assets by past returns after adjusting for specified common factors, usually market, size and value, and scaling the adjusted returns by their volatility. The exact regression window, intercept treatment and data vintage determine the score. A raw-return winner can rank last after adjustment. Below, three invented assets, twelve monthly input rows and executable Python distinguish the published construction from a causal rolling OLS-error variant.

See the decision guide

What the original residual momentum study specifies

Blitz, Huij and Martens (2011), Equation (1) and Section 3, fit monthly excess stock returns to an intercept and the Fama-French market, size and value factors. For holding month t, the estimation window is t-36 through t-1, with complete histories required. Formation uses eleven months, t-12 through t-2, skipping t-1. The adjusted formation returns are standardized by their dispersion over that same formation period; stocks enter equal-weight top and bottom deciles.

  • The paper explicitly excludes estimated alpha from the momentum calculation. Its following explanation warns that subtracting an intercept influenced by much older returns would mix long-term reversal into the signal. In the notation below, this is the factor-only adjustment u = y - beta·F = alpha + e, while the full OLS error is e = y - alpha - beta·F. Neither quantity is itself a portfolio alpha.
  • The paper uses one formation-date regression to reconstruct its historical formation returns. That reconstruction is available at that formation date, not at each earlier observation date. Its holding-period and universe rules are also part of the research specification. The short dataset below is a mechanism comparison, not a replication of its empirical results.

Declare the model, units and intercept convention

Write y(i,s) = R(i,s) - RF(s) and y = alpha + betaM × (Mkt-RF) + betaS × SMB + betaH × HML + e. R is a monthly USD simple total return, including distributions. RF is a matched monthly risk-free return, not an annual Treasury yield. Mkt-RF is already an excess return; SMB and HML are return spreads. Do not subtract RF from those factors again.

  • For real US equity research, Kenneth French documents Mkt-RF, SMB, HML and RF. Archive the actual downloaded vintage and release timestamp. A file downloaded today does not establish what was available at a past rebalance. Changing factors, geography, dividends or return units changes the experiment.
QuantityDefinition used here
FactorsExactly Mkt-RF, SMB and HML. No momentum factor is added after inspecting results.
Input scaleCSV numbers are percent per month: 1.00 means 1%, converted to 0.01 once. All regression calculations use decimals.
OLS interceptEstimate alpha alongside three slopes. Alpha is a fitted constant for a window; an individual error e is an observation-specific deviation.
Standardized accumulationS(z) = sum(z) / sample_sd(z), with ddof = 1 over the same formation months. Do not demean the numerator: that would sum to zero.
Alternative display scalemean(z) / sd(z) = S(z) / n. With the same n for every asset it changes the numerical score, not its ranking. No annualization is applied.
Raw comparisonCompound the same total-return months: product(1 + R) - 1. Do not compound regression errors as if they were an investable price series.

The exact clocks for this miniature example

The dataset is invented and has twelve calendar-month buckets from January through December 2025. A, B and C are hypothetical USD assets, all eligible throughout. Monthly factor and asset rows are assumed released on the first day of the next month at 01:00 UTC. That is a fixture assumption, not the French library publication schedule. The common decision is January 2, 2026 at 00:00 UTC; any trade must occur afterward. No fills or portfolio returns are simulated.

  • Eight estimation observations for four coefficients and three formation observations keep the arithmetic inspectable. They are too short to establish stable exposures or a useful trading rule. To study the published horizon, supply at least 36 months and eleven formation months; to freeze eleven out-of-window monthly errors with 36-month prior fits, supply the longer warm-up history.
  • A later coefficient estimate must never overwrite an error previously used to make a decision. Conversely, refitting old formation returns at the current decision is not automatically look-ahead: it becomes look-ahead when that new vintage is assigned to an earlier decision.
  • Calendar endpoints must match exactly. Different release times are acceptable only when every required input is available by its declared fit, error-computation and decision cutoff. Do not forward-fill missing months or fill an unavailable factor with zero.
ConstructionEstimation and availabilityFormation
Published-style shortened windowAt January 2, fit May-December 2025 (8 rows), with intercept. Adjust using y - beta·F.September-November (3 rows); skip December.
Refitted OLS-error comparatorUse the same May-December fit, but subtract the intercept too.Same September-November rows. This is a different intercept convention.
Frozen September errorFit January-August on September 2; compute September e on October 2.Store the error and coefficients at that timestamp.
Frozen October errorFit February-September on October 2; compute October e on November 2.Store the error and coefficients at that timestamp.
Frozen November errorFit March-October on November 2; compute November e on December 2.Accumulate the three stored errors at the January decision.

Run all three adjustments from the raw input rows

Save the following complete program as residual_momentum.py and run it in a Python environment with NumPy installed (python -m pip install numpy). Then run python residual_momentum.py. The program includes every raw return and factor row, converts percentages once, uses SVD least squares with an intercept, rejects invalid inputs, and prints coefficients, residual vectors, scores, ranks and timestamps. It needs no network access or proprietary factor library.

Synthetic data only. Three assets, twelve monthly rows, eight-month regressions and a three-month signal with December skipped. The function is intentionally limited to this dated fixture.python
"""Synthetic teaching example. Requires Python 3 and NumPy."""
import calendar
import csv
import io
import json
from datetime import datetime, timezone
import numpy as np

RAW = """month,Mkt_RF,SMB,HML,RF,A,B,C
2025-01,1.00,0.30,-0.20,0.20,2.58,0.24,1.49
2025-02,-2.00,0.50,0.40,0.20,-4.02,-0.10,-1.77
2025-03,3.00,-0.60,0.20,0.20,6.00,1.12,3.38
2025-04,0.50,0.20,-0.70,0.20,1.89,0.26,0.38
2025-05,-1.00,-0.40,0.60,0.20,-2.34,-0.12,-0.62
2025-06,2.00,0.70,0.10,0.20,4.75,0.81,1.99
2025-07,-0.50,-0.20,-0.40,0.20,-1.16,0.09,0.00
2025-08,1.50,0.10,0.80,0.20,2.90,0.93,1.47
2025-09,4.00,0.40,-0.20,0.20,7.42,3.22,4.60
2025-10,3.00,-0.30,0.50,0.20,5.43,2.41,3.47
2025-11,5.00,0.20,-0.60,0.20,8.96,4.36,5.70
2025-12,2.00,0.60,0.30,0.20,4.55,0.63,2.42
"""
UTC = timezone.utc
IDS = ('A', 'B', 'C')

def stamp(year, month, day=2, hour=0):
    return datetime(year, month, day, hour, tzinfo=UTC)

def data():
    rows = list(csv.DictReader(io.StringIO(RAW)))
    months = [row['month'] for row in rows]
    # All numeric source fields are percent per month; convert exactly once.
    values = np.array([[float(row[k]) / 100 for k in
                       ('Mkt_RF', 'SMB', 'HML', 'RF', *IDS)] for row in rows])
    ends, released = [], []
    for month in months:
        y, m = map(int, month.split('-'))
        ends.append(stamp(y, m, calendar.monthrange(y, m)[1]))
        ny, nm = (y + 1, 1) if m == 12 else (y, m + 1)
        released.append(stamp(ny, nm, 1, 1))
    return months, values, ends, released

def ols(factors, excess):
    x = np.column_stack((np.ones(len(factors)), factors))
    if len(x) <= x.shape[1] or not np.isfinite(x).all() or not np.isfinite(excess).all():
        raise ValueError('missing, non-finite or insufficient observations')
    coefficients, _, rank, singular = np.linalg.lstsq(x, excess, rcond=None)
    if rank != 4 or singular[0] / singular[-1] > 1e8:
        raise ValueError('singular or ill-conditioned factor matrix')
    return coefficients

def score(residuals):
    sd = np.std(residuals, axis=0, ddof=1)
    if not np.isfinite(sd).all() or np.any(sd <= 1e-12):
        raise ValueError('zero or invalid residual volatility')
    return residuals.sum(axis=0) / sd

def rank(values):
    return sorted(IDS, key=lambda name: (-values[IDS.index(name)], name))

def calculate(values, factor_ends, asset_ends, factor_release, asset_release,
              decision=stamp(2026, 1), fit_function=ols):
    if values.shape != (12, 7) or len(factor_ends) != 12 or len(asset_ends) != 12:
        raise ValueError('expected twelve complete monthly rows')
    expected = data()[2]
    if factor_ends != expected or asset_ends != expected:
        raise ValueError('different or missing calendar endpoints')
    if len(factor_release) != 12 or len(asset_release) != 12:
        raise ValueError('missing availability timestamps')
    if not np.isfinite(values).all() or np.any(values[:, 4:] < -1):
        raise ValueError('invalid returns or missing values')
    f, rf, returns = values[:, :3], values[:, 3], values[:, 4:]
    y = returns - rf[:, None]
    def known(indices, cutoff):
        if any(max(factor_release[j], asset_release[j]) > cutoff for j in indices):
            raise ValueError('data unavailable at declared cutoff')
    known(range(4, 12), decision)
    # At January decision: one May-December fit, then September-November scores.
    full = fit_function(f[4:12], y[4:12])
    published_style = y[8:11] - f[8:11] @ full[1:]
    refitted_errors = published_style - full[0]
    frozen, audit = [], []
    for j in range(8, 11):
        fit_at = stamp(2025, j + 1)
        residual_at = stamp(2025, j + 2)
        known(range(j - 8, j), fit_at)
        known([j], residual_at)
        if residual_at > decision:
            raise ValueError('residual not yet available')
        b = fit_function(f[j - 8:j], y[j - 8:j])
        frozen.append(y[j] - b[0] - f[j] @ b[1:])
        audit.append({'month': j + 1, 'fit_at': fit_at.isoformat(),
                      'residual_at': residual_at.isoformat(),
                      'coefficients': b.tolist()})
    frozen = np.array(frozen)
    raw = np.prod(1 + returns[8:11], axis=0) - 1
    signals = {'raw': raw, 'published_style': score(published_style),
               'refitted_ols_error': score(refitted_errors),
               'frozen_ols_error': score(frozen)}
    return {'scores': {k: v.tolist() for k, v in signals.items()},
            'ranks': {k: rank(v) for k, v in signals.items()},
            'published_style_returns': published_style.tolist(),
            'frozen_errors': frozen.tolist(), 'final_coefficients': full.tolist(),
            'audit': audit}

if __name__ == '__main__':
    _, values, ends, released = data()
    print(json.dumps(calculate(values, ends, ends, released, released), indent=2))

The resulting ranks disagree for identifiable reasons

The raw September-November return makes A the winner: 1.0742 × 1.0543 × 1.0896 - 1 = 23.400366%. A also has a large estimated market loading. Removing its factor component changes its position in the ranking. These are in-sample arithmetic outputs on invented data, with no subsequent holding-period result.

  • The final May-December alpha for A is -0.001881248 per month. At fixed slopes, each factor-only u equals the OLS error e plus this constant. Adding a constant leaves the formation standard deviation unchanged but changes the accumulated numerator by three times alpha. It can change cross-sectional rankings even though these particular two refitted rank orders match.
  • The frozen version changes the coefficients as well as their timing. Its different B/C order is not evidence that one construction is superior. It demonstrates why an implementation must name both conventions before comparing performance.
  • All ranks sort descending. Exact numerical ties use ascending permanent identifiers A, B, C. The code rejects the whole demonstration if any required asset input fails, so the comparison never silently changes its universe.
AssetRaw return / rankPublished-style S(u) / rankRefitted S(e) / rankFrozen S(e) / rank
A23.400366% / 1-1.975391 / 30.020457 / 3-4.470933 / 3
B10.316453% / 30.024843 / 20.086181 / 27.372697 / 1
C14.398708% / 27.721442 / 10.906331 / 13.800219 / 2

Inspect the frozen errors before accepting the score

The following errors are percentages for readability; the executable code stores decimals. For B, the sum is 4.019941 percentage points and the sample standard deviation is approximately 0.545247 percentage points, giving S = 7.372697. That dimensionless signal is not a realized Sharpe ratio.

  • An OLS fit with an intercept has errors summing approximately to zero over its own full estimation window. Summing those exact same in-sample errors produces no useful momentum numerator. A shorter formation subset or frozen out-of-window errors avoids that identity, but produces different statistical objects.
Error monthA error (%)B error (%)C error (%)First declared computation
September 2025-1.5441041.9193520.605408October 2, 2025 00:00 UTC
October 2025-0.2747620.8368920.119808November 2, 2025 00:00 UTC
November 2025-1.0380681.2636960.237464December 2, 2025 00:00 UTC

Failures that must stop a ranking

The executable rejects a rank-deficient design, condition number above 100 million, non-finite values, a mismatched monthly endpoint, a missed availability cutoff, and formation standard deviation at or below 1e-12 in decimal units. The condition threshold and volatility floor are explicit numerical guards for this example, not economically calibrated eligibility rules.

  • If HML duplicates SMB, the slopes are not separately identifiable. A pseudoinverse can return numbers, but this example rejects the ambiguous regression instead of presenting those numbers as unique exposures.
  • If an asset is explained perfectly, or its formation residuals are constant, its standardized score is undefined. Do not manufacture an infinite score or rank it as zero. In a larger study, predeclare exclusion and the treatment of an already-held asset.
  • One late factor row can invalidate an old estimation timestamp even when the row is available today. Store source vintages, not just the final joined spreadsheet.
  • Independent exact-rational normal equations reproduce the SVD coefficients and scores. Tests also alter December returns and confirm that the already-frozen September-November errors stay unchanged, while the January refit changes. This checks causality, not predictive skill.

Turn a signal study into an honest trading comparison

A market-adjusted score does not enforce a market-neutral portfolio. Ranking, weighting, exposure constraints, borrow, delistings and transaction costs are separate decisions. Before claiming a benefit, use dated universe membership and total returns, keep the raw and adjusted variants on the same eligible set, and freeze all model choices before examining later results.

  • Choose the number of long and short positions, tie policy, gross and net exposure, rebalance frequency and holding-cohort accounting. Record the signal cutoff, order timestamp and actual or modeled fills separately.
  • Charge both sales and purchases against drifted holdings. Include spread, impact, fees, financing and short borrow where applicable. A factor-adjusted ranking does not remove these costs or guarantee a borrowable short.
  • Compare net return, turnover, drawdown, factor exposure and concentration on later dates. Keep unsuccessful factor sets and windows in the trial record; choosing the best of many variants creates selection bias.
  • A model can omit a sector or nonlinear exposure, and estimated slopes can shift after a regime change. An unexplained return can reflect data error, omitted risk or an event. Calling it residual does not establish mispricing or alpha.

What to carry into TradingView and Pineify

TradingView can display a rule for the current chart, but this research needs dated factor returns and an aligned universe first. Do not assume that Pine includes a complete, point-in-time Fama-French factor database. Requesting another symbol does not create an independently traded position in that symbol inside the chart strategy emulator.

  • Keep the external multi-asset regression, rankings and portfolio ledger reproducible. If you later implement a chart-level diagnostic, specify the factor feed, release delay, missing-data policy and supported universe before asking for Pine code.
  • Pine Script AI Coding Agent can help implement the specified chart-level logic. Inspect the result and validate its final source. Strategy Optimizer can compare exposed Pine inputs in TradingView reports after timing and costs are fixed; it does not run this Python program or establish a cross-sectional portfolio backtest.
Where Pineify fits

Pine Script AI Coding Agent

Translate a defined chart-level diagnostic into reviewable Pine code after the factor data and timing contract are settled.

Also useful: Strategy Optimizer. Compare declared Pine input ranges using TradingView strategy reports. Keep the external universe study and later evaluation dates separate.

Specify a chart diagnostic

Frequently asked questions

Educational research, not investment advice. All inputs and release times in the calculation are synthetic. Scores are not a backtest, expected return or assurance of factor neutrality. Trading, shorting and model selection can lose money.

Sources and verification

Published by Pineify. Pineify is an independent product and is not affiliated with TradingView. Sources support the technical explanations; examples do not establish trading performance.