在 TradingView 上写 Pine Script 策略很方便,但有两个硬伤:回测只能在 TV 服务器上跑,以及实盘信号无法脱离浏览器。当策略积累到一定程度,你自然想要一个本地的回测和信号监控系统。
本文记录如何从零搭建这样的系统,以及如何将已有的 Pine 策略移植到 Python 回测引擎。
系统架构
Binance API (公开数据)
│
▼
data/fetcher.py ← 定时增量同步(cron)
│
▼
data/historical.db ← SQLite 本地数据仓库
│
├── core/backtest.py → 回测报告(HTML + equity curve)
│
└── core/monitor.py → 实盘信号监控(桌面通知)
整个系统三个层次:数据层、引擎层、策略层,单向依赖互不耦合。
数据层:本地 SQLite 仓库
设计
使用 SQLite 单文件存储,无需额外数据库服务。两个核心表:
CREATE TABLE symbols (
symbol TEXT NOT NULL,
contract_type TEXT NOT NULL,
source TEXT NOT NULL,
name TEXT,
PRIMARY KEY (symbol, contract_type)
);
CREATE TABLE klines (
symbol TEXT NOT NULL,
contract_type TEXT NOT NULL,
interval TEXT NOT NULL,
open_time INTEGER NOT NULL,
open REAL, high REAL, low REAL, close REAL, volume REAL,
PRIMARY KEY (symbol, contract_type, interval, open_time)
);
标的注册表(symbols)区分 contract_type(spot / perpetual / futures_dated)和 source(binance_spot / binance_futures / yahoo),从设计上支持多交易所、多合约类型。K 线表冗余 contract_type 避免频繁 join。
数据源按接口隔离为独立模块,新增数据源只需实现一个 fetch_klines() 接口。
增量同步
基于 MAX(open_time) 断点续传,无论关机多久都能自动补全:
TZ=UTC
0,15,30,45 * * * * 15m(对齐 K 线收盘边界)
0 * * * * 1h
0 0,4,8,12,16,20 * * 4h
0 0 * * * 1d
@reboot 开机全量更新
关键设计:只在 K 线收盘时刻触发同步,避免未收盘的半截信号干扰策略判断。
引擎层:策略 DSL + 回测
Strategy 基类
没有用 Backtrader 等第三方框架,自研引擎只有一套核心约定——约 30 行的基类:
from core.strategy import Strategy, Signal
class MyStrategy(Strategy):
symbol = "BTCUSDT"
contract_type = "perpetual"
interval = "1h"
def on_bar(self, ctx):
rsi = ctx.rsi(14)
if rsi < 30:
return Signal("buy", reason=f"RSI 超卖 {rsi}")
return None
on_bar 每根 K 线回调一次,返回 Signal 或 None。回测引擎和实盘监控器共用同一套 Context:
ctx.ma(20) # 移动平均
ctx.ema(period) # 指数移动平均
ctx.rsi(period) # RSI
ctx.macd() # (dif, dea, hist)
ctx.boll(period, k) # (mid, upper, lower)
ctx.atr(period) # 平均真实波幅
ctx.cci(period) # CCI(仅收盘价,不传 live_price 时不参与计算)
ctx.trend() # bull / bear / unknown
ctx.patterns() # K 线形态列表
# 所有指标支持多周期
ctx.rsi(14, "1d") # 日线 RSI
ctx.ma(20, "4h") # 4h MA
Context 内部做了两级优化:预计算数组(回测用)和惰性求值缓存(实盘用),避免重复计算。
回测引擎
核心循环约 150 行,支持:
- 逐根 K 线遍历回调
- 按 Signal 模拟买入/卖出及手续费
- 止损/止盈
- equity curve 记录
- 绩效指标:总收益率、Sharpe Ratio、最大回撤、胜率、盈亏比
python3 core/backtest.py \
--strategy strategies.signal_multi.SignalMulti \
--symbol BTCUSDT --contract perpetual \
--from 2024-06-01 --to 2026-04-30 \
--capital 10000 --report
输出为单页 HTML(Chart.js 绘制 equity curve),无需服务器。
策略层:从 Pine 到 Python
Pine 策略的共同模式
分析 TradingView 中积累的 Pine 文件后,发现它们共享一套设计模式:
3~4 层 EMA 判断趋势方向 (21 / 144 / 288 / 576)
+ CCI(89/144) EMA 交叉作为入场确认
+ 不同趋势强度下启用不同的 CCI 阈值
+ 超买/超卖区平仓
移植实现
三个策略移植为统一的 signal_ 命名系列:
| 策略 | 文件 | 核心逻辑 |
|---|---|---|
| SignalMulti | signal_multi.py | 3EMA + CCI EMA 交叉 + 多级阈值,最完整的 Pine 移植 |
| SignalCross | signal_cross.py | CCI(89) EMA(21/55) 交叉 + 3EMA |
| SignalTrend | signal_trend.py | 4EMA(21/144/576) + CCI(144) EMA(13),趋势跟随 |
以 SignalMulti 为例,它在 Pine 中的核心逻辑转换为 Python 后:
class SignalMulti(Strategy):
def on_bar(self, ctx):
e21, e144, e288 = ctx.ema(21), ctx.ema(144), ctx.ema(288)
bullish = (e144 < e288 and e21 > e288) or (e144 > e288 and e21 > e144)
f = self._cci_fast # CCI(89) 的 EMA(21)
if bullish and f < 100:
return Signal("buy", reason=f"多头 e21={e21:.0f} cci={f:.0f}")
return None
关键移植点:Pine 的 cci_calculation 使用 ta.stdev,我们使用平均绝对偏差(Mean Deviation)。两者数学上不同(stdev ≈ 1.25 × MD),但移植时选择保持统一的 CCI 实现,确保所有策略使用相同基准。
回测结果
在 BTCUSDT 永续合约 1h 上的表现(2024-06 ~ 2026-04,初始资金 10,000):
| 策略 | 交易数 | 胜率 | 盈亏比 | 收益率 | Sharpe |
|---|---|---|---|---|---|
| SignalMulti | 366 | 48.6% | 1.13 | -36.5% | 1.27 |
| SignalCross | 75 | 61.3% | 1.28 | -4.2% | 0.57 |
| SignalTrend | 37 | 46.0% | 1.88 | +32.2% | 0.40 |
三个策略没有一个能在所有指标上同时胜出——这正是搭建回测系统的价值。手工在 TradingView 上”看起来不错”的策略,放到系统性回测里,缺陷一览无余:SignalMulti 交易太频繁被手续费拖垮,SignalCross 胜率高但盈亏比低,SignalTrend 唯一正收益但回撤大。
经验总结
-
数据先行:没有可靠的本地数据,回测和监控都是空中楼阁。SQLite + 增量 cron 是最低成本的方案。开盘自动更新,关机自动续传。
-
自研引擎比套框架更可控:Backtrader 等框架学习成本高,策略 DSL 与 Pine 差异大。600 行的自研引擎,策略写起来和 Pine 一样直觉,调试起来比理解框架内部机制容易得多。
-
Pine 移植的关键不是代码翻译,是理解信号语义:同一个”CCI 超卖”在 Pine 和 Python 中可能因标准差/平均差的计算差异而表现不同。移植时选择统一的计算基准,比逐字逐句复制更重要。
-
回测的目的是发现缺陷,不是证明策略有效:三个移植策略中只有一个在回测期取得正收益。这不是策略失效,而是让你在实盘前看到问题出在哪里——没有硬止损、参数照搬 Pine 默认值、单标的表现可能不具普适性。
下一步的方向:硬止损、多标的验证、参数扫描、K 线形态信号增强——但那是下回的内容了。