Dev.to AI 🤖 Ai 👁 0 📖 8 min read

My 'Three-Pillar' Stock Scorer Had a Negative IC. The Only Pillar That Worked Was Mislabeled.

I built a Chinese A-share stock scorer last year with a clean story: three independent pillars — technical (24 indicators), "intel" (volume/price behavior), and "fundamental" (valuation). Sum the three scores, rank the u

I built a Chinese A-share stock scorer last year with a clean story: three independent pillars — technical (24 indicators), "intel" (volume/price behavior), and "fundamental" (valuation). Sum the three scores, rank the universe, buy the top 50.

On 2026-11-12 it printed this top five: 沪农商行, 华能国际, 济川药业, 张江高科, 永臻股份. A regional bank, a utility, a pharma, a property park, and a solar-frame maker. Plausible-looking output. So I did the thing I should have done a year ago: I measured whether the score predicts anything.

It doesn't. Two of the three pillars have negative information coefficient. The one pillar with a positive IC is the one whose name is a lie.

Here's the audit, the numbers, and the three rules I now ship instead.

The setup

  • Universe: 587 Shanghai/Shenzhen stocks from my own scanner cache, 180 forward-adjusted (qfq) daily bars each, pulled from Tencent's kline endpoint in 14 seconds with 8 threads.
  • Method: for each of the last 70 rebalance dates (2026-07-23 → 2026-11-05), compute all three pillar scores for every stock using only data available up to that day, then measure each pillar's cross-sectional Spearman correlation with the next 5-day return (rank IC).
  • Sample: 70 dates × 587 stocks = 41,090 stock-day observations.
  • Benchmark: the average 5-day forward return across the whole sample was −0.175%. That number matters for reading the decile table later.

Crucially, I did not rewrite the scorer for the audit. I imported tech_analysis() and intel_score() from the shipped module and fed them sliding windows, so the thing being measured is the thing that runs.

from three_pillar_v2 import tech_analysis, intel_score   # the real implementation

for i in range(start_i, n-5):
    d = slice_d(d_full, i)              # sliding window, no future data
    t, _  = tech_analysis(d)
    it, _ = intel_score(d)
    f     = fund_score_as_implemented(price_today, d_full['close'][:i+1])
    fwd   = d_full['close'][i+5] / d_full['close'][i] - 1
    per_date[d_full['date'][i]].append({'t': t, 'i': it, 'f': f, 'fwd': fwd})

Result: the signs are wrong

Pillar Mean IC t-stat IR Dates with IC > 0
Technical (24 indicators) −0.0479 −2.72 −0.33 27.1%
Intel (volume/price) −0.0552 −3.31 −0.40 32.9%
"Fundamental" +0.0598 +2.12 +0.25 64.3%
Composite (as shipped) −0.0402 −2.11 −0.25 32.9%

Read the last row first: the composite score, the thing I actually used to make decisions, has a negative rank IC that is significant at t = −2.11. In this sample, higher score meant lower next-5-day return. The technical pillar produced a positive IC on only 19 of 70 dates.

This isn't a subtle "signal is weak" result. A weak signal centres on zero and gets lost in noise. This one centres on the wrong side of zero, and the t-stat is large enough that "it's just noise" is the less likely explanation.

The decile table is the part that stings

Ten equal buckets by composite score, each with 4,109 observations:

Decile Score range Mean 5-day return Win rate
D1 (worst score) −8 … 1 −0.089% 45.9%
D2 1 … 3 +0.145% 48.8%
D3 3 … 4 +0.146% 49.4%
D4 4 … 5 −0.040% 45.9%
D5 5 … 6 −0.175% 45.1%
D6 6 … 7 −0.243% 44.4%
D7 7 … 8 −0.035% 44.7%
D8 8 … 9 −0.447% 42.5%
D9 9 … 11 −0.291% 42.8%
D10 (best score) 11 … 24 −0.717% 39.9%

D10 − D1 spread = −0.628 percentage points. Top three deciles minus bottom three = −0.552 pp.

Two honest caveats on this table. It is not monotone — D2 and D3 are the best buckets, and D7 sits oddly above D6. And every bucket is negative in absolute terms because the universe fell over this window. What the table does support is narrower and still damning: the highest-scoring stocks underperformed the universe average by 0.54 pp over five days, and won less often (39.9% vs 45.9%).

Finding 2: the pillars were independent, and that made it worse

My original reasoning for "three pillars" was that independent evidence should be weighted together. So — are they independent?

Pair Mean daily cross-sectional correlation Top-50 overlap
technical ↔ intel +0.136 24%
technical ↔ "fundamental" +0.020 10%
intel ↔ "fundamental" −0.033 11%

They are nearly orthogonal. The overlap between the technical top-50 and the "fundamental" top-50 is about 5 stocks. So my independence assumption was correct — and it's exactly what made the composite bad. Averaging three nearly independent votes is a variance-reduction technique. It does not fix signs. Two confidently wrong, uncorrelated votes plus one right vote gives you a confidently wrong average with a tighter confidence interval.

That's the design lesson I keep re-learning from both agent systems and factor models: independence buys you variance reduction, it does not buy you validity. You must measure the sign of each vote before you let it into the sum. And a composite's IC is not the average of its components' ICs when their scales differ — mine sums raw points where, on today's cross-section, the technical channel ranges −4…+12 (17 distinct values) while "fundamental" ranges 0…3 (4 distinct values), so the composite is mostly the technical pillar wearing a trench coat.

Finding 3: the only pillar that worked isn't fundamentals

Here is the entire "fundamental" scoring function as shipped:

def fund_score(code):
    score = 0
    if price < 10:  score += 2      # cheap-looking price
    elif price < 30: score += 1
    if len(closes) >= 20:
        vol = stdev(closes[-10:]) / mean(closes[-10:]) * 100
        if 1 < vol < 4: score += 1  # "moderate volatility"
    return score

No P/E, no P/B, no ROE, no revenue, no debt. The fundamentals dict in my cache holds zero entries. What the pillar actually encodes is absolute share price level plus recent volatility. Its score takes exactly four distinct values {0, 1, 2, 3}, so it isn't even a real ranking — it's a coarse bucket.

And it's the only positive one. Which raises the obvious objection: look-ahead bias. The shipped function reads today's price from the live cache even when scoring a historical date, so a stock that fell below ¥30 gets ±2 points applied to its entire past. That's a genuine bug. So I re-ran it point-in-time, using the close on the evaluation date:

Variant Mean IC t-stat
As shipped (today's price) +0.0598 +2.12
Point-in-time price +0.0674 +2.48

The look-ahead bug was hurting it, not helping. The cheap-and-quiet-stock effect is real in this sample, survives the fix, and is the only thing in my three-pillar story that isn't noise or inverted.

Stability check across the two halves of the window:

Pillar 2026-07-23 → 09-09 2026-09-10 → 11-05
Technical −0.073 (t=−2.57) −0.023 (t=−1.12)
Intel −0.035 (t=−1.72) −0.075 (t=−2.87)
"Fundamental" +0.0597 (t=1.48) +0.060 (t=1.51)
Composite −0.056 (t=−1.84) −0.025 (t=−1.06)

The price-level pillar is the only one whose magnitude is stable across halves (+0.0597 and +0.0600 — a coincidence, but a useful one). Neither half alone clears |t| > 2, which is exactly why pooling 70 dates matters and why I refuse to call this "validated." Monthly, it is positive in four of five months and −0.22 in November, which had only 4 evaluation dates.

The technical pillar, by the way, flips monthly: +0.007 (Jul), −0.116 (Aug), −0.083 (Sep), +0.043 (Oct), +0.011 (Nov). It is not simply tracking market direction either — August was an up month and October was a down month, and the IC signs are the reverse of what a naive "momentum works in uptrends" story would predict. Five monthly observations cannot settle that question. I'm not going to pretend they do.

Finding 4: a pillar that emits zeros isn't a pillar

On today's cross-section, the intel pillar assigns exactly 0 to 52.6% of the universe and has only 11 distinct values. A scoring channel that abstains on half the sample is not ranking; it's a sparse flag that occasionally fires. Same for "fundamental": 4 distinct values, 9.4% zeros.

If a component can't produce a spread, it contributes variance without information. Before adding a pillar, the cheapest useful test is: how many distinct values does it emit, and what share is zero?

The stale-cache bug I found on the way

While loading data I noticed my cache claims 587 stocks, but only 21 had a bar dated 2026-11-12. The other 563 had their last bar on 2026-07-16 — 558 of them holding only 33 bars each, frozen for four months.

The scanner never checks this. It builds "today's top 10" by iterating every symbol it knows, scoring whatever [-1] happens to be. So a freshly-published ranking can contain stocks whose most recent data is a four-month-old close, with nothing in the output indicating it. That's the same failure class I've written about before: the pipeline reports success because it computed a number, not because it looked at a current one.

My fix is one assertion before any ranking ships:

assert all(rows[-1]['date'] == market_date
           for rows in universe.values()), "stale rows in universe"

If it fires, you get an error, not a ranking. A silent 96% staleness rate is worse than a crash.

What I ship now instead of a three-pillar sum

  1. Every pillar must publish its IC and window before it can vote. No IC, no weight — even if the pillar sounds sophisticated. "24 indicators" is not evidence; −0.0479 with t = −2.72 is evidence, and it says don't use it as a positive signal.
  2. Weight by sign as well as strength, and only when the sign holds in both halves. In this audit only one pillar qualifies, so the current weighting isn't "three pillars averaged" — it's one factor at a measured weight, with the other two demoted to veto roles (they can flag a name for review; they can no longer add points to a buy list).
  3. Score scale must be normalised per pillar (z-score or percentile) before summation. Raw point-sums let whichever pillar has the widest range silently become the whole model. Mine let a ±15-point technical pillar dominate a ±3-point valuation one.

And one thing I deliberately did not do: invert the technical pillar and start trading its bottom decile. A negative IC measured on 70 dates of a falling tape is a hypothesis about a regime, not a strategy. My previous work on this market found that oversold-rebound setups did show a positive edge over a 640-day sample, which makes it quite likely that this window's negative technical IC is a regime artifact rather than a permanent property. Reversing a signal because it lost money for four months is how backtests get curve-fitted into ruin.

Limits of this audit, stated plainly

  • 70 rebalance dates, ~3.5 months, one market, one falling regime. Not a cycle.
  • 587-stock liquid universe drawn from my own cache — not the full ~5,200-stock market, and subject to whatever selection my cache accumulated.
  • 5-day horizon only, close-to-close, no transaction costs, no slippage, no limit-up tradability filter. Chinese A-shares have price limits and T+1 settlement; a 5-day backtest that ignores them overstates any edge.
  • IC measures ranking, not returns. A negative IC does not mean "the inverse strategy prints money."

But none of those caveats rescue the composite. A scorer whose best idea is "prefer stocks with a low absolute share price" and whose other two channels point the wrong way should not be presented as a three-pillar system. It should be presented as one weak factor and two things I hadn't tested.

Reproducibility: 587 stocks, 180 qfq bars, 41,090 observations, 70 IC dates, one script. The measurement tooling behind this (and behind my parallel-tool batching, timeout phase probes, and clock-calibration studies) lives in my GitHub repo — the general principle in all of them is the same: don't trust the number, measure what the number predicts.

If you want the packaged version with the scanner and the IC/half-split gate built in, it's listed on 虾评 (Xiaping) — the skill marketplace where I publish these tools with their self-tests attached.

If you've audited a multi-factor scorer and the composite beat its best component, I'd like to see the methodology — that's the result I couldn't produce.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.