本文重点描述回测系统引擎最核心的问题,投资组合管理,本质上其实是一个“账本”。Portfolio负责记录整个投资组合的明细,而SymbolBroker记录了单支证券的具体信息。
SymbolBroker和Portfolio本质上是管理一个dataframe存储的交易信息。dataframe是处理金融时间序列很好的数据结构。BrokerBase是把处理时间序列里,常用的一些操作,抽象出来,在两个子类中实现复用。
updateidx是游标往前走一步,同时更新当前的时间now。getitem,set_item,分别是读取和写入当前行=idx,行=column的值。
'''
SymbolBroker和Portfolio的基类,实现一些通用功能
'''
class BrokerBase(object):
def update_idx(self):
'''
更新序列游标,以及now时间索引。初始值是None
:return:
'''
if self.idx is None:
self.idx = 0
else:
if self.idx >= (len(self.df) - 1):
return True
self.idx += 1
self.now = list(self.df.index)[self.idx]
return False
def get_item(self,column,idx=None):
'''
:param column: dataframe的列名
:param idx: 取某一行,默认idx是当前行,可以自行指定
:return:
'''
if idx is None:
idx = self.idx
return self.df.iloc[idx, self.df.columns.get_loc(column)]
def set_item(self,column,value,idx=None):
'''
:param column: dataframe的列名
:param value: 新的值
:param idx:
:return:
'''
if idx is None:
idx = self.idx
self.df.iloc[idx, self.df.columns.get_loc(column)] = value
SybmolBroker记录单支证券的交易明细。内部维护一个dataframe,包括列——price,code,position,commission,分别是收盘价,证券代码,持仓数,交易佣金。
class SymbolBroker(BrokerBase):
def __init__(self,code,prices,commission_rate=0.0008):
'''
:param code: 证券代码
:param prices: pd.Series类型,证券的收盘价
'''
self.code = code
self.prices = prices
self.commission_rate = commission_rate
self.df = pd.DataFrame(index=prices.index,columns=['price','position','commission'])
self.df['price'] = prices
self.df['code'] = code
self.df['position'] = None
self.df['commission'] = None
self.idx = None
self.__last_position = None
self.__last_commission = None
外部循环会调用SymbolBroker的Onbar函数,分别调用基类的updateidx更新游标,还有自身的updatedatas
def onbar(self):
self.update_idx()
self.__update_datas()
_updatedatas的实现很简单,就是把上期的数据,同步到当期,用于可能的交易指令计算。
def __update_datas(self):
self.set_item('commission',0.0)
if self.idx == 0:
self.set_item('position',0)
else:
self.set_item('position', self.get_item('position',idx=self.idx - 1))
上述功能就完成了基础的单证券的记账和循环的功能,还需要支持交易指令完成账本更新。
adjusttotarget_share故名思义,是把持仓调整为目标数值(无论原来是多少)。返回值有两个,一上现金的变化,二是佣金。就如果是买入证券cash可能不够,这就需要在Portfolio计算买入份额时检查。
def adjust_to_target_share(self,target_share):
if target_share < 0:
return
price = self.get_item('price')
delta_pos = target_share - self.get_item('position')
commission = price * abs(delta_pos) * self.commission_rate
self.set_item('position',target_share)
self.set_item('commission',commission)
if delta_pos == 0:
logger.info('有交易信号,仓位无变化')
else:
action = '买入' if delta_pos > 0 else '卖出'
logger.info('{} - 以{}的价格,{}{}:{}股'.format(self.now,price, action,self.code, abs(delta_pos)))
# 这里有一个问题,就是cash可能不够,这就需要在Portfolio计算买入份额时检查。
return -delta_pos*price,commission
在这个基础上很容易实现平仓功能——就是把仓位调整至0。
def flat(self):
return self.adjust_to_target_shares(target_shares=0)
还有一个常用的交易更新函数,买入指定额度的现金,因为dataframe里有记录当期的收盘价,cash容易换算成希望买入的股数,然后再和当前持仓position相加,计算出需要调整到持仓数。
def long_cash(self,cash):
if cash <= 0:
logger.info('long_cash的现金需要>0')
return
price = self.get_item('price')
delta_pos = int(cash / price)
target_share = delta_pos + self.get_item('position')
return self.adjust_to_target_share(target_share=target_share)
下面具体描述Portfolio的运行机制。与SymbolBroker类似,它也维护一个dataframe的数据结构。其中数据列包含total=组合总市值,cash=现金余额,commission=当期交易佣金。另外,还维护一个SybmolBroker的dict合集brokers。
class Portfolio(BrokerBase):
def __init__(self,data,init_cash=100000.0):
self.data = data
self.init_cash = init_cash
self.brokers = {}
for col in data.columns:
self.brokers[col] = SymbolBroker(code=col,prices=data[col])
self.df = pd.DataFrame(index=data.index,columns=['total','cash','commission'])
self.df['total'] = None
self.df['cash'] = None
self.df['commission'] = None
self.idx = None
self.now = None
外部调用update,作日常更新。除调用brokers的onbar之外,还完成自身的游标的更新与数据同步。
def update(self):
done = self.update_idx()
for symbol, broker in self.brokers.items():
broker.onbar()
self.__update_datas()
return done
总市值会根据持仓与当前的价值,调用_calccurrent_total计算出来。
def __update_datas(self):
self.set_item('commission',0.0)
if self.idx == 0:
self.set_item('cash',self.init_cash)
self.set_item('total', self.init_cash)
else:
self.set_item('cash', self.get_item('cash',idx=self.idx - 1))
self.set_item('total', self.__calc_current_total())
def __calc_current_total(self):
total = self.get_item('cash')
for symbol,broker in self.brokers.items():
total += broker.get_item('position') * broker.get_item('price')
return total
如上部分代码完成Portfolio的日常更新。而step则接受外部环境传入的context,context里包含的策略执行后的结果。
context['FLAT']包含了当期需要平仓的证券代码列表。优先把这些证券平仓——把持仓调整为0。
context['LONG']包含了当期需要持仓的证券代码列表。
def step(self,context):
# 先处理FLAT
if "FLAT" in context.keys():
flats = context['FLAT']
del context['FLAT']#处理完成要清掉,否则下次循环这个还在,strategy是没有变化
target_shares = {flat:0 for flat in flats}
if len(target_shares):
self.__handle_orders(target_shares,type_of_func='target_share')
if 'weights' in context.keys():
weights = context['weights']
del context['weights'] #处理完成要清掉,否则下次循环这个还在,strategy是没有变化
cash = self.get_item('cash') * 0.98
moneys = {symbol:cash*weight for symbol,weight in weights.items()}
self.__handle_orders(moneys,type_of_func='cash')
完成交易指令后,需要更新cash和commission。
def __update_cash_commission(self,delta_cash,delta_commission):
self.set_item('cash',self.get_item('cash') + delta_cash - delta_commission)
self.set_item('commission',self.get_item('commission')+delta_commission)
self.set_item('total',self.get_item('total') - delta_commission)
Portfolio还对外提供统计接口,供回测完成后,显示回测结果之用。
def statistics(self):
self.df['returns'] = self.df['total'] / self.df['total'].shift(1) - 1
self.df['equity'] = (self.df['returns'] + 1).cumprod()
dfs = []
for name, broker in self.brokers.items():
broker.df['trade'] = broker.df['position'] - broker.df['position'].shift(1).fillna(0)
dfs.append(broker.df)
all = pd.concat(dfs)
return self.df,all
后续对整个回测环境env进行介绍,把整个系统整合起来。 代码在github上开源ailabx