8.自定义指标与性能优化
交易世界里,技术指标就像厨师手中的刀具,内置的固然锋利,但总有需要定制的时候。Jesse 提供了相当丰富的内置指标库,可一旦策略需要独特的市场视角,就得自己动手写指标了。这一章我们深入探讨如何开发自定义指标,以及让计算飞起来的性能优化技巧。
开发自定义指标的过程并不复杂,核心在于理解 Jesse 的数据结构和计算范式。整个框架基于 Numpy 构建,所有 K 线数据都以数组形式流转,这为批量计算创造了绝佳条件,但也要求我们对数组操作有基本认知。
开发自定义指标
动手之前,先确认一件事:这个指标真的需要从零写起吗?GitHub 上藏着大量现成的 Python 实现,搜索时把指标名加上 high、low、open、close 或 price 这类关键词,筛选 Python 或 Jupyter Notebook 类型,往往能找到可用代码。找到后别照搬,理解逻辑再改造,才能融入 Jesse 的生态系统。
以 Elliott Wave Oscillator 为例,这个指标在 TradingView 上的 Pine Script 实现只有几行:
//@version=3
study("Elliott Wave Oscillator")
s2=ema(close, 5) - ema(close, 34)
c_color=s2 <= 0 ? red : lime
plot(s2, color=c_color, style=histogram, linewidth=2)
逻辑很直白:5 周期 EMA 减去 34 周期 EMA,正值绿柱,负值红柱。把它搬到 Jesse 需要几个步骤。
先在项目根目录创建 custom_indicators 文件夹,里面放 __init__.py 文件。然后新建 ewo.py 存放具体逻辑。目录结构会变成这样:
├── storage
├── strategies
└── custom_indicators
├── __init__.py
└── ewo.py
__init__.py 里导入指标函数:
from .ewo import ewo
真正的计算逻辑写在 ewo.py 中。Jesse 的指标函数有固定签名:第一个参数是 candles 数组,接着是指标参数,最后必须是 sequential 布尔值控制返回类型。看具体实现:
import numpy as np
from typing import Union
from jesse.helpers import get_candle_source, slice_candles
def ewo(candles: np.ndarray, short_period: int = 5, long_period: int = 34, source_type="close", sequential = False) -> Union[float, np.ndarray]:
"""
Elliott Wave Oscillator
:param candles: np.ndarray
:param short_period: int - default: 5
:param long_period: int - default: 34
:param source_type: str - default: close
:param sequential: bool - default: False
:return: Union[float, np.ndarray]
"""
candles = slice_candles(candles, sequential)
src = get_candle_source(candles, source_type)
from jesse.indicators import ema
ewo = np.subtract(ema(src, period=short_period, sequential=True), ema(src, period=long_period, sequential=True))
if sequential:
return ewo
else:
return ewo[-1]
策略里调用时,把 custom_indicators 当成模块导入:
from jesse.strategies import Strategy
import custom_indicators as cta
class Strategy01(Strategy):
@property
def ewo(self):
return cta.ewo(self.candles, short_period=5, long_period=34, source_type="close", sequential=True)
这里用 @property 装饰器缓存计算结果,避免重复调用。Jesse 0.21.3 版本引入了 @cached 装饰器,性能提升更明显,后面会详细说。
Numpy 数组操作
Jesse 的 K 线数据是二维 Numpy 数组,每行一根 K 线,列的顺序固定为 [timestamp, open, close, high, low, volume]。记住这个顺序,后面操作数组时不会迷路。
访问特定列用切片语法。比如提取收盘价:
candles_close = candles[:, 2] # 第2列是close
各列索引对应关系:
candles[:, 1]是 opencandles[:, 2]是 closecandles[:, 3]是 highcandles[:, 4]是 lowcandles[:, 5]是 volume
常用价格类型可以直接计算:
candles_hl2 = (candles[:, 3] + candles[:, 4]) / 2
candles_hlc3 = (candles[:, 3] + candles[:, 4] + candles[:, 2]) / 3
candles_ohlc4 = (candles[:, 1] + candles[:, 3] + candles[:, 4] + candles[:, 2]) / 4
get_candle_source 函数封装了这些逻辑,支持字符串参数 "open"、"close"、"high"、"low"、"volume"、"hl2"、"hlc3"、"ohlc4",返回对应数据。自己写指标时,直接操作数组往往更快。
数组长度一致性是隐藏的大坑。Numpy 的向量化运算要求参与计算的数组形状相同,长度不一就会报错。更麻烦的是,某些指标需要历史数据预热,前 N 根 K 线算不出有效值,这时必须用 np.nan 填充,而不是 0。
为什么不能用 0?假设策略逻辑是 indicator_value < price,如果指标值因数据不足无法计算,用 0 填充可能导致条件意外成立,触发错误信号。用 np.nan 则安全得多,任何与 np.nan 的比较都会返回 False,避免误判。
处理长度不一致的数组,可以手动填充:
def make_same_length(arr: np.ndarray, target_length: int) -> np.ndarray:
if len(arr) < target_length:
padding = np.full(target_length - len(arr), np.nan)
return np.concatenate([padding, arr])
return arr[-target_length:]
Numba 性能优化
回测速度是生命线,尤其参数优化时,指标计算可能执行数万次。Jesse 内部用 Numba 加速,自定义指标同样可以。
Numba 是 Python 的即时编译器,把 Python 代码翻译成机器码,对循环和 Numpy 函数效果显著。使用方法简单,给函数加上 @njit 装饰器:
from numba import njit
import numpy as np
@njit
def fast_calculation(data: np.ndarray, period: int) -> np.ndarray:
result = np.full_like(data, np.nan)
for i in range(period, len(data)):
result[i] = np.mean(data[i-period:i])
return result
注意,Numba 不支持所有 Python 特性,函数里避免使用列表推导式、字典等动态类型。纯数值计算和 Numpy 操作最稳妥。
Jesse 0.21.3 版本在 Strategy 类中加入了 @cached 装饰器,对频繁调用的指标缓存结果。策略里可以这样用:
from jesse.strategies import Strategy
from jesse.helpers import cached
class MyStrategy(Strategy):
@cached
def my_indicator(self):
# 复杂计算只执行一次
return complex_calculation(self.candles)
缓存机制避免重复计算,某些策略能获得巨大性能提升。
自定义指标最佳实践
切片优化
回测时,策略通常只需要最近 N 根 K 线的指标值,计算全部历史数据是浪费。slice_candles 函数负责这件事:
def slice_candles(candles: np.ndarray, sequential: bool) -> np.ndarray:
warmup_candles_num = get_config('env.data.warmup_candles_num', 240)
if not sequential and len(candles) > warmup_candles_num:
candles = candles[-warmup_candles_num:]
return candles
sequential=False 时,自动截取最后 240 根 K 线(可配置)。sequential=True 时,Jesse 不知道你需要多少历史数据,所以不自动切片,这时得手动控制:
def my_indicator(candles: np.ndarray, period: int = 14, sequential=False):
if sequential:
# 需要多少就切多少
candles = candles[-500:]
else:
candles = slice_candles(candles, sequential)
# 后续计算...
避免循环
循环是性能杀手,双重循环更是灾难。Numpy 和 Scipy 提供了大量向量化函数,能替代绝大多数循环操作。比如计算 10 周期价格差:
# 错误示范:纯循环
close = candles[:, 2]
result = np.full_like(close, np.nan)
for i in range(10, len(close)):
result[i] = close[i] - close[i-10]
# 正确示范:向量化
close = candles[:, 2]
result = close - np_shift(close, 10)
np_shift 是自定义的位移函数,实现如下:
def np_shift(arr: np.ndarray, num: int, fill_value=np.nan) -> np.ndarray:
result = np.empty_like(arr)
if num > 0:
result[:num] = fill_value
result[num:] = arr[:-num]
elif num < 0:
result[num:] = fill_value
result[:num] = arr[-num:]
else:
result[:] = arr
return result
这个函数把数组向前或向后移动,空位用 np.nan 填充,避免了显式循环。
前向填充
某些指标计算中可能出现 np.nan,后续运算需要把前面的有效值向前填充。Numpy 没有内置函数,但可以这样实现:
def np_ffill(arr: np.ndarray, axis: int = 0) -> np.ndarray:
idx_shape = tuple([slice(None)] + [np.newaxis] * (len(arr.shape) - axis - 1))
idx = np.where(~np.isnan(arr), np.arange(arr.shape[axis])[idx_shape], 0)
np.maximum.accumulate(idx, axis=axis, out=idx)
slc = [np.arange(k)[tuple([slice(None) if dim == i else np.newaxis
for dim in range(len(arr.shape))])]
for i, k in enumerate(arr.shape)]
slc[axis] = idx
return arr[tuple(slc)]
这个函数会沿着指定轴,用最后一个有效值填充后续的 np.nan,直到遇到下一个有效值。
滑动窗口
计算滚动指标时,滑动窗口是常用模式。Numpy 的 stride_tricks 可以高效实现:
from numpy.lib.stride_tricks import sliding_window_view
def rolling_mean(data: np.ndarray, window: int) -> np.ndarray:
# 创建滑动窗口视图
windows = sliding_window_view(data, window)
# 计算每窗口均值
means = np.mean(windows, axis=1)
# 前 window-1 个值填充 nan
result = np.full_like(data, np.nan)
result[window-1:] = means
return result
这种方式比循环快几个数量级,内存占用也合理。
预热期配置
指标有记忆效应,计算结果依赖历史数据长度。Jesse 的 warmup_candles_num 配置就是为此设计。如果指标周期很大,比如 200 周期的均线,默认的 240 根预热 K 线可能不够,需要在 config.py 中增加:
config = {
'env': {
'data': {
'warmup_candles_num': 500 # 增加到500根
}
}
}
预热不足会导致指标值与其他平台(如 TradingView)对不上,这不是 bug,是数据长度不够导致的计算差异。
内置指标的使用
自定义指标不必从零开始,可以组合内置指标。Jesse 内置了 100 多个指标,都从 jesse.indicators 导入:
from jesse.indicators import ema, rsi, bollinger_bands
# 单值模式
current_rsi = rsi(self.candles, period=14)
# 序列模式
rsi_series = rsi(self.candles, period=14, sequential=True)
# 多返回值
bb = bollinger_bands(self.candles, period=20)
upper = bb.upperband
middle = bb.middleband
lower = bb.lowerband
内置指标已经优化过,直接调用比自己实现更可靠。组合现有指标能快速构建新指标,比如把 RSI 和布林带结合,创建波动率加权 RSI。
类型注解与文档
写指标时加上类型注解和文档字符串,方便后续维护。Jesse 的指标签名有固定模式:
def indicator_name(
candles: np.ndarray,
param1: int = 默认值,
param2: float = 默认值,
source_type: str = "close",
sequential: bool = False
) -> Union[float, np.ndarray]:
"""
指标描述
:param candles: np.ndarray - K线数据
:param param1: int - 参数描述
:param param2: float - 参数描述
:param source_type: str - 数据源类型
:param sequential: bool - 是否返回序列
:return: Union[float, np.ndarray] - 单值或序列
"""
统一的接口让策略调用更一致,也便于自动生成文档。
调试技巧
指标开发中,调试是个挑战。建议在 Jupyter Notebook 里单独测试:
from jesse import research
import jesse.indicators as ta
import numpy as np
# 获取测试数据
_, candles = research.get_candles(
'Binance Spot',
'BTC-USDT',
'1h',
start_timestamp,
end_timestamp
)
# 测试自定义指标
from custom_indicators import my_indicator
values = my_indicator(candles, sequential=True)
# 可视化
import matplotlib.pyplot as plt
plt.plot(values)
plt.show()
可视化能快速发现问题,比如 np.nan 处理不当、数值异常等。确认无误后再集成到策略中。
性能对比
优化前后,性能差异可能惊人。以简单均线为例:
# 未优化版本
def slow_sma(data, period):
result = []
for i in range(len(data)):
if i < period:
result.append(np.nan)
else:
result.append(np.mean(data[i-period:i]))
return np.array(result)
# 优化版本
@njit
def fast_sma(data: np.ndarray, period: int) -> np.ndarray:
result = np.full_like(data, np.nan)
for i in range(period, len(data)):
result[i] = np.mean(data[i-period:i])
return result
# 向量化版本
def vectorized_sma(data: np.ndarray, period: int) -> np.ndarray:
windows = sliding_window_view(data, period)
means = np.mean(windows, axis=1)
result = np.full_like(data, np.nan)
result[period-1:] = means
return result
在 10 万根 K 线上测试,未优化版本可能耗时数秒,Numba 加速后降到毫秒级,向量化版本更快。参数优化时,这种差距会被放大数千倍。
版本兼容性
Jesse 持续迭代,指标 API 可能变化。比如 0.19.2 版本引入了杠杆支持,config.py 的 margin 配置改为 futures。0.20.0 增加了多个新指标和时间周期。0.21.3 优化了字符串处理,用 f-string 替代 format,提升了性能。
写指标时,尽量用稳定的 API,避免依赖内部实现。如果必须用到新特性,注明最低版本要求:
# 需要 Jesse >= 0.21.3
from jesse.helpers import cached
这样别人使用时能提前知道兼容性。
总结
自定义指标是策略开发的进阶技能。掌握 Numpy 数组操作、Numba 加速、最佳实践,能写出既快又稳的指标。记住几个核心点:切片减少计算、NaN 保证安全、向量化替代循环、缓存避免重复。
下一章我们将进入仓位与风险管理,看看如何把指标信号转化为实际交易,并控制回撤。技术指标是武器,风险管理才是铠甲,两者结合才能在市场中长久生存。