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
- Data & method: universe, data source, and how each signal is defined.
- Part 1, The toolkit: the four jobs indicators do, and which of the ten are redundant.
- Part 2, Do the signals predict anything?: the pooled scorecard, and why near-50% hit rates are the honest baseline.
- Part 3, Does the signal hold up per stock?: the per-ticker breakdown, where the real dispersion lives.
- Part 4, Do the signals agree with each other?: testing the confirmation argument.
- Part 5, What would it cost to trade this?: gross edge versus round-trip friction.
- 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=Truewrites 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.