上升三角形形态策略
上升三角形形态策略以“阶段性低点抬高、向上突破前期高点、此前振幅足够”为入场线索,并在所持股票的价格跌破短中期趋势线时离场。本文使用 Backtest 完成股票池过滤、形态识别、开盘撮合和结果汇总,重点说明如何将形态识别思路转化为可复现、可核查的回测案例。
1. 策略介绍
上升三角形通常表现为:价格多次尝试突破相对稳定的压力位,同时低点逐步上移。本案例将上述形态转化为三个可计算的条件,并从符合条件的模拟股票中选择价格较高的标的买入。卖出规则则使用前一交易日收盘价与 20 日均线的关系,避免当日收盘信息参与当日开盘决策。
1.1 核心因子
本案例的因子是由历史 OHLC 日线预先计算的形态与趋势特征。策略在 T 日开盘时作出交易判断,所用特征均来自 T-1 或更早时点的数据,不使用未来数据。
| 特征 | 含义 | 本例使用方式 |
|---|---|---|
| 20日振幅 | 过去一段时间的高低价格空间 | 要求 T-1 的 20 日最高价大于 20 日最低价的 1.05 倍,排除波动不足的标的 |
| 10日低点抬高 | 短期支撑逐步上移 | 要求 T-1 的 10 日最低价高于 T-2 的 10 日最低价 |
| 20日高点突破 | 价格突破较长期阻力 | 要求 T-1 收盘价高于截至 T-2 的 20 日最高价 |
| 20日均线 | 中短期趋势基准 | 持仓股票在 T-1 收盘价低于 T-1 的 20 日均线时,于 T 日开盘卖出 |
前三项条件同时满足时,即形成上升三角形的买入信号。这三个条件只是对形态的量化近似:20 日高点不必严格相等,10 日低点抬高也不等同于图形上的每一个回撤低点。因此,这组条件适合用于规则化筛选,但不应被视为经典形态的唯一数学定义。
1.2 交易标的
本例使用 50 个模拟日线数据,标的代码后缀字符串为 .SH,例如 000027.SH。本例还构建了一个模拟的 000300.SH 成分股快照,将这 50 只模拟证券全部视为指数成员;ST 历史表为空,模拟上市日期均早于回测开始日一年。因此,名称中的“沪深 300”只说明了股票池过滤流程,不代表任何真实指数成分或真实证券。
回测采用普通股票现金账户,只做多,不做空、融资或借券。主板和创业板买入数量向下取整为 100 股整数倍;对科创板标的保留“满 200 股后可逐股申报”的数量规则。日线订单按开盘价撮合,设置订单簿撮合比例为 0.2,故委托可能部分成交或未成交。
接入真实数据时,应使用带历史时点的指数成分、ST 状态、上市日期、停复牌状态、复权因子与涨跌停价格。
1.3 交易规则
一次日线回调按下列顺序处理:
-
先检查已有持仓:若前一日复权收盘价低于前一日 20 日均线,并且当日有成交量、开盘未跌停,则提交卖单。
-
从预计算的模拟成分股池中取得当日候选标的,剔除无成交量、开盘涨停或已有持仓的股票,并排除历史窗口尚未形成的记录。
-
对候选股票同时应用“20 日振幅超过 5%”、“10 日低点抬高”、“收盘突破此前 20 日高点”三个条件。
-
按前一日复权收盘价降序排列,最多选择 5 只;若实际只出现 N 只候选,则按 1/N 等权分配当前账户权益,不会用不合格股票补足 5 只。
-
使用 T 日开盘价、佣金和可用现金计算买入股数,向下调整到交易单位;卖出先于买入,以释放资金和持仓额度。
-
订单只在本次回调提交,实际成交量由撮合引擎和订单簿参与比例决定。
2. 策略回测
以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
下载附录中的 ascendingTriangle.csv 文件,作为回测的案例数据。回测账户权益记录覆盖 242 个交易日,起始日为2024-01-02,结束日为2024-12-31。
策略初始化中,需要通过构建每日的成分股股票池来传参,因此额外生成成分股数据。
| 数据类别 | 关键字段 | 用途 |
|---|---|---|
| 模拟行情 | symbol、tradeTime、open、high、low、close、volume、amount | 驱动日线回调与开盘撮合 |
| 模拟交易限制 | upLimitPrice、downLimitPrice | 过滤开盘涨停买入和跌停卖出情形 |
| 复权字段 | adj_factor | 计算形态窗口与 20 日均线;本例恒为 1,仅演示接口 |
| 形态特征 | prev_close、prev_ma20、prev_high20、prev_low20、prev_low10、prev2_low10、prev2_high20 | 仅使用已完成交易日的数据识别形态与离场条件 |
| 模拟元数据 | 指数快照、ST 历史、上市日期 | 演示成分、ST 与上市满 9 0日的股票池过滤 |
为避免使用未来函数,先按交易时间对每只股票的数据排序,再将计算得到的特征序列后移一位或两位,形成相应的滞后特征。例如,T 日开盘使用的 prev_close 是 T-1 收盘价,prev2_high20 是截至 T-2 的 20 日最高价。真实研究还需处理复权、停牌、除权除息、涨跌停制度和数据缺失,而不是简单沿用模拟字段。
注意,这些模拟数值仅用于演示回测系统的执行流程,并非真实 A 股历史数据的测试结果,不能作为实盘参考。
2.2 编写策略逻辑
策略由交易单位处理、交易条件判断、初始化和日线回调构成。日线回调先处理卖出,后筛选并买入新标的,以便将卖出所得资金用于后续买入。
初始化时,将佣金设为 0.015%、卖出印花税设为 0.05%,并把调仓日历及按日期预计算的股票池放入 context。本例将所有交易日都作为检查日,但不等同于每天都交易:只有股票出现完整形态且通过各项限制时,策略才会提交该股票的买入委托。
def initialize(mutable context) {
context["commission"] = 0.00015
context["tax"] = 0.0005
context["rebalanceDates"] = objByName("rebalanceDates_")
context["poolByDate"] = objByName("poolByDate_")
}
买入筛选的核心逻辑可概括为:
-
20 日最高价 > 20 日最低价 × 1.05
-
且 T-1 的 10 日最低价 > T-2 的 10 日最低价
-
且 T-1 收盘价 > 截至 T-2 的 20 日最高价
满足条件后,策略按 T-1 收盘价从高到低取前 5 只。实际入选少于 5 只时,资金仍按实际数量等权分配;若没有候选股票,策略不补仓。持仓卖出条件为 prev_close < prev_ma20,且必须通过当日成交量和跌停价格检查。
这种设计将“信号生成”和“执行可行性”分开:形态来自前一日及更早数据,开盘价、涨跌停和成交量决定当天能否提交或成交订单。实际交易系统还应进一步纳入滑点、冲击成本、单券容量和行业集中度约束。
onBar 回调的完整代码如下:
def onBar(mutable context, msg, indicator) {
curBarTs = msg.values()[0]["tradeTime"]
curDate = date(curBarTs)
commission_ = context["commission"]
tax_ = context["tax"]
expectedCash = Backtest::getAvailableCash(context.engine)
// 第一步:开盘处理卖出(先卖后买,释放资金与持仓)
// 卖出条件:持仓股在前一日复权收盘价跌破前一日20日复权均线
for (istock in msg.keys()) {
bar = msg[istock]
if (bar["volume"] <= 0) { continue } // 停牌跳过
posInfo = Backtest::getPosition(context.engine, istock)
if (size(posInfo) == 0) { continue }
pos = posInfo["longPosition"]
pos_sellable = max(0, posInfo["lastDayLongPosition"] - posInfo["todaySellVolume"])
if (pos <= 0 || pos_sellable <= 0) { continue }
// 指标直接从行情 bar 中提取
pClose = bar["prev_close"]
pMa20 = bar["prev_ma20"]
if (isValid(pClose) && isValid(pMa20) && pClose < pMa20) {
if (!isSellableAt(bar["open"], bar)) { continue } // 开盘跌停跳过
sellQty = normalizeSellQty(istock, pos, pos_sellable)
if (sellQty > 0) {
sellPrice = iif(isNull(bar["upLimitPrice"]), bar["open"], min(bar["open"], bar["upLimitPrice"]))
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, sellPrice, sellQty, 3), "sell")
expectedCash += sellQty * sellPrice * (1.0 - commission_ - tax_)
}
}
}
// 第二步:买入条件筛选(直接通过预计算好的股票池做 O(1) 检索)
if (!(curDate in context["rebalanceDates"])) { return }
pool_today = context["poolByDate"][curDate]
if (size(pool_today) == 0) { return }
candidateSymbols = []$STRING
candidatePrevClose = []$DOUBLE
for (istock in msg.keys()) {
if (!(istock in pool_today)) { continue }
bar = msg[istock]
if (bar["volume"] <= 0) { continue } // 停牌跳过
if (!isBuyableAt(bar["open"], bar)) { continue } // 开盘涨停跳过
// 仅对当前空仓股票进行买入筛选
posInfo = Backtest::getPosition(context.engine, istock)
pos = iif(size(posInfo) > 0, posInfo["longPosition"], 0)
if (pos > 0) { continue }
pClose = bar["prev_close"]
pHigh20 = bar["prev_high20"]
pLow20 = bar["prev_low20"]
pLow10 = bar["prev_low10"]
p2Low10 = bar["prev2_low10"]
p2High20 = bar["prev2_high20"]
if (!(isValid(pClose) && isValid(pHigh20) && isValid(pLow20) && isValid(pLow10) && isValid(p2Low10) && isValid(p2High20))) {
continue
}
// 上升三角形买入三要素:
// 1) 过去20日振幅充分:过去20日最高价 > 过去20日最低价 * 1.05
// 2) 底部抬高:最新10日最低价 > 前一日的10日最低价
// 3) 向上突破:前一日收盘价 > 截至前一日的20日最高价
if ((pHigh20 > pLow20 * 1.05) && (pLow10 > p2Low10) && (pClose > p2High20)) {
candidateSymbols.append!(istock)
candidatePrevClose.append!(pClose)
}
}
// 第三步:排序截断与开盘买入(按实际选中数量 nSelected 等权满仓均分)
nCandidates = size(candidateSymbols)
if (nCandidates == 0) { return }
candTable = table(candidateSymbols as symbol, candidatePrevClose as prev_close)
selectedTable = select top 5 * from candTable order by prev_close desc
selectedStocks = exec symbol from selectedTable
nSelected = size(selectedStocks)
if (nSelected == 0) { return }
// 真正等权满仓:按实际选中只数均分 (1 / nSelected)
totalEquity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
targetBudgetPerStock = totalEquity * (1.0 / nSelected)
availCash = min(expectedCash, Backtest::getAvailableCash(context.engine))
for (s in selectedStocks) {
bar = msg[s]
openPrice = bar["open"]
if (openPrice <= 0) { continue }
budget = min(targetBudgetPerStock, availCash)
if (budget <= 0) { continue }
buyQty = roundLotBuy(s, budget / (openPrice * (1.0 + commission_)))
if (buyQty > 0) {
Backtest::submitOrder(context.engine, (s, context.tradeTime, 5, openPrice, buyQty, 1), "buy")
cost = buyQty * openPrice * (1.0 + commission_)
availCash -= cost
expectedCash -= cost
}
}
}
2.3 设置回测参数
| 参数 | 本例取值 | 说明 |
|---|---|---|
| 回测区间 | 2024-01-01 至 2024-12-31 | 使用上交所交易日历推进回测 |
| 初始资金 | 1,000,000 元 | 用于计算账户权益、净值与买入预算 |
| 手续费 | 0.00015 | 买卖均按成交金额计提佣金 |
| 卖出印花税 | 0.0005 | 卖出时计提 |
| 数据类型 | 4 | 股票日频行情并触发 onBar 回调 |
| 撮合模式 | 2 | 日频开盘价撮合 |
| 订单簿参与比例 | 0.2 | 限制委托可参与的成交量,可能出现部分成交或撤单状态 |
| 最低手续费 | 不启用 | 费用直接按比例计算 |
| 最大买入标的数 | 5 | 以实际入选数量等权,而非固定按 5 等分 |
佣金、印花税和部分成交共同影响最终账户权益。使用真实市场数据时,应按实际市场规则配置最低佣金、过户费、停牌、价格笼子和交易单位,并用合理的成交容量约束替代示例参数。
2.4 创建回测引擎
策略将 initialize 与 onBar 注册为回调,再以日频股票配置创建 Backtest 引擎。在创建前尝试清理同名引擎,避免上一次运行遗留的状态影响本次回测。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "AscendingTriangleEngine"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)
创建引擎后,行情按 tradeTime、symbol 排序送入。排序是日线事件回放的基础:同一交易日的股票构成一次回调,而策略只读取事先滞后的窗口特征,因此不会把同一天收盘信息用于同一天开盘交易。
2.5 执行回测
脚本调用 Backtest::appendQuotationMsg 回放全部模拟行情,再以 appendEndMarker 告知引擎数据结束。
allMarketData = select * from allMarketData order by tradeTime, symbol
Backtest::appendQuotationMsg(engine, allMarketData)
Backtest::appendEndMarker(engine)
2.6 获取回测结果
结果对象的使用方式如下:
// 获取核心回测结果
tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
-
returnSummary:汇总区间收益、年化收益、波动率、夏普比率、最大回撤、换手率和胜率等引擎指标。
-
tradeDetails:保留委托状态、价格和数量,必须区分提交、部分成交、撤单与实际成交。
-
dailyPosition:逐日记录持仓数量、当日买卖量、均价和收盘价,可用于核对持仓变化。
-
totalPortfolios:逐日记录现金、市值、总权益、净值、损益和累计费用。
3. 回测结果分析
本次运行的回测结果指标如下:
| 指标 | 本次结果 | 解读 |
|---|---|---|
| 初始权益 | 1,000,000.00 元 | 回测资金起点 |
| 期末权益 | 3,476,709.51 元 | 年末无持仓,全部表现已体现在现金与已实现损益中 |
| 区间总收益 | 247.67% | 单次模拟路径的区间资金变化 |
| 年化收益 | 266.04% | 引擎按日频净值折算的年化指标 |
| 年化波动率 | 378.54% | 极高,反映模拟价格路径与集中持仓造成的剧烈净值波动 |
| 最大回撤 | 84.37% | 曾出现接近全部权益损失的深度回撤,风险不可忽略 |
| 夏普比率 | 0.7028 | 收益相对于波动的补偿很低,不能仅因总收益为正就判断策略优秀 |
| 日胜率 | 51.82% | 策略在日度频率上的盈利概率 |
| 换手率 | 11.28% | 反映策略调仓频率和资金周转情况 |
本次的高收益不能证明该形态策略在真实 A 股中有效,结果中 benchmarkReturn、alpha 和 beta 呈现的零值也不能解读为相对沪深 300 的超额表现。用于真实研究时,应将数据源替换为按历史可得时点整理的真实行情、复权、指数成分与交易状态数据,再进行多区间、样本外和成本敏感性检验。
4. 总结
本案例把上升三角形拆成可执行的三项历史特征:足够的 20 日振幅、10 日低点抬高和向上突破此前 20 日高点;再以跌破 20 日均线作为离场条件。通过模拟数据和 DolphinDB Backtest,可以完整走通从模拟日线、无未来特征预计算、股票池过滤、开盘撮合到订单与账户结果读取的流程。
当前脚本可作为形态策略的教学案例,也可用于接口验证。开展真实市场的策略研究前,应先确保数据可复现,并补全真实交易约束与基准;评价单次模拟收益时,还应结合回撤、波动、容量和样本外稳定性。
5. 附录
完整脚本包含策略函数、配置和引擎执行流程。
