Chandelier 跟踪退出管理
Chandelier 退出策略将动量选股与波动率退出线结合:从沪深 300 历史成分股中选取 20 日动量排名靠前的股票,持仓后以最高复权收盘价减去 3 倍 20 期平均真实波幅作为退出线,收盘跌破该线后,在后续交易日尝试卖出。脚本通过 DolphinDB Backtest 实现日频选股、延迟下单、持仓跟踪、分红除权处理和沪深 300 基准对齐。
1. 策略介绍
动量决定股票是否进入候选名单,退出线决定已有持仓何时离场。两者独立工作:月末落选不会自动卖出,跌破退出线也不需要等待月末。
1.1 核心因子
买入信号由动量触发,动量衡量当前价格相对于历史价格的变化。设未复权收盘价为 C,复权因子为 A,则复权收盘价和 20 期动量为:
动量 M 为收益比例,这里的 20 期是每只股票输入序列向前移动 20 条记录。
平均真实波幅(ATR)用于度量价格波动,本例直接使用模块函数计算 mytt::ATR(close * adj_factor, high * adj_factor, low * adj_factor, 20):前三个参数依次为复权收盘价、最高价、最低价,最后一个参数为 20 期窗口。输出转换为 DOUBLE,价格单位与复权收盘价一致。
退出线为:
H 是持仓存在时、有效行情回调中累计记录的最高复权收盘价;L 与 ATR 均使用复权价格单位。只有 (P < L) 且存在可卖持仓时才产生卖出意向,等于退出线不触发。
这是一种 Chandelier 风格的退出实现,锚点不是最近固定窗口的最高盘中价。加仓不重置锚点,观察到持仓归零后才删除锚点。由于 ATR 会变化,退出线可能下降,代码没有对退出线本身做“只升不降”的约束。
1.2 交易标的
csv 模拟行情文件中默认 30 只虚构普通股票,代码为 000001.SZ 等形式,仅借用证券代码格式,不代表对应真实证券。采用普通股票多头现金账户,买入按 100 股整数倍,卖出受 T+1 可卖量约束,不融资、不做空。模拟行情没有 ST、停牌、新股、退市、送转、配股或现金分红,涨跌停统一设置为昨收的 ±10%。
1.3 交易规则
策略按以下顺序完成一次选股和调仓:
-
收到下一交易日日线时,先执行上个选股日锁定的买入目标;只补足目标与现有持仓之间的正差额,资金不足时缩减数量。
-
再执行此前保存的卖出意向,以当前可卖量、总持仓和意向数量的最小值提交订单。
-
更新现有持仓的最高复权收盘价,检查收盘是否跌破退出线,并记录下一日待卖数量。
-
首个回调及月末交易日按动量降序、代码升序取最多 10 只,按实际排名入选数量 N 确定每只名义权重 1/N。
-
对排名入选且信号日动量严格大于 0 的股票,按信号日权益、收盘价和手续费锁定目标股数;非正动量股票对应份额留作现金,不重新分配。
信号使用收盘数据,订单延迟到后续交易日按开盘撮合。正动量条件在信号日锁定,执行日买入分支不再用当天动量修改目标;回调入口仍会检查当天全部订阅指标的有效性。当天成交量仍用作日频可交易过滤,是日线回测假设,不是开盘时已知的实际全天成交量。
涨停价处不买、跌停价处不卖。每轮买入目标及卖出意向只尝试一次,随后清除;后续退出条件持续满足时可重新产生信号。策略没有成交回调、撤单或挂单去重机制,清除意向不代表撤销引擎订单,提交也不代表成交。最后交易日新生成的信号没有后续行情时不会执行,期末没有强制清仓。
2. 策略回测
以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与 MatchingEngineSimulator 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前的代码会检查并加载插件。
2.1 准备数据
先将配套附件 ChandelierExit.csv 保存到 DolphinDB 服务器可读取的目录,再将 csvPath 改为该文件的服务器真实路径。
csvSchema = table(
["symbol", "tradeTime", "open", "low", "high", "close", "volume", "amount",
"upLimitPrice", "downLimitPrice", "adj_factor", "vwap"] as name,
["STRING", "TIMESTAMP", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "LONG", "DOUBLE",
"DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE"] as type)
data = loadText(csvPath, ",", csvSchema, containHeader=true)
| 列名 | DolphinDB 类型 | 含义 |
|---|---|---|
| symbol | STRING | 模拟股票代码 |
| tradeTime | TIMESTAMP | 交易日零点标识,不是实际成交时刻 |
| open | DOUBLE | 开盘价,元/股 |
| low | DOUBLE | 最低价,元/股 |
| high | DOUBLE | 最高价,元/股 |
| close | DOUBLE | 收盘价,元/股 |
| volume | LONG | 成交股数 |
| amount | DOUBLE | 成交金额,元 |
| upLimitPrice | DOUBLE | 模拟涨停价,元/股 |
| downLimitPrice | DOUBLE | 模拟跌停价,元/股 |
| adj_factor | DOUBLE | 复权因子,恒为 1 |
| vwap | DOUBLE | 模拟均价,等于 (open + close) / 2 |
完整 CSV 按 tradeTime , symbol 排序后送入引擎。应保证每股每个交易日只有一条有效记录、OHLC 为正且高低顺序正确、成交额和成交量单位一致、复权尺度连续,并包含足够预热历史。回调只检查订阅指标有效性,不能代替原始数据质量检查。这里没有真实财务或事件字段,也不涉及连续合约处理。
2.2 编写策略逻辑
策略由 roundLot、initialize 和 onBar 组成。roundLot 函数处理成交数量,initialize 初始化策略状态,onBar 执行延迟调仓、市场状态判断和横截面选股。
use mytt
初始化函数 initialize(mutable context)
initialize(mutable context) 是回测引擎的初始化回调。每次创建新引擎时调用一次,用于建立本次回测需要的可变状态。
context:可变 DICTIONARY,包含传入的手续费率、最大入选数和 ATR 倍数,以及引擎提供的句柄。
initialize没有显式返回值,调用 Backtest::subscribeIndicator 并计算指标,在 context 中写入待执行状态。
def initialize(mutable context) {
metrics = dict(STRING, ANY)
metrics["adjClose"] = <double(close * adj_factor)>
metrics["mom20"] = <double((close * adj_factor) / move(close * adj_factor, 20) - 1.0)>
metrics["atr20"] = <double(mytt::ATR(close * adj_factor, high * adj_factor, low * adj_factor, 20))>
Backtest::subscribeIndicator(context.engine, "kline", metrics)
context["initialRebalancePending"] = true
context["selectedStocks"] = []$STRING
context["pendingTargetQtys"] = dict(STRING, LONG)
context["pendingRebalance"] = false
context["rebalanceDate"] = date(NULL)
context["pendingSells"] = dict(STRING, LONG)
context["pendingSellsDate"] = date(NULL)
context["posHighSinceEntry"] = dict(STRING, DOUBLE)
}
回调函数 onBar
onBar(mutable context, msg, indicator) 是主要决策入口。
-
context:可变 DICTIONARY,提供引擎句柄、交易时间和初始化状态。
-
msg:DICTIONARY,以证券代码为键、日线记录为值;代码从第一条记录中获取当前日期,因此依赖非空且同一交易日的回调消息。
-
indicator:嵌套 DICTIONARY,以证券代码为第一层键;第二层包含
adjClose、mom20、atr20三个 DOUBLE 指标。
该函数没有显式返回值,主要用于提交订单,并更新持仓高点、候选名单、目标股数和待卖数量。完整代码如下,执行顺序与第 1.3 节一致。
def onBar(mutable context, msg, indicator) {
// 引擎从startDate开始触发onBar;指标已用更早的输入行情完成预热。
if (size(msg) == 0) { return }
// 开始交易前检查订阅结果,避免预热不足被当作无信号而静默跳过。
for (s in msg.keys()) {
if (!(s in indicator)) { throw "Missing subscribed indicators: " + s }
values = indicator[s]
if (!isValid(values["adjClose"]) || !isValid(values["mom20"]) ||
!isValid(values["atr20"]) || values["atr20"] < 0) {
throw "Invalid subscribed indicators; check warmup data: " + s
}
}
curDate = date(context.tradeTime)
// 按信号日锁定数量执行;不读取执行日收盘指标决定开盘买入。
// 保留原顺序:买入先于待执行卖出,不预支预计卖出资金。
if (context["pendingRebalance"] && curDate > context["rebalanceDate"]) {
remainingCash = Backtest::getAvailableCash(context.engine)
targets = context["pendingTargetQtys"]
for (s in context["selectedStocks"]) {
if (!(s in targets) || !(s in msg)) { continue }
bar = msg[s]
if (bar.volume <= 0 || bar.open <= 0) { continue }
if (!isNull(bar.upLimitPrice) && bar.open >= bar.upLimitPrice) { continue }
position = Backtest::getPosition(context.engine, s)
held = long(0)
if (size(position) > 0) { held = long(position.longPosition) }
planned = max(0, targets[s] - held)
cash = max(0.0, min(remainingCash, Backtest::getAvailableCash(context.engine)))
unitCost = bar.open * (1.0 + context["commission"])
qty = min(roundLot(planned), roundLot(cash / unitCost))
if (qty > 0) {
Backtest::submitOrder(context.engine, (s, context.tradeTime, 5, bar.open, qty, 1), "buy")
remainingCash -= qty * unitCost
}
}
context["pendingRebalance"] = false
}
if (!isNull(context["pendingSellsDate"])) {
if (curDate > context["pendingSellsDate"]) {
for (s in context["pendingSells"].keys()) {
if (!(s in msg)) { continue }
bar = msg[s]
if (bar.volume <= 0 || bar.open <= 0) { continue }
if (!isNull(bar.downLimitPrice) && bar.open <= bar.downLimitPrice) { continue }
position = Backtest::getPosition(context.engine, s)
if (size(position) == 0) { continue }
sellable = max(0, position.lastDayLongPosition - position.todaySellVolume)
qty = long(min(position.longPosition, min(sellable, context["pendingSells"][s])))
if (qty > 0) {
price = iif(isNull(bar.upLimitPrice), bar.open, min(bar.open, bar.upLimitPrice))
Backtest::submitOrder(context.engine, (s, context.tradeTime, 5, price, qty, 3), "sell")
}
}
// 一次尝试后清空意向,未成交不自动追单;后续条件满足可重新触发。
context["pendingSells"] = dict(STRING, LONG)
context["pendingSellsDate"] = date(NULL)
}
}
for (s in msg.keys()) {
bar = msg[s]
if (bar.volume <= 0) { continue }
position = Backtest::getPosition(context.engine, s)
held = long(0)
if (size(position) > 0) { held = long(position.longPosition) }
if (held <= 0) {
if (s in context["posHighSinceEntry"]) { context["posHighSinceEntry"].erase!(s) }
continue
}
highSinceEntry = indicator[s]["adjClose"]
if (s in context["posHighSinceEntry"]) {
highSinceEntry = max(highSinceEntry, context["posHighSinceEntry"][s])
}
context["posHighSinceEntry"][s] = highSinceEntry
sellable = long(max(0, position.lastDayLongPosition - position.todaySellVolume))
stopLine = highSinceEntry - context["atrMultiplier"] * indicator[s]["atr20"]
if (sellable > 0 && isValid(indicator[s]["atr20"]) && indicator[s]["adjClose"] < stopLine) {
context["pendingSells"][s] = long(min(held, sellable))
context["pendingSellsDate"] = curDate
}
}
isRebalanceDay = context["initialRebalancePending"] ||
transFreq(monthEnd(curDate), "XSHG") == curDate
context["initialRebalancePending"] = false
if (!isRebalanceDay) { return }
symbols = []$STRING
momentums = []$DOUBLE
for (s in msg.keys()) {
bar = msg[s]
if (bar.volume <= 0 || !isValid(indicator[s]["mom20"])) { continue }
symbols.append!(s)
momentums.append!(indicator[s]["mom20"])
}
if (size(symbols) == 0) { return }
candidates = table(symbols as symbol, momentums as mom20)
limitN = min(context["holdingCount"], size(candidates))
ranked = select * from candidates order by mom20 desc, symbol asc limit limitN
// 权重仍按排名入选数量计算;非正动量股票不下单,其份额留作现金。
weight = 1.0 / size(ranked)
equity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
targets = dict(STRING, LONG)
for (s in ranked.symbol) {
if (indicator[s]["mom20"] <= 0) { continue }
targets[s] = roundLot(equity * weight / (msg[s].close * (1.0 + context["commission"])))
}
context["selectedStocks"] = ranked.symbol
context["pendingTargetQtys"] = targets
context["rebalanceDate"] = curDate
context["pendingRebalance"] = true
}
2.3 设置回测参数
通过配置字典设置账户和撮合参数,初始化用于保存策略状态的上下文,并设置回测和指标计算涉及的时间区间。
context = dict(STRING, ANY)
context["commission"] = commission
context["holdingCount"] = holdingCount
context["atrMultiplier"] = atrMultiplier
config = {
startDate: actualStartDate,
endDate: actualEndDate,
strategyGroup: "stock",
cash: initialCash,
commission: commission,
tax: tax,
dataType: 4,
enableIndicatorOptimize: true,
dataRetentionWindow: "ALL",
matchingMode: 2,
outputOrderInfo: true,
enableMinimumPerTransactionFee: false,
enableSellCloseRestrict: true
}
引擎及顶层参数含义如下:
-
startDate、endDate:DATE,引用策略回测日期,不包含预热区间。
-
strategyGroup:STRING,"stock",普通股票账户。
-
cash:DOUBLE,引用 initialCash。
-
commission、tax:DOUBLE,引用同名费率,避免账户计费与策略预算不一致。
-
dataType:INT,4,日频行情。
-
enableIndicatorOptimize:BOOL,true,开启订阅指标计算优化。
-
dataRetentionWindow:STRING,"ALL",在指标优化开启时保留全部行情历史.
-
matchingMode:INT,2,日频按开盘价撮合;信号延迟由策略状态实现。
-
outputOrderInfo:BOOL,true,保留订单状态记录。
-
enableMinimumPerTransactionFee:BOOL,false,不启用每笔最低手续费。
-
enableSellCloseRestrict:BOOL,true,限制当日买入持仓的当日卖出。
-
context:DICTIONARY,传入策略参数并承载回调状态。
2.4 创建回测引擎
callbacks 为 Dictionary<STRING, ANY>,本例只设置 initialize 和 onBar。engineName 为固定 STRING,engine 保存创建接口返回的引擎句柄。
创建接口第四个实参为 false,表示不启用 JIT。
config["context"] = context
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "ChandelierIndicatorOpt_" + strReplace(string(now(true)), ".", "_")
engine = Backtest::createBacktester(engineName, config, callbacks, false)
2.5 执行回测
将排序后的完整 data 一次送入引擎,包括配置起点之前的预热行。指标从更早行情积累历史,onBar 从配置的 startDate 开始处理交易。
Backtest::appendQuotationMsg(engine, data)
Backtest::appendEndMarker(engine)
2.6 获取回测结果
处理完结束消息后,分别查询收益概述、订单与成交明细、每日持仓和每日账户权益。用 totalEquity / initialCash 计算净值;本例没有外部资金流入或流出。
将返回表和选股日志保存到 resultDict 后,再销毁引擎。结果仍可在当前会话中查看,不需要再次连接已销毁的引擎。完整结果处理代码如下:
resultDict = dict(STRING, ANY)
resultDict["returnSummary"] = Backtest::getReturnSummary(engine)
resultDict["tradeDetails"] = Backtest::getTradeDetails(engine)
resultDict["dailyPosition"] = Backtest::getDailyPosition(engine)
resultDict["totalPortfolios"] = Backtest::getDailyTotalPortfolios(engine)
接口用途参见 Backtest 接口说明。脚本没有接入真实指数,默认基准统计不能解释为沪深 300 表现或有效超额收益。
3. 回测结果分析
使用上述版本、非 JIT 模式、附件提供的数据,回测处理 8,010 条行情,初始权益为 1,000,000.00 元,区间总收益为 2.7304%。由于数据来自模拟数据,这一指标不能用于评价真实市场中的相对表现。
| 指标 | 本次结果 | 解读 |
|---|---|---|
| 区间总收益 | 2.7304% | 期末权益相对 100 万元初始资金的累计变化 |
| 年化收益 | 2.8448% | 引擎按回测日收益年化后的结果 |
| 年化波动率 | 3.1200% | 价格路径波动幅度 |
| 夏普比率 | 0.9118 | 去掉风险波动后的收益 |
| 最大回撤 | 2.1969% | 期内净值相对先前高点的最大下跌,风险暴露一般 |
| 回撤比率 | 1.2949 | 引擎返回的收益与回撤相关统计 |
| 日胜率 | 53.5270% | 每日收益为正的交易日占比 |
4. 总结
本策略以历史成分股的 20 期动量形成月末入场名单,以持仓最高复权收盘价减去 3 倍 ATR 决定退出,并用信号日价格锁定买入目标。包含预热的 CSV 整体回放,回调由配置的起点开始处理交易;月末只补仓和逐日退出规则决定实际持仓及资金使用。
模拟数据适合用于理解策略实现与回测接口。用于真实投资研究时,应将输入替换为具有明确历史可用时点的行情和财务数据,进一步评估持仓集中、成本、行业偏离和样本外表现。完成这些核对并取得真实运行结果后,才能评价收益和风险。
5. 附录
完整源脚本包含全部函数、数据准备、配置、行情回放和结果查询。外部数据接口及 mytt 模块需由使用环境提供。
