单一合约日间趋势交易策略——单均线策略
逻辑过程
如果按最简单的方式思考,只针对一份股指期货合约,如当月的中证500股指期货合约,我们可以仅考虑在合约价格有往上涨的趋势时做多期货合约,在合约价格有往下跌的趋势时进行平仓(即做空相同期货合约)。
那么问题来了,如何判断合约价格有往上涨的趋势呢?最简单来说,假如在2023-06-13这一天,我们想判断是否在今天建仓(未来上涨long & 未来下跌short),而我们所拥有最简单的数据就是过去每天的收盘价,按正常逻辑思考,如果2023-06-12的收盘价高于2023-06-11,那我们基于经验信息对于未来价格上涨的心理倾向便会有一定程度加强。反之,如果收盘价降低,则预期未来存在下跌可能。
但单纯只判断两天的收盘价,存在很大的偶然性,无法成为一个比较好的能推断未来的特征(因子)。所以我们可以把这个特征进一步进行处理,选取过去(N_ma5 = 5)天的数据(N_ma5其实会成为我们构建策略中可调整的参数),计算其收盘价均值,也就是5日均线。那策略简单描述如下:当昨日收盘价高于5日均线时,如果多头与空头头寸为0,我们于当天做多合约,如果多头头寸为0,空头头寸不为0,我们于当天先平仓,再做多合约;当昨日收盘价低于5日均线时,如果我们多头和空头头寸为0,我们于当天做空合约,如果多头头寸不为0,空头头寸为0,我们于当天先平仓,再做多合约。
注:为了策略设计的简便,本文暂时不考虑交割日,仅在合约期内进行多空交易;另一方面,在本地编写的代码中仅考虑合约最低保证金,且不考虑手续费等其他费用;而在JoinQuant的回测环境下则考虑诸多费用。
获取中证500股指期货合约数据
我们可以在中国金融期货交易所的官网看到中证500股指期货的合约表
先简单来看6月份的当月中证500股指期货合约IC2306,本文利用JoinQuant获取合约日频bar数据:(注:下述代码需在JoinQuant研究环境下运行)
# 获取中证500股指期货合约在2023-06-01的可交易合约标的列表
IC = get_future_contracts('IC', date='2023-06-01')
# 选取当月合约
IC_current_month = IC[0]
# 获取合约的开始日期及交割日期
end_date = get_security_info(IC_current_month).end_date
# 获取当月合约的bar数据
## 因为行情数据的bar不足count个,返回的长度则小于count个数,所有count可以设置较大以获取全部日频数据
df = get_bars(IC_current_month,count=200,unit='1d',df=True,
end_dt=datetime.datetime.now(),include_now=True)
df.to_excel('IC2306_bar.xlsx') # 下载数据
df.head()
另外,为了计算每日结算下的盈亏,我们需要获取期货合约的每日结算价(注:下述代码需在JoinQuant研究环境下运行)
# 获取合约的结算价
## 将获取的数据时间长度设置与df一致,方便合并处理
df1 = get_extras('futures_sett_price',IC_current_month,count=len(df),
end_date=datetime.datetime.now(),df=True)
df1.to_excel('IC2306_settle.xlsx') # 下载数据
df1.head()
策略编写
数据导入与处理
# 读取数据
df = pd.read_excel('IC2306_bar.xlsx')
df_ = pd.read_excel('IC2306_settle.xlsx')
# 保留收盘价数据
df1 = pd.DataFrame()
df1['date'], df1['close'] = df['date'], df['close']
# 合并结算价数据
df1['settle'] = df_['IC2306.CCFX']
# 将日期数据转换为索引
df1.set_index('date', inplace=True)
df1.index.name = None
df1.head()
基本特征提取
# 定义一个策略dataframe
strategy = df1.copy(deep=True)
# 过往n天的平均收盘价
n = 5 # 作为一个可以调整的参数单独列出来
strategy['MA'] = strategy['close'].shift(1).rolling(n).mean()
# 昨日收盘价
strategy['close_1'] = strategy['close'].shift(1)
# 剔除空值
strategy.dropna(inplace=True)
strategy.head()
策略信号设计
# 添加交易信号字段'signal'
# 如果昨天收盘价高于5日均线,则signal=1,否则为0
strategy['signal'] = np.where(strategy['close_1']>strategy['MA'],1,0)
# 添加建仓信号'order'
# 当signal由0转1时,即有涨的趋势,当天平仓&做多合约,order=1;当signal由1转0时,即有跌的趋势,当天平仓&做空合约,order=-1
strategy['order'] = strategy['signal'] - strategy['signal'].shift(1)
strategy.fillna(0,inplace=True)
strategy.head(10)
可以看到,当昨日收盘价高于5日均线时,signal=1,表示做多信号;当昨日收盘价低于5日均线时,signal=0,表示做空信号。此外,为了绘制信号图,我们还需要设置下单信号’order’,在交易中,当signal突然由0转1时,我们需要下单,但当signal一直为1时,我们并不需要再下单,只是持仓便可,故对于字段下单信号’order’,我们利用’signal’做差分便可得到。‘order’=1表示平仓&多头下单信号,‘order’=-1表示平仓&空头下单信号,‘order’=0表示不下单(持仓/空仓)。
信号图
接下来,我们将上一步得到的多头信号long与空头信号short进行可视化:
# 设置画布,像素调高
plt.figure(figsize=(20,10),dpi=400)
# 解决中文显示问题
plt.rcParams['font.sans-serif']=['Heiti TC']
# 折线图
plt.plot(strategy['close'],color='darkblue',alpha=0.8,label='合约收盘价')
plt.fill_between(x=strategy.index, y1=5600, y2=strategy['close'], facecolor='royalblue', alpha=0.1) # 折线下部填充
plt.ylim(5600,6450) # y轴范围
plt.grid(alpha=0.6) # 网格
# 散点图
plt.scatter(strategy['close'][strategy['order']==1].index, strategy['close'][strategy['order']==1], marker='^', c='darkred', s=100, label='多头信号')
plt.scatter(strategy['close'][strategy['order']==-1].index, strategy['close'][strategy['order']==-1], marker='v', c='darkgreen', s=100, label='空头信号')
plt.legend(fontsize=15) # 图例
可以看到大部分建立多头头寸的信号出现后,合约价格上涨;而空头头寸信号出现后,合约价格下跌。如在2022年11月初,多头信号出现,合约价后续迅速增长;在2023年3月初,空头信号出现后,合约价后续逐渐下降。
回测
假定账户初始资金为20万,保证金比例按合约表的最低比例8%进行计算,合约乘数为每点200元。
# 账户初始资金20万
initial_cash = 200000
# 添加多头头寸字段'long_position'
strategy['long_position'] = strategy['signal']
# 添加空头头寸字段'short_position'
strategy['short_position'] = np.where(strategy['signal']==0, 1, 0)
## 在本次策略中,我们需要把最开始'signal=0'的部分剔除开来,即空头头寸为0
strategy['short_position'][:2] = 0
# 添加保证金字段'magin'
## 保证金=合约乘数(200)*价格点数*合约数(1手)*保证金比例(8%)
multiplier = 200
margin_level = 0.08
num = 1
## 这里要分两种情况,第一种是持仓时期每日的保证金由结算价计算得出,第二种是开仓时期的保证金由买入价计算得出(本文为了简便起见,以收盘价作为开仓时的买入或卖出价)
### 情况1
strategy['margin'] = (strategy['long_position']+strategy['short_position'])*multiplier*strategy['settle']*num*margin_level
### 情况2
strategy['margin'][strategy['order']!=0] = multiplier*strategy['close'][strategy['order']!=0]*num*margin_level
# 添加每日盈亏字段'profit_loss'
## 这里同样要分为2种情况,
## 第一种是持仓时期的每日盈亏由当日结算价减去昨日结算价计算得出,
## 第二种是开仓日的当日盈亏由当日结算价减去开仓价(买入或卖出价)计算得出(本文为了简便起见,以收盘价作为平仓时的买入或卖出价)
## 第三种是平仓日的当日盈亏由平仓价(买入或卖出价)减去昨日结算价计算得出(本文为了简便起见,以收盘价作为平仓时的买入或卖出价)
## 具体计算公式为价格点数差异*合约乘数*合约数
### 情况1
strategy['profit_loss'] = (strategy['settle']-strategy['settle'].shift(1))*multiplier*num
strategy['profit_loss'][strategy['short_position']==1] = -1*strategy['profit_loss'][strategy['short_position']==1]
### 情况2
strategy['profit_loss'][strategy['order']==1] = (strategy['settle']-strategy['close'])*multiplier*num
strategy['profit_loss'][strategy['order']==-1] = -1*(strategy['settle']-strategy['close'])*multiplier*num
### 情况3
strategy['profit_loss'][(strategy['order']==1)&(strategy.index!='2022-11-02')] = (strategy['close']-strategy['settle'].shift(1))*multiplier*num
strategy['profit_loss'][(strategy['order']==-1)&(strategy.index!='2022-11-02')] = -1*(strategy['close']-strategy['settle'].shift(1))*multiplier*num
strategy.head(20)
# 在本次策略中,我们需要把最开始'signal=0'部分的'profit_loss'剔除开来,即每日盈亏为0
strategy['profit_loss'][:2] = 0
# 增加账户资金余额字段'account'
strategy['account'] = initial_cash-strategy['margin']+strategy['profit_loss'].cumsum()
# 增加总权益字段'equity'
strategy['equity'] = strategy['account']+strategy['margin']
strategy.head(10)
如在2022-10-31,未出现下单信号,故多头头寸long_position与空头头寸short_position都为0;没有头寸,自然所冻结的合约保证金margin=0;没有每日盈亏,profit_loss=0;账户依然是初始资金20万(account=200000),总权益也是20万(equity=200000)。
在2022-11-02,出现多头下单信号(order=1)且多头与空头头寸为0(前一天的long_position=short_position=0),我们建立多头头寸(当天long_position=1),冻结保证金为买入价 × \times ×合约乘数 × \times ×合约数 × \times ×保证金比例(margin=93350.4),多头的当日盈亏为(当日结算价-买入价) × \times ×合约乘数 × \times ×合约数(profit_loss=1760),账户剩余资金为初始资金-保证金+每日盈亏累加值(account=108409.6);总权益(equity=201760)为账户剩余资金+保证金,或者用初始账户资金+每日盈亏累加值。
在2022-11-03,未出现下单信号(order=0),头寸与昨天保持不变,多头头寸为1、空头头寸为0(当天的long_position=1,short_position=0),冻结保证金为结算价 × \times ×合约乘数 × \times ×合约数 × \times ×保证金比例(margin=93497.6),多头的当日盈亏为(当日结算价-昨日结算价) × \times ×合约乘数 × \times ×合约数(profit_loss=80),账户剩余资金为初始资金-保证金+每日盈亏累加值(account=108342.4);总权益(equity=201840)为账户剩余资金+保证金,或者用初始账户资金+每日盈亏累加值。
在2022-11-11,出现空头下单信号(order=-1)且多头头寸为1、空头头寸为0(前一天的long_position=1,short_position=0),我们先平仓(当天long_position=0),再建立空头头寸(当天short_position=1),换句话说,在该天我们做空了两手合约,但其中一手用来平仓,头寸被清空了;冻结保证金为卖出价 × \times ×合约乘数 × \times ×合约数 × \times ×保证金比例(margin=96524.8),多头的当日盈亏为-1 × \times ×(当日结算价-卖出价) × \times ×合约乘数 × \times ×合约数(profit_loss=-8400),账户剩余资金为初始资金-保证金+每日盈亏累加值(account=126355.2);总权益(equity=222880)为账户剩余资金+保证金,或者用初始账户资金+每日盈亏累加值。
# 画布大小,调高像素
plt.figure(figsize=(20,10),dpi=400)
# 解决中文显示问题
plt.rcParams['font.sans-serif']=['Heiti TC']
plt.plot(strategy['equity'],c='darkblue',alpha=0.8,label='总权益')
plt.plot(strategy['account'],c='darkgreen',alpha=0.8,ls='--',label='账户余额')
plt.plot(strategy['margin'],c='darkgreen',alpha=0.8,label='保证金')
plt.hlines(y=initial_cash,xmin=strategy.index[0],xmax=strategy.index[-1],color='k',lw=2,alpha=0.8,label='初始账户资金')
plt.plot(strategy['profit_loss'],c='darkred',alpha=0.8,label='每日盈亏')
plt.scatter(strategy['equity'][strategy['order']==1].index, strategy['equity'][strategy['order']==1], marker='^', c='darkred', s=100, label='多头信号')
plt.scatter(strategy['equity'][strategy['order']==-1].index, strategy['equity'][strategy['order']==-1], marker='v', c='darkgreen', s=100, label='空头信号')
plt.fill_between(x=strategy.index, y1=-50000, y2=strategy['equity'], facecolor='royalblue', alpha=0.1) # 折线下部填充
plt.ylim(-50000,450000) # y轴范围
plt.xlim(strategy.index[0],strategy.index[-1])
plt.legend(fontsize=15)
plt.grid(alpha=0.6)
可以看到,在不考虑手续费等其他费用的情况下,我们的总权益在这半年多的时间差不多翻了一倍,从收益的角度来看,这个策略是不错的,但风险大不大,是否真的有效,收益率变化如何等,我们将在JoinQuant的回测环境中实现。
JoinQuant回测
不同于本地编写的回测逻辑,在JoinQuant的回测环境中,我们只需要设定每天的交易条件,但满足条件时,进行建仓或平仓。(注:以下代码需在JoinQuant回测环境中运行)
# 导入函数库
from jqdata import *
## 初始化函数,设定基准等等
def initialize(context):
# 设定沪深300作为基准
set_benchmark('000300.XSHG')
# 开启动态复权模式(真实价格)
set_option('use_real_price', True)
### 期货相关设定 ###
# 设定账户为金融账户
set_subportfolios([SubPortfolioConfig(cash=context.portfolio.starting_cash, type='index_futures')])
# 期货类每笔交易时的手续费是:买入时万分之0.23,卖出时万分之0.23,平今仓为万分之23
set_order_cost(OrderCost(open_commission=0.000023, close_commission=0.000023,close_today_commission=0.0023), type='index_futures')
# 设定保证金比例
set_option('futures_margin_rate', 0.08)
# 设置期货交易的滑点
set_slippage(StepRelatedSlippage(2))
# 运行函数(reference_security为运行时间的参考标的;传入的标的只做种类区分,因此传入'IF8888.CCFX'或'IH1602.CCFX'是一样的)
# 注意:before_open/open/close/after_close等相对时间不可用于有夜盘的交易品种,有夜盘的交易品种请指定绝对时间(如9:30)
# 开盘前运行
run_daily( before_market_open, time='09:00', reference_security='IF8888.CCFX')
# 开盘时运行
run_daily( market_open, time='09:30', reference_security='IF8888.CCFX')
# 收盘后运行
run_daily( after_market_close, time='15:30', reference_security='IF8888.CCFX')
## 开盘前运行函数
def before_market_open(context):
# 输出运行时间
log.info('函数运行时间(before_market_open):'+str(context.current_dt.time()))
## 选取要操作的合约(g.为全局变量)
# 2023年6月份的当月中证500指数期货合约IC2306.CCFX
g.IC_current_month = 'IC2306.CCFX'
## 开盘时运行函数
def market_open(context):
log.info('函数运行时间(market_open):'+str(context.current_dt.time()))
## 交易
# 获取前5日均价
MA = get_bars(g.IC_current_month,count=5,
include_now=False,df=True)['close'].mean()
close_1 = get_bars(g.IC_current_month,count=1,
include_now=False,df=True)['close'][0]
print(datetime.datetime.now())
# 获取当月合约交割日期
end_data = get_security_info(g.IC_current_month).end_date
# 当昨日收盘价高于于5日均线,且多头和空头头寸为0,则做多合约;
# 当昨日收盘价高于于5日均线,且多头头寸为0,空头头寸不为0,则先平仓,再做多合约;
if (close_1-MA > 0):
# 当月合约交割日当天不开仓
if (context.current_dt.date() == end_data):
# return
pass
else:
if (len(context.portfolio.short_positions) == 0) and (len(context.portfolio.long_positions) == 0):
log.info('昨日收盘价高于5日均线且空头头寸为0,做多合约')
# 做多1手当月合约
order(g.IC_current_month, 1, side='long')
if (len(context.portfolio.short_positions) != 0) and (len(context.portfolio.long_positions) == 0):
log.info('昨日收盘价高于5日均线且空头头寸不为0,平仓,再做多合约')
# 平仓当月合约
order_target(g.IC_current_month, 0, side='short')
# 做多1手当月合约
order(g.IC_current_month, 1, side='long')
# 当昨日收盘价低于于5日均线,且多头和空头头寸为0,则做空合约;
# 当昨日收盘价低于于5日均线,且空头头寸为0,多头头寸不为0,则先平仓,再做空合约;
if (close_1-MA < 0):
# 当月合约交割日当天不开仓
if (context.current_dt.date() == end_data):
# return
pass
else:
if (len(context.portfolio.short_positions) == 0) and (len(context.portfolio.long_positions) == 0):
log.info('昨日收盘价低于5日均线且多头头寸为0,做空合约')
# 做多1手当月合约
order(g.IC_current_month, 1, side='short')
if (len(context.portfolio.short_positions) == 0) and (len(context.portfolio.long_positions) != 0):
log.info('昨日收盘价低于5日均线且多头头寸不为0,平仓,再做空合约')
# 平仓当月合约
order_target(g.IC_current_month, 0, side='long')
# 做多1手当月合约
order(g.IC_current_month, 1, side='short')
## 收盘后运行函数
def after_market_close(context):
log.info(str('函数运行时间(after_market_close):'+str(context.current_dt.time())))
# 得到当天所有成交记录
trades = get_trades()
for _trade in trades.values():
log.info('成交记录:'+str(_trade))
log.info('一天结束')
log.info('##############################################################')
补充:为何在JoinQuant的收益与本地自己编写的代码存在差异
- JoinQuant每日开仓或平仓采用的是开盘时经滑点处理后的价格,而本地自己编写的代码为了方便处理,采用的是收盘价。
- JoinQuant考虑多了手续费等费用,本地自己编写的只考虑保证金,不考虑费用。
- 为了确保二者基本逻辑一致,我们将本地的交易详情与JoinQuant的交易详情进行对比,发现建仓平仓时机选择一致。