引子:一个”顺手”的重构

事情要从 stock-mcp-server 的技术分析模块说起。

这个模块负责计算 K 线的各种技术指标:MA、MACD、RSI、布林带……大概 9 种常用指标,服务盘中简报、收盘分析、策略回测三个场景。之前是用手写循环实现的,每算一个指标就要重新造一遍轮子。

某天我看到 MyTT 这个库——一套把通达信公式翻译成 numpy/pandas 向量化实现的工具集。代码简洁得让人心动:

1
2
3
4
5
6
# MyTT 风格的 MACD
def MACD(close):
DIF = EMA(close, 12) - EMA(close, 26)
DEA = EMA(DIF, 9)
MACD = (DIF - DEA) * 2
return DIF, DEA, MACD

对比原来的实现——for 循环里套 if-else,每算一个窗口就手动算一次均值——差距简直不忍直视。

我毫不犹豫地开启了重构。


重构前后对比:从 400 行到 200 行

先看数据。原模块的手写循环版本大概 400 行,定义了 6 个指标函数。MyTT 重写后降到 200 行,新增了 8 个指标(SAR / DMI / ATR / KDJ / CCI / WR / OBV / MFI),代码量却减少了一半。

核心工具函数的对比最能说明问题:

均线 MA

1
2
3
4
5
6
7
8
9
10
11
12
13
# 手写循环版
def ma_loop(S, n):
result = []
for i in range(len(S)):
if i < n - 1:
result.append(S[i])
else:
result.append(sum(S[i-n+1:i+1]) / n)
return result

# MyTT 向量化版
def MA(S, n):
return pd.Series(S).rolling(n).mean().values

4 行 vs 1 行。

MACD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 手写循环版
def macd_loop(S):
ema12 = ema_loop(S, 12) # 自行实现 EMA 循环
ema26 = ema_loop(S, 26)
dif = [ema12[i] - ema26[i] for i in range(min(len(ema12), len(ema26)))]
dea = ema_loop(dif, 9)
macd = [2 * (dif[i] - dea[i]) for i in range(min(len(dif), len(dea)))]
return dif, dea, macd

# MyTT 向量化版
def EMA(S, n):
return pd.Series(S).ewm(span=n, adjust=False).mean().values

def calc_macd(closes):
dif = EMA(closes, 12) - EMA(closes, 26)
dea = EMA(dif, 9)
macd = (dif - dea) * 2
...

EMA 从 8 行手写指数加权变成 1 行 ewm 调用。MACD 从 8 行变成 3 行。

MyTT 的”元工具”设计

MyTT 最巧妙的地方是定义了一套 0 级工具函数(约 15 个),所有指标都通过它们组合实现:

1
2
3
4
5
6
7
8
9
# 核心工具函数
def MA(S, N): return pd.Series(S).rolling(N).mean().values
def EMA(S, N): return pd.Series(S).ewm(span=N, adjust=False).mean().values
def HHV(S, N): return pd.Series(S).rolling(N).max().values # N日最高
def LLV(S, N): return pd.Series(S).rolling(N).min().values # N日最低
def CROSS(S1, S2): return np.concatenate(([False], (S1[:-1] <= S2[:-1]) & (S1[1:] > S2[1:])))
def SUM(S, N): return pd.Series(S).rolling(N).sum().values
def REF(S, N): return pd.Series(S).shift(N).values
def STD(S, N): return pd.Series(S).rolling(N).std(ddof=0).values

有了这些积木,新指标就像拼乐高一样简单。比如 KDJ:

1
2
3
4
5
6
7
8
def calc_kdj(closes, highs, lows, N=9, M1=3, M2=3):
hhv = HHV(highs, N)
llv = LLV(lows, N)
rsv = (closes - llv) / (hhv - llv + 1e-10) * 100
k = EMA(rsv, M1 * 2 - 1)
d = EMA(k, M2 * 2 - 1)
j = k * 3 - d * 2
...

看一眼就知道它在算什么——和通达信公式几乎一字不差。


性能测试:没想到的结果

重构完代码后,我习惯性地写了一个 benchmark,预期”向量化怎么也得快 10 倍吧”。

结果让我意外。

这是用 120 条 K 线(盘中简报的典型数据量)测的数据:

指标 手写循环 向量化 (pandas) 对比
MA5 33µs 88µs 向量化慢 2.6x
MACD 51µs 408µs 向量化慢 8x
RSI(14) 639µs 3,215µs 向量化慢 5x
布林带 868µs 770µs 接近
全指标(9种) 1.68ms 3.13ms 向量化慢 1.9x

……

是的,你没看错。在 120 条数据的场景下,向量化版本反而更慢

原因也很简单:pandas 的 .rolling()pd.Series() 创建有固定开销(大约 20-50µs 每次调用),而 120 条数据太少了,这点 overhead 比循环计算本身还大。

但当我用 5000 条 K 线(大约 20 年的日线数据)测试时,风向变了:

指标 手写循环 向量化 加速比
DMI 12.6ms 7.4ms 1.7x 更快

更大的数据量下,向量化的 C 层循环优势开始显现。


发现与反思

这个测试结果让我重新思考”优化”的真正含义。

1. 向量化≠更快,尤其是小数据集

pandas 的向量化操作本质上是 C 层面的循环,但每次调用都有一层 Python 到 C 的”翻译成本”。对于 120 条数据这种量级,翻译成本比计算本身还高。

结论:如果你的实时分析只取近 60-120 条 K 线,手写循环就够用,不必强行上 pandas。

2. 但代码质量和可维护性是天壤之别

这是最大的收获。重写后的代码:

  • 容易验证正确性MA = pd.Series.rolling(n).mean() 的语义清晰到不可能误解
  • 容易扩展:新增一个指标只需要组合已有的工具函数
  • 容易 debug:用 print(MA(closes, 5)) 就能看到完整序列,不用在循环里打点调试
  • 接近数学公式:和通达信/同花顺的公式几乎一一对应

3. 批次场景下向量化是真优势

我们的策略回测模块需要计算 2 年(约 500 条)甚至 10 年(约 2500 条)的数据。在这个量级上,向量化的性能优势开始显现,并且随着数据量增加而放大。

4. MyTT 的”组合式”设计才是精髓

MyTT 的真正价值不在于性能,而在于它提供的领域特定语言(DSL)——用 HHV、LLV、CROSS、SUM 这些积木来描述技术指标。以前写指标是”怎么算”(how),现在是”算什么”(what)。这种思维层面的跃迁,比任何性能优化都更有意义。


写在最后

这次重构让我最大的收获,不是代码更快了(事实上它慢了),而是代码变得更清晰、正确、可扩展了。

这是工程中一个容易被忽略的道理:“优化”不总是为了性能。有时优化是为了降低认知负荷、减少出错概率、让下一个人能看懂你的代码。

如果你也在做技术分析相关的工作,强烈推荐参考 MyTT 的设计思路——不在乎它是否”快到飞起”,而是享受那种用组合式积木描述指标的自由。

毕竟,最好的代码,是那种你三个月后回来看,还能一眼看懂的代码。


附:相关资源