ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Pine Script资源合集:分层筛选、回测避坑与自动化执行

Pine Script资源合集:分层筛选、回测避坑与自动化执行

Pine Script 资源合集这个词,我在不同场合被问过不下二十次。问的人里有一半是想找现成脚本直接抄,另一半是想系统学一遍但不知道从哪儿下手。我的答案每次都一样:先把资源按"可信度"分层,再决定花多少时间。因为这门脚本语言的生态有个很特殊的地方——它不是一门通用编程语言,而是长在某主流图表平台上的表达层,官方文档、内置库、社区脚本、第三方教程这四类资源的质量差距极大,混在一起看,新手很容易在三个月内写出十几个"看起来很美、一跑就亏"的策略。这篇东西就是我维护一份 Pine Script 资源合集两年多攒下来的整理逻辑、踩坑记录和实操流程,从官方语言参考怎么读,一路讲到告警推到自建执行端的完整链路。适合两类人:写过几行脚本但总在报错边缘反复横跳的;以及指标已经能跑、准备把信号接到自动化执行环节的。纯小白也能看,我会把关键概念用生活化的比方拆开讲。

1. 资源合集的设计思路:为什么按梯队分而不是按标签分

我最早那版合集是按标签分类的:指标、策略、库、教程、工具。用了一个月就发现不行,因为同一个脚本可能既是"策略"又是"教程",而且标签完全不反映质量。后来改成按可信度和使用顺序分层,维护成本立刻降下来了。这一节讲清楚我为什么这么改,以及这套分层结构的筛选门槛是什么。

1.1 我试过的三种分类法,以及它们为什么都失败

第一种是关键词分类,失败原因很直接:Pine Script 的术语重叠严重,"均线""趋势""动量"这些标签下塞满了几千个脚本,读者点进去还是要自己判断好坏。分类的意义是降低决策成本,关键词分类反而增加了。

第二种是按版本分类,v3、v4、v5、v6 各一栏。这个思路在语言大版本迁移期有用,但作为主结构不合适。因为大部分读者只关心当前版本,旧版本代码对他来说是噪音,而真正需要看旧版本代码的人,是准备做迁移的那一小撮,应该单独给一个"迁移参考"入口,而不是把整个合集切成四份。

第三种是按资产类别分,股票、外汇、加密各一栏。这个失败得最快,因为一个双均线交叉的逻辑在哪个品种上都长一个样,按品种分只会让同一份代码被复制四次,维护起来就是灾难。

最后定下来的思路是:分类维度必须和"读者的决策路径"对齐,而不是和"内容的属性"对齐。读者打开一份资源合集时,脑子里其实只有一个问题——"我现在该看哪个"。所以我按梯队排,从最权威往下走,每个梯队给定明确的进入条件和退出条件。

1.2 四层结构的设计与筛选门槛

最终结构是四层。第一层是官方语言参考和版本说明,第二层是平台内置的指标和库源码,第三层是社区开源脚本,第四层是第三方教程和视频。层与层之间不是包含关系,是"先看哪个"的顺序关系。

给每层设定的门槛是这样的:第一层必须全读,没有取舍空间;第二层按需查阅,但要求至少完整读过三个内置指标的源码;第三层严格执行五条准入信号(后面 3.1 节会展开),任何一条不满足就不进合集;第四层最严,因为它最容易掺水,我要求作者必须同时给出可运行代码和明确的失败案例说明,只有结论没有过程的教程一律不收。

提示:合集的维护成本主要来自第三层和第四层。这两层会不断产生死链和过时内容,建议每季度做一次全量体检,前两层基本不用动。

这个结构还有个隐性好处:它能反过来约束内容的生产。当你知道社区脚本要通过五条信号筛选,你自己写脚本时就会下意识地加注释、写清楚输入参数范围、标明适用周期,因为这些正是筛选时会看的东西。

2. 第一梯队与第二梯队:官方文档和内置资源怎么啃

官方语言参考大概有几百页的体量,我见过太多人从第一页开始按顺序读,读到第三十页放弃。正确的方式是带着具体问题去查,但前提是你得先知道问题在哪。这一节讲我把官方文档拆成哪几个必读模块,以及为什么内置指标源码是被严重低估的教材。

2.1 语言参考手册的正确读法:先读这五个模块

我把官方语言参考拆成必读、常用、按需三档。必读的有五块:变量声明与类型限定词、执行模型与历史引用、内置函数命名空间总览、策略函数组、告警函数组。这五块是地基,缺一块后面就会反复踩坑。

常用档是绘图函数、表格、数组、字符串格式化、时间函数。这些用到了再查,查的时候顺手看完整的参数表就够了。按需档是各类技术指标函数的具体算法说明,比如ta.rsi到底用的是哪一种平滑方式、ta.atr用的是 SMA 还是 RMA,这类细节在你做策略复现时非常关键,但不需要提前背。

一个具体的读法建议:每读完一个函数,立刻在指标脚本里调用一次,把返回值plot出来或者label.new打出来。Pine Script 的坑大部分不在语法,在语义——函数的默认行为和你脑子里想的不一样。比如ta.ema的alpha参数不填时按2/(length+1)计算,这个"默认"如果你没验证过,复现别人的策略时结果一定对不上。

2.2 内置指标源码:最便宜的高质量教材

平台上那些看起来平平无奇的均线、MACD、布林带,源码质量其实相当高。它们被官方维护、按版本更新、注释规范、边界条件处理得干净。我建议每个学 Pine Script 的人至少精读三个:一个纯计算的(比如 RSI),一个带绘图的(比如布林带),一个带状态保持的(比如超级趋势类的算法)。

读源码时要盯三件事:输入参数的默认值和取值范围、na值是怎么处理的、绘制逻辑和计算逻辑有没有分离。第三点特别重要,很多新手的脚本把计算和绘图搅在一起,导致想改个颜色要动核心逻辑,想复用计算部分又抽不出来。

举个我从内置源码里学到的写法习惯:输入的minval/maxval一定要给。很多人只写input.int(14, "长度"),用户填个 0 进去,除以零直接整段报错。正确写法是input.int(14, "长度", minval=1, maxval=500)。这种事官方源码里每个输入都做了,社区脚本里一半没做——这就是差距。

2.3 版本迁移说明:写给自己未来的保险

版本迁移文档是最没人读、但性价比最高的部分。v4 升 v5 那一次改动很大,命名空间全部重排,很多老脚本直接失效;到 v6 又在布尔类型和请求机制上收紧了规则。你要维护一份资源合集,就必须知道哪些代码已经过期、哪些只是写法旧但还能跑。

我的做法是给每个收录的脚本标一个兼容性字段,写明"v5 可直接运行""v5 运行有警告""仅 v4""需改三处才能升 v6"。这个字段在后期维护时省了大量时间——读者反馈脚本报错,我先看兼容性字段就能判断是代码问题还是环境问题。

注意:判断脚本兼容性不要只看文件头部的//@version=注释,那个可以被随手改。更可靠的方法是看它是否用了新命名空间的函数(比如ta.、math.、str.前缀),旧写法在新版本里虽然常被兼容,但行为可能微妙不同。

3. 第三、第四梯队:社区脚本与教程的取舍标准

社区脚本是这份合集里数量最多、质量最参差的部分。我最初收录了三百多个,半年后删到不足八十个。删的过程中总结出五条准入信号和三条质量水位线,这部分是我认为整份合集里最有价值的经验,因为它直接教你怎么判断一份陌生代码值不值得花时间。

3.1 判断一个开源脚本值不值得读的五个信号

第一条信号:有没有明确的失效条件说明。好的脚本作者会写清楚"这个逻辑在震荡市会连续止损"或者"不适用于低于 15 分钟周期",这类坦白比任何收益截图都有说服力。通篇只讲优点的脚本,直接跳过。

第二条信号:输入参数有没有做范围约束。前面提过的minval/maxval问题,这是代码素养的照妖镜。一个连输入校验都不做的作者,很难相信他处理了边界情况。

第三条信号:有没有用request.security请求外部数据,以及有没有加lookahead参数。这条是重绘问题的分水岭,第四节会详细讲,简单说就是用了lookahead_on的多周期脚本,回测结果基本不可信。

第四条信号:注释密度和注释质量。我不看注释行数占比,我看注释里有没有解释"为什么这样写"。// 计算均线这种是废话,// 用前一bar的值避免未来函数这种才是有信息量的。

第五条信号:更新时间与反馈处理。作者是否回过评论区的技术质疑,报错是否被修复过。一个两年没动过、评论区堆满"不工作"的仓库,不管 star 多少都不收。

这五条我做成了一张打分表,每条 0-2 分,总分低于 6 分不进合集。实测下来这个门槛比想象中严格,能过线的不到三成。

信号观察点及格线
失效条件说明是否写明适用周期与失效场景至少一段明确说明
输入参数约束minval/maxval/options是否完整关键参数全覆盖
多周期请求是否使用lookahead,是否说明影响未使用或已说明
注释质量是否解释设计意图而非复述代码关键逻辑有意图注释
维护状态更新时间与评论反馈处理一年内有响应

3.2 教程内容的三条质量水位线

教程类内容的问题不是错,是"对但没用"。很多教程把官方文档翻译一遍,配几张图,看起来完整,学完还是写不出能跑的脚本。我设了三条水位线,过了才收。

第一条:必须给出完整可运行代码,而不是片段。片段式教学的通病是省掉了版本声明、省掉了输入定义、省掉了绘图设置,读者复制过去一堆报错。完整代码的标准是:新建一个指标或策略脚本,整段粘贴进去,不修改任何东西就能跑出图。

第二条:必须包含至少一个失败案例。只说"这样写能work"的教程价值有限,说清楚"这样写会报什么错、为什么"才有价值。我最喜欢的一类教程是先展示错误写法,跑出错误结果,再逐步修正。

第三条:必须说明代码的时间成本。也就是这段逻辑在多少根K线上跑、大概要多久、有没有性能陷阱。Pine Script 有执行时间限制,一个在for循环里反复调request.security的写法,跑长周期数据直接超时,但教程里半个字不提。

第四条其实还有一条隐性的:作者是否用过自己教的东西。这条不好量化,但看得出来——真正在用的人,教程里会不自觉地带上具体的坑,比如"这里有个坑,我调了两天才发现"。这句话出现的地方,往往就是整篇教程最有价值的地方。

4. 语言核心机制拆解:不搞懂这几件事,脚本一定写错

这一节是整份合集里我补充内容最多的地方。因为翻遍社区脚本,报错和逻辑错误高度集中在四个机制上:执行模型、类型限定词、状态保持、多周期请求。这四件事搞明白了,剩下的是查手册的活。搞不明白,你会一直在"为什么回测赚钱实盘亏钱"里打转。

4.1 逐根K线执行模型:把脚本想成一台录像机

Pine Script 的执行模型是逐根K线运行的。脚本不是在"当前时刻"算一次,而是从第一根历史K线开始,一根一根往后算,每算一根就画一个点。这个模型有个生活化的类比:把它想成一台录像机,从最早一帧开始播,播到最后一帧。你在代码里写的每一行,相当于每一帧都要执行一次。

理解这一点,很多现象就顺了。为什么plot只写一次却能画出整条线?因为每根K线执行时都画了一个点。为什么脚本里不能写"等三根K线之后再做某事"这种顺序逻辑?因为在任意一根K线的执行上下文里,"之后"还没有发生。

这也解释了一个新手最常见的困惑:为什么我的变量每次循环都被重置了?因为默认情况下,你在脚本顶层声明的变量,每根K线都会重新初始化一次。想让它跨K线保持,必须用var或varip声明。这是 4.3 要讲的内容。

还有一点值得早点知道:close、high这类内置变量是 series,在每根K线上取不同的值。而close[1]是"上一根K线的收盘价",方括号是历史引用操作符。历史引用的上限由平台限制决定,超出上限会静默返回na,不会报错——这是个很阴的坑。

4.2 const / simple / series:三种限定词决定了你的报错长什么样

类型限定词是 Pine Script 里最反直觉的设计之一。简单说,const是编译期就确定的常量,simple是每根K线开始时确定、bar 内不变的值,series是每根K线都在变的值。

问题的核心在于:很多内置函数只接受simple参数,不接受series。比如给ta.sma传长度,长度必须是simple或const,不能是series。因为移动平均线的窗口长度不能每根K线都变——技术上能做,但没人这么设计。

于是新手会撞上这种报错:传了个计算结果当长度,编译器不干。典型场景是"动态周期",想按波动率调整均线长度:

//@version=5 indicator("动态长度示例", overlay=true) // 错误写法:atrLen 是 series,不能传给需要 simple 的参数 atrLen = math.round(ta.atr(14)) // ma = ta.sma(close, atrLen) // 编译报错 // 正确思路一:把长度限定为 input,变成 simple baseLen = input.int(20, "基础长度", minval=2, maxval=200) ma1 = ta.sma(close, baseLen) // 正确思路二:如果确实要动态,用条件分支选择不同的固定长度 ma2 = atrLen > 20 ? ta.sma(close, 30) : ta.sma(close, 10) plot(ma1, color=color.new(color.blue, 0), title="固定长度均线") plot(ma2, color=color.new(color.orange, 0), title="条件切换均线")

这段代码里ma2的写法值得琢磨:不是真的动态改变长度,而是在两个固定长度之间切换。这是绕开限定词限制的常用手法。代价是每根K线都要算两条均线,性能略有浪费,但换来的是可运行。

提示:遇到 "Cannot call 'xxx' with argument 'length'=series" 这类报错,先别急着改类型,先想清楚这个参数在物理意义上该不该逐根变化。多数情况下答案是"不该",那说明你的设计方向有问题,而不是编译器太严。

还有一种情况是反向的:函数返回series,你把它塞进一个需要simple的地方,比如plot的style参数。这种就得用条件表达式在有限的几个常量选项里选,不能由计算决定。

4.3 var、varip 与状态保持:计数器为什么老是归零

var的作用是让变量只在第一根K线上初始化一次,之后跨K线保持。这个机制在写计数器、累积量、状态机时是刚需。

一个典型用法是统计满足条件的K线数量:

//@version=5 indicator("状态保持示例", overlay=false) // 不带 var:每根K线重置为 0 naiveCount = 0 // 带 var:只在第一根K线初始化为 0 var int stableCount = 0 if close > open stableCount += 1 plot(stableCount, title="累计阳线数", color=color.new(color.green, 0))

naiveCount那个变量在这里是反面教材,它永远是 0。而stableCount会一路累加,第一根K线上初始化为 0 之后再没被重置过。

varip是var的实时版本。区别在于:历史K线阶段两者行为相同,但在实时行情阶段,varip变量在每次价格跳动(tick)时都会保持,而var变量在 bar 未收盘时会回滚。这个机制只在需要处理盘中逐笔逻辑时才用得上,普通指标别碰,容易写出只有自己能复现的结果。

还有个细节:var声明的变量,初始值只在第一根K线计算,这意味着初始值表达式里的东西不会在后续K线重新求值。如果你写var x = ta.sma(close, 20),第一根K线上ta.sma返回na(数据不够),那x就永久是na。这个坑我踩过一次,调了半天才想明白。正确做法是先声明var float x = na,然后在后面的逻辑里赋值。

4.4 request.security 的重绘陷阱:回测赚钱的最大来源

request.security用来请求其他周期或其他品种的数据。它的lookahead参数有三个取值:barmerge.lookahead_off(默认)、barmerge.lookahead_on、以及一个已废弃的barmerge.lookahead_on的旧写法。

核心问题在这里:用lookahead_on请求高周期数据时,你在低周期的每根K线上拿到的,是高周期那根K线的"最终收盘值",但那根高周期K线在未来才收盘。等于说你的脚本提前知道了未来。回测会给出极其漂亮的曲线,实盘一文不值。

//@version=5 indicator("多周期请求对比", overlay=true) // 危险写法:使用了 lookahead_on [htfCloseBad] = request.security(syminfo.tickerid, "D", close, lookahead=barmerge.lookahead_on) // 安全写法:默认 lookahead_off,且用 [1] 取已收盘的高周期值 htfCloseGood = request.security(syminfo.tickerid, "D", close[1], lookahead=barmerge.lookahead_off) plot(htfCloseBad, color=color.new(color.red, 0), title="可能重绘") plot(htfCloseGood, color=color.new(color.green, 0), title="相对安全")

注意安全写法里的close[1]——取上一根高周期K线的收盘价,确保这根K线已经收完了。这是社区里流传最广的一条防重绘约定。它确实会让信号延迟一根高周期K线,但延迟换真实,这笔账是划算的。

还有一个更隐蔽的重绘来源:barstate.isconfirmed。在实时行情中,最后一根未收盘的K线,它的close会随价格跳动而变化,基于它计算的指标也会变。如果你的策略在未收盘时就发信号,那这个信号可能下一秒就消失。解决办法是在策略声明里加calc_on_every_tick=false,并在条件判断里用barstate.isconfirmed做门槛。

5. 从指标到策略:回测流程与参数设定

指标和策略在语法上只差一个声明函数,但在工程上是两个东西。指标只负责"描述发生了什么",策略要负责"做决策、记账、算成本"。这一节讲我从指标改造成策略时固定会走的几步,以及回测参数怎么设才不至于自欺欺人。

5.1 最小可用策略模板:先让它跑起来,再谈优化

我建议所有人从下面这个模板起步。它包含了策略声明必填的几个参数、仓位设定、成本设定和最基本的进出场。不要一上来就写复杂逻辑,先让一个能跑通的最小版本在屏幕上跑出结果,这个反馈很重要。

//@version=5 strategy("双均线策略模板", overlay=true, initial_capital=100000, currency=currency.USD, default_qty_type=strategy.percent_of_equity, default_qty_value=10, commission_type=strategy.commission.percent, commission_value=0.04, slippage=2, calc_on_every_tick=false, process_orders_on_close=true) fastLen = input.int(20, "快线长度", minval=2, maxval=200) slowLen = input.int(60, "慢线长度", minval=3, maxval=400) fastMa = ta.sma(close, fastLen) slowMa = ta.sma(close, slowLen) longCond = ta.crossover(fastMa, slowMa) shortCond = ta.crossunder(fastMa, slowMa) if longCond and barstate.isconfirmed strategy.entry("L", strategy.long) if shortCond and barstate.isconfirmed strategy.entry("S", strategy.short) strategy.exit("L-Exit", from_entry="L", stop=strategy.position_avg_price * 0.97, limit=strategy.position_avg_price * 1.06) strategy.exit("S-Exit", from_entry="S", stop=strategy.position_avg_price * 1.03, limit=strategy.position_avg_price * 0.94)

几个参数的选择理由:initial_capital设 10 万是为了金额直观,不同资金量下百分比仓位的表现差异很大,这点后面会用例子算给你看。default_qty_type用percent_of_equity而不是fixed,是因为固定手数在净值变化后会导致风险暴露比例漂移。process_orders_on_close=true让订单在当前K线收盘时撮合,更贴近"信号确认后下单"的现实,虽然真实执行会有延迟,但比在信号K线的开盘价成交要诚实得多。

5.2 成本三件套的量化设定:手续费、滑点、点差

回测里最容易被"优化"掉的就是成本。把手续费设成 0,回测曲线立刻好看十倍。所以我强制自己做一件事:先把成本算清楚,再写策略。

具体算法:手续费按单边百分比算,commission_value=0.04意味着每笔交易单边收万分之四,一个完整的开平仓是万分之八,即 0.08%。滑点用 tick 数表示,slippage=2表示每次成交比理想价格差 2 个 tick,开平共 4 个 tick。点差如果平台支持单独设置,按实际报价差填入。

现在算一笔账。假设本金 10 万,满仓(100% 仓位),单边手续费 0.04%,滑点按币种最小变动 0.01 计 2 tick。单次完整交易的显性成本大约是0.08% + 4 tick 的价差。如果某策略一年交易 200 次,光手续费就是200 × 0.08% = 16%的净值损耗。也就是说,策略必须先赚 16% 才能打平——很多人根本没算过这笔账,拿一个年化 20% 的回测结果沾沾自喜,实际上扣完成本只剩 4%。

要把仓位改到 10%,成本占比降到 1.6%,但同样的信号带来的收益也降到十分之一。这里没有免费午餐,仓位、频率、成本三者是一个三角约束。我一般的取舍是:降低交易频率优先于降低仓位,因为高频策略的成本是线性放大,而低频策略的成本是一次性的。

注意:滑点设置不要偷懒填 0。哪怕你在做很活跃的品种,实际成交和你图上看到的价格也有偏差,这个偏差在窄幅震荡的行情里尤其明显。回测填 0 滑点的策略,实盘表现大概率会差一截。

5.3 仓位与资金管理的参数逻辑

仓位管理这块,Pine Script 给了几个选项:strategy.fixed(固定手数)、strategy.cash(固定金额)、strategy.percent_of_equity(净值百分比)。新手默认用fixed最省事,但它是错的。

原因是:固定手数意味着随着账户净值增长,你的风险暴露比例在下降;净值下跌时,风险暴露比例在上升。这是反向的。正确的做法是按净值百分比定仓位,让风险始终占总资金固定的比例。

更进一步的是按波动率定仓位。这个逻辑是:先用 ATR 算出当前波动幅度,再反推仓位大小,让每笔交易的最大亏损接近账户的一个固定百分比。简化实现如下:

//@version=5 strategy("波动率仓位示例", overlay=false, initial_capital=100000, currency=currency.USD) riskPct = input.float(1.0, "单笔风险占净值%", minval=0.1, maxval=5.0, step=0.1) atrLen = input.int(14, "ATR 长度", minval=2, maxval=100) atrMult = input.float(2.0, "止损 ATR 倍数", minval=0.5, maxval=5.0, step=0.1) atrVal = ta.atr(atrLen) stopDist = atrVal * atrMult // 每单位价格变动对应的账户金额 = 净值 * 风险比例 / 止损距离 qty = (strategy.equity * riskPct / 100) / stopDist plot(qty, title="建议数量", color=color.new(color.blue, 0)) plot(stopDist, title="止损距离", color=color.new(color.red, 0))

这里的计算过程值得掰开:假设净值 10 万,单笔风险 1%,那这笔交易最多亏 1000。ATR 是 50,止损设 2 倍 ATR 即 100 点距离。那么合适的数量是1000 / 100 = 10个单位。这样无论品种波动大小,每笔交易的潜在亏损都锁定在账户的 1%。

这个算法有个前提假设:止损一定会按设想的距离成交。现实中跳空会击穿止损,所以实际风险会略大于 1%。保守起见我会把这个参数设成 0.5% 到 0.8%,留出缓冲。

5.4 回测报告该看哪几个数,不该看哪个数

回测跑完,屏幕上会出来一堆指标。我的经验是:净利率可以扫一眼,然后立刻忽略。真正要看的是下面这几个。

最大回撤,这个数字决定了你能不能拿住这个策略。一个年化 30% 但最大回撤 40% 的策略,多数人实盘拿不到一半时间就会放弃。我一般的心理门槛是回撤不超过 15%。

盈利因子(Profit Factor),总盈利除以总亏损。低于 1.2 的策略基本没戏,因为扣掉真实成本和滑点后几乎没有余量。理想的区间在 1.5 以上。

交易次数,这个数字太小或太大都有问题。少于 30 次的回测,统计意义极弱,可能只是恰好拟合了一段行情。多于 500 次的,成本会吃掉大部分收益,而且更可能是过拟合出来的高频噪声。

胜率和盈亏比的组合,这两个必须一起看。胜率 40%、盈亏比 2:1 的策略是健康的;胜率 80%、盈亏比 0.2:1 的策略,一次极端行情就能把全年利润吐光。

还有一项,回测的时间跨度。只跑半年数据的策略,无论曲线多漂亮都不能信。我会要求至少覆盖一个完整的涨跌周期,具体长度取决于品种的周期特性。

6. 自动化交易链路:告警、Webhook 与执行端

图表端能做的是"发信号",不能做的是"下单"。中间这段链路需要自己搭。这一节讲架构分层、告警函数的选择、消息体设计和执行端的最小实现,最后是幂等和熔断这些不做会出事的细节。

6.1 整体架构:图表端只负责发信号

整条链路的角色划分必须清楚,三个部分各管一件事,不要混。

图表端(脚本)负责:算指标、判断条件、在条件成立时发出告警,并把必要信息(品种、方向、价格、时间)编码进消息。它不负责:判断下多少、账户够不够、要不要撤单。

传输层(Webhook)负责:把告警消息以 HTTP 请求的形式推送到你指定的地址。它不负责任何逻辑,只是个搬运工。

执行端(自建服务)负责:校验来源、去重、算仓位、调用交易接口、记录日志、处理失败。它不负责判断方向——方向已经在信号里了,执行端不该自作聪明地改。

这个分层最容易被破坏的地方是"让图表端也算仓位"。技术上能做(脚本里可以算数量),但问题是图表端的账户信息是静态的,跟真实账户脱节。一旦资金变化,参数就对不上。所以我的原则很硬:图表端只发方向和价格,仓位一律在执行端按实时账户余额算。

6.2 alert 与 alertcondition 的选择:动态消息用前者

两个告警函数的功能差别很关键。alertcondition只能在创建告警时设定固定的消息文本,运行时不能动态改。alert可以拼接变量,生成包含实时价格、品种、时间戳的消息。

要做自动化,必须选alert。原因很简单:执行端需要知道价格,而alertcondition给不了。

//@version=5 strategy("告警输出示例", overlay=true, initial_capital=100000, currency=currency.USD, default_qty_type=strategy.percent_of_equity, default_qty_value=10) fastLen = input.int(20, "快线", minval=2) slowLen = input.int(60, "慢线", minval=3) fastMa = ta.sma(close, fastLen) slowMa = ta.sma(close, slowLen) longCond = ta.crossover(fastMa, slowMa) shortCond = ta.crossunder(fastMa, slowMa) // 组装 JSON 字符串,注意引号转义 makeMsg(action) => '{"action":"' + action + '","symbol":"' + syminfo.ticker + '","price":"' + str.tostring(close, format.mintick) + '","bar_time":"' + str.tostring(time, "yyyy-MM-dd'T'HH:mm:ss") + '","strategy":"sma_cross","key":"replace_with_your_secret"}' if longCond and barstate.isconfirmed alert(makeMsg("buy"), alert.freq_once_per_bar_close) if shortCond and barstate.isconfirmed alert(makeMsg("sell"), alert.freq_once_per_bar_close)

alert.freq_once_per_bar_close这个频率选项很重要。它保证每根K线只发一次,且是在收盘确认后发。不写这个,实时行情下价格每跳动一次就可能触发一次告警,执行端会被刷屏。

提示:消息里的key字段是个简易的共享密钥,执行端必须校验。Webhook 地址一旦泄露,任何人都能往你的执行端推指令。这不是危言耸听,公开场合贴过截图的人不少。

6.3 Webhook 消息体设计:留足扩展字段

消息体我建议一开始就用 JSON,哪怕平台支持纯文本。因为纯文本一旦要加字段就得改两头的解析逻辑,JSON 加字段是向后兼容的。

字段设计上,除了方向、品种、价格、时间、密钥,我还会预留三个:strategy(策略标识,方便多策略共存时区分)、bar_time(信号K线的时间,用于去重)、v(协议版本,方便以后改格式时兼容旧消息)。

有个细节容易忽略:价格字段的精度。用str.tostring(close, format.mintick)而不是直接str.tostring(close),前者会按品种的最小变动单位格式化,避免科学计数法或者过长小数。执行端解析时如果按浮点数处理,精度丢失会导致下单价格偏离。

另外,消息体大小是有上限的。别把整个指标数组都塞进去,控制在几百字节内,超出可能被截断。我见过有人在消息里塞了二十个技术指标值,结果被截断成半截 JSON,执行端解析直接抛异常。

6.4 执行端最小实现:一个能跑起来的接收服务

执行端用什么写都行,我用 Python 加 Flask,因为它够简单。下面是一个最小可用的接收端,包含签名校验、去重、日志三件事。

import hmac import hashlib import json import time import logging from flask import Flask, request, abort app = Flask(__name__) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") SHARED_SECRET = b"replace_with_your_secret" seen = {} # bar_time -> 上次处理时间 DEDUP_WINDOW = 300 # 5 分钟内同 bar_time 视为重复 def verify(raw: bytes, sig: str) -> bool: expect = hmac.new(SHARED_SECRET, raw, hashlib.sha256).hexdigest() return hmac.compare_digest(expect, sig or "") @app.post("/hook") def hook(): raw = request.get_data() if not verify(raw, request.headers.get("X-Signature", "")): logging.warning("signature mismatch") abort(401) try: payload = json.loads(raw) except json.JSONDecodeError: abort(400) action = payload.get("action") symbol = payload.get("symbol") price = float(payload.get("price", 0)) bar_time = payload.get("bar_time") if action not in ("buy", "sell") or not symbol or price <= 0: abort(400) now = time.time() last = seen.get(bar_time, 0) if now - last < DEDUP_WINDOW: logging.info("duplicate ignored: %s", bar_time) return {"status": "duplicate"}, 200 seen[bar_time] = now logging.info("signal accepted: %s %s @ %s", action, symbol, price) # 在这里调用交易接口,超出本文范围 return {"status": "accepted"}, 200 if __name__ == "__main__": app.run(host="127.0.0.1", port=8080)

几个设计点解释一下。签名校验用的是 HMAC-SHA256,对比时用hmac.compare_digest而不是==,避免时序攻击。去重用的是"信号K线时间 + 时间窗口"双保险,因为同一个 bar_time 可能在网络重试时被推送多次。日志记录的是信号内容,不记录密钥。

这段代码不能直接上生产,缺的东西很多:没有限流、没有持久化、没有异常告警、没有和交易接口的对接。但它验证了链路是通的——你可以先把告警推到本地,看日志里能不能收到正确的 JSON,这一步跑通了再往下加功能。

6.5 幂等、去重与熔断:不做这三件事迟早出事

去重前面讲了,这里补充一个更隐蔽的情况:告警重复不是网络问题,是脚本逻辑问题。如果脚本里同时用了alert和alertcondition,或者告警条件在实时行情下反复触发又消失,执行端就会收到多条语义重复的消息。所以去重不能只靠时间窗,还要在信号里带一个"这根K线是否已经发过"的标记。

幂等的意思是:同一条信号被处理多次,结果和一次一样。实现方式是在执行端维护一张已处理信号表,用symbol + bar_time + action做键。处理前先查表,处理完落库。这张表要定期清理,不然会无限增长。

熔断是最后一道防线。至少要做三层:单笔金额上限、单日交易次数上限、单日亏损上限。任何一层触发就停止接收信号并发出告警。我在测试阶段吃过一次亏——脚本里的条件写错了取反,告警连发了四十多次,执行端全部照单执行。那次是模拟环境,成本为零,但教训很实在。

注意:熔断阈值不要设得太宽松,不然起不到保护作用;也不要太严,正常波动就触发会频繁误停。我的经验值是单日交易次数上限设在策略平均日频的 3 到 5 倍,留出异常波动的余量。

7. 常见问题与排查速查

这一节是我收集的读者提问和自己踩过的坑,按类型整理。很多问题看着五花八门,根子上其实就那么几类。

7.1 编译与运行报错类

"Cannot call 'xxx' with argument 'length'=series",前面 4.2 讲过,本质是把 series 传给了需要 simple 的参数。排查顺序是:先确认这个参数在物理意义上该不该逐根变,不该就改成 input 或常量;确实要变就用条件分支在有限选项里切换。

"Undeclared identifier",八成是版本迁移问题。v4 的sma()在 v5 里叫ta.sma(),命名空间改了。另一个可能是变量作用域问题——在if块里声明的变量出了块就不可见。Pine Script 的块级作用域比多数语言严格,习惯就好。

"The 'if' statement does not have a 'else' branch, so it must return a value of type 'void'",这个报错的意思是:if分支返回了值但没写else,编译器不知道不满足条件时返回什么。解决方法是补else分支,或者确保if块里不产生返回值(只做赋值和绘图)。

脚本能跑但图上什么都没有,几个检查点:plot的series参数是不是na(数据不够时常见);overlay参数设错了,指标被画在副图上而你在主图找;display参数设成了display.none;坐标轴范围被某个极端值撑爆了。

7.2 结果异常与逻辑类

回测曲线完美但实盘不灵,按概率排:第一位是lookahead_on重绘,第二位是未收盘信号反复触发,第三位是成本设为 0,第四位是回测周期太短导致过拟合。这四个逐个排查,能解决九成以上的"回测很美实盘很惨"。

指标数值和平台自带的对不上,最常见原因是平滑算法不同。你以为的 RSI 和平台内置的 RSI 在算法上可能差一个 Wilder 平滑和简单平均的区别。第二个原因是数据起点不同,指标有预热期,前 N 根的值不可信。

策略下单数量不对,检查default_qty_type和default_qty_value的组合。percent_of_equity配 10 是 10% 仓位,fixed配 10 是 10 手/10 个单位,两者差着数量级。还有一个隐蔽原因:strategy.equity在回测中包含了未平仓浮盈,用它在持仓中算仓位会得到动态变化的结果。

同一个脚本,换周期结果天差地别,这是正常的,不是 bug。任何策略都有自己的有效周期范围,双均线在日线上可能有效,在 1 分钟线上会被噪音淹没。收录脚本时我会强制标注适用周期,就是这个原因。

现象首要怀疑快速验证方法
回测收益异常高lookahead_on重绘把lookahead改回默认再跑一遍
信号忽有忽无未用barstate.isconfirmed在条件里加确认判断
指标值与内置版不符平滑算法定义不同对比源码里的平滑实现
编译报参数类型错series 传给了 simple 参数检查参数是否该逐根变
图上无输出值为na或 overlay 设错先plot(1)验证绘图通道
下单量级不对qty_type与qty_value组合打印strategy.position_size核对
告警重复推送实时行情反复触发改freq_once_per_bar_close
执行端收到空消息消息体超长被截断精简字段,控制长度

7.3 我自己的三条排查习惯

第一条:任何异常先做最小复现。把脚本砍到只剩出问题的那几行,其他全删。九成的问题在这个过程里就暴露了,因为删的过程中你会发现某一行本来就不该存在。

第二条:plot是最好的调试器。Pine Script 没有断点调试,但你可以把任何中间变量plot出来看曲线。想验证一个条件是否在预期位置触发,就plot(cond ? 1 : 0),一眼就看出来了。

第三条:怀疑数据源先去查品种的交易时段。有些品种在某些时段没有报价,K线是补齐的空值,指标算出来全是na。这类问题在跨品种策略里特别常见,也特别容易被误判成代码 bug。

8. 维护这份合集的几条实操心得

最后说点维护层面的东西,这部分跟技术关系不大,但决定了这份合集能不能活过第二年。

8.1 宁可少收,不要错收

我删掉的那两百多个脚本,大部分不是"坏",是"平庸"。平庸内容的成本被严重低估了——它会占用读者的判断力,让真正有用的东西被淹没。所以我的收录标准后来变成:如果一个脚本我没法在三十秒内说出它"值得读的具体理由",就不收。这个标准很主观,但执行下来效果很好,合集的信噪比提升明显。

另一个配套做法是给每个条目加一行"为什么收录"。这行字不是写给读者看的,是写给我自己看的。如果某天我发现写不出这行字,说明当初收录就是个错误决定。

8.2 版本迁移要提前布局,不要等报错

平台的版本升级通常会给过渡期,老版本还能跑但会有警告。我的做法是在新版本发布后的第一个月内,把所有收录脚本跑一遍,记录哪些报了警告、哪些直接失效。这个工作量不小,但比攒到半年后一起处理要轻松得多,因为到那时候你已经忘了每个脚本是干什么的。

具体操作上,我给每个条目维护一个兼容性字段,格式是"最低版本 / 最高验证版本 / 迁移难度"。迁移难度分三档:直接跑、改函数名、改逻辑。第三档的脚本会单独标记,因为它们最可能在读者手里出问题。

8.3 给读者留一条反馈通道,但不要被牵着走

合集发布后会有读者反馈,有价值的反馈大概占两成,剩下八成是"这个脚本不好用"但不说明具体情况。我的处理方式是把反馈模板化,要求提供三样东西:脚本名、报错信息原文、运行环境(版本和周期)。补齐这三样我才处理,否则一律回复模板。

这个做法看起来不近人情,但实际效果是反馈质量大幅提升。真正有问题的读者会认真填,纯抱怨的会自然流失。省下来的时间我用来做深度排查,比应付一堆模糊反馈有价值得多。

还有一点个人体会:不要把合集做成"榜单"。一旦开始排名,维护成本会指数级上升,而且排名本身没有客观标准。我现在的做法是只分层不排名,同一层内按字母序排列,把判断权交回给读者。

再分享一个小技巧,如果你也在维护类似的资源整理项目,给每个条目加一个"最后验证日期"字段,成本极低但效果很好。读者能一眼看出哪些内容可能已经过期,你自己也能快速定位需要复查的部分。这个字段我坚持了两年,是整份合集里被读者感谢最多的一个设计。

返回列表