DEV Community

Felixwang007
Felixwang007

Posted on

My A-Share Screener Fired 6,548 Times. 85% Came From 11 Months — and My New Filter Was Noise

I maintain a three-pillar A-share screener: technical structure, fundamentals, and flow/intel. Two of those pillars I refuse to backtest with the data I have, because I only hold today's PE/PB snapshot and applying it to 2024 bars is textbook look-ahead. So the measurable pillar is the technical one, and this week I tried to "improve" it by adding a liquidity floor.

It took 892,992 classified bars to learn that the floor moves the result by 0.006 percentage points — and to learn something more useful: why I nearly shipped it anyway.

The setup

  • Universe: 3,184 A-shares whose last bar equals the market's last bar (2026-10-26). My local store had 3,487 files; 303 were stale tickers whose last bar was dated anywhere from 1994 to 2026-09. Excluded, not patched.
  • Bars: 641 daily qfq bars per stock from Tencent's fqkline endpoint, sample window 2024-01-01 → 2026-10-26, 0.2 s per stock with 16 threads.
  • Target: forward 5-day close-to-close return, in percent.
  • Liquidity proxy: 20-day mean traded value = mean(volume_lots * 100 * close), computed only from bars up to the signal day.

Each bar earns at most one of six patterns, in a fixed precedence order, so "oversold" never double-counts a breakout:

breakout        close > max(high[-20:-1]) and vr >= 1.5
high_vol_drop   pos20 >= 0.70 and chg <= -3.0 and vr >= 1.3
oversold_bounce ma5 < ma20 and c20 <= -15 and chg > 2.0 and vr >= 1.2
quiet_pullback  ma5 > ma20 and chg < 0 and vr <= 0.85
uptrend         ma5 > ma10 > ma20 and close > ma20
flat            (hi20 - lo20) / lo20 < 8% and abs(chg) < 1.5
Enter fullscreen mode Exit fullscreen mode

vr = today's volume / mean volume of the previous 5 bars. pos20 = where close sits inside the 20-day close range. c20 = 20-day return.

All six patterns on the same window

pattern n mean fwd5 median win rate
oversold_bounce 6,548 +1.372% +0.814% 54.8%
flat 184,618 +0.421% +0.167% 51.2%
quiet_pullback 233,645 +0.362% −0.169% 47.9%
uptrend 400,049 +0.282% −0.336% 46.5%
breakout 60,759 −0.353% −1.379% 40.8%
high_vol_drop 7,373 −1.352% −2.234% 38.7%

Market baseline (all 592k bars with a 5-day forward window) was +0.320% in 2024, +0.794% in 2025, and −0.239% in 2026. The oversold pattern is rare — 6,548 hits out of 892,992 bars, 0.73% — and it is the only one with a mean meaningfully above drift.

The filter I was about to ship

My reasoning was the usual one: tiny, illiquid names have fat slippage and fat spreads, so a liquidity floor should sharpen the mean. Floor = amt20 >= 50M CNY and close >= 3 CNY.

Then I split by year:

window base mean floored mean delta
2024 +0.992% (n=1,432) +0.456% (n=873) −0.536 pp
2025 +2.417% (n=2,042) +2.329% (n=1,789) −0.088 pp
2026 +0.864% (n=3,078) +1.046% (n=2,773) +0.182 pp
all +1.372% (n=6,548) +1.378% (n=5,433) +0.006 pp

If I had only looked at 2026 — the year I actually care about right now — the floor would have looked like a clean +0.18 pp improvement with a small sample cost. It flips sign in 2024. A rule that flips sign by year isn't a filter, it's a bet on what kind of tape comes next.

Why individual signals lie: 6,548 samples, 11 months

Here is the part that mattered more than any mean.

  • The window has 32 months. 11 of them hold 84.7% of all signals.
  • The top 10 months = 83.1% of samples. The two biggest — 2026-07 (n=1,203) and 2025-04 (n=1,075) — are 34.8% of the entire sample.
  • The median month has ~65 signals. Months noisily cluster around V-shaped rebounds, because that is precisely when ma5 < ma20 and c20 <= -15 plus a 2% up day plus volume appears.

So when I compute "mean return of the oversold pattern", I am not averaging 6,548 independent trades. I am averaging roughly a dozen macro episodes with a couple of thousand correlated siblings each. Any filter comparison inherits that structure.

I resampled months, not samples — 10,000 block bootstrap draws, seed 20261026:

floor vs. all-signals mean difference
  point estimate          +0.006 pp
  95% CI (month blocks)   [-0.223, +0.172]
  floor was worse in      50.9% of draws
Enter fullscreen mode Exit fullscreen mode

That is a coin flip, and I should say so instead of shipping a rule and calling it an edge. The honest statement the screener now emits is not "the floor improves returns" but "the floor is unmeasurable on this sample."

What actually did change

Signal frequency, which nobody puts in the headline:

year oversold signals trading days signals/day excess over baseline
2024 1,432 205 6.99 +0.672 pp
2025 2,042 243 8.40 +1.623 pp
2026 (to 10-26) 3,078 194 15.87 +1.103 pp

Frequency per day more than doubled while the raw mean fell from +2.417% to +0.864%. Some of that drop is just a market that stopped drifting up (baseline −0.239% in 2026), which is why I compute excess over baseline at all — excess only fell from 1.62 to 1.10. But a pattern that fires 16 times a day instead of 7 is a different instrument, and it deserves a crowding check before a filter check. October 2026 so far: n=92, mean −4.98%, win rate 19.6%.

A data-hygiene bug that produced a fake +200% signal count

My first live scan of the day printed 50 oversold signals. It looked like a rich tape. Then I gated the universe on last_bar_date == market_last_date and the count fell to 16.

34 of those 50 "today" signals came from files whose last bar was months or years old — suspended, delisted, or long-halted tickers still sitting in my store. Nothing errored. Every number was internally consistent. The scan was just classifying the past and labelling it today.

Two rules from that:

  1. Gate the universe on the market's own last bar before classifying anything.
  2. Check the price level as part of hygiene, not as a strategy tweak. Of today's 16 raw triggers, 4 trade under 2 CNY — delisting-adjacent names where "oversold" describes an accounting event, not a bounce candidate.

What the screener does now

Not more filters. More refusals:

  • Every pattern report carries the cluster count (how many months hold the samples) next to the sample count.
  • Any filter claim must clear a month-block bootstrap CI that excludes zero, otherwise it is reported as unmeasured, not as an improvement.
  • Baseline drift is always printed beside the pattern mean, so 2026's lower raw numbers don't get misread as decay caused by the pattern.
  • Signal frequency per trading day is tracked as a first-class output, since it's the cheapest available crowding proxy.
  • Universe is gated on the market's last bar, and a price floor is applied as hygiene (it removes delisting-adjacent rows), not as alpha.

Today's scan of the 3,184 live names: flat 335, uptrend 174, quiet_pullback 87, breakout 66, oversold_bounce 16, high_vol_drop 4. Twelve of the sixteen oversold names survive amt20 >= 50M and close >= 3 — and per the bootstrap above, that floor's contribution to my returns is, honestly, unknown.

The code

Both scripts and the full result tables are in the repo — liquidity_study_20261026.py builds the sample and the bucket table, episode_block_bootstrap.py runs the month-level resampling:

If you build screeners on Chinese equities, the transferable part isn't my thresholds — it's that a signal which looks strong in one year and weak in another, on a few thousand clustered samples, is not a measurable improvement. Count your clusters before you count your hits.

Not investment advice. Nothing here is a recommendation to buy any ticker listed; the measured claim is about the screener, not the stocks.

Top comments (0)