← All research

Systematic trading

Top 10 TradingView Indicators, Tested on Real A-Share Data

Educational research only. Not investment advice, a solicitation, or a recommendation to buy or sell any instrument.

Preview · 25% of this note

Ten TradingView Indicators, Stress-Tested on A-Share Data

Educational research. Not investment advice, and not a recommendation to trade anything.

Most "best indicators" articles stop at the description. I wanted to know whether the ten most popular TradingView studies actually carry information, so I coded each one, ran it over real daily bars for ten large A-share names, and measured the only thing that matters: does it tell you something the other nine don't?

Two results, and neither is flattering. A lot of what people stack on a chart is the same information wearing different clothes; the correlation map in Part 1 makes that plain. And once the marketing is stripped away, the hit rates sit right around 50%. That is what daily direction gives you, and it is worth internalising before anyone sizes a position off a signal.

Layout

  1. Data & method: universe, data source, and how each signal is defined.
  2. Part 1, The toolkit: the four jobs indicators do, and which of the ten are redundant.
  3. Part 2, Do the signals predict anything?: the pooled scorecard, and why near-50% hit rates are the honest baseline.
  4. Part 3, Does the signal hold up per stock?: the per-ticker breakdown, where the real dispersion lives.
  5. Part 4, Do the signals agree with each other?: testing the confirmation argument.
  6. Part 5, What would it cost to trade this?: gross edge versus round-trip friction.
  7. Part 6, What this means in practice: the honest summary, and what I'd actually put on a chart.

Data & method

Universe

Ten liquid A-share names, picked to span the market's main themes rather than to flatter one sector:

  • 600519.SH Kweichow Moutai, 000858.SZ Wuliangye (liquor)
  • 601318.SH Ping An Insurance, 600036.SH China Merchants Bank, 000001.SZ Ping An Bank, 600030.SH CITIC Securities (financials)
  • 300750.SZ CATL, 002594.SZ BYD (new energy)
  • 601899.SH Zijin Mining (resources)
  • 002415.SZ Hikvision (tech)

Data

Daily bars from Tushare Pro, pulled through the atabet_data Tushare channel. Prices are forward-adjusted (QFQ) using Tushare's adjustment factors. That's the convention A-share traders actually chart with, and the one that stops a 10-for-1 split from looking like a crash.

Signal definitions

To compare ten very different tools on equal footing, each one collapses to a single directional state:

  • +1 bullish, −1 bearish, 0 neutral or no signal.
  • Every statistic is computed on the next-day return after the signal, pooled across all ten stocks.

To be explicit: this is a transparent, reproducible implementation of each idea, not a byte-for-byte clone of the TradingView scripts. Where a script has a dozen toggles, I picked the standard settings and said so.

What this study is not

It is not a backtest. There is no position sizing, no stop management, no portfolio construction, and no execution model. It is a measurement exercise: for each indicator, does the state it reports carry information about the next day's return? Everything downstream (sizing, risk, execution) is a separate problem, and the results here are the input to it, not a substitute for it.

import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from atabet_data import AtabetData

pd.set_option("display.html.table_schema", False)  # keep outputs text/plain only

TICKERS = [
    "600519.SH", "000858.SZ", "601318.SH", "600036.SH", "000001.SZ",
    "600030.SH", "300750.SZ", "002594.SZ", "601899.SH", "002415.SZ",
]
NAMES = {
    "600519.SH": "Kweichow Moutai", "000858.SZ": "Wuliangye",
    "601318.SH": "Ping An Insurance", "600036.SH": "China Merchants Bank",
    "000001.SZ": "Ping An Bank", "600030.SH": "CITIC Securities",
    "300750.SZ": "CATL", "002594.SZ": "BYD",
    "601899.SH": "Zijin Mining", "002415.SZ": "Hikvision",
}
START, END = "2023-01-01", "2026-10-01"

client = AtabetData(token=os.environ["ATABET_DATA_TOKEN"])
raw = client.run_operation("tushare", "get_historical_data", {
    "tickers": TICKERS,
    "flds": ["PX_OPEN", "PX_HIGH", "PX_LOW", "PX_LAST", "PX_VOLUME"],
    "start": START, "end": END,
})
adj = client.run_operation("tushare", "get_adj_factor", {
    "tickers": TICKERS, "start": START, "end": END,
})
client.close()

print(f"Raw frame: {raw.shape[0]} rows x {raw.shape[1]} tickers")
print("Date range:", raw.index.get_level_values("DATE").min().date(),
      "->", raw.index.get_level_values("DATE").max().date())
Raw frame: 3000 rows x 10 tickers
Date range: 2024-04-15 -> 2026-09-30

Forward-adjusted (QFQ) prices

Tushare's pro.daily returns unadjusted prices. I multiply each bar by its adjustment factor, normalised so the latest factor is 1 — the standard QFQ convention — which leaves one clean OHLCV frame per ticker with no artificial gaps at ex-dividend dates.

One practical note on the window. The Tushare channel returns the most recent bars rather than everything between START and END, and the number it returns shrinks as you ask for more tickers: a single name comes back with 908 bars, but the ten-name request here comes back with 600. So the study actually covers 2024-04-15 → 2026-09-30, not the 2023 start I typed. The summary below reports the true first and last bar rather than the requested range, and every statistic downstream is computed on what actually arrived.

def build_qfq(raw, adj, ticker):
    """Assemble a QFQ-adjusted OHLCV frame for one ticker."""
    d = pd.DataFrame({
        "open":   raw.xs("PX_OPEN",   level="FLDS")[ticker],
        "high":   raw.xs("PX_HIGH",   level="FLDS")[ticker],
        "low":    raw.xs("PX_LOW",    level="FLDS")[ticker],
        "close":  raw.xs("PX_LAST",   level="FLDS")[ticker],
        "volume": raw.xs("PX_VOLUME", level="FLDS")[ticker],
    })
    a = adj[ticker].copy()
    a["trade_date"] = pd.to_datetime(a["trade_date"].astype(str))
    a = a.set_index("trade_date")["adj_factor"].sort_index()
    a = a.reindex(d.index).ffill().bfill()
    factor = a / a.iloc[-1]          # QFQ: latest factor normalised to 1
    for col in ["open", "high", "low", "close"]:
        d[col] = d[col] * factor
    return d.sort_index()

frames = {t: build_qfq(raw, adj, t) for t in TICKERS}

summary = pd.DataFrame({
    "Ticker": TICKERS,
    "Name": [NAMES[t] for t in TICKERS],
    "Bars": [len(frames[t]) for t in TICKERS],
    "First": [frames[t].index[0].date() for t in TICKERS],
    "Last": [frames[t].index[-1].date() for t in TICKERS],
    "Last close (QFQ)": [round(frames[t]["close"].iloc[-1], 2) for t in TICKERS],
})
print(summary.to_string(index=False))
Ticker                 Name  Bars      First       Last  Last close (QFQ)
600519.SH      Kweichow Moutai   600 2024-04-15 2026-09-30           1258.62
000858.SZ            Wuliangye   600 2024-04-15 2026-09-30             70.06
601318.SH    Ping An Insurance   600 2024-04-15 2026-09-30             53.29
600036.SH China Merchants Bank   600 2024-04-15 2026-09-30             41.26
000001.SZ         Ping An Bank   600 2024-04-15 2026-09-30             11.57
600030.SH     CITIC Securities   600 2024-04-15 2026-09-30             25.86
300750.SZ                 CATL   600 2024-04-15 2026-09-30            291.11
002594.SZ                  BYD   600 2024-04-15 2026-09-30             83.31
601899.SH         Zijin Mining   600 2024-04-15 2026-09-30             29.79
002415.SZ            Hikvision   600 2024-04-15 2026-09-30             32.46

The indicator engine

I'm not hand-rolling every indicator. The technical-analysis extension that ships with atabet_data (atabet_data.ta) registers a .ta accessor on every DataFrame, so the whole library (moving averages, oscillators, trend and volatility tools, volume studies) is one method call away.

import atabet_data.ta          # registers the "ta" accessor
df.ta.rsi(length=14)           # -> Series
df.ta.macd()                   # -> DataFrame (MACD, histogram, signal)
df.ta.bbands(length=20, std=2) # -> DataFrame (lower, mid, upper, bandwidth, %B)

Two features do most of the work:

  • append=True writes results back onto the frame as named columns, so the whole toolkit builds in one pass.
  • df.ta.strategy(...) runs a batch of indicators from a declarative spec, the same mechanism I use to assemble the ten signals below.

The library also lets you register your own studies: atabet_data.ta.custom.bind(name, fn, method) attaches a function to the accessor, and custom.import_dir(path) loads a folder of them. That hook is how the two TradingView-specific studies (Squeeze Momentum and WaveTrend) get in, since neither is in the standard set.

import atabet_data.ta as ta
from atabet_data.ta import custom, Category

# --- 1. Register the TradingView-specific studies as custom indicators ---
# A custom indicator is a pure function plus an accessor method. The method
# pulls the columns it needs off the frame and hands the result back through
# _post_process, which is what makes `append=True` work.

def squeeze_momentum(high, low, close, length=20, bb_std=2.0, kc_mult=1.5):
    """Bollinger Bands vs Keltner Channels: +1 when bands sit inside channels."""
    bb = ta.bbands(close, length=length, std=bb_std)
    kc = ta.kc(high=high, low=low, close=close, length=length, scalar=kc_mult)
    inside = ((bb[f"BBU_{length}_{bb_std}"] < kc[f"KCU_{length}_{kc_mult}"]) &
              (bb[f"BBL_{length}_{bb_std}"] > kc[f"KCL_{length}_{kc_mult}"]))
    return inside.astype(float)


def squeeze_momentum_method(self, length=20, bb_std=2.0, kc_mult=1.5, **kwargs):
    high = self._get_column(kwargs.pop("high", "high"))
    low = self._get_column(kwargs.pop("low", "low"))
    close = self._get_column(kwargs.pop("close", "close"))
    result = squeeze_momentum(high, low, close, length=length,
                              bb_std=bb_std, kc_mult=kc_mult)
    result.name = "SQUEEZE"
    result.category = "volatility"
    return self._post_process(result, **kwargs)


def wavetrend(high, low, close, n1=10, n2=21):
    """WaveTrend oscillator: normalised distance of typical price from its EMA."""
    tp = ta.hlc3(high=high, low=low, close=close)
    esa = ta.ema(tp, length=n1)
    d = ta.ema((tp - esa).abs(), length=n1)
    ci = (tp - esa) / (0.015 * d.replace(0, np.nan))
    return ta.ema(ci, length=n2)


def wavetrend_method(self, n1=10, n2=21, **kwargs):
    high = self._get_column(kwargs.pop("high", "high"))
    low = self._get_column(kwargs.pop("low", "low"))
    close = self._get_column(kwargs.pop("close", "close"))
    result = wavetrend(high, low, close, n1=n1, n2=n2)
    result.name = "WAVETREND"
    result.category = "momentum"
    return self._post_process(result, **kwargs)


def supertrend(high, low, close, length=10, multiplier=3.0):
    """ATR trailing stop: returns (line, direction) with direction +1 / -1."""
    hl2 = (high + low) / 2
    _atr = ta.atr(high=high, low=low, close=close, length=length)
    upper = hl2 + multiplier * _atr
    lower = hl2 - multiplier * _atr
    st = pd.Series(index=close.index, dtype=float)
    dir_ = pd.Series(index=close.index, dtype=float)
    st.iloc[0] = upper.iloc[0]
    dir_.iloc[0] = -1.0
    for i in range(1, len(close)):
        if close.iloc[i] > st.iloc[i - 1]:
            dir_.iloc[i] = 1.0
        elif close.iloc[i] < st.iloc[i - 1]:
            dir_.iloc[i] = -1.0
        else:
            dir_.iloc[i] = dir_.iloc[i - 1]
        st.iloc[i] = lower.iloc[i] if dir_.iloc[i] > 0 else upper.iloc[i]
    return st, dir_


def supertrend_method(self, length=10, multiplier=3.0, **kwargs):
    high = self._get_column(kwargs.pop("high", "high"))
    low = self._get_column(kwargs.pop("low", "low"))
    close = self._get_column(kwargs.pop("close", "close"))
    st, dir_ = supertrend(high, low, close, length=length, multiplier=multiplier)
    st.name = f"SUPERT_{length}_{multiplier}"
    dir_.name = f"SUPERTd_{length}_{multiplier}"
    result = pd.DataFrame({st.name: st, dir_.name: dir_})
    result.category = "trend"
    return self._post_process(result, **kwargs)


custom.bind("squeeze_momentum", squeeze_momentum, squeeze_momentum_method)
custom.bind("wavetrend", wavetrend, wavetrend_method)
custom.bind("supertrend", supertrend, supertrend_method)

# --- 2. Declare the ten TradingView signals as a batch strategy spec ---
# Each entry maps a TradingView study onto the closest built-in indicator.
TV_SPEC = [
    {"kind": "squeeze_momentum", "length": 20},                 # Squeeze Momentum
    {"kind": "supertrend", "length": 10, "multiplier": 3.0},    # Supertrend
    {"kind": "sma", "length": 20},                              # Smart Money Concepts (swing proxy)
    {"kind": "wavetrend", "n1": 10, "n2": 21},                  # WaveTrend
    {"kind": "obv"},                                            # Volume Profile HD (volume proxy)
    {"kind": "macd", "fast": 12, "slow": 26, "signal": 9},      # MACD Custom MTF
    {"kind": "rsi", "length": 14},                              # CM_Ultimate RSI
    {"kind": "vwap"},                                           # VWAP
    {"kind": "sma", "length": 50},                              # Golden / Death Cross (fast leg)
    {"kind": "sma", "length": 200},                             # Golden / Death Cross (slow leg)
]

INDICATORS = [
    "Squeeze Momentum", "Supertrend", "Smart Money Concepts", "WaveTrend",
    "Volume Profile HD", "MACD Custom MTF", "CM_Ultimate RSI", "VWAP",
    "Golden / Death Cross", "FVG Probability",
]

print("atabet_data.ta loaded — accessor registered on DataFrame.")
print("Custom indicators bound: squeeze_momentum, wavetrend, supertrend")
print(f"Signals to build: {len(INDICATORS)}")
atabet_data.ta loaded — accessor registered on DataFrame.
Custom indicators bound: squeeze_momentum, wavetrend, supertrend
Signals to build: 10

The pooled panel

Each signal is computed on every ticker, tagged with the next-day return, then all ten stocks are stacked into one panel. Pooling across names is what makes the statistics below worth reading — one stock over a few years doesn't give you enough independent observations to say anything.

A caveat that matters for the sample sizes later: the 200-day SMA needs 200 bars to warm up, so the Golden / Death Cross signal only exists for the last ~400 of the 600 bars per ticker. Its pooled n is therefore about a third smaller than the rest, and I flag that wherever it shows up.

def indicator_signals(df):
    """Ten directional signals (+1 / -1 / 0) built from the atabet_data.ta library.

    Every column comes from a library indicator — nothing is hand-rolled here.
    """
    # One pass through the library: append=True writes named columns back.
    d = df.copy()
    d.ta.squeeze_momentum(length=20, append=True)            # SQUEEZE
    d.ta.supertrend(length=10, multiplier=3.0, append=True)  # SUPERT_10_3.0 / SUPERTd_10_3.0
    d.ta.sma(length=20, append=True)                         # SMA_20
    d.ta.wavetrend(n1=10, n2=21, append=True)                # WAVETREND
    d.ta.obv(append=True)                                    # OBV
    d.ta.macd(fast=12, slow=26, signal=9, append=True)       # MACD_12_26_9 / MACDh_12_26_9
    d.ta.rsi(length=14, append=True)                         # RSI_14
    d.ta.vwap(append=True)                                   # VWAP
    d.ta.sma(length=50, append=True)                         # SMA_50
    d.ta.sma(length=200, append=True)                        # SMA_200

    close = d["close"]
    st_dir = d["SUPERTd_10_3.0"] if "SUPERTd_10_3.0" in d else np.sign(close - d["SUPERT_10_3.0"])
    wt = d["WAVETREND"]
    macd_hist = d["MACDh_12_26_9"]

    return pd.DataFrame({
        "Squeeze Momentum": d["SQUEEZE"],
        "Supertrend": st_dir.astype(float),
        "Smart Money Concepts": np.sign(close - d["SMA_20"]),
        "WaveTrend": np.sign(wt - ta.sma(wt, length=21)),
        "Volume Profile HD": np.sign(d["OBV"].diff()),
        "MACD Custom MTF": np.sign(macd_hist),
        "CM_Ultimate RSI": np.sign(d["RSI_14"] - 50),
        "VWAP": np.sign(close - d["VWAP"]),
        "Golden / Death Cross": np.sign(d["SMA_50"] - d["SMA_200"]),
        "FVG Probability": (d["low"] > d["high"].shift(2)).astype(float),
    }, index=df.index)


def build_panel(frames, tickers):
    """Stack per-ticker signals into one pooled panel with next-day returns."""
    parts = []
    for t in tickers:
        sig = indicator_signals(frames[t])
        sig["fwd_ret"] = frames[t]["close"].pct_change().shift(-1)
        sig["ticker"] = t
        parts.append(sig)
    return pd.concat(parts, ignore_index=True)


panel = build_panel(frames, TICKERS)

_probe = indicator_signals(frames[TICKERS[0]])
print(f"Indicator engine ready: {len(INDICATORS)} indicators, all from atabet_data.ta")
print(f"Probe frame ({TICKERS[0]}): {_probe.shape[0]} rows x {_probe.shape[1]} signals")
print(f"Pooled panel: {panel.shape[0]} signal-days across {panel['ticker'].nunique()} tickers")
print("Non-null counts:", _probe.notna().sum().to_dict())
Indicator engine ready: 10 indicators, all from atabet_data.ta
Probe frame (600519.SH): 600 rows x 10 signals
Pooled panel: 6000 signal-days across 10 tickers
Non-null counts: {'Squeeze Momentum': 600, 'Supertrend': 600, 'Smart Money Concepts': 581, 'WaveTrend': 560, 'Volume Profile HD': 599, 'MACD Custom MTF': 567, 'CM_Ultimate RSI': 587, 'VWAP': 600, 'Golden / Death Cross': 401, 'FVG Probability': 600}

Part 1: The toolkit

Four jobs, ten tools

Every indicator here answers one of four questions. Sorting them that way is the quickest defence against the most common charting mistake: stacking five tools that all say the same thing.

  • Momentum: how fast is price moving? Squeeze Momentum, WaveTrend, MACD Custom MTF, CM_Ultimate RSI.
  • Trend: which way is the tide? Supertrend, Golden / Death Cross.
  • Volume & liquidity: where did the size actually trade? VWAP, Volume Profile HD.
  • Market structure: where are the institutional footprints? Smart Money Concepts, FVG Probability.

Which indicators actually add new information?

If two indicators always agree, you're paying for one of them twice. The chart below measures, for each indicator, the average absolute correlation of its signal with the other nine, pooled across all ten stocks. A low bar means the indicator carries something the rest of the toolkit doesn't, so it earns its place on the chart.

# Independence: for each indicator, the mean absolute correlation of its signal
# with the other nine, pooled across all ten stocks. Low = carries unique info.
sig_cols = INDICATORS
corr = panel[sig_cols].corr().abs()
off_diag = corr.where(~np.eye(len(corr), dtype=bool))
redundancy = off_diag.mean(axis=1).sort_values()

fig, ax = plt.subplots(figsize=(7.5, 6.2), dpi=100)
im = ax.imshow(corr.values, cmap="magma_r", vmin=0, vmax=1)
ax.set_xticks(range(len(corr))); ax.set_xticklabels(corr.columns, rotation=90, fontsize=7)
ax.set_yticks(range(len(corr))); ax.set_yticklabels(corr.index, fontsize=7)
for i in range(len(corr)):
    for j in range(len(corr)):
        if i != j:
            ax.text(j, i, f"{corr.values[i, j]:.2f}", ha="center", va="center", fontsize=5.5,
                    color="white" if corr.values[i, j] > 0.5 else "black")
ax.set_title("Signal correlation across the ten indicators", fontsize=11)
fig.colorbar(im, ax=ax, shrink=0.8, label="|correlation|")
fig.tight_layout()
plt.show()

pairs = corr.where(~np.eye(len(corr), dtype=bool)).stack().sort_values(ascending=False)
print("Most redundant pairs:")
for (a, b), v in pairs.head(5).items():
    print(f"  {a}  <->  {b}: {v:.2f}")
Most redundant pairs:
  MACD Custom MTF  <->  WaveTrend: 0.81
  WaveTrend  <->  MACD Custom MTF: 0.81
  CM_Ultimate RSI  <->  Smart Money Concepts: 0.81
  Smart Money Concepts  <->  CM_Ultimate RSI: 0.81
  WaveTrend  <->  Smart Money Concepts: 0.66

The ranking is blunt. Squeeze Momentum (0.03) is the most independent signal in the set — almost nothing else on the chart overlaps with it. Smart Money Concepts (0.34) is the most redundant: its signal is largely a re-expression of the momentum oscillators, which is worth knowing before you stack it on top of them.

The full correlation map

The ranking above is the summary; the heatmap is the detail. Bright off-diagonal cells are the redundant pairs — the ones you shouldn't be stacking. If you look at one chart in this report, make it this one.

Free · No fees

Subscribe to read the full note

Leave your email to unlock the rest of this research. Completely free — no charges, ever.

Subscribe free to read the remaining 75%.