Skip to content

Latest commit

 

History

History
1269 lines (895 loc) · 37.8 KB

File metadata and controls

1269 lines (895 loc) · 37.8 KB

QuantDinger Python v3 策略开发指南

这份指南不是单纯罗列接口,而是站在策略开发者视角,回答一个更实际的问题:

到底应该怎么写一个结构清晰、能回测、能落地成平台策略的指标策略?

QuantDinger 当前支持两条 Python 开发路径:

  • IndicatorStrategy:基于 df 的指标/信号脚本,用于 Indicator IDE、图表渲染和信号型回测。
  • ScriptStrategy:基于 on_init / on_bar 的事件驱动脚本,用于策略运行时、策略回测与实盘执行。

如果你要从零开始开发一个策略,默认建议是:

  1. 先用 IndicatorStrategy 把信号逻辑跑通。
  2. 先验证图表、信号和回测语义。
  3. 只有当你需要运行时状态、动态仓位管理或执行控制时,再升级为 ScriptStrategy

1. 先建立正确心智模型

很多开发者会把信号逻辑止盈止损仓位管理执行逻辑混在一起写,结果文档看不懂、代码也不好维护。

1.1 IndicatorStrategy 是什么

可以把 IndicatorStrategy 理解成:

  • 基于 df 计算指标序列
  • 生成布尔型 buy / sell 信号
  • 通过元数据声明默认策略配置
  • 返回 output 供图表展示

它最适合:

  • 指标研究
  • 策略原型验证
  • 参数调优
  • 信号型回测
  • 先做信号、后保存成平台策略的工作流

1.2 ScriptStrategy 是什么

可以把 ScriptStrategy 理解成:

  • 按 bar 逐根执行的运行时逻辑
  • 通过 ctx.position 读取当前持仓状态
  • ctx.buy()ctx.sell()ctx.close_position() 发出动作
  • 把退出、仓位、执行节奏写进代码

它最适合:

  • 有状态的执行逻辑
  • 动态止盈止损
  • 分批加仓、减仓、部分止盈
  • 冷却期、重入限制、bot 型执行策略

1.3 最重要的分层

对于 IndicatorStrategy,请强制把逻辑拆成三层:

  1. 指标层:均线、RSI、ATR、布林带、过滤条件。
  2. 信号层df['buy']df['sell']
  3. 风险默认配置层# @strategy stopLossPct ...takeProfitPctentryPct 等。

不要把这三层混成一团。

尤其要明确:

  • buy / sell 负责表达什么时候进出场
  • # @strategy 负责表达引擎默认如何控风险、如何设仓位
  • 杠杆属于产品配置,不属于指标脚本

2. 应该选哪种模式?

使用场景 推荐模式
写指标、叠加图表、画买卖点 IndicatorStrategy
研究 dataframe 上的进出场信号 IndicatorStrategy
只想给策略补固定止损、止盈、仓位默认值 IndicatorStrategy
需要逐根读取持仓状态做判断 ScriptStrategy
止损止盈依赖当前持仓状态动态变化 ScriptStrategy
需要分批开平仓、状态机、bot 风格执行 ScriptStrategy

一个简单判断方法:

  • 如果你的逻辑可以表述成“条件 A 出现就买,条件 B 出现就卖”,先用 IndicatorStrategy
  • 如果你的逻辑更像“开仓后要持续盯着当前持仓,并根据状态做不同反应”,那就应该用 ScriptStrategy

3. 如何开发一个 IndicatorStrategy

这是大多数新策略最推荐的开发路径。

3.1 第一步:先把元数据和默认配置写清楚

脚本开头先定义名称、描述、可调参数、默认策略配置。

my_indicator_name = "Trend Pullback Strategy"
my_indicator_description = "Buy pullbacks in an uptrend and exit on weakness."

# @param fast_len int 20 Fast EMA length
# @param slow_len int 50 Slow EMA length
# @param rsi_len int 14 RSI length
# @param rsi_floor float 45 Minimum RSI for long entries

# @strategy stopLossPct 0.03
# @strategy takeProfitPct 0.06
# @strategy entryPct 0.25
# @strategy trailingEnabled true
# @strategy trailingStopPct 0.02
# @strategy trailingActivationPct 0.04
# @strategy tradeDirection long

# @param 用来定义用户经常调的参数。

格式如下:

# @param <name> <int|float|bool|str|string> <default> <描述>

最佳实践:

  • 声明后的参数,应该通过 params.get(...) 读取
  • stringstr 等价
  • 如果声明了参数,却仍然把值硬编码在正文里,平台内置的代码质量检查会给出提醒

# @strategy 用来定义策略默认配置,比如:

  • stopLossPct:止损比例,例如 0.03 表示 3%
  • takeProfitPct:止盈比例,例如 0.06 表示 6%
  • entryPct:开仓资金占比
  • trailingEnabled
  • trailingStopPct
  • trailingActivationPct
  • tradeDirectionlongshortboth

这里有个非常关键的边界:

  • 这些是引擎读取的默认配置
  • 不是让你再去 dataframe 里造一列 stop_loss
  • 不要在这里写 leverage
  • 数值尽量保持合理,并结合回测验证;底层解析器允许的范围会比示例更宽松

3.2 第二步:复制 dataframe,再算指标

Indicator 代码运行在沙盒里,pdnpparams 字典已预置。

推荐开头固定写:

df = df.copy()

通常可用列包括:

  • open
  • high
  • low
  • close
  • volume

time 列可能存在,但不要假设其类型永远一致。

避免这些写法:

  • 网络请求
  • 文件读写
  • 子进程
  • evalexecopen__import__ 这类破坏沙盒边界的模式

3.3 第三步:把原始条件变成干净的 buy / sell

回测引擎读取的是两列布尔信号

  • df['buy']
  • df['sell']

它们应满足:

  • 与 dataframe 长度完全一致
  • fillna(False) 后为布尔值
  • 除非你明确要连续触发,否则应尽量做成边缘触发

推荐模式:

raw_buy = (ema_fast > ema_slow) & (ema_fast.shift(1) <= ema_slow.shift(1))
raw_sell = (ema_fast < ema_slow) & (ema_fast.shift(1) >= ema_slow.shift(1))

df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)
df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)

这样可以避免同一段趋势里每根 bar 都重复发信号。

3.4 第四步:先决定“谁负责退出”

止盈止损和仓位管理最容易在这里写乱。

IndicatorStrategy 里,退出逻辑通常有两种合法写法。

写法 A:信号自己负责退出

也就是由你的指标逻辑直接生成 df['sell']

典型例子:

  • 均线死叉
  • RSI 跌破阈值
  • 收盘价跌破 ATR 止损线
  • 均值回归到目标位后离场

如果退出本身就是策略思想的一部分,用这种写法最自然。

写法 B:引擎负责固定止盈止损

也就是你只定义默认配置,由引擎按固定规则处理:

  • stopLossPct
  • takeProfitPct
  • entryPct
  • trailing 系列参数

如果你的信号逻辑想保持简洁,而保护性规则是固定的,就用这种写法。

最佳实践

尽量明确一个“主退出来源”。

例如:

  • 如果你的核心逻辑是“金叉进,死叉出”,那退出就主要由 sell 信号负责
  • 如果你的逻辑是“信号进场,固定 3% 止损 + 6% 止盈管理交易”,那退出主要由 # @strategy 负责

两者可以同时存在,但一定要在注释或描述里写清楚,否则别的开发者不知道到底是信号退出还是引擎退出在起主要作用。

3.5 第五步:最后再组装 output

脚本最后必须赋值 output

output = {
    "name": "My Strategy",
    "plots": [],
    "signals": []
}

主要支持键:

  • name
  • plots
  • signals
  • calculatedVars:可选元数据

每个 plot 项通常包含:

  • name
  • data,长度必须等于 len(df)
  • color
  • overlay
  • 可选 type

每个 signal 项通常包含:

  • typebuysell
  • text
  • color
  • data:无信号的 bar 用 None

3.6 第六步:校验回测语义

指标回测是典型的信号驱动:

  • 引擎读取 df['buy']df['sell']
  • 信号按 bar close 确认
  • 通常在下一根 bar 开盘价成交

这件事非常重要,因为:

  • 你在当前 K 线上画出来的“止损线”不等于系统一定按这根 K 线内部价格成交
  • 一旦用了 shift(-1),就基本等于引入未来函数

还要注意一个实现细节:

  • 标准工作流下,最常见的成交语义仍然是“收盘确认、下一根开盘成交”
  • 但保存后的策略快照,会根据产品配置被规范成 next_bar_opensame_bar_close
  • 如果你改了成交时机配置,不要凭印象判断结果,要重新回测并核对成交明细

4. 止盈止损和仓位管理到底怎么写

这一节就是给开发者的直接答案。

4.1 在 IndicatorStrategy 里写固定止损、止盈、仓位

如果你想要的是固定默认配置,就写成 # @strategy

# @strategy stopLossPct 0.03
# @strategy takeProfitPct 0.06
# @strategy entryPct 0.25
# @strategy tradeDirection long

含义分别是:

  • stopLossPct 0.03:默认 3% 止损
  • takeProfitPct 0.06:默认 6% 止盈
  • entryPct 0.25:默认用 25% 资金开仓
  • tradeDirection long:默认只做多

这种写法适合:

  • 信号代码尽量简单
  • 希望回测时能直接读懂默认风险参数
  • 希望 UI 和引擎都能直接识别这些默认值

4.2 在 IndicatorStrategy 里写“指标驱动型止损”

如果你的“止损”本质上是策略逻辑的一部分,那就不要假装成外部配置,而是直接写进 sell 信号。

例如:跌破 ATR 风格止损线就卖出。

atr = (df['high'] - df['low']).rolling(14).mean()
stop_line = df['close'].rolling(20).max() - atr * 2.0

raw_sell = df['close'] < stop_line.shift(1)
df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)

这种写法表示:

  • 退出属于你的指标逻辑
  • 引擎不是替你“发明”一个止损
  • 你最好在描述或注释里说明这一点

4.3 IndicatorStrategy 里的仓位管理边界

IndicatorStrategy 来说,仓位管理应该尽量保持简单:

  • entryPct 管默认开仓资金占比
  • tradeDirection 管做多 / 做空 / 双向
  • 用固定的止损止盈或 trailing 默认值做保护

如果你需要下面这些能力:

  • 分批加仓、减仓
  • 部分止盈
  • 开仓前后用不同逻辑
  • 止损线会跟随当前持仓状态动态变化
  • 止损后冷却一段时间再重入

那就说明这套逻辑已经超出 IndicatorStrategy 该承担的范围,应该迁移到 ScriptStrategy


5. 完整的 IndicatorStrategy 示例

下面这个例子展示了一个更符合开发者思维的完整结构:元数据、默认配置、指标计算、信号生成、图表输出分层清楚。

my_indicator_name = "EMA Pullback Strategy"
my_indicator_description = "Buy pullbacks above the slow EMA and exit on trend failure."

# @param fast_len int 20 Fast EMA length
# @param slow_len int 50 Slow EMA length
# @param rsi_len int 14 RSI length
# @param rsi_floor float 50 Minimum RSI for entry

# @strategy stopLossPct 0.03
# @strategy takeProfitPct 0.06
# @strategy entryPct 0.25
# @strategy tradeDirection long

df = df.copy()

fast_len = int(params.get('fast_len', 20))
slow_len = int(params.get('slow_len', 50))
rsi_len = int(params.get('rsi_len', 14))
rsi_floor = float(params.get('rsi_floor', 50.0))

ema_fast = df['close'].ewm(span=fast_len, adjust=False).mean()
ema_slow = df['close'].ewm(span=slow_len, adjust=False).mean()

delta = df['close'].diff()
gain = delta.clip(lower=0).ewm(alpha=1 / rsi_len, adjust=False).mean()
loss = (-delta.clip(upper=0)).ewm(alpha=1 / rsi_len, adjust=False).mean()
rs = gain / loss.replace(0, np.nan)
rsi = 100 - (100 / (1 + rs))

trend_up = ema_fast > ema_slow
pullback_done = df['close'] > ema_fast
rsi_ok = rsi > rsi_floor

raw_buy = trend_up & pullback_done & rsi_ok & (~trend_up.shift(1).fillna(False))
raw_sell = (ema_fast < ema_slow) | (rsi < 45)

buy = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)
sell = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)

df['buy'] = buy
df['sell'] = sell

buy_marks = [df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df))]
sell_marks = [df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df))]

output = {
    "name": my_indicator_name,
    "plots": [
        {
            "name": "EMA Fast",
            "data": ema_fast.fillna(0).tolist(),
            "color": "#1890ff",
            "overlay": True
        },
        {
            "name": "EMA Slow",
            "data": ema_slow.fillna(0).tolist(),
            "color": "#faad14",
            "overlay": True
        },
        {
            "name": "RSI",
            "data": rsi.fillna(0).tolist(),
            "color": "#722ed1",
            "overlay": False
        }
    ],
    "signals": [
        {
            "type": "buy",
            "text": "B",
            "data": buy_marks,
            "color": "#00E676"
        },
        {
            "type": "sell",
            "text": "S",
            "data": sell_marks,
            "color": "#FF5252"
        }
    ]
}

这个例子刻意强调了几件事:

  • 先算指标,再出信号
  • 进出场逻辑通过布尔列表达
  • 固定风险默认值通过 # @strategy 单独声明
  • 图表输出是最后一步,不要和信号逻辑搅在一起

5.1 一个更贴近平台 UI 的示例

下面这个版本更接近 QuantDinger 当前真实使用方式:

  • # @param 暴露常调参数
  • # @strategy 暴露默认止损、止盈、仓位和跟踪止损
  • 显式声明 tradeDirection,让代码、保存后的策略、回测面板保持一致
  • 杠杆仍然留给产品 UI 管,不写进源码
my_indicator_name = "Breakout Retest With Direction Control"
my_indicator_description = "Breakout-and-retest logic with platform-friendly params and default risk settings."

# @param breakout_len int 20 Breakout lookback bars
# @param retest_buffer float 0.002 Retest tolerance ratio
# @param volume_mult float 1.2 Minimum volume filter
# @param ema_filter_len int 50 Trend filter EMA length

# @strategy stopLossPct 0.02
# @strategy takeProfitPct 0.05
# @strategy entryPct 0.2
# @strategy trailingEnabled true
# @strategy trailingStopPct 0.015
# @strategy trailingActivationPct 0.03
# @strategy tradeDirection both

df = df.copy()

breakout_len = int(params.get('breakout_len', 20))
retest_buffer = float(params.get('retest_buffer', 0.002))
volume_mult = float(params.get('volume_mult', 1.2))
ema_filter_len = int(params.get('ema_filter_len', 50))

ema_filter = df['close'].ewm(span=ema_filter_len, adjust=False).mean()
range_high = df['high'].rolling(breakout_len).max().shift(1)
range_low = df['low'].rolling(breakout_len).min().shift(1)
volume_avg = df['volume'].rolling(breakout_len).mean()

long_breakout = df['close'] > range_high
long_retest_ok = df['low'] <= range_high * (1 + retest_buffer)
long_volume_ok = df['volume'] >= volume_avg * volume_mult
long_trend_ok = df['close'] > ema_filter

short_breakout = df['close'] < range_low
short_retest_ok = df['high'] >= range_low * (1 - retest_buffer)
short_volume_ok = df['volume'] >= volume_avg * volume_mult
short_trend_ok = df['close'] < ema_filter

raw_buy = long_breakout & long_retest_ok & long_volume_ok & long_trend_ok
raw_sell = short_breakout & short_retest_ok & short_volume_ok & short_trend_ok

buy = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)
sell = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)

df['buy'] = buy
df['sell'] = sell

buy_marks = [df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df))]
sell_marks = [df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df))]

output = {
    "name": my_indicator_name,
    "plots": [
        {
            "name": "EMA Filter",
            "data": ema_filter.fillna(0).tolist(),
            "color": "#1890ff",
            "overlay": True
        },
        {
            "name": "Range High",
            "data": range_high.fillna(0).tolist(),
            "color": "#52c41a",
            "overlay": True
        },
        {
            "name": "Range Low",
            "data": range_low.fillna(0).tolist(),
            "color": "#f5222d",
            "overlay": True
        }
    ],
    "signals": [
        {
            "type": "buy",
            "text": "L",
            "data": buy_marks,
            "color": "#00E676"
        },
        {
            "type": "sell",
            "text": "S",
            "data": sell_marks,
            "color": "#FF5252"
        }
    ]
}

这个例子为什么更贴近平台:

  • # @param 的值可以直接被 AI 调参或手动参数修改流程接管
  • # @strategy 能和保存后的策略默认值、右侧回测面板风险配置更自然地对齐
  • tradeDirection both 让人一眼看出这份代码本身就是为多空双向设计的
  • 杠杆继续交给产品配置层,不会被藏进源码里造成误解

6. 什么时候该切到 ScriptStrategy

当策略需要“运行时状态”而不是“纯 dataframe 信号”时,就该迁移到 ScriptStrategy

典型信号包括:

  • 止损止盈依赖当前持仓,而不是仅依赖历史序列
  • 开仓后要动态移动止损
  • 需要部分平仓或加仓
  • 首次开仓和再次开仓逻辑不同
  • 需要冷却期、节流、bot 风格执行规则

6.1 必需函数

面向当前产品链路,最稳妥的约定是:

  • def on_init(ctx): ...
  • def on_bar(ctx, bar): ...

原因是:

  • 运行时编译器真正强制的是 on_bar
  • 但部分产品侧校验路径仍然要求源码里同时存在 on_initon_bar
  • 为了避免“运行时能跑、校验却不过”的不一致,建议两个函数都写,即使 on_init 只是做初始化或打印日志

6.2 可用对象

bar 通常提供:

  • bar.open
  • bar.high
  • bar.low
  • bar.close
  • bar.volume
  • bar.timestamp

ctx 当前通常提供:

  • ctx.param(name, default=None)
  • ctx.bars(n=1)
  • ctx.position
  • ctx.balance
  • ctx.equity
  • ctx.log(message)
  • ctx.buy(price=None, amount=None)
  • ctx.sell(price=None, amount=None)
  • ctx.close_position()

补充说明:

  • ctx 不会直接把完整交易配置对象暴露给脚本
  • 杠杆、交易标的、交易场所、账户凭证等,应放在产品配置层,而不是写死在脚本里
  • 脚本源码内部需要的默认参数,优先通过 ctx.param(...) 管理

ctx.position 同时支持数值判断和字段访问,例如:

if not ctx.position:
    ...

if ctx.position > 0:
    ...

if ctx.position["side"] == "long":
    ...

6.3 一个带运行时退出的 ScriptStrategy 示例

def on_init(ctx):
    ctx.log("strategy initialized")


def on_bar(ctx, bar):
    stop_loss_pct = ctx.param("stop_loss_pct", 0.03)
    take_profit_pct = ctx.param("take_profit_pct", 0.06)
    order_amount = ctx.param("order_amount", 1)

    bars = ctx.bars(30)
    if len(bars) < 20:
        return

    closes = [b.close for b in bars]
    ma_fast = sum(closes[-10:]) / 10
    ma_slow = sum(closes[-20:]) / 20

    if not ctx.position and ma_fast > ma_slow:
        ctx.buy(price=bar.close, amount=order_amount)
        return

    if not ctx.position:
        return

    if ctx.position["side"] != "long":
        return

    entry_price = ctx.position["entry_price"]

    if bar.close <= entry_price * (1 - stop_loss_pct):
        ctx.close_position()
        return

    if bar.close >= entry_price * (1 + take_profit_pct):
        ctx.close_position()
        return

    if ma_fast < ma_slow:
        ctx.close_position()

这种写法适合“止盈止损属于运行时持仓管理”的场景,而不是单纯图表信号输出。

仓位语义还要补一句:

  • 在当前系统里,保存后的策略回测,仓位大小仍然主要由规范化后的交易配置决定,例如 entryPct
  • 因此 ctx.buy() / ctx.sell() 里的 amount 更适合理解成运行时下单意图,而不是回测仓位的唯一来源
  • 真正准备上模拟盘或实盘前,一定要先通过“保存后的策略回测”核对实际仓位暴露

6.4 普通脚本模式与 bot 模式

大多数 ScriptStrategy 都运行在已收盘 K 线语义下:

  • 引擎会在 bar 确认收盘后调用 on_bar(ctx, bar)
  • 这也是普通策略回测和逐 bar 实盘最接近的心智模型

当前系统里还存在 bot 风格运行模式:

  • bot 模式下,系统可能会基于最新价格构造“类 tick 的伪 bar”反复调用 on_bar
  • 这种模式更适合网格、DCA 或其他更偏机器人执行的策略
  • 如果你的脚本是为 bot 模式设计的,应该和标准 bar-close 策略分开测试,不要混为一谈

6.5 一个更贴近平台实盘的 ScriptStrategy 示例

下面这个例子更接近平台里真实可落地的实盘脚本写法:

  • ctx.param(...) 管脚本级默认参数
  • 先看 ctx.position,再决定是开仓、反手、减仓还是全部平仓
  • ctx.buy() / ctx.sell() 表达方向性下单意图
  • 当你的语义是“现在全部退出”时,用 ctx.close_position() 最明确
def on_init(ctx):
    ctx.log("live strategy initialized")


def on_bar(ctx, bar):
    fast_len = int(ctx.param("fast_len", 10))
    slow_len = int(ctx.param("slow_len", 30))
    risk_pct = float(ctx.param("risk_pct", 0.25))
    stop_loss_pct = float(ctx.param("stop_loss_pct", 0.02))
    take_profit_pct = float(ctx.param("take_profit_pct", 0.05))
    allow_short = bool(ctx.param("allow_short", True))

    bars = ctx.bars(slow_len + 5)
    if len(bars) < slow_len:
        return

    closes = [b.close for b in bars]
    fast_ma = sum(closes[-fast_len:]) / fast_len
    slow_ma = sum(closes[-slow_len:]) / slow_len
    price = bar.close

    if not ctx.position:
        if fast_ma > slow_ma:
            ctx.buy(price=price, amount=risk_pct)
            return
        if allow_short and fast_ma < slow_ma:
            ctx.sell(price=price, amount=risk_pct)
            return
        return

    if ctx.position["side"] == "long":
        entry_price = float(ctx.position["entry_price"])
        if price <= entry_price * (1 - stop_loss_pct):
            ctx.close_position()
            return
        if price >= entry_price * (1 + take_profit_pct):
            ctx.close_position()
            return
        if allow_short and fast_ma < slow_ma:
            ctx.sell(price=price, amount=risk_pct)
            return

    if ctx.position["side"] == "short":
        entry_price = float(ctx.position["entry_price"])
        if price >= entry_price * (1 + stop_loss_pct):
            ctx.close_position()
            return
        if price <= entry_price * (1 - take_profit_pct):
            ctx.close_position()
            return
        if fast_ma > slow_ma:
            ctx.buy(price=price, amount=risk_pct)
            return

这个例子重点演示了:

  • ctx.param(...) 让脚本默认值集中且清晰
  • ctx.position 决定当前是空仓、做多还是做空分支
  • ctx.buy() / ctx.sell() 表达的是方向性意图,不只是孤立的“开多”或“开空”
  • 当规则的语义是“现在全部退出”时,ctx.close_position() 最不容易产生歧义

下面这些回测 / 实盘差异一定要记住:

  • 标准脚本回测和普通实盘模式都以“已确认收盘的 bar”为核心,但 bot 模式可能会用类 tick 的伪 bar 反复驱动脚本
  • amount 更适合理解成运行时下单意图;保存后的策略回测,仓位大小仍然主要受 entryPct 这类规范化交易配置影响
  • 当你在多头持仓中调用 ctx.sell(),或在空头持仓中调用 ctx.buy() 时,实际效果可能会根据运行时状态与产品配置表现为“先平后反手”一类意图
  • 如果你要的是明确的“全部平仓”,优先用 ctx.close_position(),不要依赖隐式解释

7. 回测、持久化与当前限制

保存后的策略会被后端解析成统一快照,再进入回测或执行链路。常见字段包括:

  • strategy_type
  • strategy_mode
  • strategy_code
  • indicator_config
  • trading_config

当前常见 run_type 包括:

  • indicator
  • strategy_indicator
  • strategy_script

当前限制包括:

  • cross_sectional 在当前策略快照链路中不支持
  • ScriptStrategy 当前不支持 cross_sectional 实盘运行
  • 脚本策略回测当前不会走指标侧的 MTF 执行路径
  • 策略回测要求 symbol 合法且代码非空

8. 最佳实践

8.1 始终避免未来函数

  • 只使用已完成 bar 的信息
  • 优先使用 shift(1) 做确认
  • 不要在信号逻辑中使用 shift(-1)

8.2 显式处理 NaN

滚动窗口和 EWM 都会产生前导 NaN,生成信号前必须先清理。

8.3 保持所有序列长度一致

所有 plot['data']signal['data'] 都必须与 len(df) 完全一致。

8.4 IndicatorStrategy 尽量保持向量化

核心指标计算优先用 pandas 原生向量化逻辑,不要把主逻辑写成逐行循环。

8.5 ScriptStrategy 保持确定性

ScriptStrategy 尽量避免 ctx 外部的隐式状态、随机行为,以及含糊不清的下单意图。

8.6 把配置放在正确层级

  • 指标型默认值用 # @param# @strategy
  • 脚本型默认值优先用 ctx.param()
  • 杠杆、成交时机、交易所和账户凭证放在产品配置层,不要硬编码

9. 故障排除

9.1 column "strategy_mode" does not exist

说明数据库结构版本落后于当前代码,需要对 qd_strategies_trading 执行对应迁移。

9.2 Strategy script must define on_bar(ctx, bar)

说明 ScriptStrategy 缺少必需的 on_bar

9.3 Missing required functions: on_init, on_bar

说明当前 UI 校验器要求源码里同时存在这两个函数。

9.4 Strategy code is empty and cannot be backtested

说明保存后的策略在当前模式下没有有效代码。

9.5 图表长度不一致

说明某个 plot 或 signal 的数组长度没有和 df 对齐。

9.6 回测结果很奇怪

优先检查这几件事:

  • 有没有误用未来数据
  • buy / sell 是否做成了边缘触发
  • 是否同时混用了“信号退出”和“引擎退出”却没有说明清楚
  • # @strategy 默认值是否真的符合策略风格

9.7 后端日志排查

如果策略创建、校验、回测或执行失败,请优先查后端日志。常见问题包括:

  • 数据库结构不匹配
  • JSON / 配置载荷格式错误
  • 代码校验失败
  • 市场 / symbol 不匹配
  • 交易所凭证或配置异常

10. 完整工作流:从 Indicator IDE 到保存成策略再到实盘

这是目前最符合产品链路的实战流程。

10.1 先在 Indicator IDE 里做原型

在策略还处于构思阶段时,优先从 Indicator IDE 开始:

  1. 先把指标逻辑写在 df 上。
  2. # @param 声明可调参数。
  3. # @strategy 声明默认止损、止盈、仓位和方向。
  4. 补齐图表展示需要的 plotssignals
  5. 先跑指标侧回测,确认信号密度、图表观感和成交语义都合理。

这个阶段的目标不是立刻上实盘,而是先把策略逻辑变得可见、可测、可迭代。

10.2 在产品里调参与校验

当指标逻辑已经基本正确后:

  1. 先跑代码质量检查,排除缺少元数据或明显可疑写法。
  2. 用真实的标的、周期、手续费、滑点、杠杆配置去跑回测。
  3. 必要时使用 AI 调参或结构化扫参比较参数组合。
  4. 把最终采用的参数重新写回源码,让代码本身仍然是最直观的“单一真相”。

这里推荐的分工方式是:

  • 信号逻辑由代码表达
  • # @param# @strategy 表达默认参数和风控
  • 市场、杠杆、日期区间、执行环境由产品面板管理

10.3 把指标保存成策略

当信号模型稳定后:

  1. 先保存当前指标代码。
  2. 通过产品流程创建或保存策略记录。
  3. 确认保存后的策略快照模式、默认值和交易配置都符合预期。
  4. 从持久化后的策略记录发起策略回测,而不是只看编辑器里的即时结果。

这一步很关键,因为:

  • 保存后的策略回测更接近真实执行链路
  • 规范化快照可能会补齐成交时机和交易配置默认值
  • 很多 symbol、模式、配置、持久化层面的错配,都是在这一步才暴露出来

10.4 判断 IndicatorStrategy 是否已经够用

如果满足下面这些条件,就继续保留 IndicatorStrategy

  • 进出场核心仍然主要靠信号驱动
  • 固定止损、止盈、跟踪止损默认值已经足够
  • 不需要依赖当前持仓状态做复杂运行时逻辑

如果出现下面这些需求,就应升级成 ScriptStrategy

  • 开仓后要持续盯着持仓逐根处理
  • 退出逻辑依赖当前持仓状态,而不只是历史序列
  • 需要冷却期、分批止盈、加仓、减仓、机器人式执行逻辑

10.5 进入模拟盘或实盘前的最后检查

在真正开启实盘前,至少确认:

  1. 交易所 / 经纪商 / 标的 / 凭证配置正确。
  2. 对成交时机的假设已经再次核实。
  3. 杠杆、方向、仓位大小放在了正确的产品配置层。
  4. 先用保守仓位、小范围标的做验证。
  5. 观察运行日志和真实下单行为,再决定是否放大规模。

实盘不是编辑器实验的自然延伸,而是一个单独的验证阶段。


11. 常见错误示例 vs 正确写法

这一节专门列出最容易把回测做“虚高”、把策略行为写混乱、或者把产品配置和策略代码搞串的高频坑点。

11.1 声明了 # @param,却根本没读取

错误写法:

# @param fast_len int 20 Fast EMA length

df = df.copy()
fast_len = 20
ema_fast = df['close'].ewm(span=fast_len, adjust=False).mean()

正确写法:

# @param fast_len int 20 Fast EMA length

df = df.copy()
fast_len = int(params.get('fast_len', 20))
ema_fast = df['close'].ewm(span=fast_len, adjust=False).mean()

为什么:

  • 只声明不读取,会让参数变成“看起来能调,实际上调不动”
  • 平台代码质量检查也可能对此给出提醒

11.2 buy / sell 每根 bar 都在触发

错误写法:

df['buy'] = df['close'] > ema_fast
df['sell'] = df['close'] < ema_fast

正确写法:

raw_buy = df['close'] > ema_fast
raw_sell = df['close'] < ema_fast

df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)
df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)

为什么:

  • 如果每根 bar 都重复发信号,进出场、标记、回测解释都会变得混乱
  • 大多数策略真正想表达的是“条件刚刚成立时触发一次”,而不是“条件持续成立就一直触发”

11.3 把杠杆写进策略源码

错误写法:

# @strategy leverage 10

正确写法:

# @strategy entryPct 0.2
# @strategy stopLossPct 0.02
# @strategy takeProfitPct 0.05

然后把杠杆放到产品面板或保存后的策略交易配置里。

为什么:

  • 杠杆属于执行配置,不属于指标元数据
  • 把杠杆藏进代码里,会让回测解释更混乱,也更容易和产品侧配置打架

11.4 误用 shift(-1),把未来函数写进策略

错误写法:

df['buy'] = (df['close'].shift(-1) > ema_fast).fillna(False)

正确写法:

raw_buy = df['close'] > ema_fast
df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)

为什么:

  • shift(-1) 本质上是在偷看未来数据
  • 这类策略在回测里往往会“好得离谱”,但一到真实执行就失真

11.5 在 ScriptStrategy 里把 amount 当成绝对回测仓位

错误心智模型:

ctx.buy(price=bar.close, amount=1.0)

“这就等于回测里永远按 100% 仓位开仓。”

正确心智模型:

position_pct = float(ctx.param("risk_pct", 0.25))
ctx.buy(price=bar.close, amount=position_pct)

然后再用保存后的策略回测去核对规范化交易配置。

为什么:

  • 在当前系统里,保存后的策略回测,仓位大小仍然主要由 entryPct 这类规范化配置决定
  • amount 更适合理解成运行时下单意图,而不是历史回测仓位的唯一真相

11.6 明明想“全部平仓”,却写成了 ctx.sell() / ctx.buy()

容易歧义的写法:

if stop_hit:
    ctx.sell(price=bar.close, amount=0.25)

更清晰的写法:

if stop_hit:
    ctx.close_position()

为什么:

  • ctx.buy() / ctx.sell() 表达的是方向性意图,最终效果会结合当前持仓状态解释
  • 如果你的规则语义就是“现在全部退出”,ctx.close_position() 最不容易被误解

11.7 信号退出和引擎退出混着用,却没写说明

容易出问题的写法:

# @strategy stopLossPct 0.02
# @strategy takeProfitPct 0.05

df['sell'] = some_other_exit_condition

更好的写法:

# Primary exit: reverse signal
# Secondary protection: engine-managed fixed stop-loss / take-profit
# @strategy stopLossPct 0.02
# @strategy takeProfitPct 0.05

df['sell'] = reverse_signal

为什么:

  • 两种退出方式并存本身不一定错
  • 真正的问题是没人说得清“到底谁是主退出来源”

12. 平台支持字段速查表

这一节可以当成“当前平台到底支持什么”的速查页,写策略时可以直接对着查。

12.1 # @strategy 支持的 key

Key 含义 常见写法 说明
stopLossPct 默认止损比例 # @strategy stopLossPct 0.02 引擎读取的默认风控配置
takeProfitPct 默认止盈比例 # @strategy takeProfitPct 0.05 底层解析器允许范围比示例更宽松
entryPct 默认开仓资金占比 # @strategy entryPct 0.25 是回测仓位的重要来源之一
trailingEnabled 是否开启跟踪止损 # @strategy trailingEnabled true 布尔值
trailingStopPct 跟踪止损比例 # @strategy trailingStopPct 0.015 通常与 trailing 一起使用
trailingActivationPct 启动跟踪止损前的盈利阈值 # @strategy trailingActivationPct 0.03 通常与 trailing 一起使用
tradeDirection 方向限制 # @strategy tradeDirection both 可选 longshortboth

要点:

  • 这些 key 用于指标侧默认策略配置
  • 不要把 leverage 写进 # @strategy
  • 交易所、标的、凭证、杠杆都应该放在产品配置层

12.2 # @param 速查格式

部分 示例 含义
名称 fast_len 参数键名
类型 int / float / bool / str / string 支持的类型
默认值 20 系统看到的默认值
描述 Fast EMA length 给人看的说明

示例:

# @param fast_len int 20 Fast EMA length
# @param allow_short bool true Allow short entries

随后用下面这种方式读取:

fast_len = int(params.get('fast_len', 20))
allow_short = bool(params.get('allow_short', True))

12.3 ScriptStrategy 里的 ctx 支持什么

项目 类型 含义
ctx.param(name, default) 方法 读取或初始化脚本级默认参数
ctx.bars(n=1) 方法 取得当前运行时之前的最近若干根 bar
ctx.log(message) 方法 写策略日志
ctx.buy(price=None, amount=None) 方法 表达买入 / 做多方向意图
ctx.sell(price=None, amount=None) 方法 表达卖出 / 做空方向意图
ctx.close_position() 方法 显式全部平仓
ctx.position 字段 当前持仓对象
ctx.balance 字段 当前余额快照
ctx.equity 字段 当前权益快照

ctx.position 常见字段:

字段 含义
side longshort,或空字符串表示空仓
size 当前持仓大小
entry_price 平均开仓价
direction 1-10
amount 运行时数量镜像

12.4 ScriptStrategy 里的 bar 有哪些字段

字段 含义
bar.open 开盘价
bar.high 最高价
bar.low 最低价
bar.close 收盘价
bar.volume 成交量
bar.timestamp 当前运行时传入的时间值

12.5 IndicatorStrategyoutput 允许哪些结构

顶层结构通常写成:

output = {
    "name": my_indicator_name,
    "plots": [],
    "signals": [],
    "calculatedVars": {}
}

顶层常见 key:

Key 是否必需 含义
name 建议提供 展示名称
plots 建议提供 图表曲线输出
signals 建议提供 买卖点标记输出
calculatedVars 可选 额外元数据或计算结果

每个 plot 项常见字段:

Key 含义
name 曲线名称
data len(df) 对齐的数组
color 显示颜色
overlay 是否叠加在主图上
type 可选的渲染提示

每个 signal 项常见字段:

Key 含义
type buysell
text 标记文本
color 标记颜色
data len(df) 对齐的数组;无信号位置用 None

12.6 快速提醒

  • df['buy']df['sell'] 应该是布尔值,并与 df 长度完全对齐
  • 尽量使用边缘触发信号
  • 不要在信号逻辑里使用 shift(-1)
  • 当规则语义明显是“全部退出”时,优先用 ctx.close_position()
  • amount 更适合作为运行时下单意图,最终仓位仍应通过保存后的策略回测核实

13. 推荐开发流程

  1. 先用 IndicatorStrategy 把想法原型化。
  2. 先验证图表、信号密度和 next-bar-open 的回测语义。
  3. # @param# @strategy 元数据补完整。
  4. 明确写清楚:退出到底是“信号负责”还是“引擎负责”。
  5. 保存策略后,再从持久化记录跑策略回测。
  6. 只有在确实需要运行时仓位管理时,再迁移到 ScriptStrategy
  7. 确认配置、凭证和市场语义都正确后,再进入模拟盘或实盘。