在上一篇关于事件驱动回测的文章中,我们探讨了如何构建策略类层级结构。此处所定义的策略主要用于生成交易信号,而投资组合对象则依据这些信号决定是否发送订单指令。与先前处理方式一致,我们很自然地会创建一个投资组合抽象基类(Portfolio ABC),供后续所有子类继承。
本文将介绍NaivePortfolio(简易投资组合)对象,主要实现两项功能:实时追踪投资组合持仓状态,并根据交易信号生成固定数量的股票订单。后续文章会深入探讨更复杂的投资组合对象,这些对象将整合更完善的风险管理工具,这将是以后文章的主题。
持仓追踪与订单管理
投资组合订单管理系统或许是事件驱动回测框架中最复杂的组成部分。其主要职责在于持续追踪所有当前市场持仓及其对应的市值,即持仓额。这本质上是对持仓清算价值的一个预估,其计算部分依赖于回测系统的数据处理功能。
除持仓与市值管理外,投资组合系统还需整合风险因子分析与持仓规模控制技术,以便优化发送至券商或其他市场接入渠道的交易指令。
延用事件类层级结构的设计思路,投资组合对象必须具备处理信号事件、生成订单事件以及解析成交事件以更新持仓状态的能力。因此,投资组合对象常成为事件驱动系统中代码量最庞大的模块也就不足为奇。
实现方案
我们将创建新的portfolio.py文件并导入必要库,这与大多数其他抽象基类实现方式类似。需要从math 库中导入floor函数,以便生成整数单位的订单规模。同时还需FillEvent 和OrderEvent 对象,因为投资组合系统将同时处理这两类事件。
# portfolio.pyimport datetimeimport numpy as npimport pandas as pdimport Queuefrom abc import ABCMeta, abstractmethodfrom math import floorfrom event import FillEvent, OrderEvent
Portfolio创建一个抽象基类,其中定义两个纯虚方法:update_signal和update_fill。前者负责处理从事件队列获取的新交易信号,后者则处理来自执行处理程序对象的成交信息。# portfolio.pyclass Portfolio(object):"""The Portfolio class handles the positions and marketvalue of all instruments at a resolution of a "bar",i.e. secondly, minutely, 5-min, 30-min, 60 min or EOD."""__metaclass__ = ABCMeta@abstractmethoddef update_signal(self, event):"""Acts on a SignalEvent to generate new ordersbased on the portfolio logic."""raise NotImplementedError("Should implement update_signal()")@abstractmethoddef update_fill(self, event):"""Updates the portfolio current positions and holdingsfrom a FillEvent."""raise NotImplementedError("Should implement update_fill()")
本文的核心内容是介绍NaivePortfolio(简易投资组合)类。该类设计用于管理持仓规模与当前持仓市值,但会以一种"机械"的方式执行交易指令——无论账户持有资金状况如何,都直接将预设固定数量的订单发送至券商。这些设定虽不符合实际交易场景,但有助于勾勒出投资组合订单管理系统在事件驱动框架中的运作机理。
NaivePortfolio 需要初始化资本值(本文默认设为100,000美元)及起始时间戳两个参数。该投资组合类包含all_positions(全量持仓记录)与current_positions(当前持仓)。前者按市场数据事件时间戳存储所有历史持仓记录,其中持仓量仅代表资产数量(负值表示该资产已被做空);后者以字典形式存储最近一个市场数据更新周期的当前持仓状态。
除持仓管理成员外,投资组合还设有holdings(持仓市值)模块,用于记录所持持仓的当前市场估值。此处的"当前市场估值"指代当前市场K线的收盘价——这显然是一种近似处理,但现阶段足以满足分析需求。all_holdings存储所有交易品种的历史持仓市值列表,而current_holdings则保存最新时点所有品种持仓市值的字典映射。
# portfolio.pyclass NaivePortfolio(Portfolio):"""The NaivePortfolio object is designed to send orders toa brokerage object with a constant quantity size blindly,i.e. without any risk management or position sizing. It isused to test simpler strategies such as BuyAndHoldStrategy."""def __init__(self, bars, events, start_date, initial_capital=100000.0):"""Initialises the portfolio with bars and an event queue.Also includes a starting datetime index and initial capital(USD unless otherwise stated).Parameters:bars - The DataHandler object with current market data.events - The Event Queue object.start_date - The start date (bar) of the portfolio.initial_capital - The starting capital in USD."""self.bars = barsself.events = eventsself.symbol_list = self.bars.symbol_listself.start_date = start_dateself.initial_capital = initial_capitalself.all_positions = self.construct_all_positions()self.current_positions = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )self.all_holdings = self.construct_all_holdings()self.current_holdings = self.construct_current_holdings()
接下来用construct_all_positions方法,创建每个交易品种对应的字典,将初始持仓量设为零,并添加时间戳键,最终将其存入列表。该方法采用dictionary comprehension字典推导式实现,其设计理念与列表推导式相似:
# portfolio.pydef construct_all_positions(self):"""Constructs the positions list using the start_dateto determine when the time index will begin."""d = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )d['datetime'] = self.start_datereturn [d]
construct_all_holdings 的实现逻辑与上文相似,但额外增加了三个关键字段:cash(现金余额)、commission(累计佣金)和 total(总权益)。这些字段分别代表账户在完成所有交易后的可用现金、累计产生的佣金支出,以及包含现金与所有未平仓持仓在内的账户总权益。其中空头持仓按负值计算。初始现金余额与账户总权益均设置为初始入金金额:
# portfolio.pydef construct_all_holdings(self):"""Constructs the holdings list using the start_dateto determine when the time index will begin."""d = dict( (k,v) for k, v in [(s, 0.0) for s in self.symbol_list] )d['datetime'] = self.start_dated['cash'] = self.initial_capitald['commission'] = 0.0d['total'] = self.initial_capitalreturn [d]
接下来的 coconstruct_current_holdings 与上述方法几乎完全相同,唯一的区别在于它不会将生成的字典包装在列表中:
# portfolio.pydef construct_current_holdings(self):"""This constructs the dictionary which will hold the instantaneousvalue of the portfolio across all symbols."""d = dict( (k,v) for k, v in [(s, 0.0) for s in self.symbol_list] )d['cash'] = self.initial_capitald['commission'] = 0.0d['total'] = self.initial_capitalreturn d
每当"心跳信号"触发时——即每次从DataHandler数据处理对象请求新的市场数据时,投资组合必须更新所有持仓的当前市值。在实盘交易场景中,这些信息可直接从券商处下载解析,但在回测系统中需要手动计算这些数值。
由于买卖价差和流动性因素影响,现实中并不存在所谓"精确的当前市值"。因此需要通过持仓数量乘以"价格"来进行估算。本文采用的方法是使用最近接收到的K线收盘价进行计算。对于日内交易策略而言,这种方法相对符合实际;但对于日线策略则存在较大偏差,因为开盘价可能与前一交易日收盘价存在显著差异。
update_timeindex 会负责处理持仓市值更新。首先它会从市场数据处理器获取最新价格,并通过将"新持仓"设定为"当前持仓"来创建新的交易品种持仓字典,这些持仓值只有在后续接收到成交事件时才会改变,这将在后续的投资组合中处理。接着,它还会将这组当前持仓记录追加至all_positions全量持仓列表。随后,系统将以类似方式更新持仓市值数据,区别在于需要将当前持仓数量乘以最新K线的收盘价(self.current_positions[s] * bars[s][0][5])重新计算。最后,新的持仓市值记录将被追加至all_holdings全量市值列表:# portfolio.pydef update_timeindex(self, event):"""Adds a new record to the positions matrix for the currentmarket data bar. This reflects the PREVIOUS bar, i.e. allcurrent market data at this stage is known (OLHCVI).Makes use of a MarketEvent from the events queue."""bars = {}for sym in self.symbol_list:bars[sym] = self.bars.get_latest_bars(sym, N=1)# Update positionsdp = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )dp['datetime'] = bars[self.symbol_list[0]][0][1]for s in self.symbol_list:dp[s] = self.current_positions[s]# Append the current positionsself.all_positions.append(dp)# Update holdingsdh = dict( (k,v) for k, v in [(s, 0) for s in self.symbol_list] )dh['datetime'] = bars[self.symbol_list[0]][0][1]dh['cash'] = self.current_holdings['cash']dh['commission'] = self.current_holdings['commission']dh['total'] = self.current_holdings['cash']for s in self.symbol_list:# Approximation to the real valuemarket_value = self.current_positions[s] * bars[s][0][5]dh[s] = market_valuedh['total'] += market_value# Append the current holdingsself.all_holdings.append(dh)
# portfolio.pydef update_positions_from_fill(self, fill):"""Takes a FilltEvent object and updates the position matrixto reflect the new position.Parameters:fill - The FillEvent object to update the positions with."""# Check whether the fill is a buy or sellfill_dir = 0if fill.direction == 'BUY':fill_dir = 1if fill.direction == 'SELL':fill_dir = -1# Update positions list with new quantitiesself.current_positions[fill.symbol] += fill_dir*fill.quantity
对应的update_holdings_from_fill方法与上述方法类似,但更新的是持仓额数值。为了模拟成交的成本,该方法不会直接使用FillEvent成交事件中关联的成本数据。为什么这么做呢?简而言之,在回测环境中实际的成交成本是未知的,因此必须进行估算。这里将成交成本设定为"当前市场价格"(即最近一根K线的收盘价)。随后特定品种的持仓价值将按成交成本乘以交易数量进行计算。
确定成交成本后,即可更新当前持仓价值、现金余额及总权益数据。累计佣金支出也将同步更新:
# portfolio.pydef update_holdings_from_fill(self, fill):"""Takes a FillEvent object and updates the holdings matrixto reflect the holdings value.Parameters:fill - The FillEvent object to update the holdings with."""# Check whether the fill is a buy or sellfill_dir = 0if fill.direction == 'BUY':fill_dir = 1if fill.direction == 'SELL':fill_dir = -1# Update holdings list with new quantitiesfill_cost = self.bars.get_latest_bars(fill.symbol)[0][5] # Close pricecost = fill_dir * fill_cost * fill.quantityself.current_holdings[fill.symbol] += costself.current_holdings['commission'] += fill.commissionself.current_holdings['cash'] -= (cost + fill.commission)self.current_holdings['total'] -= (cost + fill.commission)
这里实现了Portfolio ABC投资组合抽象基类中的纯虚方法update_fill,它只是简单的执行了前文已讨论过的两个前置方法:update_positions_from_fill 与 update_holdings_from_fill:
# portfolio.pydef update_fill(self, event):"""Updates the portfolio current positions and holdingsfrom a FillEvent."""if event.type == 'FILL':self.update_positions_from_fill(event)self.update_holdings_from_fill(event)
投资组合对象除了需要处理成交事件外,还必须负责在接收到一个或多个SignalEvent时生成OrderEvents。generate_naive_order方法仅根据做多或做空资产的信号,直接发送交易100股该资产的订单。此处100这个数值是任意设定的。在实盘中,该数值应由风险管理或持仓规模调整模块决定。但作为NaivePortfolio,它"机械地"将所有信号直接转化为订单,而未经过风险系统过滤。
该方法根据当前持仓数量及特定交易品种,处理建立多头、空头及平仓操作,随后生成对应的订单事件对象:
# portfolio.pydef generate_naive_order(self, signal):"""Simply transacts an OrderEvent object as a constant quantitysizing of the signal object, without risk management orposition sizing considerations.Parameters:signal - The SignalEvent signal information."""order = Nonesymbol = signal.symboldirection = signal.signal_typestrength = signal.strengthmkt_quantity = floor(100 * strength)cur_quantity = self.current_positions[symbol]order_type = 'MKT'if direction == 'LONG' and cur_quantity == 0:order = OrderEvent(symbol, order_type, mkt_quantity, 'BUY')if direction == 'SHORT' and cur_quantity == 0:order = OrderEvent(symbol, order_type, mkt_quantity, 'SELL')if direction == 'EXIT' and cur_quantity > 0:order = OrderEvent(symbol, order_type, abs(cur_quantity), 'SELL')if direction == 'EXIT' and cur_quantity < 0:order = OrderEvent(symbol, order_type, abs(cur_quantity), 'BUY')return order
我们用update_signal 调用上述方法,并将生成的订单添加到事件队列中:
# portfolio.pydef update_signal(self, event):"""Acts on a SignalEvent to generate new ordersbased on the portfolio logic."""if event.type == 'SIGNAL':order_event = self.generate_naive_order(event)self.events.put(order_event)
NaivePortfolio 中的最后一个方法是生成资金曲线。这一步骤通过创建收益率序列,并将资金曲线按百分比进行归一化处理,使得账户初始规模等于1.0:# portfolio.pydef create_equity_curve_dataframe(self):"""Creates a pandas DataFrame from the all_holdingslist of dictionaries."""curve = pd.DataFrame(self.all_holdings)curve.set_index('datetime', inplace=True)curve['returns'] = curve['total'].pct_change()curve['equity_curve'] = (1.0+curve['returns']).cumprod()self.equity_curve = curve
Portfolio投资组合对象是整个事件驱动回测系统中最复杂的组成部分。虽然当前实现方案在仓位处理上相对基础,但其内部结构已相当复杂。后续的版本将纳入风险管理与持仓规模控制模块,这将使策略绩效评估更贴近真实交易场景。
下一篇文章我们将探讨事件驱动回测系统的最后一个核心组件——ExecutionHandler执行处理器。该组件负责接收OrderEvent订单事件对象,并据此生成对应的FillEvent成交事件对象。