Your Research Agent Is an Undisclosed Factor Exposure — And So Is Everyone Else’s

In May I published a case study on running alpha research through a team of LLM agents: four roles, instrumented handoffs, and roughly a 2× lift in hypotheses tested per week. The post was careful about what it measured. It was silent about a risk I had not thought to price.

Here is the risk. If your research agent and your competitor’s research agent are the same base model, then whatever that model believes about markets is a shared prior — and a shared prior that both of you act on is, functionally, a common factor exposure that appears in neither of your risk reports. You did not choose it. You cannot see it in your covariance matrix. And it is correlated across every firm that licensed the same weights.

That argument was given a name in May by Ye, Han, Hu and co-authors, in a paper called The Alpha Illusion [1]. Their Section 3.3 is titled “Parametric Priors as Undisclosed Implicit Factor Exposures,” and they call the mechanism parametric prior lock-in. It is a good name for a real worry, and the paper is careful enough to say what it has not done:

“We propose this as an explanatory framework rather than an established unified mechanism, called Parametric Prior Lock-in.”

Their cross-model evidence is also borrowed rather than produced: Appendix D relays bias-score gaps and view-flip rates from Lee et al. (2025), and they note that persona prompts and multi-agent debate fail to dislodge shared priors. What is missing is the specific thing a risk manager would want. They run no correlation test across model outputs, and no factor regression on generated signals. So the mechanism has a name, a plausible story, and nobody has measured whether it shows up in a book.

This post is the measurement. It is a small one, and it does not come out where I expected.


What I actually tested

The claim has two parts that are easy to run together and should be kept apart.

Part one, the crowding claim: independent research runs against the same model produce books that are correlated with each other.

Part two, the attribution claim: that correlation comes from the model’s weights — a shared parametric prior — rather than from something both runs are merely reading off.

Part one is worth knowing whatever the answer to part two. Part two is what makes “lock-in” a distinct phenomenon rather than a new label for an old one. Almost all of the difficulty is in part two, because the obvious confound is enormous: if four models all propose momentum and low-volatility, that may be a shared prior, or it may be that all four correctly absorbed fifty years of published asset pricing. Correlated output is the expected result here, not the finding.

So the experiment needs baselines. Two of them, fixed before I looked at anything.

The floor: random expressions

If I generate signals at random from the same vocabulary, how correlated are the resulting books? This is what “no shared anything” looks like, and it establishes that any agreement I measure is agreement at all.

The ceiling: the published canon

If eight analysts each independently built a book from the published price-based anomaly literature — momentum, reversal, low-vol, betting-against-beta, the 52-week high, idiosyncratic skewness — how correlated would their books be?

This is the baseline that matters, and it is the one the lock-in story has to beat. If eight LLM runs are no more correlated than eight literature-reading humans, then “parametric prior lock-in” has no work left to do. The crowding would be real, but it would be the crowding the anomaly literature has always produced, arriving faster.


Setup

Universe. Daily adjusted closes for 1,455 NASDAQ Composite constituents, 2 January 2018 to 31 May 2023, from the skfolio reference datasets [2]. The panel is fully dense — not one missing observation in 1,362 days — which is convenient and also a survivorship problem I return to below. After dropping names whose median price is under $5, 1,280 stocks remain.

The expression language. Every model gets the same restricted vocabulary: close, returns(n), logret(n), ma(n), std(n), vol(n), max_(n), min_(n), skew(n), kurt(n), beta(n), corr_mkt(n), plus delay, ts_zscore, rank, cs_zscore and simple arithmetic. Prices only. No volume, no fundamentals, no news.

Forcing every proposal into one grammar is the design decision that makes the whole thing work. Free-text research ideas cannot be compared — two people can describe the same trade in different words, or different trades in the same words. An expression can be executed. Two signals that produce the same ranking of the same stocks are the same trade, whatever their authors called them, and that is the only definition of agreement a risk manager should care about.

The syntax examples in the prompt are deliberately meaningless — ts_zscore(kurt(40), 120), abs_(logret(9)) — because my first draft used momentum and reversal as the examples, which would have seeded every model with the answer I was looking for.

Protocol. Each run receives the identical prompt and returns twelve signals, each with a name, an expression, a one-line economic rationale, and the model’s own stated confidence that the signal has positive out-of-sample IC. Eight runs: four model tiers, two independent replicates each. 96 signals, of which 95 evaluate (one produced a degenerate constant).

Scoring. Each signal becomes a dollar-neutral, rank-weighted long/short portfolio, traded with a one-day implementation lag. Each run’s twelve signals are then combined, equal-weighted on volatility-standardised returns, into a single composite book — the thing a firm would actually be running if it traded that research output. The unit of analysis is the book, not the signal, and that turns out to matter more than anything else here.

The canon control. Twelve published price-only anomalies, written in the same DSL: 12-1 momentum (Jegadeesh & Titman, 1993), one-month and one-week reversal (Jegadeesh, 1990), the low-volatility effect (Ang et al., 2006), the 52-week high (George & Hwang, 2004), betting-against-beta (Frazzini & Pedersen, 2014), the idiosyncratic-skewness effect (Boyer, Mitton & Vorkink, 2010), time-series trend (Moskowitz et al., 2012), and four variants. Eight synthetic “analysts” each draw twelve weighted picks from this literature and build a composite the same way.


Results

1. The crowding is real and it is large

Eight composite books built fromMean pairwise correlationInterval
Random expressions from the same vocabulary+0.15[−0.02, +0.37]
Independent LLM research runs+0.62[+0.54, +0.69]
The published anomaly canon+0.82[+0.67, +0.91]

Intervals: 5th–95th percentile of 500 control draws; for the observed value, a 21-day block-bootstrap 95% CI over 1,341 trading days.

Eight independent research runs, no communication between them, produce books correlated at 0.62. Against a random-expression floor of 0.15, that is not close. If eight shops each ran this pipeline, they would be sharing something like 38% of their return variance without any of them having decided to.

The practitioner translation is the effective number of independent books, n / (1 + (n−1)ρ):

ρEffective independent books (of 8)Combined vol as % of one book
Random expressions0.153.8551%
LLM research runs0.621.5181%
Published canon0.821.1992%

Eight research agents buy you one and a half books’ worth of diversification. That is the number I would put in front of an allocator.

2. It gets worse in exactly the signals you would trade

The models each rated their own confidence in each proposal. Sorting by that self-assessment:

Model’s own stated confidenceSignalsMean cross-run correlation
≥ 0.6031+0.23
0.50 – 0.6038+0.13
< 0.5026+0.06

Agreement rises monotonically with the models’ own conviction. The tail of speculative proposals is genuinely diverse; the top of the list is where everyone converges. If you filter to the ideas worth researching — which is what you would do — you are filtering toward the crowd, not away from it. Diversity in the long tail is not a hedge, because the long tail is not what gets funded.

At the concept level this is stark. Mapping each proposal to its nearest canonical anomaly:

ConceptRuns proposing it (of 8)
One-month reversal8
One-week reversal8
Betting-against-beta8
Idiosyncratic-skewness effect8
Low volatility (60d)7
Low volatility (20d)7
12-1 momentum6
52-week high6
Momentum acceleration4
Intermediate momentum2
Long-horizon trend2
Path smoothness1

Four concepts appear in every single run. Six appear in seven or more. Ninety-five distinct-looking proposals collapse to an effective rank of 4.5.

3. And yet the attribution claim fails

Here is where the post stopped being the one I set out to write.

The canon control does not come in below the LLM runs. It comes in above them, at 0.82 against 0.62. The observed value sits at the 2nd percentile of the canon control distribution.

Eight analysts independently building books out of the published anomaly literature would be more crowded than eight LLM runs were.

At the individual-signal level the same ordering holds — random expressions at +0.005, model signals at +0.12, canon signals at +0.17. On every measure I ran, the models are more diverse than the literature they are echoing, not less.

So part one of the claim survives and part two does not. The exposure is real, shared, and undisclosed. But I have no evidence it comes from the weights, and the natural alternative — the models are reproducing a low-dimensional published literature, slightly noisily — explains the data at least as well and predicts more crowding than I observed.

4. One number that looked like a finding and was not

I want to show a metric that failed, because it is the sort of thing that would have made a persuasive chart.

Regressing each model-generated signal on the twelve-signal canon basis gives a mean R² of 0.87, median 0.93, with 94% of signals above 0.5. Read naively: the models are almost entirely reproducing published anomalies.

Then run the same regression on the random expressions. Mean R² 0.83, median 0.92, 91% above 0.5.

Random noise from the same vocabulary projects onto the canon basis about as well as the models do. The metric measures the dimensionality of price-based signal space, not anything about the models. It had to be thrown away.

I mention it because this is precisely the failure mode that agentic research pipelines are good at manufacturing at scale — a plausible number, in the expected direction, that dissolves against a null nobody bothered to run. Jin Choi at Two Sigma made the same point from the other direction in January: “With AI agents, researchers can easily generate a large number of hypotheses and backtest them, which can exacerbate the overfitting issue” [3]. The binding constraint on AI-assisted research is not hypothesis generation. It is that generating hypotheses faster makes overfitting worse, not better.

5. Factor tilts

Regressing each composite book on market, momentum, short-term reversal, low-volatility and BAB legs built from the same panel:

RunMOMSTRLVOLBAB
opus · 10.250.290.110.190.81
opus · 20.280.230.200.100.76
sonnet · 10.080.000.100.130.68
sonnet · 20.020.090.200.100.72
haiku · 10.05−0.110.150.030.57
haiku · 20.04−0.17−0.150.150.66
fable · 10.260.130.270.040.73
fable · 20.220.140.190.170.80

72% of individual signals have a factor R² above 0.5. Mean annualised alpha to this five-factor set is 1.2%, and fewer than half the signals have positive alpha at all. Whatever these agents are producing, most of it is factor exposure wearing a new name — which is a finding about disclosure, and survives the collapse of the lock-in attribution intact. A book you cannot decompose is a book whose risk you are not reporting.

Note also that the tilts are not a shared direction: mean cosine similarity between run-level tilt vectors is +0.10. The runs agree on which anomalies exist far more than they agree on how to weight them.

6. The one hint that the weights matter

Sorting composite-book pairs by whether they came from the same model tier:

PairsMean correlation
Same tier (two runs of the same model)4+0.79
Different tier24+0.59

That is the right shape for a dose-response on shared weights. It is also four data points, all within a single model family, and I would not defend it further than “worth running properly.”


What this does and does not show

It shows that independent research runs against LLMs produce composite books correlated at 0.62 — four times the random-expression floor — that the agreement concentrates in the highest-conviction proposals, that four canonical anomalies appeared in all eight runs, and that roughly three-quarters of the resulting signals are explicable as factor exposure.

It does not show parametric prior lock-in. On the only test I could construct for it, the published literature produces more crowding than the models did.

The limitations that matter most, in order:

All eight runs are from one model family. This is the big one. The tier contrast — opus, sonnet, haiku, fable — is a within-family proxy and cannot separate “shared weights” from “shared literature,” because these models share both. The decisive experiment is cross-family: identical prompts against models from genuinely different pretraining lineages, testing whether within-family agreement exceeds cross-family agreement. I could not run it here for want of API access to the other providers. The code is written and the harness is the same; this is a gap, not a design choice, and until someone runs it the lock-in hypothesis is neither supported nor refuted — only unmeasured.

Eight runs is not a cross-section. Four tiers, two replicates. Every interval in this post is wide, and the same-tier contrast rests on four pairs.

The DSL constrains the space, and I cannot sign the bias. Price-only signals live in a low-dimensional space, which mechanically raises agreement; but the restriction applies equally to the canon control, which is the comparison that carries the argument.

The canon control is my list. Twelve anomalies I chose. A longer or differently-weighted canon moves the 0.82. I would treat the ordering of the three baselines as the result and the levels as approximate.

The performance numbers are not tradeable. Daily rebalancing, gross of costs, on a dollar-neutral book. The 1.68 Sharpe on one-week reversal in the canon set is a well-known cost-eaten effect and appears here only as a benchmark component.

The panel is survivorship-clean in the worst way. skfolio documents nothing about how its 1,455 names were selected, but the panel’s complete absence of missing observations over five and a half years implies the universe is survivorship-conditioned by construction: delisted, acquired and bankrupt NASDAQ names from that window cannot be in it. The documentation is also explicit that this is “a stale dataset… not intended for investment, trading, or commercial use.” That biases the level of every backtest here. It does not obviously bias the correlation comparisons, which are all run on the same panel, but I would not swear to it. A serious version of this experiment runs on a point-in-time universe.

Model versions are a moving target. This is a snapshot of specific checkpoints in August 2026. Re-run it in six months and the numbers will differ; that is a property of the object being measured, not a defect in the measurement.


What I think is actually going on

The crowding is real and I would now treat it as a risk to be measured. But “parametric prior lock-in” is the wrong mental model for it, at least on this evidence.

The better model is duller and more actionable: an LLM is a fast, lossy index of the published anomaly literature. Ask it for signals and you get the canon back, sampled with noise, weighted by whatever the training corpus emphasised. The crowding does not originate in the weights. It originates in the fact that the published cross-sectional anomaly literature is itself extremely low-dimensional — twelve canonical signals with an effective rank of 2.6 — and has been the common input to systematic equity research for thirty years.

Which means the honest version of the warning is not “beware the model’s hidden priors.” It is: the tool makes it much cheaper to rediscover what everyone already knows, and it will do so with great fluency and a plausible economic rationale attached to each one.

McLean and Pontiff put numbers on what that costs [4]. Across 97 published predictors, returns are 26% lower out-of-sample — their upper bound on data mining in the original studies — and 58% lower post-publication. The difference, 32%, is the decay attributable to publication-informed trading: the part caused by other people reading the same paper and putting it on. That is the number that bites here, because an agent that reliably rediscovers published anomalies is an agent that reliably rediscovers decayed ones, and writes you a confident paragraph about the mechanism while doing it.

Their paper also contains a finding I did not expect to need: predictor portfolios show post-publication increases in correlation with other published-predictor portfolios. The literature does not merely decay after publication — it converges. That is the 0.82 canon number arriving from a completely independent direction, twenty years of data earlier.

That is a different failure than the one the Alpha Illusion paper is pointing at, and in some ways a worse one, because it is not exotic. It does not need a story about weight-sharing across firms. It only needs the thing we already knew about the anomaly literature, plus a tool that samples from it at essentially zero marginal cost.

There is also a mildly contrarian note in the data that I want to state plainly rather than bury: on every measure I ran, the models were more diverse than the literature. If your prior was that LLMs homogenise research relative to a room of well-read humans, this small experiment points the other way.

Where to take this next

  1. The cross-family arm. Same prompts, models from different pretraining lineages. Does within-family agreement exceed cross-family agreement once “family” means something? This is the experiment that settles it, and it is a few hundred dollars of API.
  2. A human control arm. Give the same prompt and the same DSL to a dozen quant researchers. If humans come in at 0.6 too, the whole topic is a non-story about tools and a real story about the literature.
  3. Adversarial evidence. Feed a model data that should flip a tilt and see whether the prior holds anyway. Agreement is not lock-in; persistence against contrary evidence is, and it is the one test that would distinguish them.
  4. Widen the vocabulary. Volume, fundamentals, and cross-sectional interactions raise the dimensionality of the space. If agreement survives a richer DSL, it is a stronger result; if it collapses, the price-only constraint was doing the work.
  5. Novelty-conditioned generation. Ask explicitly for signals not in the published literature and re-run the whole comparison. My guess, worth about as much as any guess, is that the agreement falls and the alpha falls further.

References

[1] Ye, Han, Hu, Bu, Chen, Wen, Mandic, Sun, Yinghui & Xu, The Alpha Illusion: Reported Alpha from LLM Trading Agents Should Not Be Treated as Deployment Evidence, arXiv:2605.16895, 16 May 2026. The TradingAgents Sharpe degradation from 0.43 to 0.22 under realistic frictions is their own result, from a one-year, five-ticker reproduction (Figure 1, setup in Appendix B). The frequently-quoted 71.85% post-cutoff drop in FinMem returns is not theirs — it is their citation to Li et al. (2025).

[2] skfolio, load_nasdaq_dataset — daily adjusted closes for 1,455 NASDAQ Composite constituents, 2018-01-02 to 2023-05-31. https://skfolio.org/generated/skfolio.datasets.load_nasdaq_dataset.html

[3] Jin Choi, Head of Technique Forecasting, in Two Sigma, AI in Investment Management: 2026 Outlook (Part II), 21 January 2026. https://www.twosigma.com/articles/ai-in-investment-management-2026-outlook-part-ii/

[4] McLean & Pontiff, Does Academic Research Destroy Stock Return Predictability?, Journal of Finance 71(1), 2016. 97 predictors from 80 studies.

[5] Harvey, Liu & Zhu, …and the Cross-Section of Expected Returns, Review of Financial Studies, 2016.

[6] Bailey & López de Prado, The Deflated Sharpe Ratio, Journal of Portfolio Management, 2014.

[7] Jegadeesh & Titman, Returns to Buying Winners and Selling Losers, JF 48(1), 1993 — the 12-1 skip-a-month construction is Carhart (1997); Jegadeesh, JF 45(3), 1990; Ang, Hodrick, Xing & Zhang, JF 61(1), 2006; George & Hwang, JF 59(5), 2004; Frazzini & Pedersen, JFE 111(1), 2014; Boyer, Mitton & Vorkink, Expected Idiosyncratic Skewness, RFS 23(1), 2010; Moskowitz, Ooi & Pedersen, JFE 104(2), 2012.


Code and data

The full pipeline is four files — signal_dsl.py (grammar and portfolio construction), canon.py (the twelve published anomalies), prompt.md (the frozen prompt), evaluate.py (scoring and controls). Both datasets are public and fetched by URL. It runs in about three minutes on a laptop, with fixed seeds and pinned model versions.

The one thing I would ask of anyone re-running it: run the random-expression null first. It cost me the most persuasive number in the post, which is exactly what a null is for.


"""
evaluate.py — turn every generated signal into a return stream, then measure
(a) cross-model agreement and (b) factor tilts.
"""
import json, glob, os, itertools
import numpy as np
import pandas as pd
from signal_dsl import Ctx, evaluate, ls_returns, to_weights
from canon import CANON

RUNS = {  # run id -> (family, model tier)
    "A1": ("anthropic", "opus"),   "A2": ("anthropic", "opus"),
    "B1": ("anthropic", "sonnet"), "B2": ("anthropic", "sonnet"),
    "C1": ("anthropic", "haiku"),  "C2": ("anthropic", "haiku"),
    "D1": ("anthropic", "fable"),  "D2": ("anthropic", "fable"),
}


def load_panel(path="data/prices.pkl", min_price=5.0):
    px = pd.read_pickle(path)
    px = px.loc[:, px.median() >= min_price]
    return px


def build(ctx, items, tag):
    """Evaluate a dict/list of {name, expr} into return streams. Returns (rets, sigs, errors)."""
    rets, sigs, errs = {}, {}, []
    for it in items:
        key = f"{tag}:{it['name']}"
        try:
            s = evaluate(it["expr"], ctx)
            r = ls_returns(s, ctx)
            if r.std() == 0 or len(r) < 400:
                errs.append((key, "degenerate"))
                continue
            rets[key] = r
            sigs[key] = s
        except Exception as e:  # noqa: BLE001
            errs.append((key, repr(e)[:90]))
    return rets, sigs, errs


def mean_xs_rank_corr(s1, s2, sample_every=5):
    """Average cross-sectional Spearman correlation between two signal frames."""
    idx = s1.index.intersection(s2.index)[::sample_every]
    out = []
    for d in idx:
        a, b = s1.loc[d], s2.loc[d]
        m = a.notna() & b.notna() & np.isfinite(a) & np.isfinite(b)
        if m.sum() < 50:
            continue
        c = a[m].rank().corr(b[m].rank())
        if np.isfinite(c):
            out.append(c)
    return float(np.mean(out)) if out else np.nan


def effective_rank(R):
    """Participation ratio of the correlation-matrix eigenvalue spectrum."""
    C = R.corr().values
    C = np.nan_to_num(C, nan=0.0)
    w = np.linalg.eigvalsh(C)
    w = np.clip(w, 0, None)
    return float(w.sum() ** 2 / (w ** 2).sum())


def main():
    px = load_panel()
    ctx = Ctx(px)
    print(f"universe: {px.shape[1]} names, {px.shape[0]} days, "
          f"{px.index.min().date()} to {px.index.max().date()}")

    # --- canon control arm ---
    canon_r, canon_s, canon_e = build(ctx, [{"name": k, "expr": v} for k, v in CANON.items()], "CANON")
    print(f"canon: {len(canon_r)} usable, errors={canon_e}")

    # --- model arms ---
    all_r, all_s, meta = {}, {}, {}
    for run in sorted(RUNS):
        items = json.load(open(f"gen/{run}.json"))
        r, s, e = build(ctx, items, run)
        all_r.update(r); all_s.update(s)
        for k in r:
            meta[k] = RUNS[run]
        print(f"{run} ({RUNS[run][1]:6s}): {len(r):2d}/12 usable" + (f"  errors={e}" if e else ""))

    R = pd.DataFrame(all_r).dropna(how="all")
    R.to_pickle("data/model_returns.pkl")
    CR = pd.DataFrame(canon_r).dropna(how="all")
    with open("data/meta.json", "w") as f:
        json.dump({k: list(v) for k, v in meta.items()}, f)

    keys = list(R.columns)

    # ---------------- agreement on return streams ----------------
    rc = R.corr()
    within_tier, cross_tier, all_pairs = [], [], []
    for a, b in itertools.combinations(keys, 2):
        if a.split(":")[0][0] == b.split(":")[0][0]:   # same model tier
            within_tier.append(rc.loc[a, b])
        else:
            cross_tier.append(rc.loc[a, b])
        all_pairs.append(rc.loc[a, b])

    cc = CR.corr()
    canon_pairs = [cc.loc[a, b] for a, b in itertools.combinations(CR.columns, 2)]

    print("\n=== RETURN-STREAM CORRELATION ===")
    for nm, v in [("model x model (all)", all_pairs),
                  ("  same tier", within_tier),
                  ("  different tier", cross_tier),
                  ("canon x canon (control)", canon_pairs)]:
        v = np.array(v)
        print(f"{nm:26s} n={len(v):5d}  mean={v.mean():+.3f}  median={np.median(v):+.3f}  "
              f"|r|>0.5: {(np.abs(v) > 0.5).mean() * 100:5.1f}%")

    print(f"\neffective rank: models {effective_rank(R):.2f} of {R.shape[1]} signals")
    print(f"effective rank: canon  {effective_rank(CR):.2f} of {CR.shape[1]} signals")

    # ---------------- nearest-canon projection ----------------
    print("\n=== HOW MUCH OF EACH MODEL SIGNAL IS ALREADY IN THE CANON ===")
    common = R.index.intersection(CR.index)
    X = CR.loc[common].fillna(0.0).values
    X = np.column_stack([np.ones(len(X)), X])
    r2s, best = {}, {}
    for k in keys:
        y = R.loc[common, k].fillna(0.0).values
        coef, *_ = np.linalg.lstsq(X, y, rcond=None)
        resid = y - X @ coef
        r2 = 1 - resid.var() / y.var() if y.var() > 0 else np.nan
        r2s[k] = r2
        cors = {c: abs(R.loc[common, k].corr(CR.loc[common, c])) for c in CR.columns}
        best[k] = max(cors, key=cors.get)
    r2v = np.array(list(r2s.values()))
    print(f"R^2 of model signal on canon basis: mean={np.nanmean(r2v):.3f} "
          f"median={np.nanmedian(r2v):.3f}  >0.5: {(r2v > 0.5).mean() * 100:.1f}%")

    pd.Series(r2s).sort_values(ascending=False).to_csv("data/canon_r2.csv")
    pd.Series(best).to_csv("data/nearest_canon.csv")

    # ---------------- factor tilts ----------------
    print("\n=== FACTOR TILTS ===")
    F = pd.DataFrame({
        "MKT":  ctx.ret1.mean(axis=1),
        "MOM":  canon_r["CANON:mom_12_1"],
        "STR":  canon_r["CANON:reversal_1m"],
        "LVOL": canon_r["CANON:low_vol_60"],
        "BAB":  canon_r["CANON:bab"],
    }).dropna()
    F.to_pickle("data/factors.pkl")

    rows = []
    for k in keys:
        d = pd.concat([R[k], F], axis=1).dropna()
        if len(d) < 300:
            continue
        y = d.iloc[:, 0].values
        Xf = np.column_stack([np.ones(len(d)), d.iloc[:, 1:].values])
        coef, *_ = np.linalg.lstsq(Xf, y, rcond=None)
        resid = y - Xf @ coef
        r2 = 1 - resid.var() / y.var()
        rows.append(dict(signal=k, family=meta[k][0], tier=meta[k][1],
                         alpha_ann=coef[0] * 252, MKT=coef[1], MOM=coef[2],
                         STR=coef[3], LVOL=coef[4], BAB=coef[5], r2=r2))
    T = pd.DataFrame(rows).set_index("signal")
    T.to_csv("data/tilts.csv")
    print(T[["MKT", "MOM", "STR", "LVOL", "BAB", "r2"]].describe().loc[["mean", "50%", "std"]].round(3))
    print(f"\nshare of model signals with factor R^2 > 0.5: "
          f"{(T.r2 > 0.5).mean() * 100:.1f}%")
    print(f"median |STR| beta: {T.STR.abs().median():.3f}   "
          f"median |MOM| beta: {T.MOM.abs().median():.3f}")

    # tilt-vector agreement
    V = T[["MKT", "MOM", "STR", "LVOL", "BAB"]]
    Vn = V.div(np.linalg.norm(V.values, axis=1), axis=0)
    S = Vn.values @ Vn.values.T
    iu = np.triu_indices_from(S, 1)
    print(f"cosine similarity of tilt vectors: mean={S[iu].mean():+.3f} "
          f"median={np.median(S[iu]):+.3f}")

    return R, CR, T


if __name__ == "__main__":
    main()

"""
signal_dsl.py — a restricted expression language for cross-sectional equity signals,
plus an evaluator that turns any expression into a dollar-neutral long/short return stream.

Every model in the experiment sees the SAME vocabulary and the SAME data, so any
disagreement in output is disagreement about ideas, not about tooling.

Data: daily adjusted closes, wide (dates x tickers).
"""
from __future__ import annotations
import numpy as np
import pandas as pd

# ----------------------------------------------------------------------------
# Primitives. Each returns a DataFrame aligned to (dates x tickers).
# ----------------------------------------------------------------------------

class Ctx:
    def __init__(self, px: pd.DataFrame):
        self.px = px
        self.ret1 = px.pct_change()

    # --- raw ---
    @property
    def close(self):
        return self.px

    # --- returns ---
    def returns(self, n: int):
        """Simple return over the trailing n days."""
        return self.px.pct_change(n)

    def logret(self, n: int = 1):
        return np.log(self.px).diff(n)

    # --- rolling time-series stats ---
    def ma(self, n: int):
        return self.px.rolling(n).mean()

    def std(self, n: int):
        """Rolling std of daily returns."""
        return self.ret1.rolling(n).std()

    def vol(self, n: int):
        """Annualised rolling volatility."""
        return self.ret1.rolling(n).std() * np.sqrt(252)

    def max_(self, n: int):
        return self.px.rolling(n).max()

    def min_(self, n: int):
        return self.px.rolling(n).min()

    def skew(self, n: int):
        return self.ret1.rolling(n).skew()

    def kurt(self, n: int):
        return self.ret1.rolling(n).kurt()

    def delay(self, x, n: int):
        return _df(x).shift(n)

    def ts_zscore(self, x, n: int):
        x = _df(x)
        m = x.rolling(n).mean()
        s = x.rolling(n).std()
        return (x - m) / s.replace(0.0, np.nan)

    def beta(self, n: int):
        """Rolling beta of each stock to the equal-weighted panel."""
        mkt = self.ret1.mean(axis=1)
        cov = self.ret1.rolling(n).cov(mkt)
        var = mkt.rolling(n).var()
        return cov.div(var, axis=0)

    def corr_mkt(self, n: int):
        mkt = self.ret1.mean(axis=1)
        return self.ret1.rolling(n).corr(mkt)

    # --- cross-sectional ---
    def rank(self, x):
        """Cross-sectional rank scaled to [0, 1]."""
        return _df(x).rank(axis=1, pct=True)

    def cs_zscore(self, x):
        x = _df(x)
        return x.sub(x.mean(axis=1), axis=0).div(x.std(axis=1).replace(0.0, np.nan), axis=0)

    def neg(self, x):
        return -_df(x)

    def log(self, x):
        return np.log(_df(x).where(lambda d: d > 0))

    def abs_(self, x):
        return _df(x).abs()

    def sign(self, x):
        return np.sign(_df(x))

    def clip(self, x, lo, hi):
        return _df(x).clip(lo, hi)


def _df(x):
    return x if isinstance(x, pd.DataFrame) else x


ALLOWED = [
    "close", "returns", "logret", "ma", "std", "vol", "max_", "min_", "skew", "kurt",
    "delay", "ts_zscore", "beta", "corr_mkt", "rank", "cs_zscore", "neg", "log",
    "abs_", "sign", "clip",
]


def make_namespace(ctx: Ctx) -> dict:
    ns = {}
    for name in ALLOWED:
        attr = getattr(ctx, name)
        ns[name] = attr
    ns["np"] = np
    return ns


def evaluate(expr: str, ctx: Ctx) -> pd.DataFrame:
    """Evaluate a DSL expression to a (dates x tickers) signal frame."""
    ns = make_namespace(ctx)
    out = eval(expr, {"__builtins__": {}}, ns)  # noqa: S307 - restricted namespace
    if not isinstance(out, pd.DataFrame):
        raise TypeError(f"expression returned {type(out)}, expected DataFrame")
    return out


# ----------------------------------------------------------------------------
# Portfolio construction
# ----------------------------------------------------------------------------

def to_weights(sig: pd.DataFrame, min_names: int = 50) -> pd.DataFrame:
    """
    Dollar-neutral, rank-weighted cross-sectional portfolio.
    Weights sum to zero each day and have gross exposure of 1.
    """
    s = sig.replace([np.inf, -np.inf], np.nan)
    valid = s.notna().sum(axis=1)
    r = s.rank(axis=1, pct=True)
    w = r.sub(r.mean(axis=1), axis=0)
    gross = w.abs().sum(axis=1).replace(0.0, np.nan)
    w = w.div(gross, axis=0)
    w = w.where(valid.ge(min_names), np.nan)
    return w


def ls_returns(sig: pd.DataFrame, ctx: Ctx, lag: int = 1) -> pd.Series:
    """
    Long/short return stream. Signal at t is traded into t+lag, so there is
    no same-bar look-ahead.
    """
    w = to_weights(sig).shift(lag)
    r = ctx.ret1
    common = w.index.intersection(r.index)
    out = (w.loc[common] * r.loc[common]).sum(axis=1, skipna=True)
    out[w.loc[common].isna().all(axis=1)] = np.nan
    return out.dropna()


def rank_ic(sig: pd.DataFrame, ctx: Ctx, horizon: int = 21) -> pd.Series:
    """Daily cross-sectional Spearman IC against forward `horizon`-day returns."""
    fwd = ctx.px.pct_change(horizon).shift(-horizon)
    s = sig.replace([np.inf, -np.inf], np.nan)
    ics = {}
    for d in s.index:
        a = s.loc[d]
        b = fwd.loc[d] if d in fwd.index else None
        if b is None:
            continue
        m = a.notna() & b.notna()
        if m.sum() < 50:
            continue
        ics[d] = a[m].rank().corr(b[m].rank())
    return pd.Series(ics).dropna()


A Two-Factor Model for Capturing Momentum and Mean Reversion in Stock Returns


Financial modeling has long sought to develop frameworks that accurately capture the complex dynamics of asset prices. Traditional models often focus on either momentum or mean reversion effects, struggling to incorporate both simultaneously. In this blog post, we introduce a two-factor model that aims to address this issue by integrating both momentum and mean reversion effects within the stochastic processes governing stock prices.

The development of the two-factor model is motivated by the empirical observation that financial markets exhibit periods of persistent trends (momentum) and reversion to historical means or intrinsic values (mean reversion). Capturing both effects within a single framework has been a challenge in financial econometrics. The proposed model seeks to tackle this challenge by incorporating momentum and mean reversion effects within a unified framework.

The two-factor model consists of two main components: a drift factor and a mean-reverting factor. The drift factor, denoted as d μ(t), represents the long-term trend or momentum of a stock’s price. It incorporates a constant drift parameter θ, reflecting the underlying direction driven by broader market forces or fundamental changes. The mean-reverting factor, denoted as d θt, captures the short-term deviations from the drift. It is characterized by a mean-reversion speed κ, which determines the rate at which prices revert to their long-term equilibrium following temporary fluctuations. These factors are influenced by their respective volatilities (σμ, σθ) and driven by correlated Wiener processes, allowing the model to reflect the interaction between momentum and mean reversion observed in markets

To demonstrate the model’s application, the research applies the two-factor framework to daily returns data of Coca-Cola (KO) and PepsiCo (PEP) over a twenty-year period. This empirical analysis explores the model’s potential for informing pairs trading strategies. The parameter estimation process employs a maximum likelihood estimation (MLE) technique, adapted to handle the specifics of fitting a two-factor model to real-world data. This approach aims to ensure accuracy and adaptability, enabling the model to capture the evolving dynamics of the market.

The introduction of the two-factor model contributes to the field of quantitative finance by providing a framework that incorporates both momentum and mean reversion effects. This approach can lead to a more comprehensive understanding of asset price dynamics, potentially benefiting risk management, asset allocation, and the development of trading strategies. The model’s insights may be particularly relevant for pairs trading, where identifying relative mispricings between related assets is important.

The two-factor model presented in this blog post offers a new approach to financial modeling by integrating momentum and mean reversion effects. The model’s empirical application to Coca-Cola and PepsiCo demonstrates its potential for informing trading strategies. As quantitative finance continues to evolve, the two-factor model may prove to be a useful tool for researchers, practitioners, and investors seeking to understand the dynamics of financial markets.

Two-Factor-Model-of-Stock-Returns-ver_1_1

Beta Convexity

What is a Stock Beta?

Around a quarter of a century ago I wrote a paper entitled “Equity Convexity” which – to my disappointment – was rejected as incomprehensible by the finance professor who reviewed it.  But perhaps I should not have expected more: novel theories are rarely well received first time around.  I remain convinced the idea has merit and may perhaps revisit it in these pages at some point in future.  For now, I would like to discuss a related, but simpler concept: beta convexity.  As far as I am aware this, too, is new.  At least, while I find it unlikely that it has not already been considered, I am not aware of any reference to it in the literature.

SSALGOTRADING AD

We begin by reviewing the elementary concept of an asset beta, which is the covariance of the return of an asset with the return of the benchmark market index, divided by the variance of the return of the benchmark over a certain period:

Beta formula

Asset betas typically exhibit time dependency and there are numerous methods that can be used to model this feature, including, for instance, the Kalman Filter:

 

http://jonathankinlay.com/2015/02/statistical-arbitrage-using-kalman-filter/

Beta Convexity

In the context discussed here we set such matters to one side.  Instead of considering how an asset beta may vary over time, we look into how it might change depending on the direction of the benchmark index.  To take an example, let’s consider the stock Advaxis, Inc. (Nasdaq: ADXS).  In the charts below we examine the relationship between the daily stock returns and the returns in the benchmark Russell 3000 Index when the latter are positive and negative.

 

ADXS - Up Beta ADXS - Down Beta

 

The charts indicate that the stock beta tends to be higher during down periods in the benchmark index than during periods when the benchmark return is positive.  This can happen for two reasons: either the correlation between the asset and the index rises, or the volatility of the asset increases, (or perhaps both) when the overall market declines.  In fact, over the period from Jan 2012 to May 2017, the overall stock beta was 1.31, but the up-beta was only 0.44 while the down-beta was 1.53.  This is quite a marked difference and regardless of whether the change in beta arises from a change in the correlation or in the stock volatility, it could have a significant impact on the optimal weighting for this stock in an equity portfolio.

Ideally, what we would prefer to see is very little dependence in the relationship between the asset beta and the sign of the underlying benchmark.  One way to quantify such dependency is with what I have called Beta Convexity:

Beta Convexity = (Up-Beta – Down-Beta) ^2

A stock with a stable beta, i.e. one for which the difference between the up-beta and down-beta is negligibly small, will have a beta-convexity of zero. One the other hand, a stock that shows instability in its beta relationship with the benchmark will tend to have relatively large beta convexity.

 

Index Replication using a Minimum Beta-Convexity Portfolio

One way to apply this concept it to use it as a means of stock selection.  Regardless of whether a stock’s overall beta is large or small, ideally we want its dependency to be as close to zero as possible, i.e. with near-zero beta-convexity.  This is likely to produce greater stability in the composition of the optimal portfolio and eliminate unnecessary and undesirable excess volatility in portfolio returns by reducing nonlinearities in the relationship between the portfolio and benchmark returns.

In the following illustration we construct a stock portfolio by choosing the 500 constituents of the benchmark Russell 3000 index that have the lowest beta convexity during the previous 90-day period, rebalancing every quarter (hence all of the results are out-of-sample).  The minimum beta-convexity portfolio outperforms the benchmark by a total of 48.6% over the period from Jan 2012-May 2017, with an annual active return of 5.32% and Information Ratio of 1.36.  The portfolio tracking error is perhaps rather too large at 3.91%, but perhaps can be further reduced with the inclusion of additional stocks.

 

 

ResultsTable

 

Active Monthly

 

G1000

 

Active

Conclusion:  Beta Convexity as a New Factor

Beta convexity is a new concept that appears to have a useful role to play in identifying stocks that have stable long term dependency on the benchmark index and constructing index tracking portfolios capable of generating appreciable active returns.

The outperformance of the minimum-convexity portfolio is not the result of a momentum effect, or a systematic bias in the selection of high or low beta stocks.  The selection of the 500 lowest beta-convexity stocks in each period is somewhat arbitrary, but illustrates that the approach can scale to a size sufficient to deploy hundreds of millions of dollars of investment capital, or more.  A more sensible scheme might be, for example, to select a variable number of stocks based on a predefined tolerance limit on beta-convexity.

Obvious steps from here include experimenting with alternative weighting schemes such as value or beta convexity weighting and further refining the stock selection procedure to reduce the portfolio tracking error.

Further useful applications of the concept are likely to be found in the design of equity long/short and  market neural strategies. These I shall leave the reader to explore for now, but I will perhaps return to the topic in a future post.

Combining Momentum and Mean Reversion Strategies

The Fama-French World

For many years now the “gold standard” in factor models has been the 1996 Fama-French 3-factor model: Fig 1
Here r is the portfolio’s expected rate of return, Rf is the risk-free return rate, and Km is the return of the market portfolio. The “three factor” β is analogous to the classical β but not equal to it, since there are now two additional factors to do some of the work. SMB stands for “Small [market capitalization] Minus Big” and HML for “High [book-to-market ratio] Minus Low”; they measure the historic excess returns of small caps over big caps and of value stocks over growth stocks. These factors are calculated with combinations of portfolios composed by ranked stocks (BtM ranking, Cap ranking) and available historical market data. The Fama–French three-factor model explains over 90% of the diversified portfolios in-sample returns, compared with the average 70% given by the standard CAPM model.

The 3-factor model can also capture the reversal of long-term returns documented by DeBondt and Thaler (1985), who noted that extreme price movements over long formation periods were followed by movements in the opposite direction. (Alpha Architect has several interesting posts on the subject, including this one).

Fama and French say the 3-factor model can account for this. Long-term losers tend to have positive HML slopes and higher future average returns. Conversely, long-term winners tend to be strong stocks that have negative slopes on HML and low future returns. Fama and French argue that DeBondt and Thaler are just loading on the HML factor.

SSALGOTRADING AD

Enter Momentum

While many anomalies disappear under  tests, shorter term momentum effects (formation periods ~1 year) appear robust. Carhart (1997) constructs his 4-factor model by using FF 3-factor model plus an additional momentum factor. He shows that his 4-factor model with MOM substantially improves the average pricing errors of the CAPM and the 3-factor model. After his work, the standard factors of asset pricing model are now commonly recognized as Value, Size and Momentum.

 Combining Momentum and Mean Reversion

In a recent post, Alpha Architect looks as some possibilities for combining momentum and mean reversion strategies.  They examine all firms above the NYSE 40th percentile for market-cap (currently around $1.8 billion) to avoid weird empirical effects associated with micro/small cap stocks. The portfolios are formed at a monthly frequency with the following 2 variables:

  1. Momentum = Total return over the past twelve months (ignoring the last month)
  2. Value = EBIT/(Total Enterprise Value)

They form the simple Value and Momentum portfolios as follows:

  1. EBIT VW = Highest decile of firms ranked on Value (EBIT/TEV). Portfolio is value-weighted.
  2. MOM VW = Highest decile of firms ranked on Momentum. Portfolio is value-weighted.
  3. Universe VW = Value-weight returns to the universe of firms.
  4. SP500 = S&P 500 Total return

The results show that the top decile of Value and Momentum outperformed the index over the past 50 years.  The Momentum strategy has stronger returns than value, on average, but much higher volatility and drawdowns. On a risk-adjusted basis they perform similarly. Fig 2   The researchers then form the following four portfolios:

  1. EBIT VW = Highest decile of firms ranked on Value (EBIT/TEV). Portfolio is value-weighted.
  2. MOM VW = Highest decile of firms ranked on Momentum. Portfolio is value-weighted.
  3. COMBO VW = Rank firms independently on both Value and Momentum.  Add the two rankings together. Select the highest decile of firms ranked on the combined rankings. Portfolio is value-weighted.
  4. 50% EBIT/ 50% MOM VW = Each month, invest 50% in the EBIT VW portfolio, and 50% in the MOM VW portfolio. Portfolio is value-weighted.

With the following results:

Fig 3 The main takeaways are:

  • The combined ranked portfolio outperforms the index over the same time period.
  • However, the combination portfolio performs worse than a 50% allocation to Value and a 50% allocation to Momentum.

A More Sophisticated Model

Yangru Wu of Rutgers has been doing interesting work in this area over the last 15 years, or more. His 2005 paper (with Ronald Balvers), Momentum and mean reversion across national equity markets, considers joint momentum and mean-reversion effects and allows for complex interactions between them. Their model is of the form Fig 4 where the excess return for country i (relative to the global equity portfolio) is represented by a combination of mean-reversion and autoregressive (momentum) terms. Balvers and Wu  find that combination momentum-contrarian strategies, used to select from among 18 developed equity markets at a monthly frequency, outperform both pure momentum and pure mean-reversion strategies. The results continue to hold after corrections for factor sensitivities and transaction costs. The researchers confirm that momentum and mean reversion occur in the same assets. So in establishing the strength and duration of the momentum and mean reversion effects it becomes important to control for each factor’s effect on the other. The momentum and mean reversion effects exhibit a strong negative correlation of 35%. Accordingly, controlling for momentum accelerates the mean reversion process, and controlling for mean reversion may extend the momentum effect.

 Momentum, Mean Reversion and Volatility

The presence of  strong momentum and mean reversion in volatility processes provides a rationale for the kind of volatility strategy that we trade at Systematic Strategies.  One  sophisticated model is the Range Based EGARCH model of  Alizadeh, Brandt, and Diebold (2002) .  The model posits a two-factor volatility process in which a short term, transient volatility process mean-reverts to a stochastic long term mean process, which may exhibit momentum, or long memory effects  (details here).

In our volatility strategy we model mean reversion and momentum effects derived from the level of short and long term volatility-of-volatility, as well as the forward volatility curve. These are applied to volatility ETFs, including levered ETF products, where convexity effects are also important.  Mean reversion is a well understood phenomenon in volatility, as, too, is the yield roll in volatility futures (which also impacts ETF products like VXX and XIV).

Momentum effects are perhaps less well researched in this context, but our research shows them to be extremely important.  By way of illustration, in the chart below I have isolated the (gross) returns generated by one of the momentum factors in our model.

Fig 6