diff --git a/SIGNAL_EXECUTION_STANDARD_CN.md b/SIGNAL_EXECUTION_STANDARD_CN.md new file mode 100644 index 0000000..dbb7d89 --- /dev/null +++ b/SIGNAL_EXECUTION_STANDARD_CN.md @@ -0,0 +1,260 @@ +# QuantDinger 信号与执行标准(SSOT) + +**版本**:1.0 +**状态**:现行 +**适用范围**:所有基于 `IndicatorStrategy`(指标 Python + 保存为策略)的回测与实盘 +**关联实现**:`BacktestService`、`TradingExecutor`、`validate_code_safety` / `verifyCode` +**开发指南**:[STRATEGY_DEV_GUIDE_CN.md](./STRATEGY_DEV_GUIDE_CN.md)(教程与示例) + +--- + +## 1. 目的 + +平台会同时运行**多种不同逻辑**的策略。若没有统一标准,会出现: + +- 同一套 `buy`/`sell` 在不同策略里含义不同; +- 回测按「收盘 + 下一根开盘」成交,实盘按「未收盘 K + 立即平仓」执行; +- 指标内止盈止损与 `# @strategy trailingEnabled` 叠加,导致重复平仓与拒单。 + +本标准定义:**策略作者写什么、引擎如何解释、回测与实盘必须如何对齐**。 +所有新策略 **SHOULD** 遵循;存量策略 **SHOULD** 按第 9 节迁移。 + +--- + +## 2. 术语 + +| 术语 | 含义 | +|------|------| +| **信号 K 线** | 产生布尔信号的那根 K 线(时间戳 = 该 bar 收盘时刻) | +| **成交 K 线** | 订单实际执行所锚定的 K 线(默认 = 信号 K 的下一根) | +| **边缘触发** | 仅在与上一根 K 相比由 false→true 时记为信号 | +| **重绘** | 未收盘 K 上条件随行情变化而反复成立/消失 | +| **退出负责人** | 唯一主导平仓逻辑的层:指标信号 **或** 引擎风控,不可并列窄规则 | + +--- + +## 3. 策略形态选型(必须先选) + +| 形态 | 适用 | 不适用 | +|------|------|--------| +| **A. 简单两路** `buy` / `sell` | 金叉/死叉、单方向或对称反转;无独立「仅平仓」语义 | 多空分离 tp/sl、状态机、同 bar 先平后开需写清 | +| **B. 专业四路** `open_long` / `close_long` / `open_short` / `close_short` | 状态机、触及止盈止损、多空分离、与回测队列一一对应 | 极简趋势(可用 A,但 B 更利于长期维护) | +| **C. ScriptStrategy** `on_bar` + `ctx` | 强依赖持仓、分批、冷却、bot 网格 | 纯历史序列可表达的指标信号 | + +**平台推荐默认值**:新上架、多空或带指标内退出的策略 → **形态 B(四路)**。 + +--- + +## 4. 信号输出标准(IndicatorStrategy) + +### 4.1 通用 MUST + +1. **MUST** `df = df.copy()` 作为首行可变操作。 +2. **MUST** 提供 `output` 字典(含 `name`、`plots`;`signals` 可选用于图表标记)。 +3. **MUST** 执行列与 `len(df)` 一致、类型为 `bool`(`fillna(False).astype(bool)`)。 +4. **MUST** 对执行列做**边缘触发**(见 4.3),除非策略说明中明确声明「连续持仓信号」并经产品审核。 +5. **MUST NOT** 使用 `shift(-1)` 及任何未来数据引用。 +6. **MUST NOT** 在单脚本内混用「形态 A 执行列」与「形态 B 执行列」作为成交依据(可同时保留 `buy`/`sell` 全 false 仅作兼容)。 + +### 4.2 形态 A:两路 `buy` / `sell` + +| `tradeDirection` | `buy=True` 含义 | `sell=True` 含义 | +|------------------|-----------------|------------------| +| `long` | 开多 | 平多 | +| `short` | 平空 | 开空 | +| `both` | 开多;若持空则**先平空再开多** | 开空;若持多则**先平多再开空** | + +**MUST NOT** 在 `both` 下把 `buy` 理解为「仅平空」或「仅平多」。 +若需**只平仓不反手**,**MUST** 使用形态 B 的 `close_*`。 + +### 4.3 形态 B:四路(推荐标准) + +| 列 | 含义 | +|----|------| +| `open_long` | 开多(或加多,若引擎配置允许) | +| `close_long` | 平多(全部或按 reduce 配置) | +| `open_short` | 开空 | +| `close_short` | 平空 | + +**MUST** 满足: + +- 四列均存在且为 bool。 +- **SHOULD** 同 bar 优先级:`close_*` 先于 `open_*`(脚本内互斥或交给引擎「先平后开」)。 +- **SHOULD NOT** 同 bar 同时 `open_long` 与 `open_short` 为 true(反手请用 `close_*` + 对侧 `open_*`,或分两根 K)。 +- 使用四路时,引擎按**显式信号**处理(非 buy/sell 的 both 隐式映射)。 + +边缘触发模板: + +```python +def edge(s: pd.Series) -> pd.Series: + s = s.fillna(False).astype(bool) + return s & ~s.shift(1).fillna(False) + +df['open_long'] = edge(raw_open_long) +df['close_long'] = edge(raw_close_long) +df['open_short'] = edge(raw_open_short) +df['close_short'] = edge(raw_close_short) +``` + +`output['signals']` 仅用于展示;**成交只认上述执行列**。 + +### 4.4 `tradeDirection` 与四路的关系 + +| `tradeDirection` | 行为 | +|----------------|------| +| `long` | 忽略 `open_short` / `close_short`(或校验警告) | +| `short` | 忽略 `open_long` / `close_long` | +| `both` | 四路均有效;**不**再对 `buy`/`sell` 做反手映射 | + +--- + +## 5. 退出与风控标准(解决重复平仓) + +每个策略 **MUST** 在说明或注释中声明「退出负责人」之一: + +| 模式 | 脚本 | `# @strategy` | 说明 | +|------|------|---------------|------| +| **指标退出** `exit_owner: indicator` | 输出 `close_*`,或两路中明确等价的退出语义 | `trailingEnabled false`;不要依赖 `stopLossPct` / `takeProfitPct` / trailing | 触及型、中轨/轨道 tp/sl;当前实现会关闭服务端价格退出 | +| **引擎退出** `exit_owner: engine` | 只输出入场,或只输出趋势反转用的结构性 `close_*` | `trailingEnabled` / `stopLossPct` / `takeProfitPct` 按需 | 固定止损止盈、追踪止损、简单趋势 | + +**MUST NOT**:指标内窄 tp/sl **且** `trailingEnabled true` 窄移动止损(易造成 `server_trailing_stop` 后再发 `close_*` → 数量为 0)。 + +启用 trailing 时:**SHOULD** 关闭指标内同方向的 tp/sl 布尔列,或关闭 trailing。 + +当前后端只支持 `exit_owner: indicator` 与 `exit_owner: engine`。不要生成或上架 `exit_owner: layered`;若确实需要“指标退出 + 引擎兜底”的混合方案,必须先产品评审并明确改造执行器语义。 + +--- + +## 6. 执行契约(回测与实盘对齐) + +以下配置在「保存后的策略」`trading_config` 中生效;**回测与实盘 MUST 使用同一套规范化结果**。 + +### 6.1 信号时刻(抗重绘) + +| 项 | 标准值 | 说明 | +|----|--------|------| +| `signal_mode` | **`confirmed`** | 入场信号仅来自**上一根已收盘** K | +| `exit_signal_mode` | **`confirmed`** | 出场信号同上;与回测一致 | +| 形成中 K | **不参与下单** | 实盘可刷新最后一根 K 做展示,但不应作为默认成交依据 | + +**MAY** 在产品层提供 `aggressive` 供研究;上线组合策略 **SHOULD NOT** 默认 aggressive。 + +### 6.2 成交时刻 + +| 项 | 标准值 | +|----|--------| +| 回测 `signalTiming` / 策略快照 | **`next_bar_open`**(默认) | +| 语义 | 信号 K 收盘确认 → **下一根 K 开盘价**(±滑点)成交 | + +**SHOULD NOT** 在未改回测配置的情况下,实盘使用 `exit_trigger_mode=immediate` 做指标策略。 + +### 6.3 同 bar 多信号 + +| 规则 | 说明 | +|------|------| +| 优先级 | `close_*` > `reduce_*` > `open_*` > `add_*` | +| 反手 | **方案 R1(推荐)**:同 bar 只平不開,**下一根**再 `open_*`;**方案 R2**:同 bar 先平后开(与历史 both 回测一致,需策略与回测均启用) | +| 每 tick | 指标模式默认每 tick 最多执行 **1** 个外部信号;反手若用 R2,引擎可在单次 `open_*` 内先平对侧 | + +策略说明 **MUST** 注明采用 R1 或 R2。 + +### 6.4 平仓数量 + +| 规则 | 说明 | +|------|------| +| 数量来源 | 平仓 **MUST** 以本地持仓与交易所持仓较大者为准(sync + resolve) | +| 数量为 0 | **MUST** 再 sync 一次后重试;仍为空则拒单并记日志,**MUST NOT** 静默跳过 | + +--- + +## 7. 配置标准(`# @strategy` / `# @param`) + +### 7.1 `# @strategy`(SHOULD 显式声明) + +| Key | 类型 | 说明 | +|-----|------|------| +| `tradeDirection` | `long` \| `short` \| `both` | 与执行列一致 | +| `entryPct` | 0.01–1.0 | 开仓资金占比(`1` = 100%) | +| `stopLossPct` / `takeProfitPct` | 0–1 | **标的涨跌幅**阈值;亚 1% 写 `0.001` 等小数 | +| `trailingEnabled` | bool | 见第 5 节 | +| `trailingStopPct` / `trailingActivationPct` | 0–1 | 追踪回撤/激活阈值(同为价格涨跌幅,**不除杠杆**) | + +**MUST NOT** 在 `@strategy` 中写 `leverage`(由产品 UI / 策略配置)。 + +### 7.2 推荐策略头注释块(复制模板) + +```python +# --- QuantDinger execution contract (v1) --- +# signal_form: four_way # two_way | four_way +# exit_owner: indicator # indicator | engine +# flip_mode: R1 # R1=close bar then open next bar | R2=same bar flip +# tradeDirection: both +# @strategy entryPct 1 +# @strategy trailingEnabled false +``` + +--- + +## 8. 校验与发布清单 + +### 8.1 保存 / `verifyCode` 时(引擎 SHOULD) + +- [ ] 执行列存在且长度正确 +- [ ] 禁止不安全代码(见 `safe_exec`) +- [ ] 警告:`trailingEnabled` + 高比例 `close_*` +- [ ] 警告:同 bar `open_long` & `open_short` +- [ ] 警告:连续多根同向 `open_*` 无 edge(可选启发式) + +### 8.2 上线前人工清单 + +- [ ] 已选形态 A/B/C 并写在策略说明 +- [ ] 已声明退出负责人 +- [ ] `signal_mode` / `exit_signal_mode` = `confirmed` +- [ ] 回测成交时间为 next bar open,与预期一致 +- [ ] 对比最近 20 笔:回测信号时间 vs 实盘日志时间偏差可解释 +- [ ] 小仓位、单标的试跑 24h,无大量 `invalid amount (0.0)` + +--- + +## 9. 存量策略迁移 + +| 优先级 | 特征 | 动作 | +|--------|------|------| +| P0 | `both` + `buy` 含 tp/sl + `trailingEnabled` | 关 trailing 或改四路 `close_*` | +| P1 | 触及型逻辑 + 实盘差异大 | 改 `confirmed` + 四路 + edge | +| P2 | 纯金叉死叉两路 | 可保留形态 A,补全契约注释 | +| P3 | 已是四路 | 补 edge、退出负责人、配置对齐 | + +**MAY** 在策略市场标注「契约 v1 已认证」徽章,便于用户筛选。 + +--- + +## 10. 策略分级(运营可选) + +| 等级 | 要求 | +|------|------| +| **L1 基础** | 通过 `verifyCode` + 沙箱 | +| **L2 对齐** | 四路或声明两路 + `confirmed` + 退出负责人明确 | +| **L3 生产** | L2 + 回测/实盘时间偏差评审 + 7 日模拟盘无异常拒单 | + +--- + +## 11. 与实现的对照(便于研发) + +| 标准条 | 代码锚点 | +|--------|----------| +| 形态 B 四路 | `TradingExecutor._execute_indicator_with_prices`;`BacktestService` norm_signals | +| both 两路映射 | 仅当无四路且 `buy`/`sell` 时;`_indicator_both_mode` | +| confirmed | `signal_mode` / `exit_signal_mode` 检查集 | +| 平仓重试 | `PendingOrderWorker` + `resolve_reduce_only_quantity` | +| 静态安全 | `app/utils/safe_exec.py` | + +标准变更时 **MUST** 同步更新本文件版本号与 CHANGELOG。 + +--- + +## 12. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2026-05 | 首版:四路推荐、执行契约、退出负责人、迁移与分级 | diff --git a/STRATEGY_DEV_GUIDE.md b/STRATEGY_DEV_GUIDE.md new file mode 100644 index 0000000..c335597 --- /dev/null +++ b/STRATEGY_DEV_GUIDE.md @@ -0,0 +1,1424 @@ +# QuantDinger v3 Python Strategy Development Guide + +> **Platform contract (required reading)**: [Signal & Execution Standard v1](./SIGNAL_EXECUTION_STANDARD.md) — backtest/live alignment, two-way vs four-way signals, exit ownership, and release checklist. This guide focuses on tutorials and examples. + +This guide is written from a **developer** point of view. Its goal is not only to list the current contracts, but to answer the practical question: + +**How do I build an indicator strategy that is clear, backtestable, and ready to become a saved trading strategy?** + +QuantDinger currently supports two Python authoring models: + +- **IndicatorStrategy**: dataframe-based code for indicator research, chart rendering, and signal-style backtests. +- **ScriptStrategy**: event-driven code for runtime execution, strategy backtests, and live trading. + +If you are starting a new strategy, the default recommendation is: + +1. Start with `IndicatorStrategy`. +2. Prove the signal logic visually and in backtests. +3. Move to `ScriptStrategy` only if you need bar-by-bar state, dynamic position management, or execution control. + +--- + +## 1. Start With the Right Mental Model + +The most common source of confusion is mixing up **signal logic**, **risk defaults**, and **runtime execution**. + +### 1.1 IndicatorStrategy + +Think of `IndicatorStrategy` as: + +- compute indicator series from `df` +- generate boolean `buy` / `sell` signals +- declare default strategy settings through metadata comments +- return chart-friendly `output` + +This is the best fit for: + +- indicator research +- strategy prototyping +- parameter tuning +- signal-based backtests +- saved strategies that still follow a signal-first workflow + +### 1.2 ScriptStrategy + +Think of `ScriptStrategy` as: + +- maintain runtime logic bar by bar +- inspect current position state through `ctx.position` +- place explicit actions with `ctx.buy()`, `ctx.sell()`, and `ctx.close_position()` +- manage exits and sizing in code when needed + +This is the best fit for: + +- stateful execution logic +- dynamic stop-loss or take-profit handling +- partial exits, scale-ins, cooldowns, or other runtime rules +- strategies that behave more like trading bots than pure indicators + +### 1.3 The Most Important Separation + +For `IndicatorStrategy`, you usually have **three layers**: + +1. **Indicator layer**: moving averages, RSI, ATR, bands, filters. +2. **Signal layer**: `df['buy']` and `df['sell']`. +3. **Risk defaults layer**: `# @strategy stopLossPct ...`, `takeProfitPct`, `entryPct`, and related defaults. + +Do not mix these into one thing. + +In particular: + +- `buy` / `sell` decide **when the strategy wants to enter or exit** +- `# @strategy` decides **how the engine should size and protect positions by default** +- leverage belongs in product configuration, not in indicator code + +--- + +## 2. Which Mode Should You Use? + +| Use Case | Recommended Mode | +|----------|------------------| +| Build indicators, overlays, and signal markers | `IndicatorStrategy` | +| Research entry and exit rules on a dataframe | `IndicatorStrategy` | +| Add fixed stop-loss, take-profit, or entry sizing defaults | `IndicatorStrategy` | +| Need runtime position state and bar-by-bar control | `ScriptStrategy` | +| Need dynamic exits based on current open position | `ScriptStrategy` | +| Need partial close, scale-in/out, or bot-like logic | `ScriptStrategy` | + +Rule of thumb: + +- If your logic can be described as "when condition A happens, buy; when condition B happens, sell", start with `IndicatorStrategy`. +- If your logic sounds like "after entry, keep watching the open position and react differently depending on state", you probably need `ScriptStrategy`. + +--- + +## 3. How To Develop an IndicatorStrategy + +This is the recommended workflow for most new strategy development. + +### 3.1 Step 1: Declare metadata and defaults first + +At the top of the script, define name, description, tunable params, and strategy defaults. + +```python +my_indicator_name = "Trend Pullback Strategy" +my_indicator_description = "Buy pullbacks in an uptrend and exit on weakness." + +# signal_form: two_way +# exit_owner: engine +# flip_mode: R2 + +# @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 +``` + +Use `# @param` for values the user may tune often. + +Format: + +```python +# @param +``` + +Best practice: + +- read declared params through `params.get(...)` +- `string` is accepted as the same type family as `str` +- if you declare params but hardcode the values instead, the built-in code quality checker will flag it + +Use `# @strategy` for strategy defaults such as: + +- `stopLossPct`: stop-loss ratio, for example `0.03` = **3% adverse underlying price move** (`0.001` = 0.1%) +- `takeProfitPct`: take-profit ratio, for example `0.06` = **6% favorable price move** +- `entryPct`: fraction of capital on entry (`1` = **100%**, `0.25` = 25%) +- `trailingEnabled` +- `trailingStopPct` +- `trailingActivationPct` +- `tradeDirection`: `long`, `short`, or `both` + +Important: + +- These are **defaults consumed by the engine**. +- They are not extra dataframe columns. +- Do not put `leverage` here. +- Keep the values realistic and confirm them in backtests; the parser is intentionally more permissive than the toy examples shown here. + +### 3.2 Step 2: Copy the dataframe and compute indicators + +Indicator code runs in a sandbox. `pd`, `np`, and a `params` dictionary are already available. + +Recommended baseline: + +```python +df = df.copy() +``` + +Expected columns usually include: + +- `open` +- `high` +- `low` +- `close` +- `volume` + +A `time` column may exist, but do not rely on a fixed type. + +Avoid: + +- network access +- file I/O +- subprocesses +- unsafe metaprogramming such as `eval`, `exec`, `open`, `__import__`, `getattr`, or `setattr` +- `import operator` (and dunder-bypass patterns such as string-built `__class__` / `__globals__`) + +Allowed `import` roots (anything else is rejected by validation): + +`numpy`, `pandas`, `math`, `json`, `datetime`, `time`, `collections`, `functools`, `itertools`, `statistics`, `decimal`, `fractions`, `copy` + +`pd`, `np`, and `params` are already injected — you usually do not need `import pandas` / `import numpy`. + +### 3.3 Step 3: Turn raw conditions into clean `buy` / `sell` signals + +The backtest engine reads **boolean** columns: + +- `df['buy']` +- `df['sell']` + +They should: + +- match the dataframe length exactly +- be boolean after `fillna(False)` +- usually be edge-triggered, unless repeated signals are intentional + +Recommended pattern: + +```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) +``` + +This keeps your signals from firing on every bar of the same regime. + +#### 3.3.1 How `tradeDirection` maps `buy` / `sell` at execution time + +After an indicator is saved as a strategy, the backend normalizes `df['buy']` / `df['sell']` into execution signals. In `both` mode, **do not** treat `buy` as a standalone `close_short` column: + +| `tradeDirection` | `buy=True` | `sell=True` | +|------------------|------------|-------------| +| `long` | `open_long` | `close_long` | +| `short` | `close_short` | `open_short` | +| `both` | `open_long`; if currently short, **close short then open long** | `open_short`; if currently long, **close long then open short** | + +Notes: + +- `both` matches `BacktestService` `_both_mode`; live execution is aligned with that semantics. +- Putting short take-profit / stop-loss into `df['buy']` means **exit short and possibly flip long**, not "flat only". +- For flat-only exits, use `long`/`short`, explicit four-way columns, or `ScriptStrategy` with `ctx.close_position()`. + +Typical dual-Keltner style wiring: + +```python +df['buy'] = sig_buy_entry | sig_short_tp | sig_short_sl +df['sell'] = sig_sell_entry | sig_long_tp | sig_long_sl +``` + +If backtests show "close short then open long" on the same bar, that is usually expected under `both`, not a engine bug. + +### 3.4 Step 4: Decide who owns the exit logic + +This is where stop-loss, take-profit, and position management usually become confusing. + +There are **two valid exit styles** in `IndicatorStrategy`, and the header contract must make the choice explicit. + +#### Style A: Signal-managed exits + +Your indicator logic itself decides when to exit by setting `df['sell']`. + +Examples: + +- moving-average bearish crossover +- RSI falling below a threshold +- close dropping below an ATR-based stop line +- mean reversion target hit + +Use this style when the exit is part of the strategy idea itself. + +Declare it like this: + +```python +# exit_owner: indicator +# @strategy trailingEnabled false +``` + +The current backend interprets `exit_owner: indicator` as: server-side fixed stop-loss, fixed take-profit, and trailing stop do not close positions. Exits come from indicator signals. `entryPct` / `tradeDirection` can still be used as defaults. + +#### Style B: Engine-managed exits + +You let the strategy engine apply fixed defaults declared with `# @strategy`, such as: + +- `stopLossPct` +- `takeProfitPct` +- `entryPct` +- trailing settings + +Use this style when the signal logic should stay simple, and you want the engine to handle fixed protective rules. + +Declare it like this: + +```python +# exit_owner: engine +``` + +`exit_owner: engine` keeps server-side price risk active. You may still keep structural reverse `close_*` signals, but do not also encode tight in-indicator TP/SL touch exits. + +#### Best practice + +Pick one primary owner for exits whenever possible. + +For example: + +- if your edge is "enter on crossover, exit on reverse crossover" with no extra fixed price-risk exits, make indicator signals the owner and write `# exit_owner: indicator` +- if your edge is "enter on signal and let a fixed 3% stop + 6% target manage the trade", or if `close_*` only represents structural trend reversal, make the engine the price-exit owner and write `# exit_owner: engine` + +Do not write `exit_owner: layered`. The current platform does not implement a third owner. If you truly need a mixed design, write it as `engine` and document which `close_*` signals are structural reversals rather than tight TP/SL. + +### 3.5 Step 5: Build the `output` object + +Your script must assign a final `output` dictionary: + +```python +output = { + "name": "My Strategy", + "plots": [], + "signals": [] +} +``` + +Supported keys: + +- `name` +- `plots` +- `signals` +- `calculatedVars` as optional metadata + +Each plot item should contain: + +- `name` +- `data` with length exactly `len(df)` +- `color` +- `overlay` +- optional `type` + +Each signal item should contain: + +- `type`: `buy` or `sell` +- `text` +- `color` +- `data`: list with `None` on bars without a marker + +### 3.6 Step 6: Validate backtest semantics + +Indicator backtests are signal-driven: + +- the engine reads `df['buy']` and `df['sell']` +- signals are treated as bar-close confirmation +- fills are typically on the **next bar open** + +This matters because: + +- an intrabar-looking stop drawn on the current candle is not the same as a next-bar-open fill +- using `shift(-1)` in signal logic introduces look-ahead bias + +Practical nuance: + +- the normalized strategy snapshot can execute with either `next_bar_open` or `same_bar_close` +- most indicator workflows should still be designed with a "confirm on close, usually fill on next open" mental model +- if product settings change the execution timing, rerun the backtest and review fills instead of assuming the semantics stayed the same + +#### 3.6.1 Why live entry/exit times can diverge from backtests + +| Topic | Indicator backtest | Indicator live (default) | +|-------|-------------------|-------------------------| +| Bars | Historical **closed** OHLC | Last bar may be updated with the **latest tick** (`high` / `low` / `close`) | +| Touch conditions | Known only after the bar closes | May fire earlier on the **forming** bar | +| Signal evaluation | Bar-by-bar on the backtest timeline | Indicator may be recomputed every tick; `exit_signal_mode=immediate` fires exits right away | +| Orders per tick | Queued in backtest order | Indicator mode usually **one signal per tick** (closes first) | + +If your logic uses `high >= line` / `low <= line`, backtests are often **later** than live; realtime bar updates can trigger **earlier** exits. + +Alignment tips: + +- Set `signal_mode` and `exit_signal_mode` to **`confirmed`** when you want live to track closed-bar backtests. +- If in-indicator `sig_*_tp` / `sig_*_sl` exits exist, declare `# exit_owner: indicator` and keep `# @strategy trailingEnabled false`; otherwise indicator exits plus server trailing can duplicate closes and amplify timing drift. +- Before going live, compare backtest fills with logs for `Signal submitted` and `server_trailing_stop`. + +If a live close briefly shows amount `0`, the worker **re-syncs exchange positions and resolves size again**; rejection happens only when the exchange is already flat. + +--- + +## 4. How To Write Stop-Loss, Take-Profit, and Position Sizing + +This section is the practical answer to the most common implementation question. + +### 4.1 Fixed stop-loss, take-profit, and entry sizing in IndicatorStrategy + +If you want fixed risk defaults, write them as `# @strategy` lines: + +```python +# @strategy stopLossPct 0.03 +# @strategy takeProfitPct 0.06 +# @strategy entryPct 0.25 +# @strategy tradeDirection long +``` + +Meaning: + +- `stopLossPct 0.03`: use a 3% stop-loss default +- `takeProfitPct 0.06`: use a 6% take-profit default +- `entryPct 0.25`: allocate 25% of capital on entry +- `tradeDirection long`: long-only by default + +This is the correct choice when you want: + +- simple signal code +- consistent defaults in backtests +- strategy settings that the UI and engine can understand directly + +### 4.2 Indicator-driven exits in IndicatorStrategy + +If your "stop-loss" is actually part of the indicator model, write it as a `sell` signal. + +Example: exit a long when close falls below an ATR-style stop line. + +```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) +``` + +In this style: + +- the exit belongs to your indicator logic +- the engine is not inventing the stop for you +- you should explain this in the strategy description or comments + +### 4.3 How position management works in IndicatorStrategy + +For indicator strategies, position management is intentionally simple: + +- use `entryPct` for default entry sizing +- use `tradeDirection` to limit long, short, or both +- use engine-managed stop, take-profit, or trailing defaults if they are fixed + +If you need: + +- scale-in / scale-out +- partial exits +- different logic before and after entry +- stop movement that depends on the current live position state +- cooldowns after a stop + +then the strategy has outgrown `IndicatorStrategy` and should move to `ScriptStrategy`. + +--- + +## 5. Full IndicatorStrategy Example + +This example shows a complete developer-oriented pattern: metadata, defaults, indicator calculation, signal generation, and chart output. + +```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" + } + ] +} +``` + +What this example teaches: + +- indicators are computed first +- entries and exits are expressed as boolean signals +- fixed risk defaults are declared separately through `# @strategy` +- chart output is treated as a final rendering step, not mixed into signal logic + +### 5.1 A platform-UI-aligned example + +This version is closer to how developers actually use QuantDinger today: + +- tune common values through `# @param` +- expose default stop / take-profit / entry sizing through `# @strategy` +- set `tradeDirection` explicitly so the saved strategy and backtest panel stay aligned +- keep leverage outside the code and let the product UI own it + +```python +my_indicator_name = "Breakout Retest With Direction Control" +my_indicator_description = "Breakout-and-retest logic with platform-friendly params and default risk settings." + +# signal_form: two_way +# exit_owner: engine +# flip_mode: R2 + +# @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" + } + ] +} +``` + +Why this example maps cleanly to the UI: + +- `# @param` values can be tuned by AI or by manual parameter editing workflows +- `# @strategy` defaults line up with saved strategy defaults and backtest-side risk settings +- `tradeDirection both` makes it obvious that the code is designed for long and short signals +- leverage is still controlled in the product panel instead of being hidden inside source code + +--- + +## 6. When You Should Switch to ScriptStrategy + +Move to `ScriptStrategy` when the strategy needs runtime state rather than pure dataframe signals. + +Typical triggers: + +- the stop-loss depends on the current open position rather than only on historical series +- you want to adjust stops after entry +- you need partial close or pyramiding +- you want different logic for first entry versus re-entry +- you need cooldown logic, execution throttling, or bot-style workflows + +### 6.1 Required functions + +The safest product-facing contract is: + +- `def on_init(ctx): ...` +- `def on_bar(ctx, bar): ...` + +Why this matters: + +- the runtime compiler strictly requires `on_bar` +- some product-side validation paths still expect both `on_init` and `on_bar` +- to avoid validator/runtime mismatches, define both functions even if `on_init` only initializes state or writes a log line + +### 6.2 Available objects + +`bar` typically exposes: + +- `bar.open` +- `bar.high` +- `bar.low` +- `bar.close` +- `bar.volume` +- `bar.timestamp` + +`ctx` currently exposes: + +- `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()` + +Notes: + +- `ctx` does not expose the full trading config object directly +- keep leverage, symbol, venue, and credentials in product configuration rather than hardcoding them into the script +- use `ctx.param(...)` for script-level defaults that belong in source code + +`ctx.position` supports both numeric checks and field access patterns such as: + +```python +if not ctx.position: + ... + +if ctx.position > 0: + ... + +if ctx.position["side"] == "long": + ... +``` + +### 6.3 Script example with runtime exits + +```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() +``` + +Use this style when stop-loss and take-profit truly belong to runtime position management instead of pure indicator output. + +Important sizing note: + +- in the current system, saved-strategy backtests still derive position sizing primarily from normalized trading config such as `entryPct` +- treat `amount` in `ctx.buy()` / `ctx.sell()` as runtime order intent, not as the only source of truth for backtest sizing +- always verify actual exposure with a saved-strategy backtest before promoting the script to paper or live trading + +### 6.4 Normal script mode vs bot mode + +Most `ScriptStrategy` workflows run on **closed bars**: + +- the engine evaluates `on_bar(ctx, bar)` after a bar is confirmed +- this is the best mental model for standard strategy backtests and bar-by-bar live execution + +There is also a bot-style runtime mode in the current system: + +- bot mode may feed `on_bar` with synthetic tick-like bars built from the latest price +- this is more suitable for grid, DCA, or other bot-style execution patterns +- if you write code intended for bot mode, test it separately from standard bar-close strategy behavior + +### 6.5 A platform-live-oriented ScriptStrategy example + +This example is closer to how a platform-facing live strategy is usually written: + +- use `ctx.param(...)` for script defaults +- inspect `ctx.position` before deciding whether to open, reverse, reduce, or fully close +- use `ctx.buy()` / `ctx.sell()` for directional intent +- use `ctx.close_position()` when you want an explicit full exit + +```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 +``` + +What this example demonstrates: + +- `ctx.param(...)` keeps script defaults visible and editable in source +- `ctx.position` is the switch that separates flat / long / short behavior +- `ctx.buy()` and `ctx.sell()` express directional intent, not just "open long" or "open short" in isolation +- `ctx.close_position()` is the clearest choice when your rule means "exit everything now" + +Backtest vs live differences you should remember: + +- standard script backtests and normal live mode both think in confirmed-bar logic, but bot mode may call the script with synthetic tick-like bars +- `amount` is best treated as runtime order intent; saved-strategy backtests still size mainly from normalized trading config such as `entryPct` +- `ctx.sell()` while long, or `ctx.buy()` while short, may behave like a close-plus-reverse style intent depending on runtime state and product configuration +- if you want a guaranteed full flatten action, prefer `ctx.close_position()` over relying on implicit interpretation + +--- + +## 7. Backtesting, Persistence, and Current Limits + +Saved strategies are resolved by the backend into a normalized snapshot for backtesting and execution. Common fields include: + +- `strategy_type` +- `strategy_mode` +- `strategy_code` +- `indicator_config` +- `trading_config` + +Current run types include: + +- `indicator` +- `strategy_indicator` +- `strategy_script` + +Current limitations: + +- cross-sectional strategies are not supported in the current strategy snapshot flow +- `ScriptStrategy` does not support `cross_sectional` live execution mode +- script-strategy backtests do not use the indicator MTF execution path +- strategy backtests expect a valid symbol and non-empty code + +### 7.1 Script backtest fill assumptions (not indicator strict mode) + +Script backtests have **no** indicator IDE strict/non-strict toggle. The UI label should read: + +**Script standard · bar-by-bar · next-bar open fill** + +1. Candles are replayed at the strategy timeframe; each closed bar calls `on_bar(ctx, bar)`. +2. Orders come from `ctx.buy()` / `ctx.sell()` / `ctx.close_position()` inside the script. +3. The simulator fills at the **next bar open** by default (plus slippage/fees), aligned with `execution.signalTiming = next_bar_open`. +4. Position size still mainly follows `entryPct` and related trading config, not `amount` alone. + +### 7.2 Trading Bot vs ScriptStrategy vs IndicatorStrategy + +| Type | Code storage | Entry | Notes | +|------|--------------|-------|-------| +| IndicatorStrategy | `qd_indicator_codes.code` | Indicator IDE | `df` + boolean signals | +| ScriptStrategy | `qd_strategies_trading.strategy_code` | Strategy Studio | `on_init` + `on_bar` | +| Trading Bot | same `strategy_code`, `strategy_mode=bot` | Bot wizard | Grid live logic is engine-side | + +**Clone as Script** (bot detail) copies `strategy_code` into a new ScriptStrategy on **Strategy Studio**, not into Indicator IDE. + +- **Grid bots** ship a placeholder script (`on_bar: pass`); the editor may look nearly empty by design. +- **Martingale / trend / DCA** bots generate full Python templates. +- If code is missing after clone, refresh and edit again — the app fetches `/api/strategies/detail` for the full `strategy_code`. + +--- + +## 8. Best Practices + +### 8.1 Avoid look-ahead bias + +- use completed-bar information only +- prefer `shift(1)` for confirmation +- do not use `shift(-1)` in signal logic + +### 8.2 Handle NaNs explicitly + +Rolling and EWM calculations create leading NaNs. Clean them before signal generation. + +### 8.3 Keep all series aligned + +Every `plot['data']` and `signal['data']` list must match `len(df)` exactly. + +### 8.4 Prefer vectorized indicator logic + +For `IndicatorStrategy`, core calculations should be pandas-native whenever possible. + +### 8.5 Keep runtime scripts deterministic + +For `ScriptStrategy`, avoid hidden state outside `ctx`, avoid randomness, and make order intent explicit. + +### 8.6 Put configuration in the right layer + +- use `# @param` and `# @strategy` for indicator defaults +- use `ctx.param()` for script defaults +- keep leverage, signal timing, venue configuration, and credentials outside the strategy code + +--- + +## 9. Troubleshooting + +### 9.1 `column "strategy_mode" does not exist` + +Your database schema is older than the running code. Apply the required migration on `qd_strategies_trading`. + +### 9.2 `Strategy script must define on_bar(ctx, bar)` + +Your `ScriptStrategy` code is missing the required handler. + +### 9.3 `Missing required functions: on_init, on_bar` + +The current UI verifier expects both functions to exist in the source text. + +### 9.4 `Strategy code is empty and cannot be backtested` + +The saved strategy does not contain valid code for the selected mode. + +### 9.5 Marker or plot length mismatch + +All chart output arrays must align exactly with the dataframe length. + +### 9.6 Strategy behaves strangely in backtest + +Check these first: + +- did you accidentally use future data? +- are your `buy` / `sell` signals edge-triggered? +- are you mixing signal-driven exits with engine-driven exits without documenting it? +- are your `# @strategy` defaults aligned with the strategy idea? + +### 9.7 Backend logs + +If strategy creation, verification, backtest, or execution fails, check backend logs first. Common issue classes: + +- schema mismatch +- invalid JSON or config payloads +- code verification failure +- market or symbol mismatch +- credential or exchange configuration issues + +--- + +## 10. Full Workflow: Indicator IDE -> Saved Strategy -> Live Trading + +This is the most practical product workflow for most teams. + +### 10.1 Prototype in Indicator IDE + +Start in the Indicator IDE when you are still shaping the idea: + +1. Write indicator logic on `df`. +2. Declare tunable inputs with `# @param`. +3. Declare default risk settings with `# @strategy`. +4. Add chart-friendly `plots` and `signals`. +5. Run indicator-side backtests until the signal density and fills make sense. + +At this stage, the goal is not to perfect live execution. The goal is to make the logic visible, testable, and easy to iterate. + +### 10.2 Tune and validate in the product + +Once the indicator behaves correctly: + +1. Use the code quality checker to catch missing metadata or suspicious patterns. +2. Run backtests with realistic symbol, timeframe, commission, slippage, and leverage settings. +3. If needed, use AI tuning or structured tuning to compare parameter combinations. +4. Apply tuned values back into the code so the source remains the single visible truth. + +Recommended mindset: + +- let the code describe the signal logic +- let `# @param` and `# @strategy` describe tunable defaults +- let the panel own market, leverage, date range, and execution environment + +### 10.3 Save the indicator as a strategy + +Once the signal model is stable: + +1. Save the current indicator code. +2. Create or save a strategy record from the product flow. +3. Confirm that the saved strategy snapshot has the expected mode, defaults, and trading config. +4. Run strategy backtests from the persisted record, not only from the raw editor state. + +Why this matters: + +- saved-strategy backtests are closer to the real execution path +- normalized snapshots may apply execution timing and trading-config defaults differently from the raw editor view +- this is the right place to catch symbol, mode, config, and persistence mismatches + +### 10.4 Decide whether IndicatorStrategy is enough + +Stay with `IndicatorStrategy` if: + +- entries and exits are mainly signal-based +- fixed stop-loss / take-profit / trailing defaults are enough +- you do not need position-state-dependent runtime logic + +Promote to `ScriptStrategy` if: + +- the open position must be monitored bar by bar +- exits depend on current position state rather than only on historical series +- you need cooldowns, partial exits, scale-ins, or bot-like runtime behavior + +### 10.5 Move to paper or live trading carefully + +Before enabling live trading: + +1. Verify exchange, broker, symbol, and credential configuration. +2. Recheck execution timing assumptions (`signal_mode` / `exit_signal_mode` vs backtest — §3.6.1). +3. Under `tradeDirection both`, confirm `buy` / `sell` flip semantics (§3.3.1) and avoid duplicate indicator + `trailingEnabled` exits (§11.7). +4. Confirm that leverage, direction, and sizing live in the right product configuration layer. +5. Start with conservative sizing and narrow symbol scope. +6. Review runtime logs and actual order behavior (including `server_trailing_stop` and close rejections) before scaling up. + +Live trading should be treated as a separate validation stage, not as a continuation of editor-only experimentation. + +--- + +## 11. Common Mistakes vs Correct Patterns + +This section highlights the mistakes that most often cause misleading backtests, confusing strategy behavior, or product/runtime mismatches. + +### 11.1 Declaring `# @param` but never reading it + +Wrong: + +```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() +``` + +Correct: + +```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() +``` + +Why: + +- declaring a param tells the product and AI tuning flow that the value is intended to be adjustable +- if the code never reads `params.get(...)`, the declaration becomes cosmetic and the quality checker may warn + +### 11.2 Making `buy` / `sell` fire on every bar + +Wrong: + +```python +df['buy'] = df['close'] > ema_fast +df['sell'] = df['close'] < ema_fast +``` + +Correct: + +```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) +``` + +Why: + +- repeated signals on every bar can distort entries, exits, and chart markers +- edge-triggered signals are usually closer to the intended strategy semantics + +### 11.3 Writing leverage into the strategy source + +Wrong: + +```python +# @strategy leverage 10 +``` + +Correct: + +```python +# @strategy entryPct 0.2 +# @strategy stopLossPct 0.02 +# @strategy takeProfitPct 0.05 +``` + +Then set leverage in the product panel or saved-strategy trading configuration. + +Why: + +- leverage belongs to execution configuration, not indicator metadata +- hiding leverage in code makes backtests harder to read and easier to misconfigure + +### 11.4 Using `shift(-1)` and accidentally introducing look-ahead bias + +Wrong: + +```python +df['buy'] = (df['close'].shift(-1) > ema_fast).fillna(False) +``` + +Correct: + +```python +raw_buy = df['close'] > ema_fast +df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool) +``` + +Why: + +- `shift(-1)` reaches into future data +- strategies that look amazing with future leakage usually collapse in real execution + +### 11.5 Treating `ctx.buy(..., amount=...)` as absolute backtest size + +Wrong mental model: + +```python +ctx.buy(price=bar.close, amount=1.0) +``` + +"This guarantees the backtest always uses exactly 100% of capital." + +Correct mental model: + +```python +position_pct = float(ctx.param("risk_pct", 0.25)) +ctx.buy(price=bar.close, amount=position_pct) +``` + +And then verify the saved-strategy backtest using the normalized trading config. + +Why: + +- in the current system, saved-strategy backtests still derive sizing mainly from normalized config such as `entryPct` +- `amount` is best treated as runtime order intent, not as the only source of truth for historical sizing + +### 11.6 Using `ctx.sell()` or `ctx.buy()` when you really mean "fully flatten now" + +Risky: + +```python +if stop_hit: + ctx.sell(price=bar.close, amount=0.25) +``` + +Clearer: + +```python +if stop_hit: + ctx.close_position() +``` + +Why: + +- `ctx.buy()` / `ctx.sell()` express directional intent and may be interpreted through current position state +- if your rule means "exit everything now", `ctx.close_position()` is the least ambiguous choice + +### 11.7 Mixing signal exits and engine exits without documenting it + +Risky: + +```python +# @strategy stopLossPct 0.02 +# @strategy takeProfitPct 0.05 +# @strategy trailingEnabled true + +df['sell'] = some_other_exit_condition +# plus tp/sl flags merged into df['buy'] / df['sell'] +``` + +Better (pick one primary exit path and comment it): + +```python +# Option A: exits only from indicator signals (touch-based strategies) +# exit_owner: indicator +# @strategy trailingEnabled false +# Primary exit: close_* or tp/sl conditions inside df['buy'] / df['sell'] +# stopLossPct / takeProfitPct / trailing* are not server-side exits + +# Option B: exits from engine risk (fixed SL/TP/trailing) +# exit_owner: engine +# @strategy trailingEnabled true +# @strategy trailingStopPct 0.0025 +# @strategy trailingActivationPct 0.0037 +# df['buy'] / df['sell'] or open_* should focus on entries; +# structural reverse close_* is OK, tight in-indicator tp/sl is not +``` + +Why: + +- duplicate exits produce `server_trailing_stop` next to indicator closes, timing skew, and sometimes `invalid amount (0.0)` when the position is already flat. +- `exit_owner: indicator` disables server-side fixed stop-loss, fixed take-profit, and trailing stop in backtest/live; those `# @strategy` exit fields no longer close the position. +- `exit_owner: engine` keeps server-side price risk active; indicator `close_*` signals should express structural reversals, not a second tight TP/SL system. +- the real problem is when nobody knows which exit path is supposed to dominate; `exit_owner` is the boundary. + +### 11.8 Putting all tp/sl flags into `buy` / `sell` under `both` + +```python +df['buy'] = entry_long | short_tp | short_sl +df['sell'] = entry_short | long_tp | long_sl +# @strategy tradeDirection both +``` + +Under `both`, `short_tp` inside `buy` is a **flip-long** intent, not flat-only. For flat-only exits, change `tradeDirection`, use four-way columns, or move to `ScriptStrategy`. Use `confirmed` signal modes if live fires earlier than backtests (§3.6.1). + +### 11.9 Live log: `invalid amount (0.0) for close_*` + +Common causes: + +1. Server stop-loss / take-profit / trailing already closed the leg; an indicator `close_*` was still submitted. +2. Local DB lag vs the exchange — the worker retries **sync + exchange size** before rejecting. + +Mitigation: §11.7 (one exit owner), §3.3.1 (`both` semantics). If the indicator owns TP/SL, use `# exit_owner: indicator`; if the engine owns price exits, do not also write tight in-indicator TP/SL booleans. + +--- + +## 12. Platform Reference Sheet + +Use this section as a fast "what is supported right now?" reference when writing strategy code. + +### 12.1 `# @strategy` supported keys + +| Key | Meaning | Typical Example | Notes | +|-----|---------|-----------------|-------| +| `stopLossPct` | Default stop-loss ratio | `# @strategy stopLossPct 0.02` | Engine-managed default risk setting | +| `takeProfitPct` | Default take-profit ratio | `# @strategy takeProfitPct 0.05` | Engine parser is more permissive than the toy examples | +| `entryPct` | Default capital allocation ratio | `# @strategy entryPct 0.25` | Common source of backtest sizing | +| `trailingEnabled` | Enable trailing stop logic | `# @strategy trailingEnabled true` | Boolean | +| `trailingStopPct` | Trailing stop ratio | `# @strategy trailingStopPct 0.015` | Used with trailing enabled | +| `trailingActivationPct` | Profit threshold before trailing activates | `# @strategy trailingActivationPct 0.03` | Used with trailing enabled | +| `tradeDirection` | Direction filter | `# @strategy tradeDirection both` | `long`, `short`, or `both` | + +Important: + +- these keys are for indicator-side strategy defaults +- **all values use 0–1 decimal ratios** (same as `StrategyConfigParser`, backtest, and live) +- **stop/take-profit/trailing thresholds are underlying price moves, not divided by leverage** +- **`entryPct 1` means 100% of available capital**, not 1% +- with `# exit_owner: indicator`, `stopLossPct` / `takeProfitPct` / `trailing*` do not trigger server-side closes in backtest or live; exits come from indicator signals +- with `# exit_owner: engine`, those server-side price-risk fields are active +- do not put `leverage` in `# @strategy` +- keep exchange, symbol, credentials, and leverage in product configuration + +### 12.2 Contract header comments + +| Key | Values | Meaning | +|-----|--------|---------| +| `signal_form` | `two_way` / `four_way` | Execution signal form | +| `exit_owner` | `indicator` / `engine` | Price-exit owner; `layered` is not supported | +| `flip_mode` | `R1` / `R2` | Flip timing: R1 opens on the next bar; R2 closes then opens on the same bar | + +Recommended top-of-file block: + +```python +# signal_form: four_way +# exit_owner: indicator +# flip_mode: R1 +``` + +### 12.3 `# @param` quick format + +| Part | Example | Meaning | +|------|---------|---------| +| Name | `fast_len` | Parameter key | +| Type | `int` / `float` / `bool` / `str` / `string` | Supported types | +| Default | `20` | Default value shown to the system | +| Description | `Fast EMA length` | Human-readable hint | + +Example: + +```python +# @param fast_len int 20 Fast EMA length +# @param allow_short bool true Allow short entries +``` + +And then read them with: + +```python +fast_len = int(params.get('fast_len', 20)) +allow_short = bool(params.get('allow_short', True)) +``` + +### 12.3 `ctx` methods and fields for `ScriptStrategy` + +| Item | Type | Meaning | +|------|------|---------| +| `ctx.param(name, default)` | method | Read or initialize script-level defaults | +| `ctx.bars(n=1)` | method | Get recent bars up to the current runtime index | +| `ctx.log(message)` | method | Write strategy log messages | +| `ctx.buy(price=None, amount=None)` | method | Express buy / long-side intent | +| `ctx.sell(price=None, amount=None)` | method | Express sell / short-side intent | +| `ctx.close_position()` | method | Explicitly flatten current position | +| `ctx.position` | field | Current position object | +| `ctx.balance` | field | Runtime balance snapshot | +| `ctx.equity` | field | Runtime equity snapshot | + +`ctx.position` common fields: + +| Field | Meaning | +|-------|---------| +| `side` | `long`, `short`, or empty when flat | +| `size` | Current position size | +| `entry_price` | Average entry price | +| `direction` | `1`, `-1`, or `0` | +| `amount` | Runtime amount mirror | + +### 12.4 `bar` fields for `ScriptStrategy` + +| Field | Meaning | +|-------|---------| +| `bar.open` | Open price | +| `bar.high` | High price | +| `bar.low` | Low price | +| `bar.close` | Close price | +| `bar.volume` | Volume | +| `bar.timestamp` | Time value from runtime feed | + +### 12.5 `output` structure for `IndicatorStrategy` + +Top-level structure: + +```python +output = { + "name": my_indicator_name, + "plots": [], + "signals": [], + "calculatedVars": {} +} +``` + +Supported top-level keys: + +| Key | Required | Meaning | +|-----|----------|---------| +| `name` | recommended | Display name | +| `plots` | recommended | Chart series output | +| `signals` | recommended | Buy/sell marker output | +| `calculatedVars` | optional | Extra metadata or computed values | + +Each `plot` item commonly contains: + +| Key | Meaning | +|-----|---------| +| `name` | Plot label | +| `data` | List aligned to `len(df)` | +| `color` | Display color | +| `overlay` | Whether to draw on price chart | +| `type` | Optional rendering hint | + +Each `signal` item commonly contains: + +| Key | Meaning | +|-----|---------| +| `type` | `buy` or `sell` | +| `text` | Marker label | +| `color` | Marker color | +| `data` | List aligned to `len(df)`, using `None` where no marker exists | + +### 12.6 Fast reminders + +- `df['buy']` and `df['sell']` should be boolean and length-aligned +- prefer edge-triggered signals +- avoid `shift(-1)` in signal logic +- prefer `ctx.close_position()` when the rule clearly means "exit everything now" +- treat `amount` as runtime order intent, then verify sizing with saved-strategy backtests + +--- + +## 13. Recommended Development Workflow + +1. Prototype the idea as an `IndicatorStrategy`. +2. Validate plots, signal density, and next-bar-open backtest behavior. +3. Add clear `# @param` and `# @strategy` metadata. +4. Decide explicitly whether exits are signal-managed or engine-managed. +5. Save the strategy and run strategy backtests from the persisted record. +6. Promote to `ScriptStrategy` only when you truly need runtime position logic. +7. Move to paper or live trading only after configuration, credentials, and market semantics are verified. + diff --git a/STRATEGY_DEV_GUIDE_CN.md b/STRATEGY_DEV_GUIDE_CN.md new file mode 100644 index 0000000..3682c46 --- /dev/null +++ b/STRATEGY_DEV_GUIDE_CN.md @@ -0,0 +1,1436 @@ +# 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." + +# signal_form: two_way +# exit_owner: engine +# flip_mode: R2 + +# @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 <描述> +``` + +最佳实践: + +- 声明后的参数,应该通过 `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 止损线 +- 均值回归到目标位后离场 + +如果退出本身就是策略思想的一部分,用这种写法最自然。 + +这种写法请声明: + +```python +# exit_owner: indicator +# @strategy trailingEnabled false +``` + +当前后端会把 `exit_owner: indicator` 解释为:服务端固定止损、固定止盈、追踪止损都不主动平仓;退出以指标信号为准。`entryPct` / `tradeDirection` 仍然可以继续作为默认配置使用。 + +#### 写法 B:引擎负责固定止盈止损 + +也就是你只定义默认配置,由引擎按固定规则处理: + +- `stopLossPct` +- `takeProfitPct` +- `entryPct` +- trailing 系列参数 + +如果你的信号逻辑想保持简洁,而保护性规则是固定的,就用这种写法。 + +这种写法请声明: + +```python +# exit_owner: engine +``` + +`exit_owner: engine` 表示服务端价格风控有效。代码里可以保留“趋势反转平仓”这类结构性 `close_*`,但不要再把窄止盈/窄止损触及条件也写进指标信号里。 + +#### 最佳实践 + +尽量明确一个“主退出来源”。 + +例如: + +- 如果你的核心逻辑是“金叉进,死叉出”,且没有额外固定价格风控,那退出就主要由指标信号负责,写 `# exit_owner: indicator` +- 如果你的逻辑是“信号进场,固定 3% 止损 + 6% 止盈管理交易”,或只把 `close_*` 当作趋势反转时的结构性平仓,那价格退出主要由引擎负责,写 `# exit_owner: engine` + +不要写 `exit_owner: layered`。当前平台未实现第三种退出负责人;如果你确实要混合方案,先按 `engine` 写并在评审里说明哪些 `close_*` 只是趋势反转,不是窄 tp/sl。 + +### 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` 表达退出时,声明 `# exit_owner: indicator` 并保持 `# @strategy trailingEnabled false`,否则会出现「指标退出 + 服务端追踪止损」**双重平仓**,并放大时间差。 +- 上线前对比:回测成交时间、实盘日志里的 `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." + +# signal_form: two_way +# exit_owner: engine +# flip_mode: R2 + +# @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 合法且代码非空 + +### 7.1 脚本策略回测的成交假设(与指标「严格模式」不同) + +脚本策略回测**没有**指标 IDE 里的「严格 / 非严格」开关。UI 上应显示为: + +**脚本标准回测 · 逐 bar · 下一根开盘成交** + +含义: + +1. 按策略周期(如 5m)逐根拉 K 线,每根已收盘 bar 调用一次 `on_bar(ctx, bar)`。 +2. 脚本在当根 bar 内通过 `ctx.buy()` / `ctx.sell()` / `ctx.close_position()` 表达下单意图。 +3. 撮合层默认把成交推到**下一根 K 线开盘价**(并计滑点/手续费),与 `trading_config.execution.signalTiming = next_bar_open` 一致。 +4. 仓位大小仍主要受 `entryPct` 等交易配置约束,而不是脚本里 `amount` 的唯一来源。 + +这与 **IndicatorStrategy** 的严格模式(整表布尔信号 + 可选 MTF 子周期)是两条不同路径,不要混为一谈。 + +### 7.2 交易机器人 vs 脚本策略 vs 指标策略 + +| 类型 | 代码存放 | 典型入口 | 说明 | +|------|----------|----------|------| +| **IndicatorStrategy** | `qd_indicator_codes.code` | 指标 IDE | `df` + `buy/sell` 信号,适合研究与信号型回测 | +| **ScriptStrategy** | `qd_strategies_trading.strategy_code` | 策略工作室 `/strategy-script` | `on_init` + `on_bar`,适合有状态逻辑 | +| **Trading Bot** | 同上 `strategy_code`,`strategy_mode=bot` | 交易机器人向导 | 参数化模板;Live 网格等由专用引擎执行 | + +**「克隆为脚本」**(机器人详情页)会把 `strategy_code` 复制为新的 **ScriptStrategy**,跳转到 **策略工作室** 编辑,**不会**写入指标 IDE 的 `indicator_code`。 + +- **网格机器人** 的 `strategy_code` 只是占位脚本(`on_bar: pass`),真实逻辑在 Live 挂单引擎;克隆后编辑器里看起来「几乎为空」是预期行为,不是 bug。 +- **马丁 / 趋势 / DCA** 等机器人会生成完整 Python 模板,克隆后应能看到可编辑代码。 +- 若克隆后在策略工作室仍看不到代码,请刷新页面;新版本会在编辑时调用 `/api/strategies/detail` 拉取完整 `strategy_code`。 + +--- + +## 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:退出全部由指标信号负责(推荐用于中轨/轨道触及类策略) +# exit_owner: indicator +# @strategy trailingEnabled false +# Primary exit: close_* 或 df['buy'] / df['sell'] 内的 tp/sl 条件 +# stopLossPct / takeProfitPct / trailing* 不作为服务端退出 + +# 方案 B:退出由引擎风控负责(固定止损/止盈/移动止损) +# exit_owner: engine +# @strategy trailingEnabled true +# @strategy trailingStopPct 0.0025 +# @strategy trailingActivationPct 0.0037 +# df['buy'] / df['sell'] 或 open_* 只负责入场;结构性反转 close_* 可以保留,窄 tp/sl 不要再写进指标 +``` + +为什么: + +- 两种退出方式并存时,回测与实盘都会执行**先到为准**的那条路径,日志里可能出现 `server_trailing_stop` 与指标 `close_*` 紧挨着,甚至「平仓数量为 0」的拒单(仓位已被移动止损平掉)。 +- `exit_owner: indicator` 时,后端会关闭服务端固定止损、固定止盈和追踪止损;`# @strategy` 里的这些退出参数不再生效。 +- `exit_owner: engine` 时,服务端价格风控生效;指标里的 `close_*` 应只表达趋势反转等结构性退出,不要再塞一套窄 tp/sl。 +- 真正的问题是没人说得清「主退出」是指标触及,还是 `# @strategy` 服务端风控;`exit_owner` 就是为了解决这个边界。 + +### 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 避免双重退出;检查是否应改成 `# exit_owner: indicator`,或是否同时启用了 `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% +- 若声明 `# exit_owner: indicator`,`stopLossPct` / `takeProfitPct` / `trailing*` 在回测和实盘中都不会触发服务端平仓;退出以指标信号为准 +- 若声明 `# exit_owner: engine`,这些服务端价格风控参数才会参与平仓 +- 不要把 `leverage` 写进 `# @strategy` +- 交易所、标的、凭证、杠杆都应该放在产品配置层 + +### 12.2 契约头注释 + +| Key | 支持值 | 含义 | +|-----|--------|------| +| `signal_form` | `two_way` / `four_way` | 执行信号形态 | +| `exit_owner` | `indicator` / `engine` | 价格退出负责人;当前不支持 `layered` | +| `flip_mode` | `R1` / `R2` | 反手时序:R1 下一根再开;R2 同 bar 先平后开 | + +推荐写在文件顶部: + +```python +# signal_form: four_way +# exit_owner: indicator +# flip_mode: R1 +``` + +### 12.3 `# @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. 确认配置、凭证和市场语义都正确后,再进入模拟盘或实盘。 + diff --git a/STRATEGY_RUNTIME_ALGO_TRADING_IMPLEMENTATION_PLAN_CN.md b/STRATEGY_RUNTIME_ALGO_TRADING_IMPLEMENTATION_PLAN_CN.md new file mode 100644 index 0000000..e90f473 --- /dev/null +++ b/STRATEGY_RUNTIME_ALGO_TRADING_IMPLEMENTATION_PLAN_CN.md @@ -0,0 +1,696 @@ +# ScriptStrategy 复杂策略运行时改造实施方案 + +## 1. 目标 + +让脚本策略从“按 K 线生成买卖信号”升级为“可承载复杂状态机和算法执行”的运行时,优先支持以下用户场景: + +- 多层分仓,例如 5 个大分仓,每层 3 个子单。 +- 层内/层间价格间距可调。 +- 每个子单按马丁倍数或自定义序列放大。 +- 按实际成交均价止盈,而不是按脚本计划价止盈。 +- 只做多、只做空、多空双向 basket 独立运行。 +- 合约场景支持按杠杆后的名义价值换算下单量。 +- 后端重启、网络失败、部分成交、拒单后不重复补仓。 + +核心判断:现在的脚本 API 已经有基础,真正要补的是可信状态源、订单生命周期、恢复和风控。先把运行时地基打牢,再开放更复杂模板。 + +## 2. 当前代码基础 + +现有链路已经可复用: + +- `app/services/strategy_script_runtime.py` + - `StrategyScriptContext` 已支持 `open_long/add_long/open_short/add_short/close_long/close_short`。 + - `ScriptPosition` 已有 long/short 独立腿,并保留旧版 net position 兼容视图。 +- `app/services/trading_executor.py` + - 脚本产生 `ctx._orders` 后转成执行信号。 + - `_enqueue_pending_order()` 写入 `pending_orders`。 + - `_hydrate_script_ctx_from_positions()` 会从 `qd_strategy_positions` 恢复持仓视图。 +- `app/services/pending_order_worker.py` + - `PendingOrderWorker` 拉取 `pending_orders`,执行 live/signal 模式派单。 + - 已有 live order context、client order id、成交回填等基础。 +- `app/services/pending_orders/fill_records.py` + - `persist_strategy_fill()` 按成交更新 `qd_strategy_trades` 和 `qd_strategy_positions`。 +- `app/services/live_trading/records.py` + - `apply_fill_to_local_position()` 已经按成交更新均价和部分减仓。 +- `migrations/init.sql` + - 已有 `pending_orders`、`qd_strategy_positions`、`qd_strategy_trades`、`qd_grid_resting_orders` 等基础表。 + +现有不足: + +- `pending_orders` 更像队列表,不是完整 order intent ledger。 +- `script_runtime_state` 存在 `trading_config` JSON 里,适合轻量参数,不适合作为复杂策略状态可信源。 +- 缺少 `strategy_run_id`、代码快照、参数快照和运行 epoch。 +- 缺少 basket / child order / fill / recovery event 的标准模型。 +- 多空 position API 已具备雏形,但 basket 层还没独立。 +- 订单幂等当前主要靠 `(strategy_id, symbol, signal_type, signal_ts)`,不够表达层级分仓的 `layer/order/action`。 +- 回测还没有完全复用 live 的 order intent、fill、fee、slippage 语义。 + +## 3. 总体架构 + +建议增加一层“策略运行时内核”,位于脚本和下单队列之间: + +```text +ScriptStrategy + -> StrategyRuntimeContext + -> BasketRuntime / RuntimeStateStore + -> OrderIntentService + -> ExecutionScheduler + -> PendingOrderWorker / LiveTradingClient + -> FillLedger / PositionLedger + -> RecoveryService / RiskGuard +``` + +原则: + +- 脚本只表达意图和状态机,不直接操作数据库和交易所。 +- 数据库是恢复可信源,内存只做当前循环缓存。 +- 所有真实下单先落 `order_intent`,再提交交易所。 +- basket 均价、层级、TP、风险状态由实际 fill 驱动。 +- live、paper、backtest 尽量复用同一套 order intent 和 fill 语义。 + +## 4. 数据库改造 + +新增表建议: + +```sql +strategy_runs +strategy_runtime_state +strategy_baskets +strategy_basket_orders +strategy_order_intents +strategy_order_fills +strategy_runtime_events +strategy_runtime_locks +``` + +### 4.1 strategy_runs + +记录每一次启动,不再只靠 `strategy_id` 表示运行实例。 + +关键字段: + +- `id` +- `strategy_id` +- `user_id` +- `source_version_id` +- `code_hash` +- `parameter_snapshot_json` +- `exchange_id` +- `credential_id` +- `symbol` +- `market_type` +- `position_mode` +- `runtime_status`: `running/recovering/paused/needs_review/stopping/stopped/failed` +- `runtime_epoch` +- `started_at` +- `stopped_at` +- `stop_reason` + +### 4.2 strategy_runtime_state + +替代把复杂状态塞进 `trading_config.script_runtime_state`。 + +关键字段: + +- `strategy_run_id` +- `strategy_id` +- `state_key` +- `state_json` +- `version` +- `updated_at` + +用途: + +- 保存轻量脚本状态:冷却计数、上次触发价、用户自定义变量。 +- 由运行时托管 `ctx.state.get/set/flush()`。 +- 不保存成交和订单真相。 + +### 4.3 strategy_baskets + +复杂分仓策略的核心状态。 + +关键字段: + +- `basket_id` +- `strategy_run_id` +- `strategy_id` +- `symbol` +- `side`: `long/short` +- `status`: `idle/opening/active/closing/closed/failed/needs_review` +- `current_layer` +- `current_order_in_layer` +- `total_qty` +- `total_notional` +- `avg_entry_price` +- `next_entry_trigger` +- `take_profit_price` +- `max_layer` +- `max_orders_per_layer` +- `risk_state_json` +- `created_at` +- `updated_at` + +### 4.4 strategy_basket_orders + +记录每个子单在 basket 内的归属。 + +关键字段: + +- `basket_order_id` +- `basket_id` +- `side` +- `layer_index` +- `order_index` +- `action`: `open/add/reduce/close` +- `planned_price` +- `planned_qty` +- `planned_notional` +- `status`: `planned/intent_created/submitted/accepted/partially_filled/filled/rejected/cancelled/expired/unknown` +- `order_intent_id` +- `exchange_order_id` +- `client_order_id` +- `filled_qty` +- `avg_fill_price` +- `fee` +- `error` + +唯一约束: + +```text +strategy_run_id + basket_id + side + layer_index + order_index + action +``` + +### 4.5 strategy_order_intents + +统一订单意图。后续 TWAP、BestLimit、Iceberg 都从这里开始。 + +关键字段: + +- `order_intent_id` +- `strategy_run_id` +- `strategy_id` +- `basket_id` +- `basket_order_id` +- `idempotency_key` +- `symbol` +- `market_type` +- `side` +- `position_side` +- `reduce_only` +- `order_type` +- `quantity` +- `notional` +- `limit_price` +- `execution_algo`: `market/limit/best_limit/twap/stop/iceberg` +- `status` +- `client_order_id` +- `exchange_order_id` +- `payload_json` +- `created_at` +- `updated_at` + +### 4.6 strategy_order_fills + +成交流水是均价、TP 和审计的真实来源。 + +关键字段: + +- `fill_id` +- `order_intent_id` +- `basket_id` +- `exchange_order_id` +- `exchange_fill_id` +- `side` +- `position_side` +- `price` +- `quantity` +- `notional` +- `fee` +- `fee_ccy` +- `filled_at` +- `raw_json` + +唯一约束: + +```text +exchange_id + exchange_fill_id +``` + +没有交易所 fill id 的场景,用 `order_intent_id + price + quantity + filled_at` 做近似去重。 + +## 5. 运行时 API 改造 + +保留现有 `ctx.open_long()` 等 API,同时新增更适合复杂策略的托管接口。 + +### 5.1 ctx.state + +```python +ctx.state.get("cooldown_bars", 0) +ctx.state.set("cooldown_bars", 3) +ctx.state.flush() +``` + +用途: + +- 轻量脚本变量。 +- 自动绑定 `strategy_run_id`。 +- 后端定期或关键动作前 checkpoint。 + +### 5.2 ctx.basket + +```python +long_basket = ctx.basket("long") + +if long_basket.is_idle(): + long_basket.open_child_order(layer=1, order=1, notional=100) + +if long_basket.should_add(current_price): + long_basket.open_child_order(layer=2, order=1, notional=200) + +if long_basket.should_take_profit(current_price): + long_basket.close_all(reason="avg_take_profit") +``` + +运行时负责: + +- 检查幂等键。 +- 创建 `strategy_basket_orders`。 +- 创建 `strategy_order_intents`。 +- 将 intent 推进到 `pending_orders` 或算法调度器。 +- 成交后刷新 basket 均价、TP、层级和风险状态。 + +### 5.3 ctx.long_position / ctx.short_position + +`ctx.position` 保持兼容,新增更明确接口: + +```python +ctx.long_position.size +ctx.long_position.avg_entry +ctx.short_position.size +ctx.short_position.avg_entry +``` + +多空双开时,脚本不能再依赖单一 net position 判断。 + +## 6. LayeredMartingaleBasket 官方模板 + +先内置一个官方模板,不先开放用户自由组合所有底层能力。 + +参数: + +```text +symbol +direction: long | short | both +base_order_value +leverage +layers = 5 +orders_per_layer = 3 +martingale_multiplier = 2.0 +intra_spacing_pct +inter_spacing_pct +take_profit_pct +max_total_notional +max_margin_pct +hard_stop_pct +cooldown_bars +restart_after_take_profit +``` + +运行逻辑: + +```text +idle: + 创建第 1 层第 1 子单 + +active: + 如果价格逆向达到层内/层间触发价,创建下一个子单 + 如果实际成交均价达到 TP,close basket + 如果达到最大层数,只等 TP 或风险退出 + 如果触发 hard stop / margin risk,停止补仓并按配置减仓或平仓 +``` + +第一版限制: + +- `direction=long` 或 `direction=short` 先稳定上线。 +- `direction=both` 内部拆成 `long basket` 和 `short basket`,要求交易所能力矩阵确认 hedge mode。 +- 不支持无限马丁,必须配置最大名义价值或最大保证金占用。 + +## 7. 订单生命周期改造 + +统一状态: + +```text +intent_created +submitted +accepted +partially_filled +filled +rejected +cancelled +expired +unknown +reconciled +``` + +现有 `pending_orders.status` 可以继续承载队列阶段,但不要作为专业订单状态的唯一来源。 + +推荐落地方式: + +1. `OrderIntentService.create_intent()` 先写 `strategy_order_intents`。 +2. 根据 `execution_algo`: + - `market/limit`:直接桥接到 `pending_orders`。 + - `twap/best_limit`:交给 `ExecutionScheduler` 拆 child order。 +3. `PendingOrderWorker` 执行后回写 intent 状态和 fill。 +4. `persist_strategy_fill()` 同步升级:除写 `qd_strategy_trades/positions` 外,也写 `strategy_order_fills` 并刷新 basket。 + +## 8. 幂等和单写入者 + +幂等键格式: + +```text +strategy_run_id:basket_id:side:L{layer_index}:O{order_index}:{action} +``` + +策略运行锁: + +```text +strategy_id + account_id/credential_id + symbol + side +``` + +机制: + +- 启动策略时创建 `strategy_runs`,拿到 `runtime_epoch`。 +- 每个运行线程写状态和订单时必须带 `runtime_epoch`。 +- 新线程抢锁成功后,旧线程即使还活着,也因为 epoch 不匹配不能继续写库。 +- 单机可先用数据库行锁;多实例部署再接 Redis lock + DB fencing token。 + +## 9. 重启恢复流程 + +启动或进程崩溃后恢复时: + +1. 将 run 标记为 `recovering`。 +2. 读取 `strategy_runs`、`strategy_runtime_state`、`strategy_baskets`、未完成 `strategy_order_intents`。 +3. 拉取交易所当前持仓、未成交订单、近期成交。 +4. 用交易所事实修正本地: + - open order 状态。 + - filled quantity。 + - avg fill price。 + - basket avg entry。 + - pending TP/close 状态。 +5. 对没有 `exchange_order_id` 的 intent 做幂等判断: + - 确认未提交才允许重试。 + - 状态未知则进入 `needs_review`。 +6. 重建 `ctx.state`、`ctx.basket`、`ctx.long_position/short_position`。 +7. 写 `recovery_completed` 事件。 +8. 切回 `running`。 + +恢复优先级: + +```text +交易所事实 > strategy_order_fills/order_intents > basket checkpoint > 内存状态 +``` + +## 10. 风控改造 + +新增 `StrategyRuntimeRiskGuard`,在创建 intent 前检查: + +- 最大层数。 +- 最大子单数。 +- 最大名义价值。 +- 最大保证金占用。 +- 最大账户权益占比。 +- 最大浮亏。 +- 距离强平价格最小安全距离。 +- 单策略最大错误次数。 +- 单交易所/全局 kill switch。 + +触发后写事件: + +```text +risk_guard_triggered +``` + +并进入以下之一: + +- `paused`: 暂停新增开仓。 +- `needs_review`: 状态不确定,需要人工确认。 +- `stopping`: 自动减仓或平仓中。 +- `failed`: 无法安全处理。 + +## 11. 算法交易第一版 + +新增目录: + +```text +app/services/algo_trading/ + order_intent.py + scheduler.py + child_order.py + execution_algorithms/ + twap.py + best_limit.py + stop.py + order_state.py + reconciliation.py + risk.py +``` + +P0 支持: + +- `MarketIntent`: 现有 market order 的标准化入口。 +- `LimitIntent`: 限价单标准化入口。 +- `BestLimit`: 使用 best bid/ask 挂单,超时撤单重挂。 +- `TWAP`: 按时间切片拆成多个 child order。 +- `Stop/StopLimit`: 交易所支持则原生,不支持则本地触发。 + +P1 再做: + +- Iceberg。 +- Sniper。 +- 更细的 order book 驱动。 + +## 12. 交易所能力矩阵 + +现有 `app/services/live_trading/capabilities.py` 只有市场类型能力,需要扩展为: + +```text +supports_hedge_mode +supports_reduce_only +supports_post_only +supports_stop_order +supports_iceberg +supports_client_order_id +supports_order_query_by_client_id +supports_fills_query +min_notional +min_quantity +price_tick +quantity_step +rate_limit +``` + +策略启动前做 preflight: + +- 交易所是否支持该 market type。 +- 是否支持 hedge mode。 +- API key 是否有交易、订单查询、成交查询权限。 +- 合约杠杆和保证金模式是否可配置。 +- symbol 精度和最小下单量是否满足模板参数。 + +失败时不启动策略,给用户明确错误。 + +## 13. 回测一致性 + +复杂策略不能只靠布尔信号回测。建议新增运行时回测模式: + +```text +ScriptStrategy -> RuntimeContext(backtest) -> OrderIntentService(backtest) -> SimulatedFillEngine -> BasketRuntime +``` + +模拟能力: + +- 最小下单量。 +- 数量步进。 +- 价格 tick。 +- 手续费。 +- 滑点。 +- 部分成交。 +- 下根 K 成交 / 当前 tick 成交差异。 +- 子单级成交记录。 + +验收目标: + +- 同一份 `LayeredMartingaleBasket` 模板在 backtest/paper/live 中的子单路径一致。 +- 回测报告能展示每层、每个子单、均价、TP、费用和滑点。 + +## 14. 前端需要配合的能力 + +第一版 UI 不要让用户直接编辑底层 runtime 表,而是提供模板化表单: + +- 方向:只做多 / 只做空 / 多空双向。 +- 分仓层数。 +- 每层子单数。 +- 层内间距。 +- 层间间距。 +- 马丁倍数或自定义序列。 +- 基础下单金额。 +- 杠杆。 +- 均价止盈。 +- 最大名义价值。 +- 最大保证金占用。 +- 硬止损。 +- 启动前预估最大风险。 + +策略详情页增加: + +- 当前 run id。 +- 当前 basket 状态。 +- 子单列表。 +- 未完成订单。 +- 最近成交。 +- 恢复事件。 +- 风控状态。 +- `needs_review` 人工处理面板。 + +## 15. 分期落地 + +### Phase 0:补齐可追踪运行身份 + +目标:先让每次运行可追溯。 + +任务: + +- 新增 `strategy_runs`。 +- 启动策略时生成 `strategy_run_id`。 +- 保存代码 hash、参数快照、交易所配置摘要。 +- `pending_orders.payload_json` 增加 `strategy_run_id`。 +- `qd_strategy_trades` 和 `qd_strategy_positions` 增加 `strategy_run_id` 可选字段。 + +验收: + +- 任意一笔订单能追到哪次运行、哪版代码、哪组参数。 + +### Phase 1:Basket runtime MVP + +目标:支持单向 `LayeredMartingaleBasket` 稳定运行。 + +任务: + +- 新增 basket 表、basket order 表、runtime event 表。 +- 实现 `ctx.state` 和 `ctx.basket(side)`。 +- 实现 `BasketRuntime.open_child_order/close_all/checkpoint`。 +- 实现幂等键和 DB 级唯一约束。 +- 官方模板先支持 `long` 或 `short`。 + +验收: + +- 5 层、每层 3 单能按配置触发。 +- 重启后不会重复开同一个 layer/order。 +- 均价止盈基于实际成交均价。 + +### Phase 2:Order intent 与成交驱动 + +目标:把 `pending_orders` 从唯一状态源降级为执行队列。 + +任务: + +- 新增 `strategy_order_intents` 和 `strategy_order_fills`。 +- `TradingExecutor._enqueue_pending_order()` 前置创建 intent。 +- `PendingOrderWorker` 执行后回写 intent 和 fill。 +- `persist_strategy_fill()` 刷新 basket 均价、TP 和状态。 +- 部分成交不再误判为完整开仓。 + +验收: + +- 部分成交后 basket 数量、均价、TP 正确。 +- 拒单不修改 basket 为已开仓。 +- 同一幂等键不会重复提交。 + +### Phase 3:恢复与 needs_review + +目标:重启、网络失败、未知订单状态可控。 + +任务: + +- 实现 `RecoveryService`。 +- 启动策略前自动恢复 active run。 +- 未知状态进入 `needs_review`。 +- UI 提供同步交易所状态、继续观察、关闭 basket、强制停止。 + +验收: + +- 发单后模拟进程崩溃,恢复后不重复下单。 +- 交易所和本地不一致时不会继续补仓。 + +### Phase 4:多空双 basket + +目标:安全开放 `direction=both`。 + +任务: + +- `ctx.long_position/short_position` 正式化。 +- `ctx.basket("long")` 和 `ctx.basket("short")` 独立持久化。 +- preflight 检查交易所 hedge mode。 +- 单写入锁按 `strategy_id + credential_id + symbol + side` 切分。 + +验收: + +- long basket 和 short basket 可同时存在,互不覆盖均价和层级。 +- 单向交易所不允许启动 both 模式。 + +### Phase 5:基础 AlgoTrading + +目标:把复杂策略的“如何成交”从策略逻辑里抽出来。 + +任务: + +- 实现 `BestLimit`。 +- 实现 `TWAP`。 +- 实现 `Stop/StopLimit`。 +- 子单调度器支持撤单、重挂、超时和最大滑点。 + +验收: + +- 同一个 basket 子单可选择 market、best_limit、twap 执行。 +- 执行报告展示计划成交和实际成交差异。 + +## 16. 测试清单 + +必须新增的测试: + +- `test_strategy_run_identity.py` +- `test_basket_runtime_state.py` +- `test_basket_order_idempotency.py` +- `test_basket_partial_fill_avg_price.py` +- `test_basket_rejected_order_state.py` +- `test_basket_restart_recovery.py` +- `test_basket_long_short_independent.py` +- `test_order_intent_lifecycle.py` +- `test_algo_twap_scheduler.py` +- `test_algo_best_limit.py` +- `test_exchange_capability_preflight.py` +- `test_backtest_live_basket_consistency.py` + +重点场景: + +- 同一根 K 线重复触发,不重复开同一子单。 +- 写库成功但交易所提交超时,恢复时不盲目重发。 +- 部分成交后按成交数量更新均价。 +- 拒单后 basket order 为 rejected,不增加层级。 +- 交易所最小下单量不足,策略进入 `needs_review` 或拒绝启动。 +- 多空双开时 long/short basket 独立恢复。 + +## 17. 最小可交付版本 + +建议最小版本不要一口气做完整算法交易平台,先交付: + +- `strategy_run_id`。 +- `ctx.state`。 +- `ctx.basket("long"|"short")`。 +- `strategy_baskets` / `strategy_basket_orders`。 +- order intent 幂等键。 +- fill 驱动 basket 均价。 +- `LayeredMartingaleBasket` 单向模板。 +- 重启后不重复下单。 + +这一步完成后,已经可以覆盖用户最核心的“多层分仓 + 马丁 + 均价止盈 + 可恢复”诉求。随后再扩展多空双向和 TWAP/BestLimit,会稳很多。 diff --git a/STRATEGY_RUNTIME_ALGO_TRADING_UPGRADE_CN.md b/STRATEGY_RUNTIME_ALGO_TRADING_UPGRADE_CN.md new file mode 100644 index 0000000..f77a8c6 --- /dev/null +++ b/STRATEGY_RUNTIME_ALGO_TRADING_UPGRADE_CN.md @@ -0,0 +1,724 @@ +# QuantDinger 策略运行时与算法交易升级规划 + +**状态**:规划建议 +**适用范围**:ScriptStrategy、实盘策略运行时、订单执行、算法交易执行器 +**背景**:用户开始提出更复杂的实盘策略需求,例如 5 层分仓、每层 3 个马丁子单、均价止盈、多空模式和自定义杠杆。QuantDinger 需要从“能跑策略”升级为“可靠承载复杂策略和算法执行”的量化交易基础设施。 + +--- + +## 1. 结论 + +当前 ScriptStrategy 基座可以支持一类复杂策略: + +- 有状态的分批开仓。 +- 运行时参数化。 +- 多次加仓、均价止盈。 +- 只做多或只做空。 +- K 线/实时 tick 循环驱动。 +- 回测与实盘复用同一份脚本逻辑。 + +但对于专业级“阶梯分仓马丁 / basket martingale / layered DCA”这类策略,当前系统还缺少一些基础设施级边界: + +- 独立 basket 状态持久化。 +- 多空双向 basket 的独立状态。 +- 重启恢复与成交回放。 +- 部分成交、撤单、拒单后的确定性状态机。 +- 实盘与回测在逐笔补仓、均价、手续费、滑点上的一致性校验。 +- 策略级最大风险预算、爆仓距离、保证金占用和 kill switch。 + +因此: + +- **只做多 / 只做空版本**:当前基座可以写出来,适合先落地。 +- **多空双开版本**:建议先补强 hedge-mode 状态模型,不建议直接用单一 `ctx.position` 硬写。 +- **专业级可售卖模板**:需要配套 basket runtime、订单生命周期和恢复机制后再推广。 + +--- + +## 2. 用户需求是否能由脚本策略表达 + +用户需求: + +```text +5 个大分仓 +每个分仓 3 个开单 +单内开仓间距可调 +每个子单按马丁倍数放大 +均价止盈 +前一个分仓没有盈利才开下一个分仓 +交易品种自定义 +只做多 / 只做空 / 多空双开 +基础仓位是首单金额 +合约按杠杆后的名义价值计算 +马丁倍数可自定义 +均价止盈可自定义 +``` + +### 2.1 当前可直接表达的部分 + +ScriptStrategy 已经具备这些能力: + +| 需求 | 当前支持情况 | 说明 | +| --- | --- | --- | +| 自定义交易品种 | 支持 | 由策略创建参数和运行配置选择 symbol | +| 参数可调 | 支持 | 使用 `ctx.param(...)` | +| 只做多 | 支持 | `ctx.open_long` / `ctx.add_long` / `ctx.close_position` | +| 只做空 | 支持 | `ctx.open_short` / `ctx.add_short` / `ctx.close_position` | +| 分批加仓 | 支持 | 脚本维护层级和触发价 | +| 马丁倍数 | 支持 | 通过脚本计算每次下单数量 | +| 均价止盈 | 支持 | 脚本维护或读取均价后触发平仓 | +| K 线或 tick 循环 | 支持 | 运行时按 bar/tick 调用脚本 | +| 回测 | 支持 | 可用脚本回测面板验证 | + +### 2.2 当前不建议硬写的部分 + +| 需求 | 风险点 | 建议 | +| --- | --- | --- | +| 多空双开 | 需要 long basket 和 short basket 独立状态;交易所也必须是 hedge mode | 第一版拆成两个策略实例,后续补 `ctx.long_position` / `ctx.short_position` | +| 重启后继续运行 | 内存变量可能丢失,导致重复开单或错误止盈 | 必须有 basket 状态持久化和成交回放 | +| 部分成交 | 均价和层级不能只按“已发单”计算 | 必须按实际成交回填 | +| 拒单 / 超最小下单量 | 状态机可能误以为已开仓 | 下单确认必须驱动状态迁移 | +| 合约杠杆名义价值 | 不同交易所单位、面值、张数规则不同 | 统一 order sizing 层 | +| 爆仓/保证金风险 | 马丁连续补仓容易快速提高保证金占用 | 增加策略级风险预算和强制停止 | + +--- + +## 3. 建议的脚本策略模型 + +这个用户需求应该抽象成 `LayeredMartingaleBasket`,不是普通网格,也不是简单 DCA。 + +### 3.1 参数结构 + +```text +symbol +direction: long | short | both +base_order_value +leverage +layers = 5 +orders_per_layer = 3 +martingale_multiplier = 2.0 +intra_spacing_pct = [0.5, 0.8] +inter_spacing_pct = [1.2, 1.5, 1.8, 2.2] +take_profit_pct = 2.0 +max_total_margin_pct +max_notional_pct +hard_stop_pct +cooldown_bars +restart_after_take_profit = true +``` + +### 3.2 状态结构 + +```text +basket_id +side +status: idle | opening | active | closing | closed | failed +current_layer +current_order_in_layer +filled_orders +total_qty +total_notional +avg_entry_price +next_entry_trigger +take_profit_price +last_signal_ts +last_order_id +error_count +``` + +### 3.3 执行流程 + +```text +if basket is idle: + open layer 1 order 1 + +if price moves adverse by intra/inter spacing: + open next child order + +if weighted average reaches take-profit: + close basket + reset state + +if max layer/order reached: + stop adding + wait for TP or trigger risk exit + +if hard risk limit reached: + close basket or stop strategy +``` + +--- + +## 4. ScriptStrategy 基座需要补齐的边界 + +### 4.1 Basket state persistence + +复杂策略不能只依赖脚本内存变量。建议新增 basket 状态表或 runtime state 标准: + +```text +qd_strategy_baskets +qd_strategy_basket_orders +``` + +核心能力: + +- 每个 basket 有唯一 ID。 +- 每个子单有独立状态。 +- 状态由成交事件驱动,而不是由“发单成功”驱动。 +- 后端重启后可以恢复当前层级、均价和未完成订单。 + +#### 4.1.1 脚本线程与持久化边界 + +脚本策略通常跑在线程或任务循环里,但线程内变量只能作为运行期缓存,不能作为真实状态来源。建议把策略状态拆成三层: + +| 层级 | 用途 | 建议存储 | 是否可信 | +| --- | --- | --- | --- | +| 线程内存 | 当前循环临时计算、减少查询 | Python object | 不可信,重启即丢 | +| Redis / cache | 分布式锁、短期心跳、运行中标记、限频 | Redis,可选 | 半可信,可重建 | +| 数据库 | basket、订单意图、成交、风险状态、恢复检查点 | PostgreSQL / SQLite / MySQL | 可信状态源 | + +原则是:脚本可以读写 `ctx.state`,但 `ctx.state` 背后必须由运行时托管并定期落库;策略代码不应该直接操作数据库连接,也不应该自己决定恢复逻辑。 + +建议提供运行时接口: + +```python +ctx.state.get("current_layer", 0) +ctx.state.set("current_layer", 2) +ctx.state.flush() + +ctx.basket("long").open_child_order(...) +ctx.basket("long").checkpoint() +``` + +其中: + +- `ctx.state` 保存轻量策略状态,例如当前层级、最近触发价格、冷却计数。 +- `ctx.basket` 保存交易相关状态,例如子单、成交、均价、止盈价、未完成订单。 +- `flush()` 由运行时批量落库,也可以在发单前后强制 checkpoint。 +- 真实下单前必须先写入 `order_intent`,拿到幂等键,再提交交易所订单。 + +#### 4.1.2 数据库与 Redis 的分工 + +数据库是恢复源,Redis 只做加速和协调: + +- 数据库负责:策略实例、basket、子订单、订单意图、交易所订单 ID、成交回报、风险状态、最后 checkpoint。 +- Redis 负责:策略运行锁、线程心跳、短期去重键、任务队列、行情订阅状态。 +- 如果没有 Redis,单机版本也能工作,只是不能很好地做多进程调度和快速故障切换。 +- 如果 Redis 数据丢失,不应该影响真实持仓恢复;最多影响 UI 的“运行中”即时状态。 + +推荐表结构方向: + +```text +strategy_runtime_state +strategy_baskets +strategy_basket_orders +strategy_order_intents +strategy_order_fills +strategy_recovery_events +``` + +#### 4.1.3 重启与容灾流程 + +后端重启、线程崩溃或容器迁移后,不应该直接从脚本第一行重新开始发单。恢复流程应由运行时统一执行: + +1. 标记策略实例为 `recovering`,禁止脚本继续发新单。 +2. 读取数据库中的最后 checkpoint、basket、未完成 order intent。 +3. 拉交易所当前持仓、未成交订单、最近成交。 +4. 用交易所事实修正本地状态:成交数量、均价、未完成订单、已关闭订单。 +5. 对没有交易所订单 ID 的 intent 做幂等判定:确认未提交才允许重试。 +6. 重建 `ctx.state` 和 `ctx.basket`,恢复到线程内存。 +7. 写入 recovery event,标记为 `running`。 +8. 下一轮策略循环只允许基于恢复后的状态继续运行。 + +关键点是:恢复优先相信交易所事实,其次相信数据库 checkpoint,最后才相信脚本内存。 + +#### 4.1.4 防重复下单机制 + +复杂马丁策略最怕重启后重复补仓。每次下单必须有稳定幂等键: + +```text +strategy_id + basket_id + side + layer_index + order_index + action +``` + +例如: + +```text +strategy-12:basket-20260629-long:L2:O3:open +``` + +运行时提交订单前先创建 `order_intent`: + +- 如果同一幂等键已有 `submitted / accepted / partially_filled / filled`,禁止再次提交。 +- 如果是 `rejected / expired`,根据策略配置决定是否重试。 +- 如果是 `unknown`,必须先向交易所对账,不允许盲目重发。 + +这样即使线程在“写库成功、发单超时、交易所实际已接收”的中间状态崩溃,也可以通过交易所订单查询恢复,而不是重复开单。 + +### 4.2 Position model upgrade + +当前脚本更适合单方向持仓。后续建议提供清晰接口: + +```python +ctx.position +ctx.long_position +ctx.short_position +ctx.basket(side="long") +ctx.basket(side="short") +``` + +这样多空双开不会挤在一个 `ctx.position` 语义里。 + +### 4.3 Order lifecycle standard + +复杂策略必须清楚区分: + +- intent created +- order submitted +- exchange accepted +- partially filled +- filled +- rejected +- cancelled +- expired +- reconciled + +状态迁移必须幂等,避免重启后重复下单。 + +### 4.4 Fill-driven average price + +均价止盈必须基于实际成交: + +- 不能只按脚本计划价格。 +- 不能只按信号价格。 +- 必须考虑部分成交、手续费、合约面值和滑点。 + +### 4.5 Restart recovery + +策略恢复时应执行: + +1. 读取本地 basket 状态。 +2. 拉交易所持仓和未成交订单。 +3. 对账本地订单与交易所订单。 +4. 重建均价、层级和风险状态。 +5. 再允许继续发新单。 + +这一节是恢复行为摘要,详细线程、数据库、Redis 与幂等设计见 `4.1.1` 到 `4.1.4`。 + +### 4.6 Risk guard + +马丁类策略必须内置硬风险边界: + +- 最大层数。 +- 最大子单数。 +- 最大名义价值。 +- 最大保证金占用。 +- 最大账户权益占比。 +- 最大连续亏损次数。 +- 最大浮亏。 +- 距离爆仓价最小安全距离。 +- 交易所错误过多自动停止。 + +### 4.7 Backtest/live consistency + +复杂加仓策略需要增强回测: + +- 支持逐层成交记录。 +- 支持手续费和滑点模型。 +- 支持最小下单量和价格步进。 +- 支持合约张数换算。 +- 支持“下根 K 成交”和“tick 触发”的差异报告。 +- 支持实盘偏差报告:计划成交 vs 实际成交。 + +### 4.8 Strategy run identity + +每一次策略启动都必须有独立 `strategy_run_id`。不要只用 `strategy_id` 表示一次运行,因为同一策略可能多次启动、暂停、恢复、修改参数。 + +建议记录: + +```text +strategy_id +strategy_run_id +source_version_id +parameter_snapshot +account_id +exchange +symbol +market_type +position_mode +started_at +stopped_at +stop_reason +runtime_status +``` + +这样可以回答: + +- 这笔订单属于哪一次运行。 +- 当时使用的是哪一版脚本代码。 +- 当时参数是什么。 +- 是正常停止、用户停止、风控停止,还是异常崩溃后恢复。 + +### 4.9 Single writer and lock model + +同一个策略实例同一时间只能有一个运行时写状态和发单。否则多线程、多进程或容器重启时容易重复补仓。 + +建议: + +- 以 `strategy_id + account_id + symbol + side` 作为运行锁范围。 +- 单机版可以用数据库锁。 +- 多实例部署建议用 Redis lock + 数据库 fencing token。 +- 每次写入订单状态时检查 `runtime_epoch` 或 `fencing_token`,旧线程失去锁后不能继续写库。 +- UI 上的“启动策略”如果发现已有活动 run,应提示用户恢复、接管或强制停止。 + +### 4.10 Event ledger + +除了保存当前状态,还应该保存事件流水。当前状态用于快速恢复,事件流水用于审计和重放。 + +建议事件类型: + +```text +strategy_started +signal_generated +order_intent_created +order_submitted +order_accepted +order_partially_filled +order_filled +order_rejected +order_cancelled +basket_checkpointed +risk_guard_triggered +recovery_started +recovery_completed +strategy_stopped +manual_override +``` + +有了 event ledger,后续才能做: + +- 策略事故复盘。 +- 回测与实盘偏差报告。 +- 用户投诉时还原执行过程。 +- 版本升级后的兼容性检查。 + +### 4.11 Code and parameter snapshot + +实盘运行不能只引用“当前脚本代码”。用户可能在策略运行中修改脚本,如果没有快照,会出现无法复现的问题。 + +建议: + +- 每次启动实盘时固定 `source_version_id`。 +- 保存代码 hash、参数快照、运行配置、交易账户、交易所模式。 +- 修改代码后不影响正在运行的 run,除非用户明确点击“应用并重启”。 +- 回测记录、实盘记录和市场模板都引用同一套版本快照。 + +### 4.12 Manual intervention and degraded mode + +恢复失败、交易所状态不一致或订单状态未知时,不应该让策略继续自动补仓。 + +建议增加状态: + +```text +running +recovering +paused +needs_review +stopping +stopped +failed +``` + +进入 `needs_review` 时: + +- 禁止发新开仓单。 +- 允许只减仓或撤单。 +- UI 显示差异:本地 basket、交易所持仓、未成交订单。 +- 用户可以选择:同步交易所状态、关闭 basket、继续观察、强制停止。 + +--- + +## 5. 算法交易能力缺口 + +目前 QuantDinger 已有基础下单与策略运行能力,但还不是完整 AlgoTrading 执行平台。 + +### 5.1 当前已有能力 + +- 市价单。 +- 限价单。 +- 基础取消订单。 +- 策略信号触发下单。 +- 止损、止盈、追踪止损等策略级风控。 +- 网格 resting limit orders 的部分执行基础。 +- 多交易所适配框架。 + +### 5.2 需要补齐的算法交易模块 + +建议新增独立域: + +```text +app/services/algo_trading/ + order_intent.py + scheduler.py + child_order.py + execution_algorithms/ + twap.py + iceberg.py + best_limit.py + sniper.py + stop.py + order_state.py + reconciliation.py + risk.py +``` + +### 5.3 第一阶段建议实现 + +| 算法 | 优先级 | 原因 | +| --- | --- | --- | +| TWAP | P0 | 最容易解释,最适合大额分批成交 | +| BestLimit | P0 | 基于盘口最优价挂单,适合低滑点执行 | +| Stop / StopLimit | P0 | 用户理解成本低,交易刚需 | +| Iceberg | P1 | 大单隐藏,依赖交易所支持或本地拆单 | +| Sniper | P1 | 需要盘口、成交量、触发条件和撤单速度 | +| VWAP / POV | P2 | 需要可靠成交量曲线和市场深度数据 | + +--- + +## 6. 算法交易必须补齐的边界 + +### 6.1 Unified order intent + +所有策略和手动交易都应先生成统一订单意图: + +```text +side +symbol +market_type +position_side +reduce_only +notional +quantity +limit_price +time_in_force +execution_algo +risk_budget +client_order_id +strategy_id +basket_id +``` + +### 6.2 Child order scheduler + +TWAP/Iceberg/Sniper 都需要子单调度: + +- 分片数量。 +- 分片间隔。 +- 每片最大数量。 +- 撤单重挂。 +- 超时处理。 +- 最大滑点。 +- 成交不足补单。 + +### 6.3 Market microstructure data + +算法交易不能只靠 K 线。至少需要: + +- best bid/ask。 +- order book top levels。 +- recent trades。 +- spread。 +- depth。 +- volatility。 +- exchange rate limit。 + +### 6.4 Reconciliation + +执行器必须定期对账: + +- 本地订单状态。 +- 交易所订单状态。 +- 实际成交。 +- 当前持仓。 +- 手续费。 +- 平均成交价。 + +### 6.5 Kill switch + +算法交易需要全局和策略级开关: + +- 全局暂停发单。 +- 单策略暂停。 +- 单交易所暂停。 +- 最大错误次数自动停止。 +- 最大滑点自动停止。 +- 仓位异常自动停止。 +- API key 异常自动停止。 + +### 6.6 Observability + +需要能回答: + +- 为什么下了这笔单? +- 计划成交多少? +- 实际成交多少? +- 滑点多少? +- 哪个子单失败? +- 是策略原因、交易所原因还是风控原因? + +建议新增: + +```text +algo_order_logs +algo_child_order_logs +execution_trace_id +strategy_run_id +``` + +### 6.7 Exchange capability matrix + +算法交易不能假设每个交易所能力一致。需要维护交易所能力矩阵: + +```text +exchange +market_type +supports_hedge_mode +supports_reduce_only +supports_post_only +supports_stop_order +supports_iceberg +supports_client_order_id +min_notional +min_quantity +price_tick +quantity_step +rate_limit +order_book_depth +``` + +运行时根据能力矩阵决定: + +- 能否启动某个策略。 +- 是否需要本地模拟 stop / iceberg。 +- 下单数量和价格如何 round。 +- 策略模板是否适合某个交易所。 + +### 6.8 Account, permission, and secret boundary + +算法交易会放大账户风险,必须把账户权限纳入运行前检查: + +- API key 是否具备交易权限。 +- 是否禁止提现权限。 +- 是否支持读取订单和成交历史。 +- 是否配置 IP 白名单。 +- 是否允许合约交易。 +- 是否设置账户级最大风险预算。 + +策略启动前应做 preflight check,失败时明确告诉用户缺哪个权限或配置,而不是启动后才报错。 + +### 6.9 Test and certification suite + +如果要把 QuantDinger 做成基础设施,需要有策略运行时验收测试: + +- 重启恢复测试:发单中途杀进程,恢复后不重复下单。 +- 部分成交测试:均价和 TP 按真实成交更新。 +- 拒单测试:最小下单量、余额不足、API 错误后不误改 basket。 +- 断网测试:状态进入 `needs_review`,不继续补仓。 +- 多空 hedge 测试:long basket 与 short basket 独立。 +- 回测/实盘一致性测试:同一信号路径能对齐计划成交点。 +- 交易所精度测试:价格 tick、数量 step、最小名义金额都正确处理。 + +--- + +## 7. 产品分期路线 + +### Phase 1:ScriptStrategy 专业化 + +目标:让复杂状态机策略可稳定运行。 + +- Basket runtime state。 +- 多空独立 position/basket 接口。 +- 成交驱动均价。 +- 重启恢复。 +- 策略运行 ID、代码版本快照和参数快照。 +- 事件流水和恢复审计。 +- 单写入者锁,避免重复运行。 +- 复杂策略回测报告。 +- Layered Martingale Basket 官方模板。 + +### Phase 2:基础 AlgoTrading + +目标:让用户可以选择执行算法,而不只是市价/限价。 + +- Unified order intent。 +- TWAP。 +- BestLimit。 +- Stop / StopLimit。 +- 子单状态表。 +- 执行日志和偏差分析。 +- 交易所能力矩阵。 +- 账户权限 preflight check。 + +### Phase 3:高级执行 + +目标:降低大额交易滑点,支持专业交易执行。 + +- Iceberg。 +- Sniper。 +- VWAP。 +- POV。 +- 盘口深度驱动。 +- 智能撤单重挂。 + +### Phase 4:基础设施化 + +目标:QuantDinger 成为策略、执行、风控、监控一体化基础设施。 + +- 统一策略运行 ID。 +- 全链路 execution trace。 +- 交易所能力矩阵。 +- 多账户组合执行。 +- 回测/模拟盘/实盘一致性报告。 +- 策略市场模板准入审核。 +- 风险沙盒和资金预算模拟器。 +- 运行时验收测试套件。 + +--- + +## 8. 推荐落地顺序 + +最推荐的实际落地顺序: + +1. 先做 `LayeredMartingaleBasket` 官方脚本模板,只支持 `long` / `short`。 +2. 增加 `strategy_run_id`、代码快照、参数快照,保证可追溯。 +3. 增加 basket 状态持久化和事件流水,解决重启恢复。 +4. 增加单写入者锁和幂等下单,解决重复运行和重复补仓。 +5. 增加成交驱动均价,解决部分成交和真实 TP。 +6. 增加 `long_basket` / `short_basket`,再开放多空双开。 +7. 增加交易所能力矩阵和启动前 preflight check。 +8. 增加 TWAP / BestLimit,作为算法交易第一版。 +9. 再做 Iceberg / Sniper。 + +这样既能尽快满足用户需求,又不会把复杂风险压到脚本作者身上。 + +--- + +## 9. 对外表述建议 + +当前阶段建议谨慎表述: + +```text +QuantDinger supports stateful ScriptStrategy workflows for scale-in, DCA, +martingale-style baskets, average-cost exits, backtesting, and live execution. +Advanced multi-leg basket persistence, hedge-mode basket accounting, and +algorithmic execution orders such as TWAP, Iceberg, Sniper, and BestLimit are +planned infrastructure upgrades. +``` + +中文: + +```text +QuantDinger 当前支持有状态脚本策略,可实现分批加仓、DCA、马丁篮子、均价止盈、 +回测和实盘执行。多腿篮子持久化、多空独立篮子核算,以及 TWAP、Iceberg、 +Sniper、BestLimit 等算法订单执行能力,将作为后续基础设施升级重点。 +``` + +不要过早宣传“完整算法交易平台”,应先宣传“可扩展的策略运行时 + 正在建设中的算法执行基础设施”。