# QuantDinger 指标开发指南

> 适用范围：当前 QuantDinger 图表指标契约
> 面向读者：第一次编写指标的用户、从 Pine/通达信公式迁移的开发者，以及需要审查 AI 生成代码的维护者

QuantDinger 指标是运行在指标编辑器中的 Python 图表程序。它读取当前图表的 K 线数据，计算序列，并通过 <code>output</code> 返回曲线、标记和稀疏图层。

最重要的边界是：**指标只负责看图，不负责交易执行。**

指标不能下单、回测、运行实盘、读取账户、管理仓位、设置杠杆或执行止盈止损。需要交易时，应先把指标中的视觉信号转换成 Strategy API V2 策略，再在策略页完成验证、回测和部署。

---

## 1. 先完成一个最小指标

把下面代码粘贴到指标编辑器并运行：

~~~python
my_indicator_name = "Close Line"
my_indicator_description = "Displays the close price as a chart overlay."

df = df.copy()

close_line = [
    None if pd.isna(value) else float(value)
    for value in df["close"]
]

output = {
    "name": my_indicator_name,
    "plots": [
        {
            "name": "Close",
            "data": close_line,
            "color": "#3B82F6",
            "type": "line",
            "overlay": True,
        }
    ],
    "signals": [],
    "layers": [],
}
~~~

这个例子展示了完整的最小契约：

1. 声明名称和描述。
2. 用 <code>df = df.copy()</code> 创建工作副本。
3. 计算与 K 线等长的数据。
4. 设置 <code>output</code> 字典。

建议新指标按“先画一条线，再加参数，再加事件标记，最后才加复杂图层”的顺序开发。

---

## 2. 指标、策略和转换流程的边界

| 产物 | 负责内容 | 不负责内容 |
| --- | --- | --- |
| Chart Indicator | 曲线、副图、灯带、视觉标记、区域、标签 | 回测、实盘、订单、仓位、杠杆、交易风控 |
| Strategy API V2 | 数据订阅、交易信号、订单意图、仓位、回测、实盘、保护规则 | 指标页的 <code>output</code> 图表渲染 |
| Indicator-to-Strategy | 把视觉信号的真实含义翻译成可执行策略 | 在原指标中混入下单逻辑 |

<code>output["signals"]</code> 只是图上的事件标记。例如 <code>sell</code> 类型的 “Death” 标记可以表示多头离场提醒，也可以表示行情转弱；它不会自动开空，更不会自动反手。

将只做多指标转换成策略时，通常采用：

- 明确的看多入场事件 → <code>open_long</code>
- 明确的看空离场事件 → <code>close_long</code>
- 只有用户明确要求做空，并提供独立的看空入场规则时，才生成 <code>open_short</code>

不要在指标里创建 <code>open_long</code>、<code>close_long</code>、<code>open_short</code>、<code>close_short</code>、<code>add_long</code> 或 <code>reduce_long</code> 等执行列，也不要使用旧式 <code># @strategy</code> 注解。

---

## 3. 运行环境与输入数据

运行时预置：

- <code>df</code>：当前图表的 pandas DataFrame，按时间从旧到新排列，每行对应一根 K 线。
- <code>params</code>：由参数声明和参数面板合并得到的字典。
- <code>pd</code>：预置的 pandas。
- <code>np</code>：预置的 numpy。
- <code>open</code>、<code>high</code>、<code>low</code>、<code>close</code>、<code>volume</code>：部分运行入口还提供同名便捷 Series；为了可读性和可移植性，教程建议优先使用 <code>df["close"]</code>。

标准 OHLCV 字段：

~~~python
open_price = df["open"]
high = df["high"]
low = df["low"]
close = df["close"]
volume = df["volume"]
~~~

注意：

- 不要假设一定存在 <code>time</code> 列，时间也可能已经在 DataFrame 索引中。
- 不要重命名或删除 OHLCV 列。
- 可选字段必须先检查，例如 <code>if "turnover" in df.columns:</code>。
- 修改 DataFrame 前先执行 <code>df = df.copy()</code>。
- 核心序列计算优先使用 <code>rolling</code>、<code>ewm</code>、<code>shift</code>、<code>where</code> 等向量化操作。

---

## 4. 文件结构与元数据

推荐结构：

~~~python
# @param period int 20 Calculation period

my_indicator_name = "Example Indicator"
my_indicator_description = "Explains what is drawn and how events are marked."

df = df.copy()

period = int(params.get("period", 20))

# Helper functions
# Series calculation
# Marker construction

output = {
    "name": my_indicator_name,
    "plots": [],
    "signals": [],
    "layers": [],
}
~~~

每个指标都应声明：

~~~python
my_indicator_name = "Dual EMA Viewer"
my_indicator_description = "Chart-only EMA crossover indicator with visual event markers."
~~~

名称应简短稳定；描述应说明：

- 计算了什么；
- 在主图还是副图显示；
- 标记代表什么事件；
- 有哪些重要参数。

不要在描述中承诺收益或暗示已通过实盘验证。

根据项目源码规则，代码标识符、元数据、注释、参数描述和默认显示标签使用英文。中文可以用于本教程正文；只有用户明确要求本地化显示标签时，指标中的展示文本才使用指定语言。

---

## 5. 参数声明与参数面板

语法：

~~~python
# @param <name> <int|float|bool|str> <default> <description>
~~~

示例：

~~~python
# @param fast_len int 12 Fast EMA period
# @param slow_len int 26 Slow EMA period
# @param band_pct float 1.5 Channel width percent
# @param show_marks bool true Show crossover markers
# @param source str close Price source
~~~

声明不会自动创建 Python 变量，必须显式读取：

~~~python
fast_len = int(params.get("fast_len", 12))
slow_len = int(params.get("slow_len", 26))
band_pct = float(params.get("band_pct", 1.5))
show_marks = bool(params.get("show_marks", True))
source = str(params.get("source", "close"))
~~~

硬性规则：

- 每行只声明一个参数。
- 参数名使用合法 Python 标识符。
- 类型只使用 <code>int</code>、<code>float</code>、<code>bool</code>、<code>str</code> 或 <code>string</code>。
- 注释中的默认值必须和 <code>params.get</code> 的回退值一致。
- 布尔值在声明中写 <code>true</code>/<code>false</code>，Python 中写 <code>True</code>/<code>False</code>。
- 字符串默认值不能包含空格，因为解析器把默认值读取为一个 token。

参数搜索可在描述末尾声明候选范围：

~~~python
# @param period int 20 Lookback period range=5:60:5
# @param multiplier float 2.0 Band multiplier values=1.5,2.0,2.5,3.0
~~~

<code>range=start:end:step</code> 是包含终点的等差候选序列；<code>values=a,b,c</code> 是显式候选列表。单个参数最多展开 1024 个候选值。描述中的范围标记会从用户可见描述中移除。

指标参数只控制计算和显示。不要声明账户、标的、周期、仓位、杠杆、止损或止盈等执行参数。

---

## 6. output 输出契约

指标运行结束时必须设置字典类型的 <code>output</code>：

~~~python
output = {
    "name": my_indicator_name,
    "plots": plots,
    "signals": signals,
    "layers": layers,
}
~~~

可选字段：

~~~python
output["calculatedVars"] = {}
~~~

验证要求：

- <code>output</code> 必须是字典。
- <code>plots</code> 或 <code>signals</code> 至少有一个键存在。
- 每个 <code>plot["data"]</code> 的长度必须等于 <code>len(df)</code>。
- 每个 <code>signal["data"]</code> 的长度必须等于 <code>len(df)</code>。
- 序列中不要输出 NaN、正无穷或负无穷；缺失点使用 <code>None</code>。
- <code>layers</code> 不需要逐 bar 数组，但索引、时间和价格必须落在当前数据的有效语义范围内。

推荐总是显式提供空列表，这样结构最清晰：

~~~python
output = {
    "name": my_indicator_name,
    "plots": [],
    "signals": [],
    "layers": [],
}
~~~

---

## 7. plots：主图曲线与副图序列

每个 plot 至少包含：

| 字段 | 类型 | 含义 |
| --- | --- | --- |
| <code>name</code> | str | 图例和序列名称 |
| <code>data</code> | list | 与 <code>df</code> 等长的数值/<code>None</code> 列表 |
| <code>color</code> | str | 推荐使用 <code>#RRGGBB</code> |
| <code>overlay</code> | bool | <code>True</code> 主图，<code>False</code> 副图 |
| <code>type</code> | str，可选 | 常用 <code>line</code>，也可由当前渲染器支持其他样式 |

示例：

~~~python
plots = [
    {
        "name": "EMA Fast",
        "data": fast_values,
        "color": "#22C55E",
        "type": "line",
        "overlay": True,
    },
    {
        "name": "RSI",
        "data": rsi_values,
        "color": "#8B5CF6",
        "type": "line",
        "overlay": False,
    },
]
~~~

价格均线、布林带和通道通常使用主图；RSI、MACD 和状态灯带通常使用副图。

统一处理空值：

~~~python
def to_plot_list(series):
    return [
        None if pd.isna(value) else float(value)
        for value in series
    ]
~~~

不要把价格叠加线的预热空值填成 0，否则图上会出现从零点拉到真实价格的误导性线段。

---

## 8. signals：稀疏视觉事件

signal 示例：

~~~python
signals = [
    {
        "type": "buy",
        "text": "Long Entry",
        "color": "#22C55E",
        "data": entry_marks,
    },
    {
        "type": "sell",
        "text": "Long Exit",
        "color": "#EF4444",
        "data": exit_marks,
    },
]
~~~

规则：

- <code>type</code> 通常为 <code>buy</code> 或 <code>sell</code>，只控制标记方向，不是信号名称。
- <code>text</code> 是稳定的信号名；可选 <code>textData</code> 可为每根 bar 提供不同标签。
- 只有 <code>data[i]</code> 中的有限数值会激活第 i 根 bar 的信号。
- <code>text</code> 或 <code>textData</code> 本身不会激活信号。
- 无信号位置必须使用真实的 <code>None</code>。
- 默认标记一次性事件，不要在条件持续为真时每根 bar 都重复标记。

把状态转换成边沿事件：

~~~python
def edge(condition):
    current = condition.fillna(False).astype(bool)
    previous = current.shift(1, fill_value=False).astype(bool)
    return current & ~previous
~~~

生成价格标记：

~~~python
entry_event = edge(ema_fast > ema_slow)
exit_event = edge(ema_fast < ema_slow)

entry_marks = [
    float(df["low"].iloc[i] * 0.995)
    if bool(entry_event.iloc[i])
    else None
    for i in range(len(df))
]

exit_marks = [
    float(df["high"].iloc[i] * 1.005)
    if bool(exit_event.iloc[i])
    else None
    for i in range(len(df))
]
~~~

如果要求“确认后下一根显示”：

~~~python
confirmed_entry = edge(raw_entry).shift(
    1,
    fill_value=False,
).astype(bool)
~~~

这只是把已确认事件向后移动一根，并没有读取未来数据。

---

## 9. layers：区域、线段和标签

普通指标优先使用 plots 和 signals。只有在供需区、支撑阻力、通道、失效位或结构标签确实能提高可读性时才使用 layers。

区域：

~~~python
{
    "type": "zone",
    "startIndex": 120,
    "endIndex": 180,
    "top": 105.2,
    "bottom": 101.8,
    "text": "Demand",
    "fillColor": "#22C55E",
    "borderColor": "#22C55E",
    "opacity": 0.12,
}
~~~

水平线：

~~~python
{
    "type": "line",
    "startIndex": 100,
    "endIndex": len(df) - 1,
    "price": 98.5,
    "text": "Support",
    "color": "#F59E0B",
    "dashed": True,
}
~~~

斜线把 <code>price</code> 换成 <code>startPrice</code> 和 <code>endPrice</code>。标签：

~~~python
{
    "type": "label",
    "index": len(df) - 1,
    "price": float(df["close"].iloc[-1]),
    "text": "Trend Weakens",
    "color": "#EF4444",
    "textColor": "#FFFFFF",
}
~~~

索引写法对当前 <code>df</code> 最稳定。也支持与 K 线时间戳匹配的 <code>startTime</code>、<code>endTime</code> 和 <code>time</code>。

图层仍然只是视觉对象，不能表示真实订单、仓位或已托管止损。

---

## 10. pandas 与 numpy 类型陷阱

最常见的错误是把 numpy ndarray 当成 pandas Series。

错误：

~~~python
values = np.where(close > close.shift(1), close, 0)
average = values.rolling(10).mean()
~~~

<code>np.where</code> 可能返回 ndarray，而 ndarray 没有 <code>rolling</code>、<code>shift</code>、<code>ewm</code>、<code>fillna</code> 或 <code>iloc</code>。

优先使用 pandas 原生写法：

~~~python
values = close.where(close > close.shift(1), 0)
average = values.rolling(10).mean()
~~~

必须包装 ndarray 时：

~~~python
array = np.where(close > close.shift(1), close, 0)
values = pd.Series(array, index=df.index)
~~~

一定传入 <code>index=df.index</code>。否则新 Series 使用 RangeIndex，与 DatetimeIndex 数据做运算时会静默错位。

常用替换：

| numpy 写法 | pandas 优先写法 |
| --- | --- |
| <code>np.where(cond, a, b)</code> | <code>a.where(cond, b)</code> |
| <code>np.maximum(s, 0)</code> | <code>s.clip(lower=0)</code> |
| <code>np.minimum(s, k)</code> | <code>s.clip(upper=k)</code> |
| <code>np.abs(s)</code> | <code>s.abs()</code> |

---

## 11. 避免未来数据和重绘

指标只能使用当前及历史 bar。禁止：

- <code>shift(-1)</code>、<code>shift(-N)</code>；
- 循环中的 <code>iloc[i + 1]</code>；
- <code>bars_ago(-N)</code>；
- <code>rolling(..., center=True)</code>；
- 用完整数据集最后一行反向修改历史信号；
- 任何利用未来最高价、最低价或未来确认结果标记过去 bar 的写法。

合法确认通常使用当前条件与上一根状态：

~~~python
cross_up = (
    (ema_fast > ema_slow)
    & (ema_fast.shift(1) <= ema_slow.shift(1))
)
~~~

如果信号必须等当前 bar 收盘才能确定，转换成策略后应在下一根 bar 执行，不要为了让图形更漂亮而把信号提前。

---

## 12. 沙箱与安全限制

允许的计算模块包括 numpy、pandas、math、json、datetime、time、collections、functools、itertools、statistics、decimal、fractions 和 copy。<code>pd</code> 与 <code>np</code> 已预置，通常无需 import。

禁止：

- 网络、文件、数据库和子进程访问；
- <code>eval</code>、<code>exec</code>、<code>compile</code>、<code>open</code>；
- 反射、动态导入、dunder 逃逸和沙箱绕过；
- pandas/numpy 的文件读取、写入和序列化方法；
- <code>os</code>、<code>sys</code>、<code>requests</code>、<code>socket</code>、<code>subprocess</code>、<code>threading</code>、<code>sqlite3</code>、<code>pathlib</code>、<code>pickle</code>、<code>ctypes</code>、<code>operator</code> 等模块。

指标验证有超时限制。避免无界循环、递归爆炸和逐行执行的高复杂度算法。

---

## 13. 完整教程：双 EMA 交叉指标

~~~python
# @param fast_len int 12 Fast EMA period range=5:30:1
# @param slow_len int 26 Slow EMA period range=10:80:2
# @param confirm_next_bar bool false Show markers one bar after confirmation
# @param show_marks bool true Show crossover markers

my_indicator_name = "Dual EMA Viewer"
my_indicator_description = "Displays two EMAs and marks confirmed crossover events."

df = df.copy()

fast_len = int(params.get("fast_len", 12))
slow_len = int(params.get("slow_len", 26))
confirm_next_bar = bool(params.get("confirm_next_bar", False))
show_marks = bool(params.get("show_marks", True))

close = df["close"]
high = df["high"]
low = df["low"]


def edge(condition):
    current = condition.fillna(False).astype(bool)
    previous = current.shift(1, fill_value=False).astype(bool)
    return current & ~previous


def to_plot_list(series):
    return [
        None if pd.isna(value) else float(value)
        for value in series
    ]


ema_fast = close.ewm(span=fast_len, adjust=False).mean()
ema_slow = close.ewm(span=slow_len, adjust=False).mean()

golden = edge(ema_fast > ema_slow)
death = edge(ema_fast < ema_slow)

if confirm_next_bar:
    golden = golden.shift(1, fill_value=False).astype(bool)
    death = death.shift(1, fill_value=False).astype(bool)

golden_marks = [
    float(low.iloc[i] * 0.995)
    if show_marks and bool(golden.iloc[i])
    else None
    for i in range(len(df))
]

death_marks = [
    float(high.iloc[i] * 1.005)
    if show_marks and bool(death.iloc[i])
    else None
    for i in range(len(df))
]

output = {
    "name": my_indicator_name,
    "plots": [
        {
            "name": "EMA Fast",
            "data": to_plot_list(ema_fast),
            "color": "#22C55E",
            "type": "line",
            "overlay": True,
        },
        {
            "name": "EMA Slow",
            "data": to_plot_list(ema_slow),
            "color": "#3B82F6",
            "type": "line",
            "overlay": True,
        },
    ],
    "signals": [
        {
            "type": "buy",
            "text": "Long Entry",
            "color": "#22C55E",
            "data": golden_marks,
        },
        {
            "type": "sell",
            "text": "Long Exit",
            "color": "#EF4444",
            "data": death_marks,
        },
    ],
    "layers": [],
}
~~~

逐步理解：

1. 参数声明决定参数面板和搜索范围。
2. <code>params.get</code> 读取与声明完全一致的默认值。
3. 两条 EMA 是持续状态，因此放进 plots。
4. 金叉和死叉是一次性事件，因此经过 <code>edge</code> 后放进 signals。
5. 空标记使用 <code>None</code>。
6. 指标明确把死叉命名为 “Long Exit”，避免转换策略时误解成开空。

---

## 14. 验证、调试和常见错误

建议每次按以下顺序：

1. 保存版本。
2. 运行/预览指标。
3. 执行代码验证。
4. 检查图上起始空值、极端行情和短数据区间。
5. 修改参数，确认参数确实影响结果。
6. 检查信号是否只在事件 bar 出现。

| 提示或错误 | 原因 | 修复 |
| --- | --- | --- |
| <code>EMPTY_CODE</code> | 代码为空 | 提供完整指标源码 |
| <code>MISSING_OUTPUT</code> | 没有设置 <code>output</code> | 添加字典输出 |
| <code>MissingOutput</code> | 执行后未得到输出变量 | 检查分支和变量作用域 |
| <code>InvalidOutputType</code> | <code>output</code> 不是字典 | 改为 dict |
| <code>InvalidOutputStructure</code> | plots/signals 键都不存在 | 至少提供其中一个 |
| <code>LengthMismatch</code> | 序列长度与 K 线不一致 | 让每个 data 等于 <code>len(df)</code> |
| <code>MISSING_DF_COPY</code> | 缺少工作副本 | 在计算前添加 <code>df = df.copy()</code> |
| <code>PARAM_DEFAULT_MISMATCH</code> | 声明和读取默认值不同 | 对齐两个默认值 |
| <code>DECLARED_PARAMS_NOT_READ_VIA_PARAMS_GET</code> | 声明后没有读取 | 显式调用 <code>params.get</code> |
| <code>EXECUTION_COLUMNS_IGNORED_FOR_INDICATOR</code> | 指标中写了交易执行列 | 删除并转换成 V2 策略 |
| <code>STRATEGY_ANNOTATIONS_IGNORED_FOR_INDICATOR</code> | 使用旧策略注解 | 删除旧注解 |
| <code>NDARRAY_PANDAS_METHOD_MISUSE</code> | ndarray 被当作 Series | 用 pandas 写法或包装并保留索引 |
| <code>FUTURE_DATA_LEAK</code> | 检测到未来数据 | 改为只使用当前和历史数据 |

---

## 15. 转换成策略前的语义清单

转换前明确回答：

- 哪个标记是多头入场？
- 哪个标记是多头离场？
- 是否真的需要做空？看空离场不能自动等同于做空入场。
- 是否需要反手？如果需要，平仓和反向开仓是否为两个独立动作？
- 信号在哪个周期、哪根已收盘 bar 确认？
- 是否允许重复入场、加仓或减仓？
- 仓位大小、止损、止盈和追踪止损如何定义？
- 交易标的和市场类型是什么？

转换后应删除图表专用的颜色、标签偏移、plots、layers 和 marker 数组，保留信号代数，并用 Strategy API V2 明确声明标的、周期、仓位和风险。

生成的策略必须重新验证和回测。发布到市场前，系统要求至少有一条成功回测记录。

---

## 16. 发布前检查清单

- [ ] 名称和描述存在，且不包含收益承诺。
- [ ] 代码注释、标识符、元数据和默认标签为英文。
- [ ] <code>df = df.copy()</code> 已执行。
- [ ] 每个参数都通过 <code>params.get</code> 读取，默认值一致。
- [ ] 不包含订单、仓位、杠杆或交易风控代码。
- [ ] <code>output</code> 是字典。
- [ ] 每个 plot/signal data 长度都等于 <code>len(df)</code>。
- [ ] 缺失值使用 <code>None</code>，没有 NaN 或无穷值输出。
- [ ] signals 表示稀疏事件，持续状态放在 plots 或少量 layers。
- [ ] 不读取未来数据。
- [ ] numpy 结果在调用 pandas 方法前已转换成带正确索引的 Series。
- [ ] 短数据、预热区和异常参数不会导致崩溃。
- [ ] 已保存版本并通过预览与验证。
