从单机策略脚本一路做到多策略并行、多周期共存的量化系统,你会发现一个绕不开的阶段:系统开始"能跑"了,但"改不起"了。一个均线周期改一下,要把回测、实盘、参数寻优几个模块挨个翻一遍;一个新指标上线,要动策略基类,还得担心影响已有策略。我在自己的AI量化系统里被这些问题反复按在地上摩擦之后,才下定决心做了一次系统架构优化,核心就两件事:指标模块化和动态加载。
这篇文章不聊选股公式怎么写,也不讨论深度学习模型调参,就纯讲系统架构层面怎么把"指标"从一个散落在各处的函数,升级成可注册、可动态加载、支持热替换的模块体系。适合那些手头量化系统已经写了半年以上、开始觉得拓展一个指标像动一次大手术的人。如果你还在单文件堆代码阶段,可能暂时感受不到痛点;但只要你打算把系统往前推,这篇文章里提到的设计思路和踩坑记录,迟早用得上。
1. 系统从"能跑"到"难改":指标腐化是怎么发生的
1.1 从单文件脚本到策略工厂:系统经历的三次变形
我最早写量化系统的时候,心里只有一件事:跑通。一个Python文件,里面从数据拉取到策略逻辑到下单接口全部堆在一起。均线金叉策略?写一个 cross_over 函数,放到 utils.py 里,然后在 main.py 里循环调用。当时觉得挺清晰,因为总共就三四个策略。
等策略数量上了两位数,我开始做第一次结构化:把所有策略抽象成 Strategy 基类,每个策略一个文件,通过统一的 run() 接口执行。这是第一次"模块化",但模块化的粒度是"整个策略",指标还是策略内部的私有函数。金叉在A策略里写一份,B策略里又复制了一份,改一个参数要全局搜索替换。
第二次变形是引入参数寻优工具。为了能让网格搜索自动跑,我必须把策略里的参数全部抽出来,通过配置文件传进去。这时候指标函数开始暴露新的问题:很多指标写死在了策略内部,你想扫描 ma_window 参数,但 ma 函数在策略文件里根本没法独立调用。于是我把指标挪出来,单独建了一个 indicators.py,然后把所有策略 import 它——这就是典型的"共享模块"阶段。
1.2 指标耦合进业务逻辑后,改一个公式有多痛
共享模块阶段看起来比之前好了,但真正让我痛到决定重构的是一次上线新因子的过程。
当时要给一个趋势策略增加"ADX过滤"逻辑。ADX是方向性运动指数,计算中需要先算 +DI 和 -DI。这些基础指标 indicators.py 里没有,于是我在文件末尾添加了三个函数。然后发现回测引擎里有一份指标缓存机制,但我改的是 indicators.py,缓存key没有覆盖到新指标,导致相同时间窗口的历史数据命中旧缓存,回测结果莫名其妙。接着又发现实盘runner里没有重新加载模块,跑的还是进程内存里的旧函数,ADX过滤完全没生效。
那天晚上我统计了一下,为了加一个指标,我改了:indicators.py(新增函数)、backtest/cache.py(加缓存key)、live/runner.py(重启和加载逻辑)、config/schema.py(加参数声明)、三个策略文件(各自import)。一共7个文件。最恐怖的是,这中间的任何一步漏掉了,系统都不会报错,只会悄悄给你一个错误的结果——这在量化系统里是最危险的。
这次经历给了我一个核心判断:指标不能是"共享函数库",而必须是"独立插件"。每个指标应该有自己清晰的边界:能算哪些参数、依赖哪些其他指标、输出结果怎么存储、版本是什么。使用方不关心指标内部怎么实现,只通过统一接口对话。
1.3 模块化不是"把函数拆文件",是"插件化"思考
很多人一听模块化,第一反应是"多建几个文件夹、把函数拆开放"。但真正的模块化,尤其是针对量化系统这种需要长期演进的项目,本质是插件化。
插件化的关键区别有三个:
- 独立性:一个指标模块的开发、调试、测试可以完全不依赖其他指标。它声明自己的输入输出,像一个黑盒。
- 可发现性:系统能自动发现有哪些指标可用,而不是靠手动 import 或者每次改注册表。
- 生命周期管理:指标可以被加载、刷新、卸载,而不影响系统的其他部分。
这三个特性,恰好就是动态加载要解决的事情。指标模块化解决的是"怎么组织代码",动态加载解决的是"怎么让系统知道指标并随时切换"。两者配合,才让"加一个指标不动旧代码"成为可能。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指标模块化的核心设计:描述、计算、注册三分离
2.1 先把"指标是什么"定义清楚:元信息与计算逻辑分离
在动手拆模块之前,我花了比较多时间想一个问题:一个指标,本质上是什么?
后来我给自己总结了一个答案:指标 = 元信息(meta)+ 计算函数(compute)。元信息描述这个指标叫什么、有哪些参数、依赖什么数据、输出什么形状;计算函数负责拿到数据,按参数计算,返回结果。
这个"描述分离"的设计非常关键。为什么?因为系统里有很多地方,只需要指标的元信息就能工作,根本不需要真正计算。比如:
- 前端画指标配置表单,需要知道指标有哪些参数、参数类型是什么。
- 参数寻优工具,需要知道参数的取值范围和步长。
- 回测引擎在安排计算顺序时,需要知道指标的依赖关系。
- 结果存储模块,需要知道输出值的类型(是标量、Series还是DataFrame)。
如果这些信息散落在函数实现里,系统就只能靠黑盒测试或硬编码去猜;但如果把它们前置到元信息里,一切都可以驱动起来。
下面是我在实际系统中定义的指标基类,用Python dataclass来组织:
python复制from typing import Any, Dict, List, Optional, Type
import pandas as pd
from pydantic import BaseModel
class IndicatorParam(BaseModel):
"""指标参数描述,参数寻优和前端表单都靠这个"""
name: str
type: str # int / float / str / bool
default: Any = None
min_value: Optional[float] = None
max_value: Optional[float] = None
description: str = ""
class IndicatorMeta(BaseModel):
"""指标元信息:系统调度和UI展示的依据"""
name: str
display_name: str
description: str = ""
params: List[IndicatorParam] = []
dependencies: List[str] = [] # 依赖的其他指标名
required_columns: List[str] = [] # 需要数据中哪些字段,如 close/high/low/volume
output_type: str = "series" # series/dataframe/scalar
version: str = "1.0.0"
class BaseIndicator:
meta: IndicatorMeta = None # 子类必须声明
def __init__(self, **params):
self.params = params
# 参数校验
for p in self.meta.params:
if p.name not in params:
self.params[p.name] = p.default
self._result_cache: Dict[str, pd.Series] = {}
self._raw_data = None
def compute(self, data: pd.DataFrame) -> Any:
"""子类实现具体计算逻辑"""
raise NotImplementedError
用 pydantic 而不是普通 dataclass,是因为参数描述要对外提供 JSON Scheme,给未来的Web配置界面用。IndicatorMeta 里包含 dependencies 和 required_columns,这两个字段在后面做调度和动态加载时非常重要。
2.2 注册表机制:让新增指标像打卡上班一样简单
有了基类,下一个问题是:系统怎么知道有哪些指标?最初我试过用配置文件,在 yaml 里列所有指标路径,但每加一个指标就要改配置文件,容易漏;后面改成了注册表 + Python元类自动收集。
元类自动收集的实现很巧妙:Python每个类的定义过程都会经过它的元类,在元类里检查类属性 meta 是否非空,如果非空就自动登记到全局注册表。
python复制class IndicatorRegistry:
_indicators: Dict[str, Type[BaseIndicator]] = {}
@classmethod
def register(cls, indicator_cls: Type[BaseIndicator]):
meta = getattr(indicator_cls, "meta", None)
if meta is None:
return indicator_cls # 抽象基类不注册
cls._indicators[meta.name] = indicator_cls
return indicator_cls
@classmethod
def get(cls, name: str) -> Type[BaseIndicator]:
if name not in cls._indicators:
raise KeyError(f"指标 [{name}] 未注册")
return cls._indicators[name]
@classmethod
def all_indicators(cls):
return dict(cls._indicators)
class IndicatorMetaType(type):
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
if name != "BaseIndicator":
IndicatorRegistry.register(cls)
return cls
把 BaseIndicator 的 metaclass 指定为 IndicatorMetaType,然后在文件末尾:
python复制from .indicators import ma, ema, macd, kdj, adx
当模块被 import 时,所有指标类都会通过元类自动注册。这个方案让我加一个新指标只需要两步:写一个类、在 indicators/__init__.py 里加一行 import。
你可能觉得"自动注册"不够显式,有魔法味道。我承认这一点,但在这个场景下,自动注册换来的好处非常大:新指标不需要去改任何配置文件或中心化注册表,团队协作时大家各写各的模块,几乎不会产生冲突。
2.3 依赖声明:当一个指标需要另一个指标的输出
指标模块化有个躲不开的问题:指标会依赖其他指标。MACD不仅依赖原始行情,还依赖EMA;ADX依赖+DI和-DI;很多基本面类指标可能依赖财务数据的衍生指标。
依赖如果不显式声明,系统的调度顺序就无法保证。我见过很多系统的做法是在 compute 里直接调别的指标函数,比如:
python复制def compute(self, data):
ema12 = my_ema(data, 12)
ema26 = my_ema(data, 26)
...
这种写法在模块化场景下有个致命问题:无法从元信息层面知道计算顺序和依赖关系。如果系统做并行计算、增量更新,或需要预计算所有依赖,这种隐式依赖就成了一团麻。
我的处理方式是:在 IndicatorMeta.dependencies 里声明依赖的指标名,系统调度引擎会根据依赖关系做拓扑排序,保证父指标先算完,子指标再算。指标内部的 compute 不需要自己 import 其他指标,而是通过一个 IndicatorContext 获取依赖结果:
python复制class IndicatorContext:
def __init__(self, registry: IndicatorRegistry, data: pd.DataFrame):
self.registry = registry
self.data = data
self._result_pool = {}
self._resolved_stack = []
def get_indicator(self, name: str, **params):
"""动态获取一个指标的计算结果,带缓存带依赖解析"""
key = (name, tuple(sorted(params.items())))
if key in self._result_pool:
return self._result_pool[key]
cls = self.registry.get(name)
indicator = cls(**params)
indicator.set_context(self)
result = indicator.compute(self.data)
self._result_pool[key] = result
return result
class BaseIndicator:
def set_context(self, ctx: IndicatorContext):
self._ctx = ctx
def get_indicator(self, name: str, **params):
if self._ctx is None:
raise RuntimeError("IndicatorContext 未注入")
return self._ctx.get_indicator(name, **params)
这样一个指标的 compute 里写的就是声明式的依赖获取,系统自动处理缓存和顺序,我完全不用关心 ema12 是不是已经算过了。
2.4 为什么选择约定优于配置
在模块化设计过程中,我反复斟酌过"约定优于配置"和"显式配置"的取舍。最终大部分场景选了约定优于配置,原因很实在:
量化系统的指标数量会快速增长,如果每加一个指标需要同时改多处配置,那这个系统的扩展成本就永远降不下来。约定统一的目录结构、文件名、类名、meta 声明方式,让模块变成一种"填表式"开发。新来的同事看一眼现有模块,就能照着写。
显式配置保留下来的只有少量系统级参数(比如指标版本、默认参数、是否启用),这些不放在运营环境里,而是放在指标自身的设计阶段,通过参数默认值来管理。
3. 动态加载落地:importlib 之外,还需要什么
3.1 两种主流动态加载姿势:importlib 与 entry_points
指标模块化之后,下一个要解决的是动态加载问题。系统启动时不可能把几千个指标全部 import 进内存,也不需要。正确做法是:按需加载 + 缓存。
Python 里动态加载的主流方式有两种:
第一种是 importlib,直接按模块路径加载:
python复制import importlib
import importlib.util
from pathlib import Path
def load_indicator_module(module_path: str):
"""从文件路径动态加载一个指标模块"""
path = Path(module_path)
module_name = f"aiq_indicators_{path.stem}"
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None:
raise ImportError(f"无法加载模块: {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
第二种是基于 importlib.metadata.entry_points 的插件机制,Python 安装包可以通过声明 entry point 暴露自己的指标模块。
我实际生产环境用了 importlib 动态按路径加载,因为量化系统的指标很多是策略团队临时的研究和实验,不想每次都要打包安装成 Python 包。但我也强烈建议,在正式发布的"稳定指标池"部分通过 entry_points 接入,把它当成标准包依赖来管理。
这两种策略并存的结构其实是:开发环境用路径加载实现热更新,生产环境用包管理实现稳定发布。
3.2 组件生命周期:加载、刷新、卸载
动态加载绝不是"import 一个模块"这么简单。一个指标从一个文件路径变成一个可以被调用的计算单元,需要完整生命周期管理。我把它分成五个阶段:
- 发现(Discovery):扫描指标目录,找到符合命名规范的模块文件。
- 加载(Loading):通过 importlib 执行模块,触发元类注册,把指标类加入注册表。
- 依赖校验(Validation):检查
meta.dependencies里的每个依赖是否可解析。 - 实例化(Instantiation):创建指标对象,注入参数。
- 就绪(Ready):指标可被调度。
这里最容易出问题的是"加载"和"验证"。加载时,模块文件如果是新增的,Python 的 sys.modules 里没有,importlib 可以正常执行;但如果同一个模块被加载过一次,后来文件修改了再加载,Python 默认会复用 cache,不重新读取,这就是热重载要特殊处理的地方。
我的处理方式是给每个加载的模块分配独立的包命名空间,并在加载前主动从 sys.modules 中清理旧条目。在 IndicatorRegistry 里也加了 unregister 方法,在刷模块时先把旧类下线。
3.3 热重载:修改指标公式后不用重启系统
热重载是我这次架构优化最想要的能力,没有之一。AI量化研究阶段,一个指标公式经常一天要改十几个版本。改完要跑回测看效果,如果每次都要重启整个系统,单是重新加载数据、重新初始化引擎就得花几分钟。改成热重载之后,我只需要点一个"刷新指标"按钮,系统检测到某几文件变更,重新加载,然后跑回测。
触发方式的选择上,我觉得没必要上文件系统 watcher,除非你的系统已经跑在服务环境里。研究阶段最实用的方案就是手动触发 + 文件变化指纹检查:
python复制import hashlib
from pathlib import Path
class HotReloader:
def __init__(self, indicator_dirs: list[str]):
self.indicator_dirs = indicator_dirs
self._file_fingerprints: Dict[str, str] = {}
def scan_and_reload(self):
changed_files = self._detect_changed_files()
if not changed_files:
return []
reloaded = []
for file_path in changed_files:
self._unload_indicator_module(file_path)
self._load_indicator_module(file_path)
reloaded.append(file_path)
self._file_fingerprints[file_path] = self._fingerprint(file_path)
return reloaded
def _detect_changed_files(self):
changed = []
for d in self.indicator_dirs:
for f in sorted(Path(d).glob("*.py")):
if "__" in f.name:
continue
fp = self._fingerprint(str(f))
if self._file_fingerprints.get(str(f)) != fp:
changed.append(str(f))
return changed
def _fingerprint(self, file_path):
h = hashlib.md5()
h.update(Path(file_path).read_bytes())
return h.hexdigest()
3.4 加载失败时的回退策略:动态加载的安全兜底
动态加载比静态 import 风险高的原因是,你导入了一个不在"预期内"的新代码。如果新指标模块有语法错误、依赖缺失、版本不兼容,动态加载会直接抛异常。最危险的是,这个异常发生在一个正在跑实盘的环境里,会导致整个策略进程崩溃。
我的兜底策略分三层:
- 回退到上一版本:如果加载新模块失败,注册表里还保留旧类,系统捕获异常后用旧类继续运行,不中断。
- 隔离加载线程:动态加载永远在一个隔离的线程里执行,主计算进程不受影响。加载线程内部把异常记录到专门日志,等人工排查。
- 健康检查接口:系统每 30 分钟主动 reload 一次,加载失败时发出告警,而不是等新策略真正上线了才发现问题。
还要注意一点:动态加载模块本身不能有"模块级副作用"。比如模块顶层写了一些全局链接、连接池、外部服务调用,这会随着加载立即执行,有可能在加载线程里对外部系统产生不可控影响。规范必须写明:模块只允许定义类、函数、常量,所有副作用逻辑放在 compute 内。
4. 性能取舍与调度开销:模块化最容易失控的地方
4.1 动态加载的性能误区:不是每次 compute 都去加载模块
模块化和动态加载有一个非常容易踩的性能误区:把动态加载机制用在了高频计算路径上。
如果你的行情推送是每秒一次,然后每个 tick 来了,策略引擎为了算一个指标,去扫描目录、计算哈希、加载模块、注册类,那性能一定踩穿地板。事实上,我见过有人把 reload 逻辑放在策略主循环里,理由是想让指标"永远最新"——这种做法在回测里勉强能跑,在实盘低延迟环境里直接不可用。
正确的分层是:
- 启动时预加载:系统启动时,扫描所有指标目录,把每个指标类加载一次放进注册表缓存。
- 空闲时热更新:后台定时器在系统空闲时检测文件变化,变化了才做动态重载。
- 计算时只查表:策略请求某个指标时,永远是从注册表内存字典里 O(1) 拿到类,再实例化。
动态加载是低频运维操作,指标调用是高频计算操作,两者一定不能混在一个层次。
4.2 指标实例缓存与数据版本校验
每个指标对象内部如果做了中间计算结果缓存,性能会非常好,但也会引入一个一致性问题:同一份指标,抓取的是不同时间段的数据,缓存里的结果还正确吗?
我采用的方案是"数据版本号 + 参数Key"联合缓存。DataFrame 从数据源取回来时,带一个 version_token,只要数据本身没变,指标缓存可以直接命中;数据更新了,缓存自动失效。
python复制class IndicatorResultCache:
def __init__(self):
self._cache: Dict[tuple, ResultItem] = {}
def get(self, indicator_name: str, params: tuple, data_version: str):
key = (indicator_name, params)
item = self._cache.get(key)
if item and item.data_version == data_version:
return item.result
return None
def set(self, indicator_name: str, params: tuple, data_version: str, result):
key = (indicator_name, params)
self._cache[key] = ResultItem(data_version=data_version, result=result)
这样在回测过程中,历史数据分块反复计算时,很多中间结果能跨策略复用。同样的 MA 202 和 MA 606,多个策略都会用到,一份数据版本下只计算一次。
4.3 实例池与线程安全
动态加载 + 缓存之后,还有一个绕不开的问题:多线程环境下,指标实例能不能安全并发?
Python 有 GIL 的保护,但 GIL 只保护单个字节码,不代表你的整个 compute 过程是原子操作。尤其当指标内部状态比较多(比如计算中保存中间变量、结果缓存),两个线程同时操作同一个实例存在数据竞争风险。
我的处理方式是"实例池"而不是"共享单例":
python复制class IndicatorPool:
def __init__(self, registry: IndicatorRegistry, pool_size: int = 8):
self.registry = registry
self.pool_size = pool_size
self._pools: Dict[str, list] = {}
def acquire(self, name: str, **params) -> BaseIndicator:
key = (name, tuple(sorted(params.items())))
pool = self._pools.setdefault(key, [])
if pool:
return pool.pop()
return self.registry.get(name)(**params)
def release(self, ind: BaseIndicator):
key = (ind.meta.name, tuple(sorted(ind.params.items())))
pool = self._pools.setdefault(key, [])
if len(pool) < self.pool_size:
pool.append(ind)
同时要求指标对象内部尽量无状态:所有临时计算放局部变量,不写实例属性;如果确实需要写,加 threading.Lock 保护。实践之后发现,大多数经典指标算法(MA/EMA/MACD/KDJ/RSI)都可以写成纯函数式的状态机,这对模块化复用特别友好。
4.4 调度优化的实际效果:一次重构的收益
做完整套架构优化后,我拿真实系统做了一次对照测试。条件:8年份的日线数据,横跨400只股票,回测一个双均线+MACD过滤策略。旧系统里指标的调用是散装的、直接调函数的;新系统里改成了注册表 + 指标实例池 + 缓存结构。
结果挺有意思:
- 冷启动加载时间:从 40 秒降到 12 秒(因为按需加载不再全量 import)。
- 回测总耗时:从约 210 秒降到 165 秒(25%左右的提升,主要来自缓存命中)。
- 新增指标耗时:从原来改 7 个文件、20 分钟,降到写 1 个文件、5 分钟。
性能提升不是这次重构最核心的收益,真正的收益是扩展性的量级变化。以前加一个指标要小心翼翼,现在写一个模块就能自动生效,我的研究迭代速度快了很多。
5. 回测、实盘、研究三套环境如何共用同一套指标架构
5.1 接口统一带来的调试收益
模块化了指标以后,一个额外的惊喜是:回测、实盘、研究三个环境,终于可以共用同一套指标代码了。
以前我经常遇到这类问题:回测环境里指标用的 pandas Series 数据格式,实盘环境里收到的行情是 numpy 数组,研究环境又是别的格式。每个环境写一份指标实现,然后验证两边结果是否一致,要花大量时间。
统一接口后,指标层的输入输出固定为 pd.DataFrame 加一个数据版本号。实盘环境在接入行情推送后,先做一步"标准化转换",把实时数据包装成统一 DataFrame 格式,再交给同一套指标引擎计算。这就保证了:回测好的策略,实盘跑的底层计算逻辑是完全一致的。
5.2 数据格式差异:tick、分钟线、日线怎么统一处理
实际落地时,"统一用 DataFrame"没有听上去那么简单。不同数据源、不同周期的数据,格式差异很大:
- tick 数据可能是不规则时间戳,需要对齐到标准时间桶。
- 分钟线数据经常有空缺,接口返回的字段命名不统一。
- 日线数据收盘价有的叫
close,有的叫end_price,有的只给 OHLC 不给成交量。
我最终的做法是在数据接入层做"归一化":无论什么原始格式,进到指标引擎前先转换成统一 schema,最低要求是包含 datetime、open、high、low、close、volume 六个字段,多余字段保留并打上原始标签。
指标计算不关心数据来自什么源、什么周期,只关心标准 schema。这样一来,同一个 MA 指标模块,不需要为 tick、分钟、日线各写一份。指标层面只约定:你传进一个标准时间序列表格,我返回一个等长序列。
需要注意的一点是,周期不同的数据在同一指标里混合使用,要小心时间索引对齐。比如你要算"周线级别MACD",原始数据是日线,指标模块内部要做重采样,但重采样的规则属于"数据预处理"还是"指标逻辑",边界必须明确。我的原则是:重采样属于数据层,指标层只做窗口计算,不负责周期转换。这样职责清晰,动态加载时也不用担心隐藏的数据转换依赖。
5.3 指标版本化与参数归档
模块化后,指标在系统里变成了可插拔的组件,但这也带来一个隐患:同一个指标名,不同时间可能对应不同的实现。对量化系统来说,不可复现的结果,等于没有结果。如果一个版本跑出来的回测结果,过了两周就复现不出来了,那这个系统的可信度就归零。
所以我在指标模块的 meta 里加入了 version 字段,并在每次热重载或代码变更时强制要求更新版本号。系统记录每一次指标模块的 checksum、版本号、变更时间和变更说明。回测结果里会打上所有相关指标的版本组合快照,这样任何历史回测记录都可以精确追溯到当时跑的指标代码。
参数归档是另一个容易忽略的点。回测时传入的指标参数,必须跟着回测结果一起存储,不能默认取当时最新默认值。我吃过一次亏:某个指标改了默认参数,导致历史回测结果无法复现,排查了半天发现是 meta.params 里默认值变了,但历史记录里没有存参数快照。
6. 我在实际落地中踩过的坑,以及现在仍在坚持的规范
6.1 动态加载路径污染的坑
动态加载模块时,如果直接在 sys.modules 里塞自定义名字,不做命名空间隔离,很容易出现怪问题。我试过两个指标文件都叫 utils.py,结果第二次加载把第一次的文件覆盖了,第二个模块 import 到的 utils 里面的东西完全变了。
解决方案很简单:加载时给模块加一个专属命名空间前缀,保证模块名唯一。具体实现是让 module_name 带上指标目录的哈希后缀,不含路径歧义。
6.2 指标命名冲突与命名空间隔离
除了模块名,指标内部的 meta.name 也会有冲突。一个人写了一个 MA 指标,另一个人也写了一个 MA,谁的注册生效完全取决于加载顺序,这是典型的包冲突。
我的做法是引入"命名空间前缀"的设计:内部基础指标库以 core. 开头,如 core.ma;策略团队的自研指标以 team_name.indicator_name 命名。注册表内部把 meta.name 作为主键,遇到冲突时先比较版本号,如果版本相同则后加载者覆盖并打日志告警;版本不同则两个都保留,通过显示的名字区分。
6.3 指标之间互相引用导致的循环加载
动态或静态加载中,循环引用都是大坑。我在一个动量类指标里写了依赖另一个自研指标,而那个指标又反过来依赖于它,结果加载时直接递归爆栈。
解决办法是我在系统里加了一个依赖解析器,专门负责构建指标依赖的拓扑图。启动加载完成后做一次全量的有向图循环检测,一旦发现循环依赖,立刻报错并且详细列出环路上的所有指标。
6.4 对新指标先跑影子回测,再进正式策略
模块化的目的是让新指标接入变得更轻快,但轻快不等于可以直接进实盘。我给自己的流程加了一道"影子回测":新指标模块加载进来后,先与现有系统并行运行几天,计算结果和旧版本做对比,确认差异只来自预期内的逻辑改变后,再切换为正式依赖。
这套流程配合动态加载机制,让我可以在实盘环境安全地做灰度验证。新指标跑了三天,每天输出一份差异报告,没问题了再把它提升为正式模块。
6.5 我的指标模块编写四原则
经历的坑多了,我慢慢总结出几个自己现在写指标模块时一定会遵守的硬性规范:
- 模块只定义类、函数、常量,绝不产生副作用。任何外部调用、连接、全局注册操作都放进 compute 或显式的
init()方法里。 - 每个指标必须自带单元测试,且测试数据固定为一张内置的 CSV,不允许依赖外部数据源。保证任意时刻 checkout 代码都能独立跑通。
- 指标代码必须写明数据周期假设。默认假设传入的是日线,如果是分钟级别使用,必须在 meta 中声明支持,否则数据层会拒绝调用。
- 参数默认值只在 IndicatorParam 中声明一次,compute 内部不写魔法数字。
这套规范看起来简单,实际执行起来能挡住绝大多数因为模块化带来的"隐性破坏"。我见过很多系统模块化做了,但没有配套规范,结果模块之间仍然互相拉扯,根本享受不到插件化的好处。
现在我的系统里,指标模块的数量已经破百,但新加一个指标的时间成本几乎固定在三五分钟。这种"扩展不再伴随恐惧"的感受,可能才是这次架构优化最有价值的收获。如果你也在做 AI 量化系统的架构,指标模块化和动态加载这一层,值得尽早花时间做扎实。
