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
fqklineendpoint, 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
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 <= -15plus 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
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:
- Gate the universe on the market's own last bar before classifying anything.
- 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:
- Repo: https://github.com/Felixwang007/a-share-signal-lab
- Data source: Tencent
web.ifzq.gtimg.cn/appstock/app/fqkline/get(free, no key), 641 qfq bars/stock in ~55 s for 3,487 tickers with 16 threads.
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)