DEV Community

Felixwang007
Felixwang007

Posted on

I Split My A-Share Signal Study Into 6 Time Halves. The Top-Return Pattern Held in 4 of Them - a "Stay Out" Signal Went 6 for 6

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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. 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.
  3. Print the per-half firing count next to the average. A frequency that moves 6.8x is measuring the regime, not the setup.
  4. 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}')
Enter fullscreen mode Exit fullscreen mode

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)