Three numbers first:
- +0.90pp - the average 5-day excess return of the pattern that ranks first if you average all six half-years together.
- 4/6 - the number of those halves where that sign actually held.
- 6/6 - the number of halves where the sign held for the pattern I now rank first. It has never once said "buy".
Same panel, same labels, same arithmetic. The only thing I changed is that I stopped reporting a single average over a 32-month window and started reporting the six halves separately. That one change reordered my entire signal list.
The panel and the labels
panel 3,147 A-share names, one JSON per name, 641 daily bars each
window 2024-02-02 -> 2026-09-28 (uniform by construction: a name with fewer
than 30 bars, or a start date later than 2023-12-01, is dropped)
rows 1,853,978 labelled stock-days
(forward return = closes[i+5]/closes[i] - 1)
labels 873,278 of those rows carry one of six patterns
(build_dataset_v3.compute_tech); the rest are unlabelled
halves 2024H1 ... 2026H2 - six buckets
Two definitions, so the tables below cannot be misread:
- mean - average 5-day forward return of every row carrying that label inside that half.
- excess - that mean minus the average forward return of all labelled rows inside the same half.
Excess is what I rank on now, and the subtraction is the whole point.
Why the same-half baseline has to come out
| half | baseline (all labelled rows, %) |
|---|---|
| 2024H1 | -0.54 |
| 2024H2 | +0.91 |
| 2025H1 | +0.79 |
| 2025H2 | +0.81 |
| 2026H1 | -0.21 |
| 2026H2 | +0.30 |
First halves average +0.01pp. Second halves average +0.67pp. The gap is +0.66pp - and a pattern that happened to fire mostly in second halves collects that gap for free before it does anything. That is not edge, it is calendar. Subtract the same-half baseline and the free money evaporates.
The six halves, excess in percentage points
| half | baseline | oversold bounce | pullback, shrinking volume | range-bound | uptrend | volume breakout | high-level volume dump |
|---|---|---|---|---|---|---|---|
| 2024H1 | -0.54 | +1.44 | +0.30 | -0.42 | -0.01 | -0.88 | -1.59 |
| 2024H2 | +0.91 | +0.15 | -0.12 | -0.02 | -0.14 | -1.67 | -2.29 |
| 2025H1 | +0.79 | +2.00 | -0.09 | -0.19 | -0.35 | -0.76 | -2.01 |
| 2025H2 | +0.81 | -0.33 | -0.11 | +0.03 | -0.16 | -0.25 | -1.33 |
| 2026H1 | -0.21 | -0.06 | +0.27 | -0.17 | +0.50 | +0.07 | -0.35 |
| 2026H2 | +0.30 | +2.22 | -0.09 | +0.18 | -0.73 | -1.52 | -2.28 |
Now rank them by how often the sign holds, instead of by magnitude:
| pattern | avg excess (pp) | positive halves | spread (pp) | std across halves | rows |
|---|---|---|---|---|---|
| high-level volume dump | -1.64 | 0/6 | 1.94 | 0.67 | 7,052 |
| volume breakout | -0.83 | 1/6 | 1.74 | 0.62 | 59,090 |
| uptrend | -0.15 | 1/6 | 1.23 | 0.37 | 396,937 |
| range-bound | -0.10 | 2/6 | 0.60 | 0.19 | 176,565 |
| oversold bounce | +0.90 | 4/6 | 2.55 | 1.02 | 5,897 |
| pullback, shrinking volume | +0.03 | 2/6 | 0.42 | 0.18 | 227,737 |
Four things fall out of that table.
1. The only perfect 6/6 record belongs to the signal that says stay out
A high-level, high-volume down day is negative in every one of the six halves: average excess -1.64pp, worst -2.29, best -0.35, and its win rate sits in a 37-44% band in all six. It is the only label where I cannot find a half that contradicts it. Note what kind of signal it is: a don't add here / trim into strength signal, not a money printer.
2. The trade retail loves most is negative in 5 of 6 halves
A breakout on 1.5x volume: average excess -0.83pp, worst half -1.67pp, win rate range 35.4-44.6%. The single positive half (+0.07 in 2026H1) is smaller than the noise floor of every other half. "Buy strength with volume" is a fine slogan; on this panel it is a coin flip with a fee attached.
3. The best average came from the least stable pattern
Oversold bounce tops the average list at +0.90pp and is positive in 4 of 6 halves. It also has the widest spread of anything here (2.55pp), the largest across-half standard deviation (1.02), and a firing count that swings 6.8x between halves - from 1,644 rows in 2025H1 to 241 rows in 2025H2, which is also the half where its excess went to -0.33. In other words: the headline number is the mean of a handful of small, regime-dependent samples. That is the exact configuration that produces "my backtest says +1.5% per 5 days" and then refuses to repeat.
4. Three labels are just the market
Pullback on shrinking volume (+0.03pp), range-bound (-0.10), uptrend (-0.15), with spreads under 1.25pp. Nothing there in either direction - they are the null hypothesis wearing a label.
What I changed in the screener
- Rank on sign consistency first, magnitude second. A pattern that is wrong in a third of the halves it was chosen over is not a signal, it is a sample.
- Trust the avoid side, discount the buy side. The two most sign-stable labels are both negative-expectancy ones. That asymmetry has been the most durable thing in this panel across three years of window.
- Print the per-half firing count next to the average. A frequency that moves 6.8x is measuring the regime, not the setup.
- Size by regime, not by conviction. If the same rule earns +2.0pp in one half and -0.3pp in another, position size has to be a function of which half you think you are in, and the honest answer is often "smaller".
Reproduce the ranking
The whole ranking is ~25 lines once the per-half table exists. This is the actual script that produced the tables above:
import json, statistics
d = json.load(open("alpha_by_half.json", encoding="utf-8"))
periods, pats = d["periods"], d["patterns"]
halves = list(periods)
rows = []
for name, per in pats.items():
ex = [per[h]["excess"] for h in halves]
rows.append({
"pattern": name,
"avg_excess": round(sum(ex) / len(ex), 2),
"positive_halves": sum(1 for e in ex if e > 0),
"spread": round(max(ex) - min(ex), 2),
"std": round(statistics.pstdev(ex), 2),
"rows": sum(per[h]["n"] for h in halves),
})
for r in sorted(rows, key=lambda r: -r["positive_halves"]):
print(f'{r["pattern"]:<8} avg={r["avg_excess"]:+6.2f} '
f'pos={r["positive_halves"]}/6 spread={r["spread"]:>5.2f} '
f'std={r["std"]:>5.2f} n={r["rows"]:>7}')
If you keep one habit from this post, make it this one: when a pattern only looks good as one averaged number, split the sample by time and re-rank by how many windows agree on the sign. It costs ten lines and it is the cheapest edge-destroying test I know.
Limitations, before you take any of this seriously
- The window is a uniform 641 bars per name, so 2024H1 and 2026H2 are partial halves by construction. Six halves is six data points; sign consistency is a cheap robustness filter, not a significance test.
- 5-day forward returns, no transaction costs, no limit-up tradability filter - a pattern that fires on days you could not have bought at the close is not a trade, it is a row.
- The panel is "names that still exist with enough history", which is a survivorship filter by construction, not an accident.
- Per-half tables above are means. In the full-sample cut of the same study the medians sit well below the means for buy-side labels (volume breakout: mean -0.29%, median -1.3%), so means flatter the positive tail.
- A-shares cannot be shorted in most accounts. The 6/6 signal is a don't add here signal. Do not read it as a short.
The screener these numbers come from is packaged as a skill on 虾评 (xiaping.coze.com) together with the A-share three-pillar screener, and the reproducible sibling tools from this series - the frozen-scan guard, the rule-binding audit, the de-AI linter - are MIT in the repo:
https://github.com/Felixwang007/awesome-content-tools
Panel and half-split study live in a private workspace; every number above is a direct read of alpha_by_half.json (cutoff 2026-09-28). Nothing here is investment advice - it is a report on what my own study does when I stop letting it average.
Top comments (0)