Replication and production-scale extension of "(Re-)Imag(in)ing Price Trends" (JF 78(6), 2023) using the I20/R20 configuration — a 5-CNN ensemble trained on 64×60 candlestick chart images to predict 20-day forward return direction for ~1,000 US stocks.
Reference paper: Pattern-2.pdf. Full spec: PRD.md.
Trained 28 expanding-window retrained ensembles on 8× NVIDIA A100 (80 GB), ~65 GPU-hours total.
| Metric | Value |
|---|---|
| Observations | 9,249,453 (ticker × end_date) |
| Unique tickers / dates | 3,011 / 5,759 |
| Overall Test AUC | 0.5068 |
| Windows with positive LS | 25 / 28 |
| Decile | Cum × | Ann. comp. |
|---|---|---|
| D1 (short) | 0.12 | −8.71 % |
| D2 | 1.16 | +0.64 % |
| D3 | 2.26 | +3.64 % |
| D4 | 3.73 | +5.93 % |
| D5 | 3.40 | +5.49 % |
| D6 | 3.82 | +6.03 % |
| D7 | 4.33 | +6.62 % |
| D8 | 5.95 | +8.11 % |
| D9 | 6.02 | +8.17 % |
| D10 (long) | 9.32 | +10.26 % |
Decile monotonicity is clean: D1 is a persistent loser and D10 a persistent winner.
| Portfolio | Cum × | Ann. comp. | Ann. vol | Sharpe | NW t(19) |
|---|---|---|---|---|---|
| LS D10−D1 | 39.44 | +17.44 % | 14.07 % | 1.22 | +7.99 |
| LS Top3−Bot3 (top/bot 30 %) | 7.27 | +9.07 % | 8.66 % | 1.05 | +6.60 |
Newey–West t-stats account for the 20-day overlap in daily forward-return observations (lag=19, Bartlett kernel); |t|≈8 is overwhelming statistical significance.
1999 −7.57 % (known: dotcom peak, model trained 1996–98 only)
2000 +43.70 %
2001 +37.83 %
2002 +36.76 %
2003 +5.41 %
2004 +14.58 %
2005 +9.50 %
2006 +5.08 %
2007 +11.37 %
2008 +37.18 % (GFC)
2009 +2.44 %
2010 +17.64 %
2011 +20.55 %
2012 +16.39 %
2013 +1.18 %
2014 +21.54 %
2015 +16.68 %
2016 −1.65 %
2017 +9.35 %
2018 +8.06 %
2019 +10.88 %
2020 +13.64 % (COVID)
2021 +49.51 % (meme stocks)
2022 +13.61 %
2023 +28.36 %
2024 +30.91 %
2025 +9.78 %
2026 +15.38 % (YTD through Mar-2026)
Two of the three weak years (1999, 2016) are structurally unavoidable or mild; 2013 is a known regime where cross-sectional momentum broke.
Charts: runs/expanding/<run_dir>/decile_cumulative.pdf, top3_vs_bot3.pdf.
| Pathway | Config | Status |
|---|---|---|
| Expanding (train grows each year) | configs/prod_expanding.yaml |
Complete — runs/expanding/20260419_174908_cdef6809 |
| Rolling (train capped at 5 yr, trailing) | configs/prod_rolling.yaml |
Running (pipelined with expanding on idle GPUs) |
| Comparison (merged per stock-date) | scripts/merge_pathways.py |
Pending rolling completion |
pattern/
config.py Pydantic schemas (PRD §10)
cli.py python -m pattern.cli {train,backtest}
data/
loader.py CSV → tidy DataFrame + adjusted returns
splits.py debug / expanding / rolling retrain schedules
imaging/
renderer.py Vectorized per-stock OHLC+MA+volume image gen
cache.py memmap uint8 (N,1,H,W) + parquet sidecar
models/
blocks.py Conv→BN→LeakyReLU→MaxPool building block
cnn.py Parametric builder for I5/I20/I60
train/
dataset.py PyTorch Dataset over memmap cache
loop.py 5-seed ensemble loop with early stopping
backtest/
deciles.py Cross-sectional decile portfolios
metrics.py Sharpe, NW t, turnover, drawdown
report.py Auto-generate report.md + plots
scripts/
run_multi_gpu.py 8-GPU fan-out driver (round-robin per-shard windows)
gpu_scheduler.py Work-stealing scheduler (opportunistic GPU use)
merge_pathways.py Expanding + rolling → per-stock-date comparison parquet
infer_fullperiod.py Re-score saved ensembles over any date range
train_extra_seeds.py Add ensembles to an existing run
configs/
debug.yaml Small universe / few windows for smoke tests
production.yaml Baseline full-period single-pass config
prod_expanding.yaml 27-year expanding retrain schedule
prod_rolling.yaml 27-year rolling (5-year trailing) schedule
tests/ Pixel-exact renderer checks, labelling, splits
- 5-seed ensemble in a single training call.
train/loop.pynow iterates seeds internally;cli.pywires up aggregation. - Embedding capture.
cnn.py::forward_with_featuresreturns the 256-dim global-avg-pooled penultimate tensor alongside the logits.predict(return_features=True)now returns(probs, labels, logits, embeddings)per seed. - Rich per-window artefacts. Every window now writes:
window_NN_predictions.parquet— ticker, end_date, label, forward_return, per-seedp_up_*,p_up_mean,p_up_std,logit_down_mean,logit_up_mean,rank_pct,decile,window.window_NN_features.npz— per-seed logits (K,N,2), per-seed embeddings (K,N,256), ensemble-mean embedding (N,256).window_stats.csv— train/val/test years, sample counts, wall seconds, peak GPU memory.
- Subset training via CLI.
--window-indices "0,3,5-9"+--run-dirlet an external orchestrator drive a single shared run directory. - Two-pathway retrain schedule. Expanding and rolling configs run side-by-side on the same 28 test years;
merge_pathways.pyjoins them on(ticker, end_date)so each stock-date has both ensembles' probabilities / ranks / deciles. - Multi-GPU drivers.
run_multi_gpu.py— static round-robin fan-out, one shard per GPU, drives a single pathway.gpu_scheduler.py— work-stealing scheduler that pollsnvidia-smievery 30 s and grabs whichever GPU has no compute apps, then pops the next pending window from its queue. Used to pipeline the rolling pathway on GPUs freed by the expanding run.
- Idempotent run-dir setup. Concurrent shards write their own per-window outputs; the driver/scheduler does the final concat once all shards finish.
- Python 3.14, PyTorch with CUDA or MPS.
- A single CSV
r1000_ohlcv_database.csv(Ticker, Date, Open, High, Low, Close, Volume, AdjClose, Return, MarketCap).
python -m pattern.cli train --config configs/debug.yamlpython -m pattern.cli train --config configs/production.yamlpython scripts/run_multi_gpu.py --config configs/prod_expanding.yaml --prebuild-cacheBuilds /data/Pattern/cache/prod_I20/images.npy + index.parquet (~20 GB for the 1000-stock universe).
python scripts/run_multi_gpu.py --config configs/prod_expanding.yaml --n-gpus 8Round-robin shard assignment: GPU g trains windows {g, g+8, g+16, …}. Each shard writes its own per-window parquets; driver concatenates them into predictions.parquet at the end.
python scripts/run_multi_gpu.py --config configs/prod_rolling.yaml --n-gpus 8python scripts/gpu_scheduler.py \
--config configs/prod_rolling.yaml \
--run-dir /data/Pattern/runs/rolling/<ts> \
--n-windows 28 --n-gpus 8The scheduler checks each GPU's compute-app list every 30 s; whenever a GPU is free it launches the next pending window there. Clean handoff — same shared run directory convention, no racing on the memmap cache (read-only after build).
python -m pattern.cli backtest --config configs/prod_expanding.yaml \
--run-dir runs/expanding/20260419_174908_cdef6809Writes portfolio parquets and report.md with Sharpe, turnover, drawdown, Newey-West t-stats and per-decile cumulative returns.
python scripts/merge_pathways.py \
--expanding runs/expanding/20260419_174908_cdef6809 \
--rolling runs/rolling/20260420_003938_fb2563f5 \
--out-dir runs/comparisonProduces pathway_comparison.parquet (one row per stock-date with both ensembles' output) and pathway_comparison_summary.csv (per-date correlation, decile disagreement).
images.npy— memmap uint8 array(N, 1, 64, 60), row order matchesindex.parquet.index.parquet—ticker, end_date, label_h, forward_return, label, has_ma, has_volume, window.- ~20 GB total for the full I20 universe. Immutable after build; every training shard and every backtest reads the same file.
Each run writes to runs/<pathway>/<timestamp>_<config_hash>/:
20260419_174908_cdef6809/
config.yaml frozen copy of the training config
sha.txt git sha
pip_freeze.txt
window_00_predictions.parquet ... window_27_predictions.parquet
window_00_features.npz ... window_27_features.npz
window_stats.csv
predictions.parquet concatenated final (9.25 M rows for expanding)
shard_gpu{0..7}.log driver logs
shard_gpu{g}_w{w:02d}.log scheduler logs
portfolios.parquet backtest output
report.md auto-generated narrative + plots
decile_cumulative.pdf 10-decile log-scale cumulative returns
top3_vs_bot3.pdf softer top/bot-30% version
Remote node: 8× A100-80GB, /data/Pattern/ workspace.
Local: M4 Max, 128 GB RAM, MPS — used for development, analysis, plotting.
Peak per-shard GPU memory: 0.60 GB (batch 128). Network training is compute-bound on the memmap loader, not memory-bound.
| Quantity | Value |
|---|---|
| Windows trained | 28 |
| Ensembles per window | 5 |
| Total networks trained | 140 |
| Wall-clock (8× A100 pipelined) | 9 h 27 min |
| Sum of per-shard GPU hours | 64.74 |
| Mean per-window wall | 138.7 min |
| Median peak GPU memory | 0.60 GB |
Cost at the rented node rate (~$10.69/h) ≈ $101 for both pathways pipelined.
The headline numbers above come from the full R1000 universe. After the expanding-pathway run completed we ran a long series of cross-sectional slicing experiments to answer two questions:
- Where inside the universe does the signal concentrate?
- Is the concentrated signal actually tradable after real-world frictions?
All artefacts below live under
runs/expanding/20260419_174908_cdef6809/, using
predictions_monthly.parquet (one row per ticker × month-end with
p_up_mean, p_up_std, forward_return, label).
We built scripts/backtest_generic.py as a one-stop slicer: feed it any
categorical/numeric column, let it per-date bucket it, and report a 50/50
or 10-decile LS portfolio per bucket. Used it for:
| Slice | Out-dir | Notes |
|---|---|---|
| BICS Level 1 | backtest_by_bics_level_1_5050/ |
12 sectors |
| BICS Level 2 | backtest_by_bics_level_2_5050/ |
40+ industry groups |
| BICS Level 3 | backtest_by_bics_level_3_5050/ |
Deep industries |
| Dollar-volume tertiles (mcap proxy) | backtest_by_mcap_proxy_3/, …_5/ |
60d mean $vol |
| Momentum (12-1) tertiles | backtest_by_mom_12_1_3/ |
log(P_{t-21}) − log(P_{t-21-252}) |
| Realized-vol (60d) tertiles | backtest_by_vol_60d_3/ |
std(daily ret) × √252 |
Prediction disagreement (p_up_std) |
backtest_by_p_up_std_3/ |
cross-seed dispersion |
- Size: LS monotonically stronger in smallest $-volume tertile — the signal is largely a small-cap phenomenon, consistent with JKX paper.
- Vol: High realized-vol tertile has both highest gross LS and the highest rebalancing frequency.
- Momentum: Bottom-momentum tertile (recent losers) has the richest LS — CNN exploits the short-horizon reversal inside loser names.
- Disagreement: Sorting by cross-seed
p_up_stddoes not improve LS noticeably — ensemble disagreement is not a useful signal filter. - Sectors: 3 weak sectors emerge — Utilities, Real Estate, Industrials — with essentially zero or negative LS. Most other BICS-1 sectors have positive and significant LS.
Full per-slice CAGR / Sharpe / NW-t tables live in each
*_summary.xlsx file.
Inspired by the size/vol/momentum findings, we intersected the three "bad-name" buckets and ran a 50/50 LS inside the intersection.
Scripts:
scripts/backtest_trash_tier.py— per-filter 50/50 LS portfoliosscripts/trash_tier_turnover.py— month-over-month ticker turnoverscripts/trash_tier_yearly.py— per-calendar-year LS return and turnover
Filter stack (univ = all R1000 names with all 3 features available):
| Filter | Mean univ names/mo | Mean top-side names |
|---|---|---|
| universe | ~910 | ~455 |
| small | ~303 | ~151 |
| small & high-vol | ~193 | ~97 |
| small & recent-loser | ~148 | ~74 |
| high-vol & recent-loser | ~168 | ~84 |
| triple (small & high-vol & recent-loser) | ~124 | ~62 |
| Stat | Universe 50/50 | Triple 50/50 |
|---|---|---|
| Months | ~324 | ~317 |
| TOP CAGR | +10.5 % | +27.5 % |
| BOT CAGR | +4.0 % | −6.5 % |
| LS CAGR | +6.1 % | +28.5 % |
| LS ann-vol | ~6.8 % | ~19.2 % |
| Sharpe | ~0.90 | ~1.07 |
| NW t(0) | +4.5 | +5.05 |
| Cum × | ~9 | ~360 |
- Universe: ~42 % per month, per side
- Triple: ~60 % per month, per side (annualised ≈ 720 % two-sided)
- Avg holding period: ~0.8 months (less than a month)
The triple-filter portfolio is a high-turnover trash-name book that leans heavily on intra-month reversal. Gross returns are excellent; whether they survive costs is the rest of this addendum.
| Year | LS (%) | Year | LS (%) | Year | LS (%) |
|---|---|---|---|---|---|
| 2000 | +82 | 2009 | +12 | 2018 | −33 |
| 2001 | +74 | 2010 | +41 | 2019 | +25 |
| 2002 | +52 | 2011 | +28 | 2020 | +54 |
| 2003 | +15 | 2012 | +22 | 2021 | +66 |
| 2004 | +19 | 2013 | −3 | 2022 | +18 |
| 2005 | +14 | 2014 | +30 | 2023 | +47 |
| 2006 | +9 | 2015 | +25 | 2024 | +39 |
| 2007 | +18 | 2016 | +17 | 2025 | +14 |
| 2008 | +41 | 2017 | +14 | 2026 YTD | +22 |
2018 is the single problem year — concentrated in Dec 2018 when the Fed pivoted dovish and low-quality / short-interest stocks rocketed. The triple filter is long-loser-short-winner, so that short squeeze bit hardest exactly where the model is most exposed.
Filtering out Utilities, Real Estate and Industrials before running the triple filter:
- Universe / mo: ~100 (vs 124)
- LS CAGR: +30.0 % (vs +28.5 %)
- Sharpe: 0.90 (vs 1.07)
- NW t: +4.28 (vs +5.05)
Conclusion: the excluded sectors were mild drags on gross return but
useful diversifiers on risk. Removing them improves CAGR marginally but
hurts Sharpe / t-stat — the three weak sectors are noise-dampeners, not
alpha-dilutors. Artefacts in backtest_trash_tier_ex3sectors/ and
predictions_monthly_ex3sectors.parquet.
Hypothesis 1 — Small-cap relative momentum. 2018 was a strong- small-cap year, so maybe LS fails when small-caps outrun large-caps. We computed a rolling small-minus-large relative-momentum factor (smallest-tertile $vol stocks' 12-1 mean ret – largest-tertile's) and correlated it with monthly triple-filter LS.
Result: ρ = +0.36, the opposite sign from the hypothesis. The median LS return is almost identical between strong-small vs weak-small regimes — what differs is the hit-rate (57 % vs 86 %). The bad months in 2018 are not a systematic small-running-hot regime; they are a squeeze event. Hypothesis rejected.
Hypothesis 2 — Short-term reversal overlay. We built binary and linear aggressiveness overlays keyed off 1-month and 3-month LS momentum (idea: shrink positions after the strategy gets hot). Every overlay version reduced CAGR and Sharpe. Binary variants shut off in productive months (2009, 2020, 2023) while only modestly dampening 2018. No overlay we tried dominates the unconditional strategy.
We built two cost studies:
-
scripts/Corwin-Schultz H/L-based estimator (backtest_by_cs_spread.pystyle). Uses daily high/low prices (Corwin-Schultz 2012) to estimate an unobserved bid-ask spread at the stock-day level, then averages across a rebalance's holdings.- Mean CS spread on triple-filter portfolio: ~208 bps (full)
- This is likely overstated by 2–3× for small-cap high-vol names, which the Corwin-Schultz estimator is known to inflate.
-
Break-even analysis. With ~12 rebalances/year and ~60 % one-sided turnover per side per month, the cost drag at a full spread
sbps is approximately:drag ≈ 12 × 2 × turnover × (s / 2) / 10000 ≈ 12 × 2 × 0.6 × (s / 2) / 10000 ≈ 0.0072 × s (per year, as fraction)Triple LS gross ~28.5 %. Break-even full-spread
s* ≈ 28.5 / 0.0072 ≈ 3,958 bps— but this double-counts both long and short sides. One-sided break-even on just the half-spread:LS break-even half-spread h* ≈ 28.5 / (24 × 0.6) × 100 bps ≈ 198 bpsEven the inflated Corwin-Schultz spread of 208 bps is uncomfortably close to break-even. A realistic half-spread of ~40–50 bps (CS × 0.4 haircut) puts net LS around +10–15 % — still attractive but fragile.
-
$5M-per-side capacity (
scripts/ibkr_triple_tier_costs.pydriver, offline mode). Applied Almgren-style impact model:impact_bps = c × σ_daily × sqrt(Q / ADV) × 10000 (one-way)with
c = 1.5,σ_dailyfrom realized daily return std,ADVfrom 60d mean $vol,Q = 5e6. Added a liquidity filter (ADV ≥ $5M, per- name cap 10 % of ADV).- Universe after liquidity filter: 29 / 28 names (top/bot), down from ~62 each side. About 75 % of the alpha-richest names drop out because they don't have $5M ADV.
- Gross LS after liquidity filter: +13.9 % (from +28.5 %).
- Mean one-way impact: ~80–95 bps.
- Round-trip impact × 2 sides × 12 months × 60 % turnover: cost drag ~37 %/yr.
- Net LS: ≈ −21.5 %.
Conclusion: the triple-filter strategy as-is does not survive $5M-per-side. Capacity is probably $500k–$1M per side. Any deployment needs either:
- A much smaller AUM target, or
- A slower-turning variant (e.g., quarterly rebalance, or position- by-position Kelly-shrunk), or
- Better execution (VWAP, IS algos, internal cross) than the blunt impact assumption above.
scripts/ibkr_triple_tier_costs.py connects to a local TWS or IB
Gateway (via ib_insync) and pulls per-ticker:
- Live (or delayed) best bid / best ask → realized half-spread
reqHistoricalData(whatToShow='BID_ASK')over 40 days → stable time-averaged spread estimate- Shortability tick (generic tick
236) — proxy for whether the short side of the LS is executable at all - Realized daily σ over the historical window for impact calculation
It then re-prices the triple-filter monthly portfolios using the measured IBKR spreads (capped, Winsorised) instead of the Corwin- Schultz estimate.
- The user's IBKR account does not have a live US-equity market-data
subscription, so
reqMktDatareturnsdelayedBid=None, delayedAsk= Noneon unsubscribed tickers — only trades (last/HLC) come through. - Running against the live TWS (port 7496) confirms the connection and symbol lookup work; the cost model falls back to the CS estimate for any ticker where both live and historical BID_ASK come back empty.
- Next step: either enable the "US Securities Snapshot and Futures Value Bundle" (~$10/mo) on the IBKR account, or scrape AltaVista ETF Research's "Avg Sp" column for the small number of ETF-like proxies that AltaVista covers.
ib_insync needs an event-loop shim at import time under 3.14:
import asyncio as _asyncio
try:
_asyncio.get_event_loop()
except RuntimeError:
_asyncio.set_event_loop(_asyncio.new_event_loop())
from ib_insync import IB, Stock, utilWithout this, import fails with RuntimeError: There is no current event loop in thread 'MainThread' because 3.14 removed the implicit
event-loop-on-demand behaviour.
python scripts/ibkr_triple_tier_costs.py \
--predictions runs/expanding/20260419_174908_cdef6809/predictions_monthly.parquet \
--ohlcv data/r1000_ohlcv_database.parquet \
--out-dir runs/expanding/20260419_174908_cdef6809/ibkr_costs \
--ib-host 127.0.0.1 --ib-port 7496 --ib-client-id 42 \
--aum-per-side 5e6 --min-adv 5e6 --adv-cap 0.10 --c-impact 1.5 \
--market-data-type 3--market-data-type: 1=live, 2=frozen, 3=delayed (free), 4=delayed-frozen.
Outputs:
ibkr_costs/ibkr_spreads.parquet— per-ticker IBKR spread snapshotibkr_costs/triple_net_costs.xlsx— per-month gross / impact / spread / net, plus summary rowibkr_costs/triple_net_cum.pdf— cumulative gross vs net
- The paper replicates cleanly. Full R1000 LS: +17.4 %, Sharpe 1.22, NW t +7.99 over 1999-03 → 2026-03.
- Alpha concentrates in small, high-vol, recent-loser names. The triple-filter subset gross-returns ~28.5 % with Sharpe ~1.07 on a ~124-name universe.
- Alpha is high-turnover. ~60 % of names rotate every month per side. The strategy is an intra-month reversal exploit, not a buy-and-hold anomaly.
- Simple regime overlays don't help. Small-cap-momentum and short- term reversal timing both reduce Sharpe. The bad year (2018) is a squeeze event, not a regime feature.
- Costs are the binding constraint. Estimated half-spread on the triple-filter book is 50–100 bps realistic, 208 bps inflated (Corwin-Schultz). Break-even half-spread is ~200 bps, so costs eat most of the gross alpha. At $5M per side with a 10 %-ADV cap, net return is negative — capacity is probably $500k-$1M.
- The signal is strongest exactly where transactions are most expensive. This is the central tension of the paper's trash-tier alpha — small, illiquid, volatile names. Any production deployment needs realistic execution cost modelling and low AUM.
After concluding that the trash-tier triple-filter is capacity-limited to ~$500k–$1M, the next two questions were:
(i) Does the CNN transfer zero-shot to a universe with naturally low spreads and easy borrow — i.e. ETFs? (ii) Inside R1000, is there a cell with modest gross return but genuinely cheap execution that survives realistic costs?
Universe build. Pulled a 4,374-ETF Bloomberg screen
(/Users/arjundivecha/Downloads/ETF.xlsx), converted "SPY US" → "SPY"
for yfinance, dropped leveraged / inverse / 2x / 3x / YieldMax / covered-call
products, then filtered on:
bid-ask spread ≤ 5 bps AND
30-day $-volume ≥ $10M AND
30-day share-volume ≥ 100k
→ 477 liquid ETFs (AssetList_liquid_etfs.xlsx). Spread
distribution: 223 @ 1-2 bps, 96 @ 2-3, 69 @ 3-4, 49 @ 4-5.
Data. 100 % yfinance fetch success →
data/liquid_etf_ohlcv.{csv,parquet} (1.6 M rows, 477 tickers,
1993-01 → 2026-04). History depth: 378 ETFs ≥ 5 yr, 300 ≥ 10 yr,
209 ≥ 15 yr.
Image cache. Added a --monthly flag to
scripts/render_etf_cache.py that keeps only the last trading day
of each calendar month per ticker (≈ 20× smaller cache, monthly-cadence
predictions). Final cache:
cache/liquid_etf_I20_monthly/images.npy 215 MB, (58,767, 1, 64, 60) uint8
cache/liquid_etf_I20_monthly/index.parquet 603 KB, 58,767 rows, 427 tickers,
361 monthly dates 1996-03 → 2026-03
Predictions. Applied the 28-window R1000-trained ensemble zero-shot. Overall OOS AUC = 0.5078 (vs R1000 benchmark 0.5068) — there is a trace of signal, but it is not economically useful.
Backtests (in runs/liquid_etf_expanding/):
| Filter | CAGR | Sharpe | NW t |
|---|---|---|---|
| 50/50 LS | −0.44 % | −0.08 | −0.39 |
| Top/Bot 10 % | +0.27 % | +0.02 | +0.13 |
| Top/Bot 20 names | +1.21 % | +0.13 | +0.66 |
| triple (small & high-vol & loser) | −1.42 % | −0.18 | −0.95 |
Conclusion. The CNN alpha is a single-stock idiosyncratic-reversal effect. ETFs are diversified baskets of dozens to hundreds of single names, so the very source of the signal is averaged away on the underlying basket. Even the triple-filter, which on R1000 single names is the strongest cell of the entire study, goes negative once the universe becomes ETFs. Cheap execution does not rescue a signal that isn't there.
Question (ii) — finding a tradable R1000 cell with modest return but cheap execution — was the productive turn.
scripts/backtest_liquid_grid.py buckets every month-end by tertile on
dv_60d, vol_60d, and mom_12_1, then builds 50/50 long-short books
inside each cell. Per cell it reports gross LS, NW t, one-sided
monthly turnover, and a realistic cost drag using
half-spread = {Low-dv: 25 bps, Mid-dv: 8 bps, High-dv: 2.5 bps}.
| Cell | months | gross LS | net LS | NW t | half-spread |
|---|---|---|---|---|---|
| dv = Low (all) | 282 | 9.63 % | 6.56 % | 5.71 | 25 bps |
| dv = Mid (all) | 282 | 3.52 % | 2.49 % | 3.18 | 8 bps |
| dv = High (all) | 282 | 1.49 % | 1.19 % | 1.17 | 2.5 bps |
| All R1000 | 282 | 4.86 % | 3.92 % | 4.49 | 8 bps* |
* notional weighted-average tier.
| Cell | names | net LS | Sharpe | NW t |
|---|---|---|---|---|
| dv=Low × vol=High | 214 | 16.48 % | 1.21 | 5.90 |
| dv=Mid × vol=High | 150 | 7.34 % | 0.74 | 3.59 |
| dv=High × vol=High | 146 | 6.01 % | 0.61 | 2.98 |
| Cell | names | net LS | Sharpe | NW t |
|---|---|---|---|---|
| dv=Low × mom=Low | 211 | 16.07 % | 1.34 | 6.50 |
| dv=Mid × mom=Low | 163 | 6.98 % | 0.85 | 4.11 |
| dv=High × mom=Low | 146 | 3.96 % | 0.44 | 2.11 |
| dv=High × mom=Mid | 181 | 2.24 % | 0.46 | 2.24 |
| Cell | names | gross LS | net LS | Sharpe | NW t | half-spread |
|---|---|---|---|---|---|---|
| Low × High × Low | 124 | 26.97 % | 23.42 % | 1.04 | 5.05 | 25 bps |
| Mid × High × Low | 71 | 14.28 % | 13.01 % | 0.79 | 3.86 | 8 bps |
| Mid × High × High | 51 | 10.34 % | 9.01 % | 0.57 | 2.76 | 8 bps |
| High × High × Low | 59 | 7.25 % | 6.85 % | 0.42 | 2.02 | 2.5 bps |
| Mid × Low × Low | 37 | 5.77 % | 4.34 % | 0.46 | 2.22 | 8 bps |
Of 27 cells, only those 5 pass the (net > 0, t ≥ 2) hurdle. The structure is highly concentrated: High-vol is the load-bearing filter, mom=Low (recent loser) stacks on top of it, and the effect persists out of the cheap-to-trade zone for the first time.
-
Takeaway #5 / #6 from section G need to be qualified. Yes — the strongest gross alpha is in the trash tier (Low-dv × High-vol × Low-mom, 23 % net, 124 names). But there are now two cleaner-cost alternatives:
- Mid-dv × High-vol × Low-mom: ~71 names, ~$500M–$5B per name, 8 bps half-spread, easy borrow. +13 % net CAGR, Sharpe 0.79, t +3.86. This is the workhorse cell — modest return for a single-name strategy but it actually clears realistic costs.
- High-dv × High-vol × Low-mom: ~59 mega-cap names (TSLA / NVDA / COIN-type beaten-down volatile blue chips), 2.5 bps half-spread, trivial borrow. +6.9 % net, Sharpe 0.42, t +2.02. Largest capacity of any cell.
-
A reasonable production stack is Mid-cell + High-cell ≈ 130 names per side, gross ~10 %, Sharpe ~0.7, deep capacity.
All nine Mid-vol cells are flat or negative net. All six dv × mom=High cells are flat or negative net. The CNN's edge is concentrated entirely in the high-vol / low-momentum tails — exactly the regime where mean-reversion dominates trend.
scripts/backtest_liquid_grid.py— 27-cell grid driver.runs/expanding/20260419_174908_cdef6809/backtest_liquid_grid/liquid_grid_summary.xlsx— sheetsmarginals,dv_x_vol,dv_x_mom,dv_x_vol_x_mom,tradable.
The signal documented in section G is real and the trash-tier result (28 % gross, $500k-$1M capacity) still stands. What changed is that section G's pessimistic conclusion — "costs eat all the alpha" — is universe-specific, not signal-specific. Climb one tertile up the dollar-volume ladder, keep the High-vol × Low-mom sub-filter, and you get a tradable ~13 % net book with eight-times the capacity. The CNN signal degrades smoothly with size and liquidity rather than collapsing — the right deployment is the mid-cap high-vol loser cell, not the smallest-name trash tier.
Motivation. Author works at an investment firm with restrictions on trading individual stocks but is permitted to trade ETFs of any kind, including single-stock ETFs (SSEs). The question for this addendum: can the R1000-trained CNN edge be deployed through any combination of ETFs the firm allows?
Short answer: No. The alpha is a single-stock, small-cap, high-vol, recent-loser cross-sectional effect. ETFs — basket or single-stock — either average it away or carry it on the wrong tail of the distribution. Below are the four attempts and why each failed.
Hypothesis. Maybe zero-shot transfer fails because the CNN never saw ETF charts. Retraining on the ETF universe (with or without warm-start from the R1000 ensemble) might fix it.
Test. On window 11 (2010 test year), trained two ETF-specialised variants against the R1000 zero-shot baseline:
| Variant | AUC | LS CAGR | LS Sharpe |
|---|---|---|---|
| R1000 baseline (zero-shot) | 0.5125 | +1.14 % | +0.127 |
| Train from scratch on ETFs | 0.5040 | −0.59 % | −0.064 |
| Fine-tune from R1000 | 0.5041 | −0.68 % | −0.063 |
Both ETF-trained variants underperformed the zero-shot R1000 model. Retraining does not help — the ETF universe simply lacks the cross-sectional dispersion the CNN can exploit. Full 28-window retrain was skipped on this evidence (running it would just confirm the result more rigorously at the cost of ~1 hour of compute).
Files: runs/etf_scratch_w11/, runs/etf_finetune_w11/ (each contains
comparison.txt, *_predictions.parquet, training log).
Trick. SSEs are the loophole around the firm's stock-trading restriction: they ARE ETFs but each tracks ONE underlying. A long/short book can be built entirely from long positions in two products per underlying — the 2x bull-leveraged ETF for "long" bets and the inverse-leveraged ETF for "short" bets. No actual short-selling, no borrow costs.
Universe. Hand-curated 48-pair seed list spanning the major issuers (Direxion, GraniteShares, T-Rex/REX Shares, Tradr, Defiance) across both Direxion-style asymmetric pairs (+2x long / −1x short) and the cleaner symmetric ±2x pairs from T-Rex / Defiance. Validated each ticker via yfinance:
- 121 candidate tickers (44 underlyings + 77 wrappers)
- 120 returned valid price history; 1 delisted (AMDS)
- 44 underlyings with at least one long wrapper, 29 of which have a matching inverse wrapper (the "complete-pair" subset)
History depth:
- Aug-Sep 2022 inception (6 underlyings: AAPL, AMZN, GOOGL, MSFT, TSLA + COIN long-only) — ~3.7 yr
- Dec 2022 / 2023 expansion (+NVDA, BABA) — ~3 yr
- 2024 expansion (+META, MSTR, TSM, MU, PLTR, SMCI) — ~1.5 yr
- 2025+ rest of the universe — < 1 yr each
Effective backtest window: 2022-09 → 2026-04 (44 monthly observations), universe growing from 6 → 30+ over time.
Files:
data/sse_pairs_seed.csv— hand-curated 48-row pair listdata/sse_pairs.csv— yfinance-validated pair tabledata/sse_underlying_ohlcv.csv— 179,843 rows × 44 underlyingsdata/sse_wrapper_ohlcv.csv— 33,103 rows × 77 wrappersscripts/fetch_sse_data.py
Setup. Score every (underlying, month-end) using the existing R1000-trained 28-window ensemble (same approach as the liquid-ETF test in section H). Image cache geometry identical to training.
Overall OOS AUC on the 44 SSE underlyings = 0.5136 — slightly better than the R1000 baseline (0.5068) because these are mostly volatile mega-cap names the CNN has seen during training. The model "recognises" the universe.
Trading mechanic. Per month-end, rank by p_up_mean. Top half →
buy the 2x long-leveraged ETF. Bottom half → buy the inverse-leveraged
ETF (long position, no shorting). Portfolio return per dollar of
capital = 0.5 × (mean(R_long_etf | top) + mean(R_inverse_etf | bot)).
Costs modelled: 1.0 % p.a. expense ratio prorated + 5 bps half-spread per leg.
Results (44 months, 2022-09 → 2026-04, mean univ ≈ 20):
| Variant | Months | Gross CAGR | Net CAGR | Sharpe | NW t |
|---|---|---|---|---|---|
| complete-pairs (true LS via wrappers) | 44 | −16.47 % | −19.31 % | −0.50 | −0.96 |
| long-only (top-half via long-ETF) | 44 | +51.02 % | +46.09 % | +1.11 | +2.15 |
| full (long-only with hedge where available) | 44 | −26.67 % | −29.20 % | −1.40 | −2.70 |
Diagnosis (the smoking gun). Computing the underlying-only L/S spread (i.e. if we could trade the actual stocks long-short, no wrapper):
- Underlying TOP half (CNN predicts UP): +2.04 %/mo
- Underlying BOT half (CNN predicts DOWN): +5.67 %/mo
- Underlying LS: −3.64 %/mo, t = −2.14, Sharpe = −1.12
The CNN signal is inverted on this universe in this regime. The loss is NOT a wrapper-decay artifact. An underlying-only L/S book loses 38 % CAGR. The reason: the CNN learned a 20-day mean-reversion pattern from R1000 1999-2022. The SSE universe is dominated by mega-cap momentum names (TSLA, NVDA, MSTR, COIN, PLTR, Mag 7) where momentum persists. The signal's "oversold buy" calls land on names that keep falling; its "overbought sell" calls land on names that keep ripping. Hence the inversion.
For context, the R1000 signal still works in the same window (2022-09 → 2026-04 on R1000): +7.89 % CAGR, t = +3.20, Sharpe = +1.82. The signal is universe-specific, not regime-broken.
Files:
runs/sse_underlying_expanding/predictions.parquet— 5,970 rowsruns/sse_underlying_expanding/backtest_sse/sse_summary.xlsxruns/sse_underlying_expanding/backtest_sse/sse_cum.pdfscripts/backtest_sse.py
Hypothesis. Maybe a different signal (momentum follower, not reversal) works on the SSE universe. Tested six classic cross-sectional signals against the same wrapper mechanics:
mom_12_1, mom_6_1, mom_3_1, rev_1m, low_vol (vol_60d inverted), trend
| Signal | Gross CAGR | NW t | Underlying-LS CAGR (no leverage) |
|---|---|---|---|
| rev_1m | +547 % | 7.21 | −7.6 % |
| EW_basket (no signal) | +422 % | 7.88 | n/a |
| mom_3_1 | +263 % | 5.06 | −8.9 % |
| mom_6_1 | +241 % | 4.61 | −21.1 % |
| trend | +216 % | 4.72 | −11.5 % |
| mom_12_1 | +157 % | 3.60 | −16.8 % |
| low_vol | +14 % | 1.31 | −47.8 % |
| Signal | Gross CAGR | NW t | Underlying LS |
|---|---|---|---|
| rev_1m | +166 % | 5.21 | −32 % |
| mom_6_1 | +101 % | 3.93 | −23 % |
| mom_3_1 | +92 % | 3.82 | −7 % |
| trend | +91 % | 4.15 | +12 % |
| mom_12_1 | +62 % | 2.77 | −33 % |
| low_vol | −54 % | −7.84 | −67 % |
The crucial column is underlying-LS CAGR — the signal's true stock-picking skill, stripping away wrapper leverage. Every signal is zero or negative in underlying space. Translation:
-
"+547 % CAGR rev_1m long-only" is not signal alpha — the no-signal EW basket of all 44 long-ETFs returns +422 % CAGR on its own. rev_1m adds ~+2.5 %/mo on top, plausibly from leverage convexity rather than picking-skill (the underlying-LS is −7.6 %).
-
"+166 % complete-pairs rev_1m" is roughly half the long-only result because the inverse-ETF leg averages ~0 over this bull market — it hedges market beta but contributes no cross-sectional alpha.
-
What 2022-2026 actually rewarded: owning 2x mega-cap SSEs unhedged. The +422 % EW basket CAGR is the 2x leverage compounding in a smooth bull market on mega-cap momentum names. No signal needed.
-
Low-vol is the only signal with significantly negative underlying skill (−48 %) — high-vol mega-caps decisively beat low-vol in this period (classic low-vol-anomaly inversion in a mega-cap-momentum regime).
Files:
scripts/backtest_sse_momentum.pyruns/sse_underlying_expanding/backtest_sse_momentum/sse_momentum_summary.xlsxruns/sse_underlying_expanding/backtest_sse_momentum/sse_momentum_cum.pdf
The CNN edge lives in the single-stock cross-section of the broader R1000, specifically in small-cap, high-vol, recent-loser names (section H's tradable cell: +13 % net CAGR, t +3.86, Mid-dv × High-vol × Low-mom). For an ETF-only mandate, this is structurally inaccessible:
-
Liquid baskets average it out. Section H's 477-liquid-ETF zero-shot test: all LS results between −1 % and +1 %. Diversification eliminates the idiosyncratic-reversal signal by construction.
-
Single-stock ETFs cover the wrong tail. Issuers make wrappers for ~40 ultra-popular mega-cap names — the exact opposite of the alpha's natural habitat. No one issues SSEs on unknown small-cap losers because there is no retail demand.
-
Sector / thematic ETFs are baskets of those same mega-caps. XLK, SOXX, ARKK, MAGS, etc. Same diversification problem.
-
Retraining on ETFs does not help — I.1 documented that ETF-specialised models underperform the zero-shot R1000 transfer. The ETF universe lacks the cross-sectional dispersion to train on.
-
Generic momentum / reversal signals on SSEs have no stock-picking skill — I.4 documented zero/negative underlying-LS for every classical signal tried. Wrappers + bull market mask this in headline returns.
The replication is correct (section A–B). The alpha is real and deployable in single-stock space (section G + H's mid-cap cell). It cannot be deployed via any ETF wrapper available to a US investor. This is a constraint mismatch, not a model failure:
- The signal is structurally cross-sectional, idiosyncratic, and concentrated in names too small/illiquid/specific for any ETF issuer to wrap.
- Both directions tried (basket-level ETFs and single-stock ETFs) fail for orthogonal reasons (averaging vs universe-skew).
- Retraining on the constrained universe does not produce a deployable alternative — the universe is the binding constraint.
For a fund with the user's restrictions, the CNN-pattern signal is not actionable. Pursue an unrelated alpha source compatible with ETF-only execution (sector rotation with macro inputs, vol-of-vol on VXX/UVXY, calendar/seasonality on broad-market ETFs, fixed-income or FX-ETF carry/momentum). Pattern-CNN remains a documented, working single-stock alpha that requires single-stock execution capability.
This addendum supersedes the headline numbers in sections B, G and H. Those results are inflated roughly 2× by sub-penny bankruptcy shells. The signal is real and statistically significant at every filter tested — it is about half the size previously reported.
While validating the Bloomberg→yfinance splice (§J.4), the cross-sectional daily return moments were plotted around the seam as a sanity check. Two pre-seam days showed a cross-sectional standard deviation of 132 % (2026-04-14) and 407 % (2026-04-16) across ~1,336 stocks — arithmetically impossible for real equities.
The defect is in the Bloomberg leg, predates the splice, and has been present in every backtest this project has run.
The Bloomberg panel contains 2,017 rows across 309 tickers with a 1-day
AdjClose return exceeding ±100 %, spanning 1996-04-12 → 2026-04-16.
They are sub-penny bankruptcy shells — -Q suffix tickers (DZSIQ, SPWRQ,
EVVAQ, WLTGQ, BIGGQ, BLIAQ …) trading between $0.0001 and $0.03, where a
single tick is a +4,800 % return. A few divide by a near-zero prior price and
produce inf.
This is not a data error to be cleaned away. The prices are what Bloomberg reports and the returns are arithmetically correct. They are economically meaningless.
A sub-penny bankrupt shell has:
| Feature | Value | Tertile |
|---|---|---|
dv_60d (dollar volume) |
tiny | Low |
vol_60d (realized vol) |
enormous | High |
mom_12_1 (momentum) |
terrible | Low |
So it sorts into the small × high-vol × recent-loser cell by construction. The contamination is not spread evenly across the panel — it concentrates precisely where the headline alpha is measured.
In the monthly prediction set: 1,002 of 453,002 rows (0.22 %) have |forward return| > 100 %, carrying 3.80 % of total absolute return mass. 53.7 % are sub-$1 and 31.6 % are sub-$0.10.
Both backtest_trash_tier.py and backtest_liquid_grid.py now take
--min-price. --min-price 5.0 is the deployable setting; 0 reproduces
the old published numbers. A $5 floor drops 7.1 % of rows (453,002 → 421,007).
| Variant | Gross CAGR | Sharpe | NW t | Mean names |
|---|---|---|---|---|
| As published (no floor) | +23.5 % | 1.04 | 5.04 | 124 |
| Winsorize returns ±100 % | +21.0 % | 1.18 | 5.72 | 124 |
| Price ≥ $5 | +12.8 % | 0.76 | 3.71 | 90 |
| Price ≥ $5 + winsorize | +10.1 % | 0.79 | 3.82 | 90 |
| Variant | Gross CAGR | Sharpe | NW t |
|---|---|---|---|
| As published (no floor) | +43.9 % | 1.43 | 3.29 |
| Price ≥ $5 | +26.7 % | 1.14 | 2.64 |
Full sample (282 months):
| Cell | Names | Gross | Net | Sharpe | t | Half-spread |
|---|---|---|---|---|---|---|
| Low × High × Low (trash) | 90 | +12.8 % | +8.9 % | 0.76 | 3.71 | 25 bp |
| Mid × High × High | 54 | +9.3 % | +7.9 % | 0.55 | 2.66 | 8 bp |
| Mid × High × Low | 70 | +8.4 % | +7.1 % | 0.55 | 2.67 | 8 bp |
| Low × High (marginal) | 175 | +9.8 % | +6.2 % | 0.92 | 4.46 | 25 bp |
| Mid × High (marginal) | 151 | +6.7 % | +5.5 % | 0.66 | 3.22 | 8 bp |
| All R1000 | 1,451 | +2.8 % | +1.9 % | 0.61 | 2.97 | 8 bp |
Since 2020 (63 months):
| Cell | Names | Gross | Net | Sharpe | t | Half-spread |
|---|---|---|---|---|---|---|
| Low × High × Low (trash) | 101 | +26.7 % | +22.9 % | 1.14 | 2.64 | 25 bp |
| High × High × High | 52 | +16.1 % | +15.7 % | 0.93 | 2.14 | 2.5 bp |
| Low × High (marginal) | 178 | +14.4 % | +10.8 % | 1.26 | 2.91 | 25 bp |
| High × High (mega-cap, high-vol) | 126 | +10.8 % | +10.5 % | 1.23 | 2.84 | 2.5 bp |
| All R1000 | 1,360 | +3.9 % | +2.9 % | 0.97 | 2.24 | 8 bp |
Note that Mid × High × Low — the "workhorse" cell recommended in §H.2 — does not appear in the since-2020 tradable list at all, consistent with the regime finding already recorded there.
Winsorizing and price-flooring answer different questions, and the gap between them is the diagnosis:
- Winsorizing at ±100 % barely moved the result (+23.5 % → +21.0 %).
- A $5 price floor roughly halved it (+23.5 % → +12.8 %).
If a handful of freak outliers were driving the result, winsorizing would have killed it. It did not. So the driver is a broad population of low-priced names, and most of those returns are real — just untradeable. A one-cent spread on a $0.30 stock is 333 bp; the strategy would pay the entire edge away on the bid-ask bounce.
That makes this a capacity finding, not a data-cleaning one — and it compounds the §E/§G conclusion rather than replacing it.
- The signal is real. The Newey-West t-statistic stays between 2.6 and 3.8 in every filtered variant. Significance never breaks.
- The magnitude was about double. Honest deployable gross is ~+13 % full-sample and ~+27 % since 2020 at a $5 floor, not +28.5 % / +44 %. Net of the 25 bp half-spread drag, roughly +9 % and +23 %.
- One genuinely new candidate. Since 2020, the mega-cap high-vol cell (High-dv × High-vol, 126 names) returns +10.5 % net at a 2.5 bp half-spread with Sharpe 1.23 — the best cost-adjusted cell in the recent regime, and by far the largest capacity. It rests on only 63 months (t 2.84) and did not work full-sample, so treat it as a lead to test, not a result.
scripts/splice_daily_update.py extends the Bloomberg panel forward with
yfinance so the pipeline refreshes daily without a Bloomberg pull. Bloomberg
remains the history of record and is never rewritten.
The one thing that matters: the two AdjClose columns are not on the
same scale. Bloomberg's total-return index accumulates dividends forward
from 1996; yfinance's Adj Close is back-adjusted from today. Measured level
ratios on 2026-04-17 — AAPL 1.19, MSFT 1.64, JNJ 2.10. Concatenating them
would inject a fake 20–110 % one-day return at the seam. AdjClose is
therefore chained off the Bloomberg anchor; raw OHLCV is appended directly
(verified to match Bloomberg exactly, return correlation 1.000000).
Validation, 2026-08-04:
| Check | Result |
|---|---|
| yfinance coverage | 99.0 % (198/200 sampled) |
| Overlap return correlation | median 1.000000, min 0.9949 (1,339 tickers) |
| Median absolute daily difference | 0.0015 bp |
| Panel extended | 2026-04-17 → 2026-08-04 |
Guards: ticker-reuse (only currently-live names extend, so a ticker that went dark in 2005 is never resurrected); per-ticker overlap correlation test with quarantine (correctly rejected APTV and BDX, both real spinoffs); atomic writes; and a staleness manifest.
Known free-source holes — 33 of 1,371 names (2.4 %) do not extend. BK,
CTRA and VSCO hard-404 on Yahoo despite being live liquid companies; MASI,
SATS, IAC, NSA, JHG, BLD, LC and GOCO return 1–29 rows over seven months.
Retested with pauses — this is not rate-limiting. All 33 are written to
data/r1000_tradeable_universe.csv with tradeable=False; daily scoring
must drop them, because a stale price silently poisons dv_60d, vol_60d
and mom_12_1 and can put a name in the wrong tertile.
python scripts/splice_daily_update.py --csv # daily refreshRefresh from Bloomberg once a year, right after the June reconstitution. That is both the minimum and genuinely sufficient:
- Deletions self-resolve — a dead name stops returning yfinance data, is flagged stale, and drops out automatically.
- Additions are the only real gap (~150–200 names/yr enter the R1000), but
the CNN needs 20 days for the image and
mom_12_1needs 273 days, so a new entrant is not scoreable for ~13 months anyway. Chasing quarterly IPO adds buys nothing. - Cost is trivial — ~1,400 unique securities once a year against a ~5,000/month quota.
Caveat: tertile boundaries drift as the universe ages. One year (~15 % turnover) is tolerable; two or more degrades the buckets. The trash tier is more sensitive than the full universe because it lives at the R1000/R2000 boundary where churn is highest.
scripts/splice_daily_update.py— the splice, with validation and guardsdata/r1000_ohlcv_spliced.{parquet,csv}— 12,536,918 rows, 3,185 tickers, 1996-01-02 → 2026-08-04data/r1000_tradeable_universe.csv— staleness manifest (1,338 tradeable)runs/splice/<TIMESTAMP>/{splice_report.xlsx, splice_summary.json, splice.log}runs/expanding/.../backtest_trash_tier_p5/— trash tier at a $5 floorruns/expanding/.../backtest_liquid_grid_p5/— 27-cell grid at a $5 floorruns/expanding/.../backtest_liquid_grid_p5_since2020/— same, since 2020
This addendum supersedes the CAGR figures in every prior section, including addendum J. Addendum J corrected a contamination problem; this corrects an arithmetic one, and it is the larger of the two. The signal remains real.
Verified empirically against the price panel, not assumed:
median | log(P_t+20 / P_t) − forward_return | = 0.00000 (h=20)
median | (P_t+20 / P_t − 1) − forward_return | = 0.0024 (h=20)
forward_return is a 20-trading-day LOG return. Every LS number this
project has published is therefore a difference of mean log returns across
names, compounded.
An equal-weighted portfolio earns the mean of simple returns, not the mean
of log returns. By Jensen, mean(log) < log(1 + mean(simple)), with the gap
approximately σ²/2 where σ is the cross-sectional dispersion of returns
within the leg.
For a long/short spread the two legs' penalties do not cancel — the short leg is subtracted, so its dispersion penalty is added to the reported spread. The trash tier's bottom leg has a cross-sectional SD of 0.178 per 20-day period, i.e. a σ²/2 term of ~1.58 %/month, or ~19 %/yr of phantom return before the long leg's own (smaller) offset.
The effect is largest exactly where dispersion is largest — the trash tier.
Trash tier (Low-dv × High-vol × Low-mom), $5 floor, 282 months:
| Convention | Mean/month | CAGR |
|---|---|---|
| Mean log returns (as published) | +0.892 % | +11.29 % |
| Simple returns (a portfolio) | +0.425 % | +4.15 % |
| Difference | +7.14 pp |
Full R1000 universe, no price floor:
| Book | Log CAGR | Simple CAGR |
|---|---|---|
| 50/50 top-vs-bottom half | +5.10 % | +4.12 % |
| Decile D10−D1 | +17.36 % | not computable |
Two things to note in that second table:
- The gap shrinks to ~1 pp on the broad universe (+5.10 % → +4.12 %), because cross-sectional dispersion is far lower there. The convention inflates concentrated, high-dispersion cells and barely touches diversified ones.
- The decile book is not computable at all under simple returns. Some months go below −100 %: shorting a name that 10×'s loses 900 %. The log convention bounded that tail and hid it. The $5 price floor is what makes the tracked series well-defined.
Does not change: the CNN has genuine cross-sectional predictive power. The decile monotonicity, the AUC, the sign and significance of the spread all stand. This is a reporting correction, not a refutation.
Does change: every headline CAGR. The deployable trash-tier number is ~+4 %/yr gross on portfolio arithmetic, not +28.5 % (original), and not +12.8 % (addendum J). Layer the ~25 bp half-spread cost drag on top and the trash tier is, on these numbers, not obviously a viable book at all.
Stacking both corrections, in order:
| Trash tier, full sample | CAGR |
|---|---|
| As originally published | +28.5 % |
| After the $5 price floor (addendum J) | +12.8 % |
| After portfolio arithmetic (this addendum) | +4.2 % |
| Less ~25 bp half-spread turnover drag | ≈ 0 % |
That last line is the honest bottom line for the trash tier as a standalone book, and it is a materially different conclusion from where this project started.
scripts/mark_book.py publishes two sleeves using real portfolio arithmetic —
actual prices, held shares, simple returns, marked daily:
| Sleeve | Cell | Full-sample CAGR | Sharpe | Since-2020 CAGR |
|---|---|---|---|---|
pattern-trash |
Low-dv × High-vol × Low-mom | +4.24 % | 0.31 | +4.40 % |
pattern-megacap |
High-dv × High-vol | +2.42 % | 0.27 | — |
That the marker independently reproduces the +4.15 % analytic figure (+4.24 % with the daily-marking calendar) is the cross-check that the implementation is right and the convention was the problem.
Both are paper — the book needs single-stock shorting. The series is
backtest before paper_start and paper after, carried in a track column,
because the backtest leg has now been revised twice and must not quietly become
the track record.
Benchmark is cash, not the cell's equal-weight return: these are
dollar-neutral books, so excess against a long-only cell is a beta statement,
not alpha. ew_cell is carried in the file as context only.
bash scripts/run_daily.sh # splice -> score -> mark -> publish (~10 min)splice_daily_update.py --csv— extend the Bloomberg panel with yfinancescore_live.py— w27 ensemble → today'sp_up(tradeable names only)mark_book.py— build/mark both sleeves, publishdata/tracker/<sleeve>/
Must finish before Tracker's 13:45 PT consolidation.
Rebalance guard. score_live.py emits the current day as the canonical
month-end of the running month. Left unhandled, mark_book.py would treat
every run day as a rebalance — daily turnover on a monthly strategy. It
therefore drops prediction dates falling in the latest incomplete calendar
month, so the book set at the last completed month-end is held through the
current one.
pattern-trash and pattern-megacap, both mode: paper, cadence: daily,
track: true, read by collect_pattern_sleeve in Tracker's adapters.py.
Perf packs build via build_perf_report.py --strategy pattern-trash.
Not yet wired: a holdings page, which needs a loader returning
(list[HoldingRow], BookCharacteristics). The book itself IS published to
data/tracker/<sleeve>/holdings_latest.csv and the hub row carries the
position count.
scripts/mark_book.py— book construction and daily markingscripts/run_daily.sh— the daily jobcache/r1000_I20_monthly/— 483,339 images, 2,846 tickers (pixel-stat reference)runs/live/live_predictions.parquet— latestp_updata/tracker/{pattern-trash,pattern-megacap}/— what Tracker readslogs/daily/YYYY-MM-DD.log
Rather than reflexively tuning the paper's hyperparameters, the project stepped back to ask two questions that had never been asked: is the out-of-sample machinery actually sound, and does the CNN know anything a free signal doesn't? Answers: yes, and — as deployed — no.
tests/test_splits.py only ever exercised debug_split at the default
purge_days=0, on a single-ticker monthly fixture, asserting naive set
non-overlap. The purge that underwrites every published number — all of it
produced by expanding/rolling — was untested.
tests/test_purge_guard.py now freezes it. The invariant, derived rather than
assumed: a sample at t carries an image over [t-window+1, t] and a label
over (t, t+horizon], so a train sample at t and a later sample at v
require v - t ≥ window + horizon = 40 trading days, else the training label
period physically overlaps the evaluation image.
purge_days |
train→val and val→test gap, all three modes |
|---|---|
| 39 (production) | exactly 40 trading days — the tight bound |
| 0 | 1 trading day |
No leakage exists. The out-of-sample claim was always sound; the problem was the return arithmetic (addendum K), never look-ahead.
Related fix: .gitignore carried a bare data/, which matches a directory at
any depth and so excluded the source package pattern/data/ — including
splits.py and loader.py — from every commit since the first. The two most
correctness-critical modules in the repo had never been under version control.
That is also how the April 2026 "relative label" experiment was lost: its code
was untracked, later overwritten, unrecoverable.
scripts/evaluate_signal.py scores any ranking signal identically:
portfolio (simple-return) arithmetic as the headline, the mean-log number kept
only as a labelled cross-reference, $5 floor, deciles + 50/50, NW t,
turnover, sub-periods. Validated against four independently-derived pins before
use (log/unfloored decile +16.26%, simple/floored +1.57%, legs 12.09/10.76/8.92,
trash-cell 50/50 +4.15%).
It also reports a computable flag instead of a misleading number: the
unfloored simple-return decile book has no defined CAGR — months fall below
−100% — and the unfloored bottom decile compounds at +92.5%/yr, which is the
penny-shell blowup that makes shorting it catastrophic, now quantified.
The paper names weekly reversal (WSTR) and TREND as its closest competitors,
yet no simple-signal comparison had ever been run on R1000 — every backtest in
this repo ranks by p_up and uses momentum/vol only to define buckets.
Predictive IC (Spearman vs 20-day forward return, oriented, 283 months):
| Signal | mean IC | NW t | % months +ve |
|---|---|---|---|
| rev_5d (WSTR) | 0.0218 | 3.43 | 54.6 |
| combo z(−STR)+z(−vol) | 0.0172 | 2.08 | 54.6 |
| mom_12_1 | 0.0154 | 1.60 | 57.1 |
| vol_60d | 0.0135 | 1.03 | 50.4 |
| rev_1m (STR) | 0.0125 | 1.79 | 52.1 |
| p_up (CNN) | 0.0099 | 2.61 | 57.4 |
The CNN has the lowest mean IC of the six. A one-line 5-day reversal has more than double the predictive correlation of a 5-seed CNN ensemble that cost 65 A100-hours.
Honest long/short (simple returns, $5 floor, identical rows, 282 months):
| Signal | Decile CAGR | t | 50/50 CAGR | t | Turnover |
|---|---|---|---|---|---|
| rev_5d (WSTR) | +7.87% | 2.53 | +4.44% | 3.00 | 0.85 |
| p_up residual | +4.19% | 3.01 | +1.68% | 2.70 | 0.89 |
| p_up (as deployed) | +1.68% | 1.05 | +0.46% | 0.63 | 0.89 |
| mom_12_1 | +1.47% | 1.05 | +0.47% | 0.50 | 0.28 |
| rev_1m (STR) | −1.46% | 0.25 | +2.09% | 1.25 | 0.84 |
| combo | −4.87% | −0.60 | −0.35% | 0.05 | 0.76 |
| vol_60d | −12.17% | −0.84 | −4.32% | −1.01 | 0.27 |
As deployed it is not usable. +1.68%/yr at t = 1.05 — not distinguishable from zero — and beaten by weekly reversal by 6.2pp/yr.
But it is not a repackaged reversal either. Cross-sectional rank
correlation of p_up against every baseline is low (max |ρ| = 0.18), and the
sign is informative: p_up leans toward recent 1-month winners
(ρ = +0.13 with rev_1m) and toward low volatility (ρ = −0.13 with
vol_60d). It is doing something genuinely different from the classic signals.
The orthogonal part is the real signal. Regressing p_up cross-sectionally
on all baselines each month and ranking on the residual gives +4.19%/yr
at t = 3.01 — the highest t-statistic of anything tested, with the best
Sharpe stability across sub-periods (0.62 / 0.47 / 0.59). The residual is
better than the raw signal, which is only possible if the raw signal's factor
tilts are actively hurting it: the CNN's low-vol preference is a bet on the
worst-performing signal in the table (−12.2%/yr).
The residual uses only formation-date information (a cross-sectional regression of contemporaneous signals; the forward return never enters), so there is no look-ahead.
Sub-period caveat, and it applies to everything. Nothing is significant after 2015 on its own — WSTR decays from +7.9% (t 2.53) to +3.8% (t 0.65) by 2020+, and the residual from +4.2% (t 3.01) to +4.5% (t 1.35). With 115 and 63 months the power is limited, but the honest read is that all of these edges are weakening together.
Not hyperparameter tuning. The finding is that the CNN carries a small
genuine orthogonal edge wrapped in harmful factor exposure, so the highest-value
next step is post-processing, not retraining: neutralize p_up against the
baseline factors and re-evaluate — which costs no GPU time at all.
It also means the Tracker paper book is currently ranking on a signal with t = 1.05. Switching it to the residual is a candidate, but it should wait until the neutralization is properly out-of-sample tested.
tests/test_purge_guard.py,scripts/evaluate_signal.py,scripts/baseline_gauntlet.pyruns/experiments/stage0_reference_p_up{,_trash}/signal_card.{xlsx,json}runs/experiments/stage1_baselines/{baseline_signals.parquet,gauntlet_summary.xlsx}runs/experiments/stage1_eval/<signal>/signal_card.{xlsx,json}
Gate G1 showed raw p_up failing (+1.68%/yr, t 1.05) while its residual beat
it. scripts/neutralize_p_up.py tests whether that is real, where it comes
from, and whether it is implementable.
Per formation date, regress the 20-day forward simple return on rank-normalized signals (each mapped to [−0.5, 0.5], so coefficients are a bottom-to-top return spread and immune to the wildly different raw scales). Average across 282 months, Newey-West t:
| Spec | Regressor | Mean coef (%) | NW t |
|---|---|---|---|
| univariate | p_up | 0.113 | 0.76 |
| multivariate | p_up | 0.326 | 3.07 |
| multivariate | rev_5d (WSTR) | 0.646 | 3.47 |
| multivariate | mom_12_1 | 0.562 | 1.51 |
| multivariate | rev_1m (STR) | 0.378 | 1.52 |
| multivariate | vol_60d | −0.562 | −1.09 |
On its own p_up does not predict returns (t 0.76). Controlling for the baselines, its coefficient triples and becomes strongly significant (t 3.07). This is a textbook suppression effect: the CNN's factor tilts were masking its own signal. Only two regressors are significant in the multivariate spec — WSTR and p_up.
This test was specified before the result was seen and is not one of the variants searched over below.
| Neutralized against | Decile CAGR | t | Sharpe | corr w/ raw p_up |
|---|---|---|---|---|
| — (raw p_up) | +1.68% | 1.05 | 0.22 | 1.000 |
| vol only | +4.21% | 2.76 | 0.57 | 0.988 |
| rev_1m only | +3.98% | 2.16 | 0.45 | 0.977 |
| vol + rev_1m | +5.59% | 3.33 | 0.69 | 0.965 |
| all four | +5.60% | 3.72 | 0.77 | 0.957 |
Both exposures do roughly equal damage and the effects are additive — the G1 write-up's "it's the low-vol tilt" hypothesis was only half right. Note the neutralization changes the ranking barely at all (rank correlation 0.957 with raw p_up): a ~4% change in the ordering triples the return. A small factor tilt was doing disproportionate damage.
The neutralization uses contemporaneous cross-sectional betas. That is not look-ahead: the forward return never enters the regression, every input is known at the formation date, and cross-sectionally neutralizing an alpha against current factor exposures is exactly what a risk model does. It is executable in real time.
As a robustness check, resid_all_causal refits betas using only months
strictly before t (36-month burn-in). Full-sample it looks much weaker
(+2.64%, t 1.54) — but that is a burn-in artifact, and the period split
reverses the conclusion:
| Signal | Full CAGR / Sharpe | Since-2015 | Since-2020 |
|---|---|---|---|
| rev_5d (WSTR) | 7.87% / 0.52 | 5.57% / 0.41 | 3.80% / 0.28 |
| p_up neutralized (contemp.) | 5.60% / 0.77 | 3.97% / 0.58 | 5.69% / 0.71 |
| p_up neutralized (causal) | 2.64% / 0.34 | 4.66% / 0.56 | 7.68% / 0.81 |
| p_up raw | 1.68% / 0.22 | 1.67% / 0.23 | 2.20% / 0.27 |
WSTR is decaying (Sharpe 0.52 → 0.41 → 0.28). Neutralized p_up is not. Since 2020 the strictly-causal neutralized CNN is the best signal tested on both return and Sharpe — and it is the most conservative construction of the lot.
Turnover is ~0.89 one-sided monthly for every p_up variant. Full-sample decile
LS, net of 12 × 2 × turnover × half-spread:
| Signal | Gross | Net @ 8bp | Net @ 25bp | Sharpe | t |
|---|---|---|---|---|---|
| combo: neutralized p_up + WSTR | 9.48% | +7.77% | +4.14% | 0.76 | 3.70 |
| rev_5d (WSTR) alone | 7.87% | +6.23% | +2.74% | 0.52 | 2.53 |
| p_up neutralized alone | 5.60% | +3.89% | +0.25% | 0.77 | 3.72 |
| p_up raw (as deployed) | 1.68% | −0.02% | −3.64% | 0.22 | 1.05 |
The equal-weight combination of neutralized p_up and WSTR is the best book on the full sample — +7.8%/yr net at 8bp with Sharpe 0.76 and t 3.70 — and beats either component alone, so the two carry genuinely complementary information. Since 2020, though, adding WSTR hurts (Sharpe 0.81 → 0.57) because WSTR has decayed; the pure neutralized CNN is the better recent book.
The Stage-1 conclusion — "the CNN loses to a one-liner" — was right about the signal as deployed and wrong about the signal as available:
- Raw
p_upis genuinely worthless net of costs: −0.02%/yr at 8bp. The Tracker paper book currently ranks on exactly this. - Factor-neutralized, the CNN carries real, independently-significant content (FM t 3.07; decile t 3.72; Sharpe 0.77 — the best risk-adjusted number of anything tested), it is implementable, and unlike WSTR it has not decayed.
- The remaining caveat is unchanged and material: no signal reaches t = 2 in any post-2015 sub-period. The strong t-statistics all lean on 1999–2014. With 63 months since 2020 the power simply is not there yet, and roughly a dozen variants were searched over to find these — the Fama-MacBeth test is the one pre-specified result that does not carry that discount.
scripts/neutralize_p_up.pyruns/experiments/stage1b_neutralize/{neutralized_signals.parquet, combo_signals.parquet, fama_macbeth.xlsx}runs/experiments/stage1b_eval/<variant>/signal_card.{xlsx,json}
L.4 neutralized against four price-based baselines only. That is not "all
factors" — the residual could still be loading on size, liquidity or sector.
scripts/neutralize_p_up.py now also controls for log(dv_60d)
(size/liquidity) and 10 BICS-level-1 sector dummies.
Average rank-normalized p_up by sector (0 = neutral):
| Favoured | Avoided | ||
|---|---|---|---|
| Real Estate | +0.032 | Communications | −0.031 |
| Utilities | +0.030 | Health Care | −0.021 |
| Financials | +0.026 | Consumer Discretionary | −0.019 |
| Industrials | +0.011 | Technology | −0.018 |
The model leans toward Real Estate, Utilities and Financials — the classic low-volatility, high-payout sectors — and away from Communications, Health Care and Tech. That is the same low-vol preference found in L.4, expressed sectorally. The tilts are individually small (±6% of the rank range), which is consistent with neutralization changing the ranking by only a few percent while changing the return a lot.
| Spec | p_up coef (%) | NW t |
|---|---|---|
| univariate | 0.113 | 0.76 |
| + 4 baselines | 0.326 | 3.07 |
| + baselines + size + sector | 0.330 | 3.40 |
Adding size and sector does not weaken p_up — it slightly strengthens it. Over the full sample, the incremental content is not a size or sector bet.
Decile LS, progressively stricter neutralization:
| Neutralized against | Full | Since 2015 | Since 2020 |
|---|---|---|---|
| baselines | 5.60% / t 3.72 | 3.97% / t 1.80 | 5.69% / t 1.63 |
| + size | 5.05% / t 3.50 | 3.10% / t 1.44 | 3.32% / t 1.00 |
| + sector | 5.11% / t 3.85 | 2.71% / t 1.35 | 2.68% / t 0.84 |
| + size + sector | 4.65% / t 3.57 | 2.40% / t 1.20 | 0.54% / t 0.25 |
Full sample: robust. Every variant lands between +4.7% and +5.6% with t 3.5–3.85. Stripping size and sector costs about a percentage point and the significance is untouched.
Since 2020: it disappears. Fully neutralized, the signal falls to +0.54% (Sharpe 0.11, t 0.25). A large share of the recent apparent performance is attributable to size and sector tilts rather than to chart-reading.
Both directions are underpowered. Nothing in the since-2020 column reaches t = 2 in any specification, so 63 months cannot distinguish "+5.7% and real" from "+0.5% and nothing." The monotone decay as controls are added is a warning sign, not a proof.
The defensible summary:
- Over 27 years the CNN carries genuine predictive content that survives every control tested (FM t 3.40) — this is the strongest evidence the project has.
- Over the last 6 years that content is indistinguishable from a size/sector bet, and the sample is too short to settle it either way.
- Only forward evidence resolves this, which is an argument for paper-tracking the neutralized signal rather than for deploying or killing it.
Tracking resid_all (baselines only) risks tracking a size/sector bet and
learning nothing from a good result. Tracking resid_all_size_sector tests the
defensible claim — if that works forward, it is chart-reading. Running both
as separate sleeves costs nothing and turns the ambiguity into a live
experiment.
runs/experiments/stage1c_extended/{neutralized_signals.parquet, fama_macbeth.xlsx}runs/experiments/stage1c_eval/<variant>/signal_card.{xlsx,json}
scripts/plot_signal_comparison.py plots the cumulative books. Two things are
immediately visible that no summary statistic in L.3–L.5 revealed.
First, raw p_up loses money net of costs over 27 years: ×0.99. Not "a
small positive" — it ends below where it started. That is the signal the
Tracker paper book is currently ranking on.
Second, and far more important: every line goes flat after 2004.
Decile LS by era, net of 8bp (t-statistics on gross):
| Signal | 1999–2004 | 2005–2019 | 2020–2026 |
|---|---|---|---|
| raw p_up | +4.7% (t 1.51) | −2.1% (t 0.02) | +0.5% (t 0.63) |
| WSTR | +21.1% (t 2.58) | +2.2% (t 1.31) | +2.1% (t 0.72) |
| neutralized (baselines) | +14.7% (t 3.47) | −0.3% (t 0.96) | +3.9% (t 1.81) |
| neutralized (+size+sector) | +14.5% (t 3.66) | +0.1% (t 1.62) | −1.2% (t 0.25) |
| combo | +28.0% (t 4.27) | +2.0% (t 1.52) | +3.4% (t 1.11) |
Across the fifteen years from 2005 to 2019 — 180 months, the majority of the sample — every single signal returns approximately zero net. Nothing reaches t = 1.7. The impressive full-sample t-statistics of 3.4–3.9 reported in L.4 and L.5 are almost entirely produced by a six-year window that ended two decades ago.
This is not a CNN-specific problem. WSTR shows the identical shape (+21% then +2%), which is what one would expect of short-horizon cross-sectional effects that were arbitraged away after decimalization and the arrival of systematic quant capital. The 1999–2004 window also contains the dot-com bust, an exceptional dispersion regime that flatters every cross-sectional reversal strategy.
The earlier framing — "over 27 years the CNN carries genuine predictive content that survives every control (FM t 3.40), which is the strongest evidence the project has" — is arithmetically correct and practically misleading. The honest version:
- The content is real in 1999–2004.
- It is absent, for every signal tested, in 2005–2019.
- The last six years are too noisy to call either way.
A signal that worked before 2005 and has done nothing measurable since is not evidence for deployment. It is evidence that the effect existed and decayed.
- The methodology is now sound and reusable: the leakage guard is frozen, one measuring stick scores everything, and factor neutralization is understood and implemented.
- The diagnosis stands: raw
p_upis contaminated by factor tilts, and neutralizing genuinely improves it in every era where anything works at all. - What does not survive is the case for spending further compute. The binding constraint was never the model, the hyperparameters, or the training budget — it is that the effect this project is trying to harvest largely stopped paying twenty years ago.
The remaining live question is narrow and cheap: does the neutralized signal do anything in the next few years? That is answered by paper-tracking it, not by another backtest and not by another GPU run.
runs/experiments/charts/signal_comparison.pdf— gross vs net, full sample vs since-2020runs/experiments/charts/neutralization_ladder.pdf— stripping the tilts one at a time
Run before Stage 2, using only already-trained models and portfolio-level changes — no new fitting, so neither consumes a pre-registered variant slot (PREREGISTRATION.md §7). Both scored against the frozen strawman on the primary 2005+ window.
The runs/rolling pathway (trailing 5-year training window) has existed since
April and had never been scored honestly. If the post-2005 decay were a regime
change, recency-weighted training should help.
It does the opposite. Fama-MacBeth incremental content of p_up:
| Pathway | univariate | + baselines | + size + sector |
|---|---|---|---|
| expanding | 0.113 (t 0.76) | 0.326 (t 3.07) | 0.330 (t 3.40) |
| rolling | 0.056 (t 0.43) | 0.178 (t 1.45) | 0.209 (t 1.90) |
And as a portfolio, 2005+, net@8bp: expanding-neutralized +0.88%, rolling- neutralized −2.57% (Sharpe −0.10).
More data beats recent data, decisively and counter-intuitively. Whatever the CNN learns is stable enough that a 5-year window starves it, so the decay is not something a shorter memory fixes.
An equal-weighted book hands the most risk to the most volatile names — the opposite of what a risk-aware book does — so 1/vol weighting was the obvious free improvement. Tested on both the candidate and the strawman, since a portfolio-construction gain that helps one would help the other.
Decile LS, 2005+, net@8bp:
| Signal | Weighting | Net | Sharpe | Alpha vs strawman | t |
|---|---|---|---|---|---|
| CNN neutralized | equal | +0.88% | 0.434 | +1.82% | 1.25 |
| CNN neutralized | inverse-vol | +0.73% | 0.445 | +1.79% | 1.36 |
| strawman (combo) | equal | +2.40% | 0.408 | — | — |
| strawman (combo) | inverse-vol | +1.97% | 0.380 | −0.14% | −0.22 |
A wash at best: a hair of Sharpe on the neutralized CNN (0.434 → 0.445), and it hurts the strawman on every measure. Not the fix.
PREREGISTRATION.md §5 requires, over 2005+, alpha t ≥ 2.0 and own net ≥ +2.0%/yr. Best candidate here reaches alpha t 1.36 and net +0.88%. Nothing is promoted.
Worth noting the by-now familiar shape: the expanding neutralized CNN's alpha over the strawman is +3.02% at t 2.01 on the full sample — nominally over the line — and +1.82% at t 1.25 from 2005. This is exactly why §3 makes the full sample non-promotable.
(Reporting artefact: regressing the strawman on itself yields alpha 0.00 with a meaningless t on the full sample — floating-point residuals of ~1e-17 — and a correct NaN on 2005+. Ignore that cell.)
An independent run (runs/residual_geometry/phase0_20260809, produced outside
this review) reported a free "last-bar geometry" signal, rlcc_or, at +13.6%
LS with t 5.86 — stronger than anything found here. Its p_up_resid figure of
+4.2%/t 3.01 reproduces §L.4 exactly, so the two pipelines agree and the
comparison is apples-to-apples.
rlcc measures where the close sat within its daily range on the formation
date, relative to the prior 19 days; oriented to buy stocks that closed low
in their range. There is no look-ahead — it uses only that day's OHLC — but the
construction is the classic signature of bid-ask bounce: a stock closing at
the bid looks like it "closed low in its range," and the next print lands at the
ask, producing a mechanical gain that cannot be captured.
Compute the signal at the close of day t, but enter at the close of day t+1. Same 20-day hold, one day later — enough to skip the bounce.
| Signal | IC entering at t | IC entering at t+1 | Retained |
|---|---|---|---|
rlcc_or |
0.0319 (t 5.91) | 0.0124 (t 2.33) | 38.9% |
geom_score |
0.0328 (t 5.59) | 0.0200 (t 3.70) | 61.1% |
rev_5d (WSTR) |
0.0215 (t 3.39) | 0.0118 (t 1.83) | 54.6% |
p_up_resid |
0.0080 (t 2.76) | 0.0063 (t 2.10) | 79.0% |
p_up_mean (raw) |
0.0093 (t 2.42) | 0.0076 (t 1.84) | 81.7% |
rlcc_or loses 61% of its edge to a single day of delay. Its t collapses
from 5.91 to 2.33.
Confirmed by where the loss concentrates — retention rises monotonically with liquidity, exactly as spread-driven artefacts do:
rlcc_or retention |
Q1 (least liquid) | Q2 | Q3 | Q4 (most liquid) |
|---|---|---|---|---|
| by dollar volume | 32.1% | 38.6% | 40.4% | 46.2% |
WSTR shows the same pattern even more starkly (16.5% retained in the least
liquid quartile, 75.8% in the most). p_up_resid shows no such gradient.
Credit where due. The external report's decisions were correctly hedged — it labelled the geometry signals "known-factor refinement, not mystery alpha" and declined to promote them as a capital book. Those calls stand. What was missing was the era split, turnover, net-of-cost, and the lag test; the headline +13.6% is ~61% microstructure.
rlcc_or is still not nothing. It survives the lag at t 2.33, and unlike
everything else in this review it holds up post-2005 (2015+: gross +9.0%, net
+7.3%, t 2.62). It is a real but heavily-taxed effect, not an artefact.
The genuinely new finding favours the CNN. p_up_resid is the most
implementation-robust signal tested — 79% of its edge survives a day's delay,
against 39% for rlcc_or and 55% for WSTR. It is small, but it is not a
microstructure illusion, and it is the only signal here whose edge does not
concentrate in the least liquid names. That is a point in its favour neither
this review nor the external report had established.
And the bar just moved up. geom_score retains 61% at t 3.70 after the lag
— on that basis it is a better free baseline than the frozen combo strawman.
PREREGISTRATION.md §2 deliberately forbids re-tuning the strawman mid-flight, so
the frozen benchmark stands for Stage 2; but the honest reading is that the real
hurdle for any retrained CNN is higher than +2.40%/yr, and geometry belongs in
the baseline family alongside WSTR when the strawman is next re-frozen.
Run under PREREGISTRATION.md, committed before the data was touched again. Platform: xgboost on 21 features covering the same information as the CNN's 20-day image (price path, daily ranges, close location, volume texture, MA relationship), on the CNN's exact expanding-window schedule. Purge audited at 40 trading days — the production bound — in every window.
Early stopping on validation IC for all labels, implementing the Stage-0 finding that the production CNN selects on cross-entropy, which is not the objective.
Five seeds, identical config, decile LS CAGR 2005+:
| Label | Mean | SD |
|---|---|---|
| L0 (control) | +3.76% | 1.42 |
| L1 | +4.76% | 1.14 |
| L2 | +6.00% | 1.22 |
| L3 | +7.46% | 1.72 |
Floor = 1.42pp; §6 requires ≥ 2.83pp to count.
| Signal | Net | Sharpe | t | Alpha vs strawman | alpha t | §5 |
|---|---|---|---|---|---|---|
| L0_neut (production label) | +1.75% | 0.485 | 2.17 | +3.82% | 2.22 | no |
| L1_neut | +2.98% | 0.618 | 2.80 | +6.19% | 3.85 | PASS |
| L2_neut | +2.98% | 0.587 | 2.55 | +5.86% | 3.12 | PASS |
| L3_neut | +3.29% | 0.582 | 2.44 | +5.84% | 2.65 | PASS |
Three candidates satisfy §5's letter. Two things stop that being a result.
Best case is L3_neut − L0_neut = +1.54pp, against a floor of 1.27–2.83pp depending on how much ensembling reduces seed noise. No label-vs-label difference is distinguishable from seed noise. The label question is unresolved, not answered.
Note also that L0 — the current production label — already beats the strawman (alpha t 2.22), and beats the CNN's own neutralized signal (alpha t 1.25, §L.7) on the same label. Whatever the GBDT gained, it did not come from the label.
The implementation-lag test from §L.8 — not pre-registered, added after vetting the external geometry result, and strictly more stringent than §5 — applied to our own work:
| Signal | IC enter t | IC enter t+1 | Retained |
|---|---|---|---|
| L0_neut | 0.0182 (t 4.25) | 0.0094 (t 2.32) | 51.7% |
| L1_neut | 0.0219 (t 6.12) | 0.0133 (t 3.81) | 60.8% |
| L3_neut | 0.0183 (t 4.72) | 0.0090 (t 2.29) | 48.9% |
(reference) p_up_resid |
0.0080 (t 2.76) | 0.0063 (t 2.10) | 79.0% |
Half the edge dies from one day's delay. As a portfolio, L3_neut goes from +4.80% (t 2.37) to +1.85% (t 1.14).
That is unsurprising in hindsight: the feature set includes clv_last — the
same close-location construction as rlcc — and r_lag1. Re-running without
those and gap_last:
| Signal | Enter t | Enter t+1 | Retained |
|---|---|---|---|
| L0_neut | +2.86% (t 1.87) | +1.89% (t 1.36) | 64.7% |
| L3_neut | +2.16% (t 1.10) | +0.90% (t 0.62) | 72.3% |
Retention improves, confirming those were the bounce channel — but the returns fall below the strawman, lose significance entirely, and L3 becomes worse than L0, reversing the ordering. The apparent label effect was better exploitation of microstructure, not better forecasting.
No promotion. Per §5's pre-committed rule, Stage 2 stops here. The lockbox (§8) remains sealed and unspent — there is no winner to spend it on.
Recorded as amendments A1 and A2 in PREREGISTRATION.md.
- The label was not the problem. The Stage-1 diagnosis — that the binary sign label trains a Sharpe-ranker (forward-vol IC 0.097, t 19.1) — is correct and remains the best explanation of the CNN's factor tilts. But fixing it does not recover tradable content. Diagnosing a defect and being able to profit from repairing it are different things, and this is the distinction the protocol was built to enforce.
- The image representation is not earning its keep. A GBDT on 21 hand features beats the CNN on the identical label (alpha t 2.22 vs 1.25). By the asymmetry noted in the script's header, that is strong evidence the CNN's vision machinery is unnecessary here.
- The CNN residual remains the most implementation-robust signal found (79% retained vs 49–61% for the GBDT). It is small and it does not clear the post-2005 bar — but alone among everything tested, it is not primarily a microstructure artefact.
- Most short-horizon cross-sectional "alpha" on this panel is bid-ask
bounce. It shows up in
rlcc_or(39% retained), WSTR (55%), and now our own GBDT (49–61%). Any future work on this panel should treat the implementation-lag test as mandatory, not optional.
§L.8 asserted that geom_score "is a better free baseline than the frozen combo
strawman" and that "the real bar is higher than +2.40%/yr." That was based on a
full-sample IC t-statistic of 3.70. Tested properly — post-2005, as a
portfolio, net of turnover, under both entry timings — the claim is directionally
right but far weaker than stated, and it surfaces something worse.
| Signal | Entry | Net | Sharpe | t | Alpha vs strawman | alpha t |
|---|---|---|---|---|---|---|
| Dixon geometry | t | +4.89% | 0.526 | 2.61 | +4.89% | 1.88 |
| Dixon geometry | t+1 | +2.17% | 0.333 | 1.80 | +2.52% | 1.02 |
rlcc last-bar |
t | +7.40% | 0.936 | 3.82 | +9.60% | 3.75 |
rlcc last-bar |
t+1 | +2.20% | 0.433 | 1.75 | +4.38% | 1.71 |
| WSTR | t | +2.05% | 0.322 | 1.43 | +0.05% | 0.03 |
| WSTR | t+1 | −1.73% | 0.071 | 0.34 | −3.41% | −1.66 |
| FROZEN strawman | t | +2.26% | 0.395 | 1.82 | — | — |
| FROZEN strawman | t+1 | −0.10% | 0.194 | 0.91 | −2.04% | −2.10 |
| CNN residual | t | −0.16% | 0.260 | 1.24 | +0.33% | 0.24 |
| CNN residual | t+1 | +0.13% | 0.307 | 1.47 | +0.72% | 0.55 |
1. The L.8 claim was overstated. Geometry does beat the strawman on both entry timings — but the alpha is +1.88 t unlagged and +1.02 t lagged. Neither is significant. Quoting a full-sample IC t of 3.70 as though it settled a post-2005 portfolio question was exactly the error §3 of the pre-registration exists to prevent, and I made it in prose while the protocol was sitting in the repo.
2. The frozen strawman is itself substantially bid-ask bounce. It goes from +2.26% to −0.10% on a one-day delay. So the §5 bar of "+2.40%/yr" was largely measuring something non-tradeable. This does not change Stage 2's verdict — if anything it strengthens it, since the candidates were bounce-driven too and were being judged against a bounce-driven benchmark. But it means the whole Stage-2 comparison sat on a baseline that does not survive realistic implementation.
3. WSTR post-2005 is essentially all bounce — +2.05% unlagged, −1.73% lagged. The "closest competitor" the paper names does not survive a day's delay in the modern era.
Almost nothing. Best post-lag is geometry or rlcc at ~+2.2% net with t ≈ 1.8 —
gross of borrow, on a book turning 89% of its names monthly.
The one signal that does not degrade is the CNN residual: −0.16% → +0.13%, Sharpe 0.260 → 0.307, t 1.24 → 1.47. It is the only thing tested that improves under lag. But its level is approximately zero, so the honest reading is "genuinely nothing" rather than "fake something" — which is a real distinction, just not a profitable one.
Entering at the t+1 close is conservative. In practice the signal cannot be computed before the day-t close (it uses that close), so t entry is impossible; but t+1 open would capture more than t+1 close. The truth lies between the two columns, closer to the lagged one. Nothing here changes qualitatively at an intermediate assumption — the signals that collapse, collapse.
The bar was never +2.40%/yr. On an implementable basis the bar post-2005 is approximately zero, and so is everything measured against it. That is the cleanest statement this review can make, and it applies to the CNN, to the free baselines, and to the geometry signals alike.
The implementation-lag test used throughout §L.8–L.10 entered at the t+1 close. That discards the whole of day t+1 — the exact day a one-day reversion signal predicts. It is not a bounce filter; it is a filter that removes the effect being measured.
The realistic implementation is the next morning's open: the signal is known at the day-t close, and you buy at the t+1 open. (Near-t-close entry is also roughly feasible via market-on-close orders, so the true range is bounded by the two.) Testing all three:
| Entry | All names | Liquid half (top-50% $ volume) |
|---|---|---|
| t close (MOC-feasible) | +7.40%, Sharpe 0.94, t 3.82 | +4.69%, t 2.27 |
| t+1 OPEN (realistic) | +6.18%, Sharpe 0.82, t 3.41 | +3.89%, t 2.04 |
| t+1 close (the flawed test) | +2.20%, Sharpe 0.43, t 1.75 | +1.14%, t 1.14 |
Entering at the next open retains 84% of the edge and stays significant — including in the liquid half, where spreads are narrowest and bounce is smallest. Were this bid-ask bounce, the overnight gap would have consumed it and the t+1-open column would be empty. It is not. This is genuine short-horizon reversion, concentrated in day t+1, and it is capturable.
| Signal | Net | Sharpe | t | alpha t vs strawman |
|---|---|---|---|---|
rlcc last-bar |
+6.18% | 0.823 | 3.41 | 3.38 |
| Dixon geometry | +3.62% | 0.436 | 2.23 | 1.62 |
| GBDT L3 (best Stage-2) | +1.84% | 0.424 | 1.74 | 1.94 |
| Frozen strawman | +1.48% | 0.326 | 1.46 | −1.26 |
| WSTR | +0.71% | 0.231 | 1.02 | −0.28 |
| CNN residual | −0.30% | 0.234 | 1.18 | 0.31 |
Corrected — L.10 was wrong. I wrote that "on an implementable basis the
post-2005 bar is approximately zero." It is not. At the fair entry rlcc
delivers +6.18%/yr net with t 3.41, post-2005, in a period where every other
signal in this review is flat. That statement was produced by a mis-specified
test, and the mis-specification ran through L.8, L.9 and L.10.
Not corrected — Stage 2 still fails. At the t+1-open entry the best Stage-2 candidate, GBDT L3, reaches alpha t 1.94 against a §5 bar of 2.0. The "no promotion" verdict holds at the fair entry, so PREREGISTRATION A2 stands unamended.
The uncomfortable summary. The strongest implementable signal found anywhere in this review is a free, one-line piece of OHLC arithmetic — where a stock closed within its daily range — which arrived from an outside run, not from the CNN, the GBDT, or any of the machinery built here. The CNN residual at the fair entry is −0.30%.
- Turnover is ~89% one-sided monthly; at a 25bp half-spread rather than 8bp the edge would be largely consumed. The liquid-half result (+3.89%, t 2.04) is the relevant one for capacity.
- The edge concentrates in day t+1, which makes it sensitive to execution slippage on a single day and argues for open-auction or VWAP entry.
- It is a short-horizon reversal-family effect: capacity-constrained, and in the same family as the WSTR/geometry signals whose earlier eras decayed.
- It has now been tested several ways on the same R1000 panel by me, after arriving with its full-sample numbers visible. That is selection pressure.
rlcc is the natural and only candidate for the NKY lockbox (§8), which
remains sealed and unspent. It is precisely what that one shot was reserved
for, and it would need its own pre-registration first.
Before spending the NKY lockbox on rlcc, its parameter space was swept. It is
pure OHLC arithmetic, so 144 variants cost 8 minutes rather than GPU time.
Anti-overfitting design: every variant reported on a time split — tune 2005–2015, validate 2016–2026 — and ranked on the worse of the two halves, so a variant must work in both. NKY stayed sealed throughout.
n_last |
mean worst-half net | % positive in both halves |
|---|---|---|
| 1 | +3.52% | 94% |
| 2 | +4.62% | 100% |
| 3 | +1.69% | 89% |
| 5 | +0.09% | 47% |
lookback |
mean | % positive both |
|---|---|---|
| 5 | +1.39% | 69% |
| 10 | +2.46% | 89% |
| 20 | +2.07% | 72% |
| 60 | +4.01% | 100% |
Normalization (raw / z / norm01) and CLV definition barely matter — 2.0–3.0%
mean across all three — which is what one wants from nuisance parameters.
mid and hl are provably identical (they differ by a constant that cancels
in the difference), a useful check that the implementation is correct.
58 of 144 variants (40%) clear +2% net with t > 1.5 in both halves. A fluke
produces one or two cells, not 58. The best region is n_last ≤ 2, lookback = 60; the original construction (n_last=1, lookback≈20) ranks 26th of 144,
so the published setting was mid-plateau rather than optimal.
31% of the 20-day return lands on day 1; the per-day rate decays from 25bp to 4bp. That suggested a shorter hold, and a cost-sensitivity table built on monthly-formation returns projected H=5 at +25.5%/yr net @8bp.
That projection was wrong. It annualized month-end-formation returns as though such a book could be formed every day. Building the actual daily overlapping implementation, with measured turnover:
| Hold | Ann. turnover | Gross | Sharpe | Net @8bp |
|---|---|---|---|---|
| 1 | 341× | +5.4% | 0.42 | −49.2% |
| 3 | 149× | +6.5% | 0.67 | −17.3% |
| 5 | 90× | +4.6% | 0.60 | −9.7% |
| 20 | 23× | +1.8% | 0.46 | −1.8% |
Every daily-formation variant loses money at realistic cost. Two causes: real turnover is roughly double what monthly rebalancing implied, and — the larger effect — gross return collapses from 7.9% to 1.8% at the same 20-day hold simply by forming every day instead of at month-end.
20-day LS by the formation day's distance to month end:
| Formation day | n | Mean 20d LS | t |
|---|---|---|---|
| 0 (month-end) | 259 | +0.739% | 3.72 |
| 1–2 before | 518 | +0.261% | 1.92 |
| 3–5 | 777 | +0.122% | 0.99 |
| 6–10 | 1,290 | +0.179% | 1.54 |
| 11+ | 2,568 | +0.048% | 0.66 |
6.5× at month-end versus all other days, decaying monotonically. This was not selected for — the month-end grid was inherited from the CNN's cadence, and the effect was discovered only by testing daily formation. It has a plausible mechanism: month-end concentrates index rebalancing, window dressing and fund flows, so flow-driven dislocation — exactly what a close-location reversal signal detects — should peak there.
Not a daily reversal book. It is: at each month-end, buy the decile that closed lowest in their daily range relative to a 60-day baseline (2-bar signal), enter at the next open, hold 20 days. Twelve rebalances a year, ~89% one-sided turnover per rebalance, and the L.11 headline (+6.18%/yr net @8bp, t 3.41, 2005+) stands.
- 259 month-end observations. The whole result rests on one formation day a month; the daily data mostly serves to show the other days do not work.
- Month-end anomalies are among the most documented and therefore among the most likely to have been arbitraged — though this one is still present in 2016–2026 (validate half: +6.7 to +6.9% net, t 2.1–2.4).
- Best-cell numbers are selected using the validate half (the ranking uses the
worse of the two), so they are optimistically biased. The marginal findings
—
n_last ≤ 2,lookback = 60, indifference to normalization — are the trustworthy part. - Capacity is bounded by the month-end trade, and the signal lives in exactly the flow-congested window where everyone else is also trading.
Rather than spend the single NKY lockbox shot — whose failure mode would have been uninterpretable, since Japan's month-end structure genuinely differs — the signal was tested on three developed markets never touched by this review: Australia (407 names), the UK (525), Japan (224), all from the Kronos data tree.
Pre-committed before running. The R1000-swept parameters were transplanted
unchanged (n_last=2, lookback=60, month-end formation, next-open entry,
20-day hold, decile LS). Nothing was fitted on these markets. The success
criterion was fixed in advance: positive with t ≥ 2.0 in at least two of the
three markets.
| Market | Names | Months | Gross | Sharpe | t | Net @8bp | Pass |
|---|---|---|---|---|---|---|---|
| Australia | 407 | 191 | +8.38% | 0.527 | 2.09 | +6.68% | YES |
| UK500 | 525 | 254 | +2.16% | 0.302 | 1.35 | +0.49% | no |
| Japan | 224 | 254 | −1.81% | −0.097 | −0.49 | −3.53% | no |
One of three. The pre-committed verdict is FAILS TO REPLICATE.
The specific claim from L.12 was not merely that the signal works, but that it concentrates at month-end — 6.5× on R1000. Testing that directly (other-days t-statistics use Newey-West lag 21, since those are 20-day holds sampled daily and lag 3 would overstate them):
| Market | Month-end | Other days | Ratio |
|---|---|---|---|
| R1000 (in-sample) | +0.739% (t 3.72) | +0.113% | 6.52× |
| Australia | +0.815% (t 2.09) | +0.604% (t 6.09) | 1.35× |
| UK500 | +0.206% (t 1.35) | −0.248% (t −1.06) | −0.83× |
| Japan | −0.095% (t −0.49) | +0.217% (t 3.13) | −0.44× |
The month-end concentration replicates nowhere. In Australia the signal is far stronger on ordinary days than at month-end (t 6.09 vs 2.09). In Japan it works on ordinary days (t 3.13) and is negative at month-end. Two of three markets invert the pattern outright.
The month-end effect is not established. Either it is genuinely US-specific — plausible, since US month-end index rebalancing and fund flows are the largest anywhere — or the R1000 result was 259 observations of luck. This test cannot separate those, but it removes the basis for treating the month-end concentration as a general phenomenon, and that concentration was the whole mechanism story of L.12.
Combined with what was already known — 259 formation days, an effect that
declines monotonically the further from month-end you look, and the general
decay of short-horizon anomalies — the honest status of the rlcc month-end
book is an interesting US result that failed its first out-of-sample test.
A separate finding, untested and not to be treated as a result. The underlying close-location reversal signal shows general (non-month-end) power in Australia (t 6.09) and Japan (t 3.13) on ordinary days. That is a different claim from the one tested here, it emerged from looking at the data, and it would need its own pre-registration and its own out-of-sample markets before it means anything. It is recorded so it is not lost, not so it can be traded.
Still sealed and now unspendable on this candidate — Japan was one of the
three markets tested, so the NKY panel is no longer untouched for rlcc. That
is the correct trade: three interpretable markets bought more than one
ambiguous shot would have.
- These panels appear built from current index membership, so both legs are drawn from survivors. A long/short is far less exposed than a long-only book, but absolute levels should be read as optimistic.
- Cross-sections are 200–400 names against R1000's ~1,500, so deciles hold 20–40 names and are noisier.
- Australia's data ends 2020; its 191 months exclude the recent period entirely.
- Prices are local-currency and close-to-close, so dividends are missing — which largely cancels in a long/short.