# QuantDinger Python v3 策略开发指南

> **平台级契约（必读）**：[信号与执行标准 v1](./SIGNAL_EXECUTION_STANDARD_CN.md) — 适用于所有指标策略的回测/实盘对齐、两路/四路选型、退出负责人与上线清单。本指南侧重教程与示例。

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

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

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 ...`、`takeProfitPct`、`entryPct` 等。

不要把这三层混成一团。

尤其要明确：

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

---

## 2. 应该选哪种模式？

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

一个简单判断方法：

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

---

## 3. 如何开发一个 IndicatorStrategy

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

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

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

```python
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` 用来定义用户经常调的参数。

格式如下：

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

最佳实践：

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

`# @strategy` 用来定义策略默认配置，比如：

- `stopLossPct`：止损比例，例如 `0.03` 表示 **标的价格下跌 3%** 触发（0–1 小数；`0.001` = 0.1%）
- `takeProfitPct`：止盈比例，例如 `0.06` 表示 **标的价格上涨 6%** 触发
- `entryPct`：开仓资金占比（0–1；**`1` = 100%**，`0.25` = 25%）
- `trailingEnabled`
- `trailingStopPct`
- `trailingActivationPct`
- `tradeDirection`：`long`、`short` 或 `both`

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

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

### 3.2 第二步：复制 dataframe，再算指标

Indicator 代码运行在沙盒里，`pd`、`np` 和 `params` 字典已预置。

推荐开头固定写：

```python
df = df.copy()
```

通常可用列包括：

- `open`
- `high`
- `low`
- `close`
- `volume`

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

避免这些写法：

- 网络请求
- 文件读写
- 子进程
- `eval`、`exec`、`open`、`__import__`、`getattr` / `setattr` 等破坏沙盒边界的模式
- `import operator`（以及通过字符串拼接访问 `__class__` / `__globals__` 等 dunder 的绕过写法）

允许 `import` 的白名单模块（其余会被校验拒绝）：

`numpy`、`pandas`、`math`、`json`、`datetime`、`time`、`collections`、`functools`、`itertools`、`statistics`、`decimal`、`fractions`、`copy`

环境已预置 `pd`、`np`、`params`，一般无需再 `import pandas` / `import numpy`。

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

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

- `df['buy']`
- `df['sell']`

它们应满足：

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

推荐模式：

```python
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.3.1 `tradeDirection` 与 `buy` / `sell` 在引擎里如何解释

指标保存为策略后，后端会把 `df['buy']` / `df['sell']` 规范成执行信号。请按下面表格理解，**不要**在 `both` 模式下把 `buy` 当成“单独的平空列”：

| `tradeDirection` | `buy=True` | `sell=True` |
|------------------|------------|-------------|
| `long` | 开多 `open_long` | 平多 `close_long` |
| `short` | 平空 `close_short` | 开空 `open_short` |
| `both` | 开多 `open_long`；若当前持空则**先平空再开多** | 开空 `open_short`；若当前持多则**先平多再开空** |

要点：

- `both` 与回测 `BacktestService` 的 `_both_mode` 语义一致；实盘不应再拆出独立的 `close_short` / `close_long` 去“辅助”表达平仓。
- 若你把「空侧止盈 / 空侧止损」写进 `df['buy']`，在 `both` 下表达的是**退出空头并可能反手做多**，不是“只平空、保持空仓”。
- 若只想平仓、不想反手，请改用 `tradeDirection long/short`、四路布尔列（`open_long` / `close_long` / …），或迁移到 `ScriptStrategy` 用 `ctx.close_position()`。

常见组合写法（双向肯特纳类策略）：

```python
df['buy']  = sig_buy_entry | sig_short_tp | sig_short_sl
df['sell'] = sig_sell_entry | sig_long_tp | sig_long_sl
```

写代码时可以这样自检：回测成交里若出现「平空后立刻开多」，通常就是 `both` 下 `buy=True` 的预期行为，而不是引擎 bug。

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

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

在 `IndicatorStrategy` 里，退出逻辑通常有两种合法写法。

#### 写法 A：信号自己负责退出

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

典型例子：

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

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

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

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

- `stopLossPct`
- `takeProfitPct`
- `entryPct`
- trailing 系列参数

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

#### 最佳实践

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

例如：

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

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

### 3.5 第五步：最后再组装 `output`

脚本最后必须赋值 `output`：

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

主要支持键：

- `name`
- `plots`
- `signals`
- `calculatedVars`：可选元数据

每个 `plot` 项通常包含：

- `name`
- `data`，长度必须等于 `len(df)`
- `color`
- `overlay`
- 可选 `type`

每个 `signal` 项通常包含：

- `type`：`buy` 或 `sell`
- `text`
- `color`
- `data`：无信号的 bar 用 `None`

### 3.6 第六步：校验回测语义

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

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

这件事非常重要，因为：

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

还要注意一个实现细节：

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

#### 3.6.1 实盘与回测为什么会在“开平仓时间”上差很多

| 维度 | 指标回测 | 指标实盘（默认） |
|------|----------|------------------|
| K 线 | 历史**已收盘** OHLC | 会用最新价**刷新未收盘 K** 的 `high` / `low` / `close` |
| 盘中触及 | 只在整根 K 收盘后才知道 high/low | 未收盘 K 上也可能提前触发「触及中轨 / 外轨」类条件 |
| 信号检查 | 按回测时间轴逐根推进 | 每个 tick 可能重算指标；`exit_signal_mode=immediate` 时平仓类信号可立即下单 |
| 每 tick 下单 | 按回测队列顺序 | 指标模式通常**每 tick 最多执行 1 个**信号（优先平仓） |

若策略逻辑依赖 `high >= 某线`、`low <= 某线` 这类**盘中触及**，回测往往比实盘**更晚**才出现信号；若未收盘 K 被实时刷新，实盘可能**更早**止盈/止损。

建议（与回测对齐时）：

- 策略配置里将 `signal_mode`、`exit_signal_mode` 设为 **`confirmed`**（只在上一根已收盘 K 上读信号）。
- 指标内已用 `sig_*_tp` / `sig_*_sl` 表达退出时，避免再开 `# @strategy trailingEnabled true`，否则会出现「指标止盈 + 服务端移动止损」**双重平仓**，并放大时间差。
- 上线前对比：回测成交时间、实盘日志里的 `Signal submitted` / `server_trailing_stop` 是否成对出现。

实盘平仓若短暂出现「数量为 0」：执行器会**再次同步交易所持仓并重新解析数量**；若交易所确已空仓，才会拒单（例如移动止损已先平掉）。

---

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

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

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

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

```python
# @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 风格止损线就卖出。

```python
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 示例

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

```python
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 管，不写进源码

```python
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_init` 和 `on_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` 同时支持数值判断和字段访问，例如：

```python
if not ctx.position:
    ...

if ctx.position > 0:
    ...

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

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

```python
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()` 最明确

```python
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. 补齐图表展示需要的 `plots` 和 `signals`。
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. 对成交时机的假设已经再次核实（`signal_mode` / `exit_signal_mode` 是否与回测一致，见 §3.6.1）。
3. `tradeDirection both` 时已理解 `buy` / `sell` 的反手语义（§3.3.1），指标内 tp/sl 与 `trailingEnabled` 未重复（§11.7）。
4. 杠杆、方向、仓位大小放在了正确的产品配置层。
5. 先用保守仓位、小范围标的做验证。
6. 观察运行日志和真实下单行为（含 `server_trailing_stop`、拒单原因），再决定是否放大规模。

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

---

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

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

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

错误写法：

```python
# @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()
```

正确写法：

```python
# @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 都在触发

错误写法：

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

正确写法：

```python
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 把杠杆写进策略源码

错误写法：

```python
# @strategy leverage 10
```

正确写法：

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

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

为什么：

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

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

错误写法：

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

正确写法：

```python
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` 当成绝对回测仓位

错误心智模型：

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

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

正确心智模型：

```python
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()`

容易歧义的写法：

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

更清晰的写法：

```python
if stop_hit:
    ctx.close_position()
```

为什么：

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

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

容易出问题的写法：

```python
# @strategy stopLossPct 0.02
# @strategy takeProfitPct 0.05
# @strategy trailingEnabled true

df['sell'] = some_other_exit_condition  # 指标内多侧止盈
# 同时 buy 里还有 sig_short_tp / sig_short_sl …
```

更好的写法（二选一，并在注释里写清楚）：

```python
# 方案 A：退出全部由指标信号负责（推荐用于中轨/轨道触及类策略）
# @strategy trailingEnabled false
# Primary exit: df['buy'] / df['sell'] 内的 tp/sl 条件

# 方案 B：退出由引擎风控负责（固定止损/止盈/移动止损）
# @strategy trailingEnabled true
# @strategy trailingStopPct 0.0025
# @strategy trailingActivationPct 0.0037
# df['buy'] / df['sell'] 只负责入场，不要在同 bar 再写 tp/sl
```

为什么：

- 两种退出方式并存时，回测与实盘都会执行**先到为准**的那条路径，日志里可能出现 `server_trailing_stop` 与指标 `close_*` 紧挨着，甚至「平仓数量为 0」的拒单（仓位已被移动止损平掉）。
- `trailingEnabled true` 时，引擎会关闭与移动止损冲突的固定止盈语义；但这**不能**代替你在 `df['buy']`/`df['sell']` 里写的指标内止盈。
- 真正的问题是没人说得清「主退出」是指标触及，还是 `# @strategy` 服务端风控。

### 11.8 `both` 模式下把止盈/止损全塞进 `buy` / `sell`

容易误解的写法：

```python
df['buy']  = entry_long | short_tp | short_sl
df['sell'] = entry_short | long_tp | long_sl
# @strategy tradeDirection both
```

需要建立的心智模型：

- `short_tp` / `short_sl` 进入 `buy` 后，在 `both` 下不是「仅平空」，而是**平空并可能开多**（与回测一致）。
- 若你期望「空侧止盈后保持空仓」，当前 `IndicatorStrategy` + `both` **不适合**，应改 `tradeDirection`、四路信号或 `ScriptStrategy`。
- 实盘在 `exit_signal_mode=immediate` 时，这类退出可能比回测更早触发；要与回测对齐请用 `confirmed`（见 §3.6.1）。

### 11.9 实盘日志里 `invalid amount (0.0) for close_*`

常见原因：

1. 服务端移动止损/止盈已平仓，指标信号又提交了一次同向 `close_*`。
2. 本地持仓表滞后于交易所；执行器会先 **sync + 按交易所持仓重算数量**，仍为空才拒单。

处理建议：

- 按 §11.7 避免双重退出；检查是否同时启用 `trailingEnabled` 与指标内 tp/sl。
- 确认 `tradeDirection both` 下对 `buy`/`sell` 的解释符合 §3.3.1，不要期待单独的 `close_short` 列。

---

## 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` | 可选 `long`、`short`、`both` |

要点：

- 这些 key 用于指标侧默认策略配置
- **数值单位统一为 0–1 小数比例**（与 `StrategyConfigParser`、回测、实盘一致）
- **止损/止盈/追踪按标的涨跌幅计算，不除以杠杆**（杠杆只影响盈亏金额与爆仓）
- **`entryPct 1` 表示 100% 可用资金开仓**，不是 1%
- 不要把 `leverage` 写进 `# @strategy`
- 交易所、标的、凭证、杠杆都应该放在产品配置层

### 12.2 `# @param` 速查格式

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

示例：

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

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

```python
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` | `long`、`short`，或空字符串表示空仓 |
| `size` | 当前持仓大小 |
| `entry_price` | 平均开仓价 |
| `direction` | `1`、`-1`、`0` |
| `amount` | 运行时数量镜像 |

### 12.4 `ScriptStrategy` 里的 `bar` 有哪些字段

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

### 12.5 `IndicatorStrategy` 的 `output` 允许哪些结构

顶层结构通常写成：

```python
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` | `buy` 或 `sell` |
| `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. 确认配置、凭证和市场语义都正确后，再进入模拟盘或实盘。

