A Sharpe of 2.1 From Nothing: The Second Number Your Agent Doesn’t Log

September 2026

I gave a research agent four years of prices with no predictable structure in them — none, by construction — and it came back with a long/short book, an in-sample Sharpe of 2.1, and a paragraph explaining the economics of an effect that does not exist.

That is the measurement in this post. The more useful result is the second one: 88% of that number is accounted for by two integers — how many backtests the agent ran, and how many of the winners it blended into the book it reported. Only one of those is in the log everybody proposes to collect.

This closes a sequence. In August, having built an agentic research pipeline in May and measured a 2× lift in hypotheses tested per week, I priced a risk I had not thought to price: independent research runs against the same model produce books correlated at 0.62, a crowding exposure that appears in nobody’s risk report. That post ended with a claim I stated and did not measure — that faster hypothesis generation makes overfitting worse rather than better. This is the measurement, and my pre-registered prediction about how it would come out was wrong.


The one property agentic research has that human research never had

Every multiple-testing correction in finance founders on the same rock: you cannot observe the denominator. Harvey, Liu and Zhu built their t-statistic hurdle on an estimate of how many factors had been tried across the profession, not how many were published [1]. Harvey’s 2017 AFA presidential address is largely an argument about unreported trials [2]. The Deflated Sharpe Ratio requires you to supply the number of trials, and its authors are candid that in practice you are guessing [3]. Each method asks the researcher a question the researcher cannot honestly answer: how many things did you try before this one?

An agentic pipeline is different in exactly one respect. It has to ask the harness for every backtest it runs. The trial count is not a memory or an act of professional honesty. It is a log file.

So I built a minimal research agent, gave it one tool, recorded everything, and checked what the log is worth. The short answer is that it is worth less than I expected, for a reason that turns out to be measurable and fixable.


Setup

The harness. One command: submit up to 25 expressions, receive their in-sample scores. At most 12 such calls. The agent never sees prices, dates, tickers, or the holdout — the panel is anonymised to integer asset IDs and an integer time index, and the holdout files were physically absent from the filesystem while the runs executed. Every submission is logged with a timestamp, alongside a one-line hypothesis per batch and a free-text journal. The run ends when the agent reports exactly three signals.

What a “book” is, and the two counts that matter. Every arm’s output is scored the same way: three signals, equal-weighted into one book. The pre-registered primary rule takes the three highest-scoring signals a run evaluated, not the three it chose to report — the gap between the two is small and is itself reported below. N is the number of trials in the log. k is the number of additive legs in the resulting book: three for a searcher whose signals are single expressions, more for one whose signals are themselves sums. Those two integers carry the whole argument.

The signal language. A small price-only grammar: returns, moving averages, rolling moments, range position, rolling beta and correlation to the equal-weight panel, plus cross-sectional and time-series normalisations and arithmetic. It is the grammar from the August post, which matters — it was fixed before this hypothesis existed. Each signal becomes a dollar-neutral, rank-weighted long/short book with a one-day implementation lag.

The comparison arms. Three mechanical arms plus a no-selection placebo. The two that search run at the agent’s interaction schedule — 12 rounds of 25 candidates, feedback once per round. Matching rounds rather than evaluations is deliberate: in adaptive data analysis the damage scales with how many times you look and act, not with the raw query count [4].

ArmWhat it is
AGENTThe LLM researcher above
OPTEvolutionary search over the same grammar, at three selection pressures (soft, medium, hard — how aggressively each generation is bred from the current leaders)
CANON-SAMPLERCanon-shaped expressions with jittered parameters, no feedback — prior direction without optimisation
CANON placeboTwelve published anomalies in the same grammar, no selection at all

The data. The primary setting is synthetic: factor-structured panels with regime-switching volatility and fat tails, and zero predictability by construction. Volatility is forecastable; returns are not. The true Sharpe of every signal is zero, so every point of in-sample Sharpe is selection, and I can generate independent panels at will. The second setting is the real NASDAQ panel from the August post — 1,280 names, 2018–2023, train through 2021, holdout 2022 to May 2023.

The analysis plan, estimators, inclusion rule and predictions were committed to git before the first agent run. The repo holds the commit history, every harness log with its batch notes and research journals, a manifest of all 227 attempted runs including the five abandoned and why, and a list of every deviation from the plan.


1. What a research agent does with data that contains nothing

Twelve independent zero-alpha panels, one agent run each, identical prompt to the real-panel runs.

Every run found something. Every run wrote a confident rationale. From the report of a run on a panel whose returns are serially unpredictable by construction:

“All three combine the two independently validated engines: long-horizon cross-sectional mean reversion (ma 120-250, plateau confirmed across ret, ma, minr and maxr formulations at horizons 60-250) and a weekly return echo (returns from days t-8 to t-5 predict positively, with a coherent oscillating lag profile).”

There is no echo. There is no reversal. There is nothing.

Zero-alpha panelsReported in-sample SharpeRealised out-of-sample Sharpe
LLM agent (12 runs)2.12 (SE 0.10)+0.18 (SE 0.44)
Evolutionary, soft (12 panels)1.68 (SE 0.07)+0.14 (SE 0.24)
Evolutionary, medium1.67 (SE 0.05)+0.17 (SE 0.22)
Evolutionary, hard1.65 (SE 0.11)+0.28 (SE 0.22)
Canon sampler, no feedback1.26 (SE 0.08)−0.24 (SE 0.29)

The reported book is the equal-weight composite of the three highest-scoring signals a run evaluated — the pre-registered primary rule, which is why it differs slightly from what the agent chose to report. True out-of-sample Sharpe is zero for every arm by construction, and every row is consistent with that. Standard errors are across panels.

A Sharpe ratio of 2.12, in-sample, on a book, over four years of daily data, from a panel with no signal in it.

A little over half of that is textbook. The expected maximum Sharpe of a single expression over the agent’s own median 146 logged trials is 1.15 (SE 0.03, across the twelve panels’ random-expression pools) — the expected-maximum-Sharpe effect [3], computed here as the exact order statistic over an empirical null pool drawn from the same grammar. That accounts for 54% of the 2.12. Note what the benchmark cannot do: those are single expressions, and it takes a single expression as the answer. Hold that thought — the missing 0.97 is the subject of section 3.

The agent’s own judgment is not the problem. It usually declines to report its top three by raw score, preferring what it calls “plateau” specifications; that restraint moves the number by 0.03 (2.08 chosen versus 2.12 argmax).

In-sample versus out-of-sample by search arm
In-sample versus out-of-sample by search arm

2. It manufactures more than a machine built to overfit

Paired by panel, against the evolutionary optimiser at the identical interaction schedule, using only the twelve primary runs:

Contrast, in-sample Sharpe (12 paired panels)DifferenceSEt
Agent − evolutionary (soft)+0.440.11+3.9
Agent − evolutionary (medium)+0.450.11+4.1
Agent − evolutionary (hard)+0.460.09+5.2

On the real panel the gap is larger: 3.10 against 1.41–1.67.

That is the in-sample difference, and it is solid. The out-of-sample differences are not: those contrasts carry standard errors of 0.35 to 0.42, against a minimum detectable effect of about 1.0 Sharpe, and the pre-registered block bootstrap on the real panel returns a 95% interval of [−0.53, +1.99] for the agent’s realised Sharpe alone. Nothing in this study establishes that the agent’s books perform worse out of sample than a mechanical optimiser’s. What it establishes is that at a matched interaction budget, the agent converts noise into reported Sharpe more efficiently.

My pre-registered prediction was the opposite — that the model’s priors would act as a regulariser, keeping it in the published canon rather than in the noise, so it would overfit less. Wrong, at t ≈ 4.

3. Where the extra Sharpe comes from is not more searching

The mechanism is in the logs, and it is not model priors.

The agent logs a median of 146 unique backtests per zero-alpha run, against 148–225 for the evolutionary arms: level with the hardest setting, well below the softer ones. It is not searching harder — and note that the largest gap in the table above, +0.46, is against the arm that runs the same number of trials.

The difference is in what gets reported. Every run reports three signals, which are equal-weighted into one book. For the mechanical arms each of those three is a single expression, so the book has 3 legs. The agent’s three are themselves sums: a median of 4.2 legs each on zero-alpha panels, so its book carries about 12.5. That is the second integer, and no trial-count correction records it.

To isolate it I ran a controlled experiment on the zero-alpha panels with no agent involved: draw N random expressions, keep the top k by in-sample Sharpe, equal-weight them into a book, and record what the book reports.

The selection-plus-aggregation surface
The selection-plus-aggregation surface

Moving between curves is the familiar overfitting-versus-trials axis. Moving right along a curve is the aggregation axis nobody logs. The two trade off against each other: a pipeline that logs 100 backtests and blends its top 12 reports 1.72, while one that logs 400 and reports a single best reports 1.49. No alpha in either case, and the first pipeline’s log looks four times cleaner.

The arithmetic is standard portfolio algebra pointed at noise. Selecting k signals on in-sample performance and averaging them keeps the selected mean and cuts the variance — but the legs are not independent, so the gain is √(k / (1 + (k−1)ρ̄)), not √k. Fitting that form column by column over the monotone region gives ρ̄ of 0.41–0.47 at the trial counts that matter here, a ceiling of about 1.5× however many legs you add. That is why the curves flatten. They turn down at small N for a different reason: once k is a large fraction of N you are averaging in candidates that were barely selected at all. Novy-Marx made the combination point for strategies built from multiple signals and derived corrected critical values for it [5]; what is new here is an agent that was never asked to combine anything doing it unprompted, and the interchangeability of the two axes at a fixed log.

The closure. Take each run’s own logged trial count and its own book leg count, look up what blind top-k-of-N selection produces at that point on the surface, and compare:

ArmMedian trialsLegs in bookBlind top-k-of-N predictsActually reportedResidual
LLM agent14612.51.862.12+0.26
Evolutionary, hard14831.681.65−0.03
Evolutionary, medium20531.731.67−0.05
Evolutionary, soft22531.771.68−0.09
Canon sampler11031.561.26−0.31

Two integers and no model price the evolutionary arms to within 0.09, and account for 88% of the agent’s number. The residual is +0.26 (t ≈ 2.1 once the surface’s own estimation error is propagated) — small next to the 1.86 that blind selection explains. And the leg axis alone carries most of the agent’s edge over the mechanical searchers: holding trials at the agent’s own 146 and moving the book from 3 legs to 12.5 adds +0.25, against a measured agent-minus-mechanical gap of +0.45.

So the agent beats the optimiser and is beaten by blind selection at its own operating point, and both facts have one cause. It blends; they do not.

That also settles what happened to the estimator I pre-registered. I had planned to report an effective trial count — the random draws from this grammar needed to match a run’s best score. It cannot be computed for most agent runs: 10 of 12 exceed the best their own panel’s 1,500-draw random pool reached, so no trial count reproduces them. That is partly a property of a finite pool and it is not agent-specific — 13 of 36 hard evolutionary runs also clear their pool — so nothing here rests on it. The direction is informative, though: a deeper random-expression pool (depth 6, mean complexity 5.9 against the shallow pool’s 3.6) lifts the 99th percentile from 0.95 to 1.24 and the maximum to 1.79 without closing the gap, because random expressions almost never build composites — mean legs 1.15.

Depth is not the axis. Blending is.

What the log records versus what random search reaches
What the log records versus what random search reaches

4. The real panel

Six agent runs on the NASDAQ panel, trained through 2021, scored on 2022 to May 2023.

Real panelTrainHoldoutLegs in bookDaily turnover
LLM agent (6)3.10 (SE 0.15)+0.72 (SE 0.18)6.00.24
Canon sampler (5)1.79 (SE 0.10)+1.09 (SE 0.07)30.45
Evolutionary, soft (5)1.67 (SE 0.13)+1.13 (SE 0.16)30.43
Evolutionary, medium (5)1.62 (SE 0.09)+0.63 (SE 0.32)30.26
Evolutionary, hard (5)1.41 (SE 0.10)+0.94 (SE 0.29)30.49
12 published anomalies, no selection−0.11+0.810.14

The last row is the control that makes the rest interpretable, and it is scored exactly like every other row — one equal-weight composite, same backtester, same holdout — with no selection applied. It does not decay across this boundary. It improves, from −0.11 to +0.81. The 2022–23 environment was kinder to these exposures on this universe than the training window was.

So the regime component of the agent’s decay is not merely small; it is negative. The control licenses one claim and not a stronger one: the unselected canon did not decay here, so the regime cannot explain the agent’s 2.4-point gap. It does not follow that selection explains all of it — the canon composite is loaded the opposite way from a book selected to score 3.10 in the training window, and the pre-registered random-search leg that would have measured the selection component directly was not run.

Note what the ordering does not do. It is not monotone — the medium evolutionary arm has the lowest holdout Sharpe of any arm, below the agent’s — and every one of those holdout differences sits inside the block-bootstrap intervals. The real panel cannot adjudicate between these arms.

Turnover does not explain the gap either: the agent’s books turn over 24% of gross per day, at the low end of the arms rather than the high end.

The unselected canon did not decay across this boundary
The unselected canon did not decay across this boundary

5. What the number is worth, and what it is not

The obvious next move is to use the zero-alpha number as a correction: subtract what the pipeline manufactures from noise off the face value of what it reports on real data. Since true Sharpe on the synthetic panels is zero by construction, the manufactured component is the reported in-sample Sharpe itself — 2.12 for the agent. That gives 3.10 − 2.12 = 0.98 predicted against 0.72 realised, which looks like a hit.

It is not. Run the same arithmetic for every arm:

ArmZero-alpha manufactureReal facePredictedRealisedPredicted − realised
LLM agent2.123.100.980.72+0.26
Evolutionary, soft1.681.67−0.001.13−1.13
Evolutionary, medium1.671.62−0.050.63−0.68
Evolutionary, hard1.651.41−0.250.94−1.18
Canon sampler1.261.790.541.09−0.55

A negative last column means the haircut left too little on the table. Mean error −0.66. The correction under-predicts realised performance in four arms out of five, and the agent’s near-miss is the one that landed the other way. These are five books on one shared holdout path, not five independent draws, so this is one observation with five views of it rather than five tests. The reason is in the previous table: this holdout carried a tailwind of roughly +0.9 for canonical exposures, which a calibration built on noise cannot know about.

So the zero-alpha number measures how much in-sample Sharpe your pipeline manufactures from nothing. It is not a forecast of out-of-sample performance, because realised performance also contains whatever the regime does to your exposures, and that term is not small. What it is worth is the overstatement:

Selection overstatement — $100M book at 10% target volatility
Face in-sample Sharpe of the reported book3.1
Measured manufacturing capacity (zero-alpha calibration)2.1
Annual return overstatement≈ $21M
In basis points of notional≈ 2,100 bp

The amount by which the in-sample report overstates, measured on data containing no alpha. Gross of costs, rounded. Not a forecast and not strategy P&L: the row above shows the haircut does not predict realised returns. Absolute performance levels on a survivorship-conditioned panel are not defensible and no such claim is made.


Things that did not work

Two pre-registered predictions failed. The first, above: the prior did not act as a regulariser. The second concerned the planted-alpha panels, where I buried two effects of equal calibrated in-sample strength — one canon-shaped (short-horizon reversal), one deliberately anti-canon (a kurtosis effect the literature points away from) — expecting the agent to find the canon-shaped one better and the mechanical arms to show no such asymmetry. Both halves were wrong. At the higher plant strength the agent captured the anti-canon plant better (0.50 versus 0.41), and it was the evolutionary arm that showed the large asymmetry (0.87 versus −0.02) and delivered more of the real alpha out of sample (1.15 versus 0.50). The comparison is confounded — the plants were matched on in-sample strength, but their oracle holdout Sharpes came out at 0.46 and 1.30 — and the agent contributes four runs per cell.

A metric that dissolved against its null — for the second post running. Regressing the agent’s real-panel books on the twelve-anomaly basis gives a mean R² of 0.50: the agent is largely reproducing published anomalies. Run the same regression on random expressions from the same grammar and you get 0.72. Noise projects onto the canon basis better than the agent’s books do — so the metric ranks the agent as less canonical than random noise, which is not a statement about the agent at all. It measures the dimensionality of price-signal space. The lesson is cheap and general: any spanning statistic needs a null drawn from the same generator, or it is measuring the basis.

The look-ahead screen cannot fire. The holdout sits inside the model’s training corpus, so I pre-registered a one-sided screen against block-bootstrap continuations of the training panel — futures the model cannot have seen. Resampling training returns reproduces the structure the books were selected on, so the synthetic benchmark runs at 1.5–2.0 Sharpe for selected books and the statistic is negative by construction (Δ = −0.92; −1.64 under the demeaned variant). It found no evidence of pretraining leakage; it also could not have. The construction is in the repo.

The model changed underneath the experiment. Two-thirds of the way through, a rate limit forced a checkpoint switch. Four partly-completed runs were abandoned under the pre-registered inclusion rule and re-run on the same four panels; a fifth run was abandoned after I contaminated it with an operator timing probe. All five are in the manifest. The twelve primary zero-alpha runs are all on the first checkpoint. Three bridge runs on the second checkpoint over the same panels reported 2.74 against 2.09 for the first checkpoint on those panels. That gap is not identified, by this post’s own mechanism: the bridge runs used their full 300-trial budget against the primary runs’ ~145, and at fixed leg count the surface predicts about half of the 0.65 gap from trials alone. Three runs is an anecdote in any case; it is reported because it is the clearest available evidence that these numbers are a snapshot of specific checkpoints. Which checkpoint served each run was never recorded — it is reconstructed from run identifiers and timing, which is a defect in my instrumentation and is flagged in the repo.


What this does and does not show

It does not show that agent-generated books underperform mechanically-generated ones out of sample. Those contrasts are inside their standard errors and the design cannot resolve them.

It does not show that a zero-alpha haircut predicts realised performance. Section 5 shows it does not.

The limitations that matter, in order. This is a minimal single-loop researcher — one agent, one tool, ≤300 trials, no holdout gate, no research committee — one to two orders below a production pipeline, and everything a real stack adds either raises the trial count or is a control whose value this same instrumentation would demonstrate. It is a floor. One model family, and a checkpoint that changed mid-study; the cross-family experiment could not be run. The real panel is one shared out-of-sample path on a survivorship-conditioned universe inside the model’s training corpus, so every real-panel number here is descriptive and the inference lives in the synthetic arm. Twelve panels is a small cross-section and every interval is wide. And the mechanical arms are matched on rounds and grammar but not perfectly: the evolutionary arm is seeded and mutated at bounded expression depth while the agent writes free-form strings, so the agent searches a strictly larger subspace — which is consistent with the finding, since composite depth is exactly the axis that matters, but it means “same grammar” is doing less work than it sounds like.

Eleven deviations from the pre-registration — the censored trial-count estimator, the random-search decomposition leg that was not run, 60 continuations instead of 200, the warm-start evaluation basis, a prompt revised after the plan was committed, and the rest — are listed in DEVIATIONS.md.


So what do you do

Build the surface for your own stack. This is the differentiated move and it costs almost nothing. Construct a panel matched to your universe — same factor covariance, same volatility dynamics, same fat tails — with the conditional mean stripped out, and verify the construction by checking that an oracle signal earns zero. Then run your own pipeline against it, unmodified, and record what it reports at each (trials, legs) pair you actually operate at. That grid is your pipeline’s manufacturing capacity in the units you use, and you can look up any future result on it. For the pipeline here it was 2.1 Sharpe. The generator and the surface code are in the repo and the whole thing runs on a laptop.

Log two numbers, not one. The trial count is now an artifact rather than a memory, and a pipeline that cannot produce one is worse off than this toy. But on its own it prices nothing: a 12-leg book from 100 trials carries more selection than a single expression from 400, and only the first of those facts is in the log everyone proposes to keep. With both numbers you can look the answer up on your own surface. With one you cannot.

Then subtract, and stop there. The result tells you how much of the reported number is manufacturing. It does not tell you what the book will earn, because that also depends on what the regime does to your exposures — and section 5 shows that term is larger than the correction.

Every zero-alpha run in this study produced a good economic story — volatility term structure, lottery preference, reversal at horizons where reversal is documented — attached to nothing. The pipeline is a fine instrument. It is also, on data containing nothing, a machine for producing a Sharpe of 2.1 and a paragraph about why.


Code and data

Repo: jkinlay/agent-selection-surface

The repository contains the pre-registered analysis plan committed before the first run, a deviations list, the frozen prompt, the harness, the mechanical arms, the synthetic generator with its calibration constants, every run log with its batch notes and research journals, the manifest of all 227 attempted runs with dispositions and reasons, the backtester canary tests, and the analysis and figure code. Everything downstream of the LLM calls reproduces from seeds; the LLM calls are not re-runnable, which is why the logs are included in full.

Two requests of anyone re-running it. Run the zero-alpha arm first — it is what makes every subsequent number interpretable. And log the leg count, not just the trial count.


References

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

[2] Harvey, Presidential Address: The Scientific Outlook in Financial Economics, Journal of Finance 72(4), 2017.

[3] Bailey & López de Prado, The Deflated Sharpe Ratio, Journal of Portfolio Management 40(5), 2014; Bailey, Borwein, López de Prado & Zhu, Pseudo-Mathematics and Financial Charlatanism, Notices of the AMS 61(5), 2014, for the expected-maximum-Sharpe result used in section 1.

[4] Dwork, Feldman, Hardt, Pitassi, Reingold & Roth, The reusable holdout: Preserving validity in adaptive data analysis, Science 349(6248), 2015 — guarantees degrade with the number of adaptive rounds, which is why every arm here is matched on rounds rather than evaluations.

[5] Novy-Marx, Backtesting Strategies Based on Multiple Signals, NBER Working Paper 21329, 2015 — in-sample test statistics inflate with the number of combined signals, with corrected critical values. The aggregation axis in section 3 is this effect, arrived at by an agent that was not asked to combine anything.

[6] skfolio, load_nasdaq_dataset — daily adjusted closes, 1,455 NASDAQ constituents, 2018-01-02 to 2023-05-31, documented by its authors as a stale dataset not intended for investment or commercial use. Filtered here to 1,280 names with median price ≥ $5; SHA-256 of the source file is in the analysis plan.

[7] Canonical anomalies in the placebo: Jegadeesh & Titman (1993) with the Carhart (1997) 12-1 construction; Jegadeesh (1990); Ang, Hodrick, Xing & Zhang (2006); George & Hwang (2004); Frazzini & Pedersen (2014); Boyer, Mitton & Vorkink (2010); Moskowitz, Ooi & Pedersen (2012); Novy-Marx (2012). The twelfth, a 60-minus-120-day momentum-acceleration variant, is a construction of my own.

Disclosure: I run systematic strategies. Nothing here is a recommendation, and no strategy discussed is one I trade. These are diagnostic quantities from a methodological experiment on a stale public dataset, not a track record.

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()