Chandelier 跟踪退出管理

Chandelier 退出策略将动量选股与波动率退出线结合:从沪深 300 历史成分股中选取 20 日动量排名靠前的股票,持仓后以最高复权收盘价减去 3 倍 20 期平均真实波幅作为退出线,收盘跌破该线后,在后续交易日尝试卖出。脚本通过 DolphinDB Backtest 实现日频选股、延迟下单、持仓跟踪、分红除权处理和沪深 300 基准对齐。

1. 策略介绍

动量决定股票是否进入候选名单,退出线决定已有持仓何时离场。两者独立工作:月末落选不会自动卖出,跌破退出线也不需要等待月末。

1.1 核心因子

买入信号由动量触发,动量衡量当前价格相对于历史价格的变化。设未复权收盘价为 C,复权因子为 A,则复权收盘价和 20 期动量为:

Pt=CtAt,Mt=PtPt−20−1

动量 M 为收益比例,这里的 20 期是每只股票输入序列向前移动 20 条记录。

平均真实波幅(ATR)用于度量价格波动,本例直接使用模块函数计算 mytt::ATR(close * adj_factor, high * adj_factor, low * adj_factor, 20):前三个参数依次为复权收盘价、最高价、最低价,最后一个参数为 20 期窗口。输出转换为 DOUBLE,价格单位与复权收盘价一致。

退出线为:

Ht=max(Ht−1,Pt),Lt=Ht−3ATR20,t

H 是持仓存在时、有效行情回调中累计记录的最高复权收盘价;L 与 ATR 均使用复权价格单位。只有 (P < L) 且存在可卖持仓时才产生卖出意向,等于退出线不触发。

这是一种 Chandelier 风格的退出实现,锚点不是最近固定窗口的最高盘中价。加仓不重置锚点,观察到持仓归零后才删除锚点。由于 ATR 会变化,退出线可能下降,代码没有对退出线本身做“只升不降”的约束。

1.2 交易标的

csv 模拟行情文件中默认 30 只虚构普通股票,代码为 000001.SZ 等形式,仅借用证券代码格式,不代表对应真实证券。采用普通股票多头现金账户,买入按 100 股整数倍,卖出受 T+1 可卖量约束,不融资、不做空。模拟行情没有 ST、停牌、新股、退市、送转、配股或现金分红,涨跌停统一设置为昨收的 ±10%。

1.3 交易规则

策略按以下顺序完成一次选股和调仓:

  1. 收到下一交易日日线时,先执行上个选股日锁定的买入目标;只补足目标与现有持仓之间的正差额,资金不足时缩减数量。

  2. 再执行此前保存的卖出意向,以当前可卖量、总持仓和意向数量的最小值提交订单。

  3. 更新现有持仓的最高复权收盘价,检查收盘是否跌破退出线,并记录下一日待卖数量。

  4. 首个回调及月末交易日按动量降序、代码升序取最多 10 只,按实际排名入选数量 N 确定每只名义权重 1/N。

  5. 对排名入选且信号日动量严格大于 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)
表 1. 表 2-1 csv模拟行情字段说明
列名 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. 表 3-1 回测结果
指标 本次结果 解读
区间总收益 2.7304% 期末权益相对 100 万元初始资金的累计变化
年化收益 2.8448% 引擎按回测日收益年化后的结果
年化波动率 3.1200% 价格路径波动幅度
夏普比率 0.9118 去掉风险波动后的收益
最大回撤 2.1969% 期内净值相对先前高点的最大下跌,风险暴露一般
回撤比率 1.2949 引擎返回的收益与回撤相关统计
日胜率 53.5270% 每日收益为正的交易日占比

4. 总结

本策略以历史成分股的 20 期动量形成月末入场名单,以持仓最高复权收盘价减去 3 倍 ATR 决定退出,并用信号日价格锁定买入目标。包含预热的 CSV 整体回放,回调由配置的起点开始处理交易;月末只补仓和逐日退出规则决定实际持仓及资金使用。

模拟数据适合用于理解策略实现与回测接口。用于真实投资研究时,应将输入替换为具有明确历史可用时点的行情和财务数据,进一步评估持仓集中、成本、行业偏离和样本外表现。完成这些核对并取得真实运行结果后,才能评价收益和风险。

5. 附录

完整源脚本包含全部函数、数据准备、配置、行情回放和结果查询。外部数据接口及 mytt 模块需由使用环境提供。