纳指ETF量化策略

同样100万元,保留底仓并加快买回后,期末资产比全部资金采用慢双均线多了134.35万元,达到911.50万元。 但一直持有是1014.62万元。新方案改善了结果,仍没有解决择时拖累收益的问题。
首图由聚宽已完成回测的账户数据重绘;下文保留真实平台指标截图。
1、收益与回撤对比
标的为国泰纳斯达克100ETF(513100),聚宽代码513100.XSHG。
回测从上市首日2013-05-15到2026-09-16,共3246个交易日,覆盖全部可交易历史。 基金成立至上市之间没有场内成交,不能把这段时间虚构为可买入;本轮全部实验统一截至2026年9月16日。
| 指标 | 本文策略 | 买入后一直持有 |
|---|---|---|
| 期末资产 / 万元 | 911.50 | 1014.62 |
| 累计收益 | 811.50% | 914.62% |
| 年化收益 | 18.55% | 19.54% |
| 最大回撤 | 27.39% | 27.84% |
| 平均实际仓位 | 91.42% | 95.93% |
与一直持有相比,策略少赚103.12万元,最大回撤减少0.45个百分点。要判断这种保护是否划算,应把这两项放在一起看。
两组起始资金均为100万元,最初买入预算均为90万元。“买入后一直持有”是从首个可成交日起按预算完成建仓,之后一直不动,不主动卖出、不补仓,现金分红留在账户。 余款保留现金,不追加本金。
本文策略将本金分为两个50万元子账户,各自使用45万元建仓预算、0.5%的分钟成交量限制;持有对照为单账户、1%的限制。下文另列同样分仓但取消择时的结果,分清分仓执行与择时的影响。

最大回撤是账户从历史高点向后的最大跌幅;收益已计入平台记录的分红、份额调整和费用,年化收益沿用聚宽口径。
本文使用聚宽开发与回测,环境准备可参照基础篇。
2、量化策略的思路与实现
纳指的长期上涨,让错过反弹的代价不断累积。本次保留长期底仓,同时缩短择时部分重新入场的等待。
全部资金采用慢双均线时,重新确认上涨需要较长等待。本文将100万元等分为长期、择时两部分,择时部分用长期线决定退出,用短期修复决定买回。
| 同区间方案 | 累计收益 | 最大回撤 |
|---|---|---|
| 全部资金采用慢双均线 | 677.15% | 26.57% |
| 本文方案 | 811.50% | 27.39% |
| 买入后一直持有 | 914.62% | 27.84% |
| 环节 | 代码执行规则 |
|---|---|
| 资金分配 | 长期、择时各50万元;各自最初买入预算90% |
| 退出 | 昨收低于MA250的97%,且20日涨跌幅低于−3% |
| 恢复 | 昨收高于MA20的101%,且20日涨跌幅高于3% |
| 跨境时点 | 只读取该场内ETF已经完成的A股交易日日线 |
| 底仓 | 长期账户不参与退出,不在两个账户间再平衡 |
这同时改变了参与程度和恢复速度。长期账户承受完整ETF涨跌,择时账户可在趋势修复早期回场。200日、300日窗口检验长期线附近的变化,额外延迟一天则检查优势是否过分依赖某个成交日。
人民币场内ETF价格还包含汇率、溢折价及跟踪误差。没有使用当天尚未发生的美股收盘,也没有用纳指指数收益冒充513100的可成交收益。
与“全部资金采用慢双均线”相比,收益提高的同时,最大回撤也从26.57%扩大到27.39%。保留更多上涨机会,也承受了更多下跌。

prepare_signal()只读取已经完成的日线;strategy_rule()判断继续持有或退出;execute_signal()提交今天的限价单;close_report()按真实成交核算现金、份额和费用。信号出现不代表一定成交,买回也不保证比卖出价更低。
重新入场只使用当时可交易账户总资产的90%;持有期间维持原份额,不每天补回90%仓位。
完整历史有成交订单92笔,累计佣金6762.32元。订单数量包含建仓和退出的分批成交,不等于买卖轮数。

改变条件后,收益还能留下多少
| 检验 | 累计收益 | 最大回撤 |
|---|---|---|
| 本文策略 | 811.50% | 27.39% |
| 佣金与滑点翻倍 | 803.07% | 27.39% |
| 信号额外延迟1日 | 804.04% | 27.39% |
| MA200 | 779.65% | 27.38% |
| MA300 | 792.14% | 27.38% |
| 同样分仓,取消择时 | 914.62% | 27.84% |

四项成本、延迟与邻近参数检验中,0/4项超过原持有对照。主策略仍未超过持有,不能把终值提高等同于择时有效。
双倍成本把单边佣金从0.03%提高到0.06%、滑点从0.10%提高到0.20%,最低佣金仍为5元,限价偏移从0.2%扩大到0.4%。它与原成本持有账户比较,是更严格的压力检验;延迟试验额外跳过最近1个完整交易日的行情。邻近参数在提交回测前已固定,未改用其中收益最高的一组。
保持相同两账户和成交限制、取消择时后,期末为1014.62万元。本文策略比它少103.12万元,说明收益差异主要来自择时,不能归因于分仓成交方式。

滚动252交易日收益超过持有的窗口占9.08%;按不重叠的252交易日分段,胜出比例为8.33%。滚动窗口相互重叠,不是不相关的独立样本。
| 连续原账户分段 | 本文策略 | 一直持有 |
|---|---|---|
| 2013-05-15 至 2014-05-28 | 21.33% | 21.33% |
| 2014-05-29 至 2015-06-08 | 17.10% | 17.10% |
| 2015-06-09 至 2016-06-20 | 4.76% | 6.12% |
| 2016-06-21 至 2017-07-03 | 27.35% | 27.82% |
| 2017-07-04 至 2018-07-12 | 26.02% | 26.37% |
| 2018-07-13 至 2019-07-25 | 6.75% | 10.86% |
| 2019-07-26 至 2020-08-07 | 37.17% | 38.05% |
| 2020-08-10 至 2021-08-19 | 21.94% | 22.31% |
| 2021-08-20 至 2022-09-02 | -11.12% | -12.06% |
| 2022-09-05 至 2023-09-15 | 29.27% | 31.52% |
| 2023-09-18 至 2024-10-09 | 23.83% | 24.48% |
| 2024-10-10 至 2025-10-22 | 28.34% | 31.47% |
| 2025-10-23 至 2026-09-16 | 17.04% | 17.58% |
以上均沿用原账户,不在分段起点清仓或重新投入100万元;末段不足252交易日的部分不纳入分段胜出比例。
当前证据还不足以称为“稳定高收益”。 本轮规则是在已经查看过的历史上修订的,此前也进行过多轮研究;全历史、分段、成本检验均属于回顾性验证。下一步应冻结规则,用尚未发生的行情做模拟跟踪,记录每次信号、未成交和实际成本,再判断能否投入实盘。
3、在聚宽复现回测
- 在“我的策略”中新建Python 3空白策略,粘贴下一节全部代码。
- 回测起点设为2013-05-15,终点设为2026-09-16,初始资金1000000元,运行频率选分钟。
- 检查代码中的ETF代码
513100.XSHG,保持主参数和费用不变,运行到显示“回测完成”。 - 在“收益概述”核对收益、回撤,在“交易详情”核对成交,在“每日持仓&收益”核对资产。平台的“基准收益”是ETF参考线,不是本文独立运行的“买入后一直持有”账户。

上图为对应主策略的聚宽原始回测截图,包含回测区间、初始资金、完成状态、收益指标及完整收益曲线,未重写平台数字。图中基准线为对应ETF的页面参考线;正文“买入后一直持有”对照为另行回测的同一ETF账户,两者口径不同。
程序09:05读取昨日及更早行情,10:00挂限价单,10:05撤余单,15:10记账。上市起先按预算建仓,积累250个完整交易日后才启用择时,预热期的涨跌仍计入全历史。
费用为买卖各0.03%、每笔最低5元、基金印花税为0,单边滑点0.10%。买单按100份整手提交,部分成交可能不是整手;未完成买入按剩余原预算续单,卖出持续到可卖份额处理完毕。
复现持有对照时,复制一份相同代码,在CONFIG中只修改以下两项,回测资金、区间、频率保持一致:
'mode': 'hold',
'experiment': 'ETF17F_17_hold_compare',
要再检验“同样分仓、取消择时”,把mode设为split_hold并另换一个experiment名称,保留core: 0.5。
代码运行后会在研究文件中生成每日账户、订单和逐笔成交记录。复核总资产要用账面现金加持仓市值;可用现金可能不包含暂时冻结的资金。以上是历史撮合模拟,尚未接入券商实盘。
4、完整策略代码
复制以下全部代码即可运行。中文注释覆盖参数、信号、下单预算、成交限制和账户核对。
# -*- coding: utf-8 -*-
"""聚宽Python3完整策略。分钟回测,100万元;信号仅使用已完成日线。
长期底仓和择时仓使用真实子账户,资金总和始终为原100万元。
不借钱、不追加本金、不把固定资金比例误写成每日固定ETF仓位。
"""
from jqdata import *
from builtins import all, any, max, min
import csv
import io
import json
import math
import numpy as np
# 纳指ETF量化策略;国泰纳斯达克100ETF。
# mode: strategy为正文策略;hold为买入后一直持有;split_hold移除择时但保留分仓。
# core是初始本金分给长期账户的比例;后续不在账户之间转钱、不再平衡。
# delay_days只供延迟检验;reinvest_months为分红再投入的检查间隔。
CONFIG = {'security': '513100.XSHG',
'listing': '2013-05-15',
'mode': 'strategy',
'commission': 0.0003,
'slippage': 0.001,
'limit_offset': 0.002,
'delay_days': 0,
'reinvest_months': 1,
'experiment': 'ETF17F_17_main',
'core': 0.5,
'params': {'family': 'recovery', 'slow': 250, 'band': 0.03, 'recovery': 20, 'momentum': 20},
'warmup': 250}
def strategy_rule(h, active, cfg):
"""入场与退出不对称;持有时查退出,空仓时查恢复,不用未来价格。"""
p, c = cfg['params'], h.close.values
if p['family'] == 'recovery':
momentum = c[-1] / c[-p['momentum']-1] - 1
leave = c[-1] < c[-p['slow']:].mean()*(1-p['band']) and momentum < -p['band']
enter = c[-1] > c[-p['recovery']:].mean()*1.01 and momentum > p['band']
elif p['family'] == 'fast_guard':
leave = (c[-1] < c[-p['high_window']:].max()*(1-p['drop'])
and c[-1] < c[-p['slow']:].mean())
enter = not leave and c[-1] > c[-p['recovery']:].mean()*1.01
else:
raise ValueError('未知择时规则')
if active:
return not leave, '触发风险退出' if leave else '继续持有择时仓'
return bool(enter), '满足恢复入场' if enter else '等待价格恢复'
def initialize(context):
"""按配置划分原始本金;手续费、滑点和交易时间与对照相同。"""
g.cfg = dict(CONFIG)
assert abs(context.portfolio.starting_cash - 1000000) < .01
assert g.cfg['mode'] in ('strategy', 'hold', 'split_hold')
g.out = g.cfg['experiment']
# hold只有一个不卖出的账户;split_hold保留相同分仓、移除择时作消融。
core = 1. if g.cfg['mode'] == 'hold' else g.cfg['core']
fractions = [('core', core), ('tactical', 1-core)]
g.sleeves = []
accounts = []
for role, fraction in fractions:
if fraction <= 0:
continue
idx = len(accounts)
cash = 1000000*fraction
accounts.append(SubPortfolioConfig(cash=cash, type='stock'))
g.sleeves.append(dict(index=idx, role=role, initial_cash=cash,
active=True, pending_exit=False, remaining=None, started=False, ever_bought=False,
target=None, reason='', signal_date='', attempt=None, reserve=None))
set_subportfolios(accounts)
g.days = 0
g.order_owner = {}
g.reinvest_clock = 0
set_benchmark(g.cfg['security']) # 页面参考线仍不是本文独立持有账户。
set_option('use_real_price', True)
set_option('avoid_future_data', True)
# 两个子账户各用0.5%,总和不超过1%,避免分账户放大可成交量。
set_option('order_volume_ratio', .01/len(g.sleeves))
set_order_cost(OrderCost(open_tax=0, close_tax=0,
open_commission=g.cfg['commission'], close_commission=g.cfg['commission'],
close_today_commission=0, min_commission=5), type='fund', ref=g.cfg['security'])
set_slippage(PriceRelatedSlippage(2*g.cfg['slippage']), type='fund', ref=g.cfg['security'])
run_daily(prepare_signal, time='09:05')
run_daily(execute_signal, time='10:00')
run_daily(cancel_remaining, time='10:05')
run_daily(close_report, time='15:10')
write_file(g.out+'_daily.csv', 'date,total_value,cash,shares,position_value,exposure,target,signal_date,reason\n', append=False)
write_file(g.out+'_sleeves.csv', 'date,pindex,role,total_value,cash,shares,position_value,active,pending_exit,remaining,reserve\n', append=False)
write_file(g.out+'_orders.csv', 'date,order_id,is_buy,requested,filled,price,commission,status,pindex\n', append=False)
write_file(g.out+'_trades.csv', 'time,trade_id,order_id,amount,price\n', append=False)
log.info('CONFIG '+json.dumps(g.cfg, ensure_ascii=True, sort_keys=True))
def position_of(context, s):
"""始终在对应子账户内读取持仓,底仓不会被择时卖单误卖。"""
positions = context.subportfolios[s['index']].long_positions
return positions[g.cfg['security']] if g.cfg['security'] in positions else None
def prepare_signal(context):
"""09:05决策;延迟检验多跳过最近1个已完成交易日。"""
today = context.current_dt.date()
prior = context.previous_date
new_month = (today.year, today.month) != (prior.year, prior.month)
if new_month and (today.month-1) % g.cfg['reinvest_months'] == 0:
g.reinvest_clock = 1 + g.cfg['delay_days']
reinvest_due = g.reinvest_clock == 1
if g.reinvest_clock:
g.reinvest_clock -= 1
for s in g.sleeves:
s.update(target=None, reason='', signal_date='')
pos = position_of(context, s)
held = int(pos.total_amount) if pos else 0
passive = s['role'] == 'core' or g.cfg['mode'] == 'split_hold'
if passive or g.cfg['params']['family'] == 'reinvest':
s['target'], s['reason'] = True, '买入后一直持有'
# 红利策略只投入已到账分红;首次建仓留下的现金储备固定保留。
# 两个边界:首次建仓完成前不再投入;已有续单不重复发放预算。
if (g.cfg['mode'] == 'strategy' and g.cfg['params']['family'] == 'reinvest'
and reinvest_due and s['started'] and s['remaining'] is None):
sub = context.subportfolios[s['index']]
s['remaining'] = max(0., sub.available_cash - s['reserve'])
s['reason'] = '定期将已到账分红再投入'
continue
if s['pending_exit'] and held:
s['target'], s['reason'] = False, '继续完成择时仓退出'
continue
s['pending_exit'] = False
if g.days < g.cfg['warmup'] + g.cfg['delay_days']:
s['target'], s['reason'] = True, '上市初期先建仓,历史齐备后择时'
continue
try:
count = g.cfg['warmup'] + g.cfg['delay_days']
h = attribute_history(g.cfg['security'], count, '1d',
['close', 'paused'], skip_paused=False, df=True, fq='pre')
if h is None or len(h) != count or str(h.index[0].date()) < g.cfg['listing']:
s['reason'] = '上市后历史不足,保留原状态'
continue
if h.index.has_duplicates or not h.index.is_monotonic_increasing or h.index[-1].date() != prior:
raise ValueError('历史日期异常')
if not np.isfinite(h.close.values).all() or (h.close <= 0).any():
raise ValueError('历史价格异常')
if h.paused.isnull().any() or h.paused.iloc[-1] != 0:
raise ValueError('上日停牌或停牌字段异常')
if g.cfg['delay_days']:
h = h.iloc[:-g.cfg['delay_days']]
active, reason = strategy_rule(h, s['active'], g.cfg)
s.update(active=active, target=active, reason=reason, signal_date=str(h.index[-1].date()))
if not active:
s['remaining'] = None
s['pending_exit'] = bool(held)
except Exception as error:
s['reason'] = 'DATA_ERROR '+str(error)
log.warn(s['reason'])
def limit_price(price, high, low, buying, offset):
"""0.001元报价精度,涨停不追买、跌停不假定能卖出。"""
if not all(math.isfinite(x) and x > 0 for x in (price, high, low)):
return None
if high <= low or not low <= price <= high:
return None
if buying:
if price >= high-.001:
return None
return math.floor((min(price*(1+offset), high-.001)+1e-9)*1000)/1000
if price <= low+.001:
return None
return math.ceil((max(price*(1-offset), low+.001)-1e-9)*1000)/1000
def affordable_amount(budget, price, commission):
"""买单预算含佣金,向下取100份整数;不挪用另一账户现金。"""
if budget <= 5 or price <= 0:
return 0
return max(0, int((budget-5)/(price*(1+commission))/100)*100)
def execute_signal(context):
"""每个子账户每天最多一个限价单;未成交部分次日按剩余预算续单。"""
today = context.current_dt.date()
security = g.cfg['security']
quote = get_current_data()[security]
price = float(quote.last_price)
if quote.paused or not math.isfinite(price) or price <= 0:
return
for s in g.sleeves:
if s['target'] is None or s['attempt'] == today:
continue
sub = context.subportfolios[s['index']]
pos = position_of(context, s)
held = int(pos.total_amount) if pos else 0
buying = s['target']
if buying and held and s['remaining'] is None:
continue
if not buying and not held:
continue
lp = limit_price(price, float(quote.high_limit), float(quote.low_limit), buying, g.cfg['limit_offset'])
if lp is None:
continue
if buying:
if s['remaining'] is None:
# 每次重新入场只使用本子账户资产的90%;原始建仓按原始本金。
s['remaining'] = (sub.total_value if s['ever_bought'] else s['initial_cash'])*.9
budget = min(s['remaining'], sub.available_cash)
amount = affordable_amount(budget, lp, g.cfg['commission'])
if amount == 0:
s['remaining'] = None
if not s['started']:
s['started'] = True
# 锁定首次建仓后剩余现金(含整手零头),今后只动增量分红。
s['reserve'] = float(sub.total_value - sub.positions_value)
else:
amount = -min(held, int(pos.closeable_amount))
if not amount:
continue
s['attempt'] = today
result = order(security, amount, style=LimitOrderStyle(lp), pindex=s['index'])
if result is not None:
g.order_owner[str(result.order_id)] = s['index']
def cancel_remaining(context):
"""撤掉本策略余单,保留实际成交;清仓指令持续到该子账户卖完。"""
for o in get_open_orders().values():
if o.security == g.cfg['security']:
cancel_order(o)
def append_csv(suffix, values):
buffer = io.StringIO()
csv.writer(buffer).writerow(values)
write_file(g.out+suffix, buffer.getvalue(), append=True)
def close_report(context):
"""逐笔成交核算预算,并保存各子账户,用于核查底仓、分红与资金守恒。"""
cancel_remaining(context)
fills = {}
for t in get_trades().values():
if t.security == g.cfg['security']:
key = str(t.order_id)
amount, turnover = fills.get(key, (0, 0.))
fills[key] = amount+t.amount, turnover+t.amount*t.price
append_csv('_trades.csv', [str(t.time), str(t.trade_id), key, t.amount, t.price])
for o in get_orders().values():
if o.security != g.cfg['security']:
continue
key = str(o.order_id)
idx = g.order_owner[key]
s = g.sleeves[idx]
amount, turnover = fills.get(key, (0, 0.))
assert amount == o.filled, '订单与逐笔成交量不一致'
append_csv('_orders.csv', [str(context.current_dt.date()), key, bool(o.is_buy),
o.amount, o.filled, turnover/amount if amount else 0., o.commission, str(o.status), idx])
if o.is_buy and o.filled and s['remaining'] is not None:
s['ever_bought'] = True
s['remaining'] = max(0., s['remaining']-turnover-o.commission)
total = cash = value = shares = 0.
for s in g.sleeves:
sub = context.subportfolios[s['index']]
pos = position_of(context, s)
qty = int(pos.total_amount) if pos else 0
pv = float(sub.positions_value)
total += sub.total_value
cash += sub.available_cash
shares += qty
value += pv
append_csv('_sleeves.csv', [str(context.current_dt.date()), s['index'], s['role'],
sub.total_value, sub.available_cash, qty, pv, s['active'], s['pending_exit'],
s['remaining'], s['reserve']])
assert abs(total-context.portfolio.total_value) < .05, '子账户资金合计异常'
record(account_nav=total/1000000, exposure=value/total)
sources = [s['signal_date'] for s in g.sleeves if s['signal_date']]
append_csv('_daily.csv', [str(context.current_dt.date()), total, cash, shares, value,
value/total, '', max(sources) if sources else '', '|'.join(s['reason'] for s in g.sleeves)])
g.days += 1
纳指这次改动减少了空仓的机会成本,但与一直持有相比,少赚的金额仍大,回撤改善却很有限。按提高收益并兼顾稳定性的目标,这套择时不宜替代长期持有。后续应固定规则跟踪新增行情,先证明退出有用,再讨论增加策略复杂度。



