作 者:老余捞鱼
原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。
本文约 3100 字 | 预计阅读 11 分钟,后有完整代码,建议先收藏。
TradingView 上有这么一套Supertrend加VAPI的日内组合,规则写得特别干净。
Supertrend 给方向,VAPI 给确认,K 线形态再筛一道。我照着规则写成代码跑了一遍,六只标的七个月,Supertrend 一共翻向 58 次,四道条件全筛完,BUY 信号 0 个,SELL 信号 3 个。
跑完那天我盯着这组数字看了半天,一个做多的都没有。
3 个信号测不出胜率,所以这篇文章我不打算告诉你这套方法赚不赚钱。我要说的是另一件事。这套规则里最狠的不是给出信号的条件,是它挡掉信号的条件。
下面这几张是我从 TradingView 上扒下来的实盘图,标的是 NYMEX 天然气期货,1 分钟周期。

Supertrend 到底在算什么
Supertrend 不算任何指标,就做一件事,在价格周围画一条带棘轮的线。
这条线的位置由两个数决定,ATR 周期 10,乘数 3。ATR 衡量最近 10 根 K 线的平均波动幅度,乘以 3 再加到中位价上,就是线的初始位置。
所谓棘轮,是这条线只会朝一个方向挪。多头状态下止损线只上抬,空头状态下只下压。这么设计的原因很简单。止损线往下挪等于把已经收紧的风险重新放开,那不叫跟踪,那叫找死。

价格从线的下方穿到上方,线翻到价格上面,标签就从 buy 变成 sell。反过来也一样。信号只是翻转的那一下,除此之外不需要任何额外计算。
这套逻辑在震荡市里会被反复打脸。价格在一个区间里来回蹭,线就跟着来回翻,每一次都是信号,每一次都是错的。所以单靠它一根线不够,得再叠一根量能线来确认方向,这就是 VAPI 存在的理由。
VAPI 是量能版的收盘价位置
VAPI 全称 Volume Accumulation Percentage Indicator,TradingView 社区里 LazyBear 2015 年 2 月发布的那一版。
公式很短。每一根 K 线先算一个位置值 x,等于收盘价在这根 K 线区间里的相对高度,收在最高点 x 等于 1,收在最低点 x 等于 -1,收在一半 x 等于 0。一字线没有区间,x 记 0,不让这种没有波动的 K 线混进来搅乱数字。
然后把每根 K 线的成交量乘上它的 x,在 length 周期里求和,再除以同一周期内的成交量总和,乘 100,默认 length 取 10。
读法很直白。数值为正,说明最近这 10 根里收在区间上部的 K 线占了大头,有资金在往上承接;数值为负,反过来。

它跟 OBV 那一类指标的区别在加权方式。OBV 是成交量简单累加,收在哪儿都一样加;VAPI 不一样,收得越靠近区间高点,这根 K 线的成交量权重越大,这就是它名字里那个 intraday volatility 的来源。
四道门槛长什么样

做多要同时满足四条。第一,Supertrend 翻成多头,标签出现 buy;第二,触发信号的那根 K 线只有一根影线,不能上下都被刺;第三,前一根 K 线是绿的,证明不是孤立的一根;第四,VAPI 为正。
做空把这四条整个反过来。整套逻辑可以压成一行速记,Supertrend 给信号,K 线形态确认,VAPI 确认。
这四条听起来都很合理,特别是第二条,看到图里那种干净利落的信号 K 线,确实会觉得这种形态更值得做。
问题就在第二条。把它写成代码跑一遍,答案就出来了。
58 次翻向,被影线条件挡掉 88%
跑完第一件事先确认数据干不干净。SPY 最大单根涨跌 2.3%,AAPL 9.3%,NVDA 7.5%,TSLA 13.4%,都在正常范围,没有拆分跳变,也没有零价格坏点。
然后统计那 58 次翻向时信号 K 线长什么样。

趋势翻转的那一刻,K 线上下都有影线,占了 88%。这很合理,翻向意味着多空双方在那个价位激烈争夺,争夺的结果就是影线。两边都留下痕迹,才说明这个位置真的有人买也有人卖。
我看到 88% 这个数的时候愣了一下。这跟我在图上看到的完全不是一回事,屏幕上那些信号 K 线看着挺干净,落到代码里一算,八成上面下面都带刺。
而规则要的是只有一根影线,这种形态在 58 次里只出现 6 次。

漏斗里每一层砍掉多少一目了然。Supertrend 翻向 58 次,加上影线条件剩 6 次,加上前一根同色剩 5 次,VAPI 同向剩 3 次。BUY 侧更极端,27 次翻向,最后 0 个。
我怀疑是我的判定写错了
第一反应是自己实现有问题。这个 88% 我盯着看了很久,顺着两个方向查了下去。
一是影线的判定阈值。我一开始取的是影线长度超过 K 线全幅 2% 才算一根,怕浮点毛刺被算成影线。这个阈值是不是定得太严了?我做了一轮扫描。
二是 Supertrend 的参数。会不会是我的参数太敏感,翻向次数太多,才显得单影线比例低?我把 ATR 周期和乘数从最激进的 7/2 一直调到最保守的 20/5。

参数的影响确实很大。ATR 用 7/2 的时候翻了 164 次,用 20/5 只翻 11 次,但不管哪一组,单影线的占比都没超过 20%,最松的那组也只到 20%。
我盯着这四组参数看了半天,想找出有没有哪一组能把单影线比例拉上去。没有。四组全在 20% 以下。
影线容差这边同样如此,0% 判定时单影线占 1.7%,放宽到 5% 占 25.9%,放宽到 10% 占 44.8%,放宽到 20% 才到 53.4%。
要让「只有一根影线」这个条件看起来不那么苛刻,判定阈值得放宽到 K 线全幅的两成。那已经不叫影线了,那叫噪声。
结论站得住。趋势翻向那一根 K 线,上下都带影线是常态,不是意外。
剩下的 3 个信号,我不敢算胜率
先看宽松口径下 Supertrend 单独的表现,也就是只要求趋势翻向、不管后面三道门槛。这一步是为了给后面一个参照系。

BUY 信号之后 3 根 K 线内上涨占 59.3%,27 个样本,比基准 52.3% 高 7 个百分点。往后拉长就不行,24 根之后跌回 48.1%。
SELL 信号之后 3 根上涨占 54.8%,跌的只有 45.2%,而基准是跌 47.7%。这一条得说明白,SELL 信号之后价格上涨的概率比下跌还高,方向是反的。
27 个和 31 个样本,胜率波动几个百分点完全落在噪音范围内。加上四道条件之后 BUY 是 0 个、SELL 是 3 个。我不用 3 个样本算出一个 66% 之类的数字挂上去,那是在给自己找台阶。
VAPI 单独用也不行。为正的 3370 个样本,之后 3 根上涨占 53.4%,比基准高 1.1 个百分点,这个数字同样落在噪音里。
那到底该怎么用这套东西
我的答案是分两步走,先数信号,再谈方向。顺序反了就是自欺欺人,先盯胜率的人永远找不到问题出在哪。
数信号这件事很简单,把常做的品种拉七个月历史数据,跑一遍这套筛选,看最后剩几个下来。我的数据是 3 个,其中做多方向一个都没有。按这个比例,一个季度大概率在 0 到 2 个之间。这个数字本身就是答案,不管它是 0 还是 2,都说明这套条件不能当成主策略用。
如果 0 到 2 个仍然想用,那它只能当副线使,而且得换个用法。别再等四道条件全齐才动手,改成「先有方向,再看条件」。Supertrend 给出 buy 就进,VAPI 和影线两根 K 线只用来决定仓位大小,条件全齐时加到满仓,条件差一根两根时减到三分之一。这么用的好处是条件从开关变成了调节器,它挡不住信号来,也挡不住震荡里反复挨打。

这张图是这次最想拿出来的一张。左边三条线是三种筛法,只要趋势翻向、只加一根影线、换成实体过半,三条的区别只在收紧程度。条件越严信号越少,这一半符合直觉。右边才是关键。同一条筛选规则,把 ATR 参数从最激进的 7/2 拧到最保守的 20/5,超额的正负号来回翻。「换成实体过半」在 ATR(7,2) 下超额 -8.4 个百分点,到 ATR(20,5) 下还是 -12.3 个百分点;「只有一根影线」在 ATR(10,3) 下超额 +14.4,到 ATR(14,4) 就变成了 -2.3。
规则一个字没改,参数一动,结论就变号。这说明这些超额全部来自小样本抖动,不是筛选条件本身的效力,两者看着像一回事,其实完全不是。
我更倾向把这件事说重一点。多叠一条条件提高胜率这条路是走不通的,叠上去只是多砍掉一批样本,剩下那几个赢不赢基本靠抽签。真要提升,只能换维度,比如加一个能带来独立信息的过滤,而不是再叠一条同源的条件。
所以接下来该做什么很清楚。别先调条件,先看样本量。
说不好在你自己的品种上会是什么结果,但一个策略七个月只剩三笔信号,既没法验证它,也没法证伪它,这种东西放进实盘不叫策略,叫赌。真正能拿来做决策的信号量级,至少得凑出几十上百笔,赚了亏了才说明得了问题。
我跑完的两个脚本放在文末,改成手上的标的就能跑。一个跑主复现,一个跑这张对照表。想动手的,建议先只跑信号数这一项,把七个月的数字看清楚再决定用不用。
参考资料
LazyBear 在 TradingView 发布的 VAPI 指标说明(VAPI_LB 默认 length 10)。Supertrend 的 ATR 周期 10 / 乘数 3 默认组合,TradingView 多个实现版本一致。NYMEX 天然气期货 1 分钟图截图取自 TradingView 公开图库。
完整脚本
脚本分四段,分别是 Supertrend、VAPI、影线判定、信号筛选与统计。装好依赖直接跑。
pip install pandas第一段 Supertrend
棘轮逻辑写在循环里,多头时止损线只上抬,空头时只下压。
import pandas as pd
def true_range(df):
prev_close = df["close"].shift(1)
return pd.concat([
df["high"] - df["low"],
(df["high"] - prev_close).abs(),
(df["low"] - prev_close).abs(),
], axis=1).max(axis=1)
def supertrend(df, period=10, mult=3.0):
atr = true_range(df).ewm(alpha=1 / period, adjust=False,
min_periods=period).mean()
hl2 = (df["high"] + df["low"]) / 2.0
upper = (hl2 + mult * atr).values
lower = (hl2 - mult * atr).values
close = df["close"].values
n = len(df)
up, dn = upper.copy(), lower.copy()
direction = [1] * n
stop = lower.copy()
for i in range(1, n):
up[i] = min(up[i], up[i - 1]) if close[i - 1] > up[i - 1] else up[i]
dn[i] = max(dn[i], dn[i - 1]) if close[i - 1] < dn[i - 1] else dn[i]
if close[i] > up[i - 1]:
direction[i] = 1
elif close[i] < dn[i - 1]:
direction[i] = -1
else:
direction[i] = direction[i - 1]
stop[i] = dn[i] if direction[i] == 1 else up[i]
return (pd.Series(direction, index=df.index, name="direction"),
pd.Series(stop, index=df.index, name="stop"))第二段 VAPI
位置值 x 在一字线上要记 0,否则没有区间的 K 线会除出无穷大污染整段。
def vapi(df, length=10):
rng = (df["high"] - df["low"]).replace(0, float("nan"))
x = (2 * df["close"] - df["high"] - df["low"]) / rng
x = x.fillna(0.0)
num = (df["volume"] * x).rolling(length).sum()
den = df["volume"].rolling(length).sum().replace(0, float("nan"))
return (100 * num / den).fillna(0.0)第三段影线判定
阈值是可调参数。这一段直接决定信号的存亡,扫一遍就知道它有多要紧。
def wick_count(row, eps=0.02):
rng = row["high"] - row["low"]
if rng <= 0:
return 2
threshold = rng * eps
upper = row["high"] - max(row["open"], row["close"])
lower = min(row["open"], row["close"]) - row["low"]
n = 0
if upper > threshold:
n += 1
if lower > threshold:
n += 1
return n第四段信号筛选与统计
前向收益必须在单标的内部算完再拼表。跨标的直接 concat 会让 shift 串行,这是个很容易踩的坑。
def annotate(df):
d = df.copy()
d["direction"], d["stop"] = supertrend(d)
d["vapi"] = vapi(d)
d["wick"] = d.apply(wick_count, axis=1)
d["is_buy_flip"] = (d["direction"] == 1) & (d["direction"].shift(1) == -1)
d["is_sell_flip"] = (d["direction"] == -1) & (d["direction"].shift(1) == 1)
d["prev_green"] = d["close"].shift(1) > d["open"].shift(1)
d["prev_red"] = d["close"].shift(1) < d["open"].shift(1)
d["full_buy"] = (d["is_buy_flip"] & (d["wick"] == 1)
& d["prev_green"] & (d["vapi"] > 0))
d["full_sell"] = (d["is_sell_flip"] & (d["wick"] == 1)
& d["prev_red"] & (d["vapi"] < 0))
return d
def win_rate(d, mask, base):
r = (d["close"].shift(-3) / d["close"] - 1.0)[mask].dropna() * 100
if len(r) == 0:
return {"n": 0}
wr = float((r > 0).mean() * 100)
return {"n": int(len(r)), "win_rate": round(wr, 1),
"vs_base_pp": round(wr - base, 1)}第五段 换筛法对照
这一段是我那张对照图背后的逻辑。把「一根影线」换成「实体过半」,再看两组 ATR 参数下超额变不变号。三个函数就够了,数据和前面复用。
def build(raw, atr_p, atr_m):
"""按给定 ATR 参数生成一张全量表。"""
frames = []
for df in raw.values():
d = df.copy()
d["direction"], _ = supertrend(d, period=atr_p, mult=atr_m)
d["vapi"] = vapi(d)
d["wick"] = d.apply(wick_count, axis=1)
rng = (d["high"] - d["low"]).replace(0, float("nan"))
d["body_ratio"] = ((d["close"] - d["open"]).abs() / rng).fillna(0.0)
d["buy_flip"] = (d["direction"] == 1) & (d["direction"].shift(1) == -1)
d["sell_flip"] = (d["direction"] == -1) & (d["direction"].shift(1) == 1)
d["prev_green"] = d["close"].shift(1) > d["open"].shift(1)
d["prev_red"] = d["close"].shift(1) < d["open"].shift(1)
for n in (3, 6):
d[f"fwd{n}"] = (d["close"].shift(-n) / d["close"] - 1) * 100
frames.append(d)
return pd.concat(frames, ignore_index=True)
def masks(A):
"""五种筛选口径,命名与正文一致。"""
up = A["prev_green"] & (A["vapi"] > 0)
dn = A["prev_red"] & (A["vapi"] < 0)
core = (A["buy_flip"] & up) | (A["sell_flip"] & dn)
return {
"只要趋势翻向": A["buy_flip"] | A["sell_flip"],
"只有一根影线(原条件)": core & (A["wick"] == 1),
"换成实体过半": core & (A["body_ratio"] > 0.5),
}两个脚本我跑完的结果都放在 repro_results.json 和 repro_results_alt.json 里,改成手上的标的重跑就能对照。
— 本文完 —
#TradingView #Supertrend #VAPI #日内交易 #趋势跟踪 #成交量指标 #信号过滤 #量化交易 #期货 #K线形态 #交易纪律 #指标组合
Be First to Comment