Yes, that can be really sophisticated even using such nice tools as pandas.
But the basic idea is to find position enters & exits to derive cashflow.
Here is my code to derive all that stuff from generated signals (in my backtester signals are fractions of 2 stocks in portfolio for each moment). I hope I've found all bugs here, but no warranties.
Total_invested variable is used to calculate total number of shares in portfolio using only initial capital.
Actually, this code is part of my backtest engine that I'm gonna open-source in a few months. It also deals with spreads and fixed commissions, so you may skip this part. It will require adaptation to your conditions, but hope that helps!
Also take a look at articles by Mike Halls-Moore. This site helped me a lot!
def get_portfolio(self, signals, prices):
time_index = prices.index
end = time_index[-1]
# Assets prices
x_prices = prices[self.sym1]
y_prices = prices[self.sym2]
# Assets weights in portfolio
x_weights = signals.map(lambda sig:sig[self.sym1])
y_weights = signals.map(lambda sig:sig[self.sym2])
# Total number of invested shares
total_invested = pd.Series(index = signals.index)
# Need trade enters to calculate portfolio size
enter_points = x_weights.diff().fillna(0)!=0
# Index magic here:
# We need to delete position exits, we suppose there are separate enters/exits
enter_points[enter_points[enter_points].index[1::2]] = False
# capital = w₁⋅total⋅p₁ + w₂⋅total⋅p₂ (cover position)
total_invested[enter_points] = (self.initial_capital
/(x_weights.abs()[enter_points]*x_prices[enter_points]
+ y_weights.abs()[enter_points]*y_prices[enter_points]))
# Zero for right initial open and final close
total_invested.iloc[0] = 0; total_invested.iloc[-1] = 0;
total_invested.fillna(method='ffill', inplace=True)
# Positions rounded to lot sizes
x_positions = (total_invested*x_weights//self.lot1*self.lot1).fillna(0)
y_positions = (total_invested*y_weights//self.lot2*self.lot2).fillna(0)
long_pos = x_positions.copy(); long_pos[long_pos<0] = 0
short_pos = x_positions.copy(); short_pos[short_pos>0] = 0
x_pos_diff = x_positions.diff().fillna(0)
y_pos_diff = y_positions.diff().fillna(0)
# Tribute to the market-makers: spread
# Binary divide because we count twice: enter & exit
tribute = (abs(x_pos_diff)*self.spread1 + abs(y_pos_diff)*self.spread2)/2
# Cashflow & Turnover: not the same!
cashflow = -x_pos_diff*x_prices - y_pos_diff*y_prices
turnover = abs(x_pos_diff)*x_prices + abs(y_pos_diff)*y_prices
# Commission from turnover
commission = turnover * self.commission
commission[cashflow!=0] += self.fixed_commission
portfolio = pd.DataFrame(index=time_index, columns=['EQ','holdings',
'cash','cashflow','long_trades','short_trades',
'x_positions','y_positions'])
# Value of shares
portfolio['holdings'] = x_positions*x_prices + y_positions*y_prices
# Value of cash, paid commission on each transaction
portfolio['cash'] = self.initial_capital + (cashflow
- commission - tribute).cumsum()
# Equity is the sum of both
portfolio['EQ'] = portfolio['cash'] + portfolio['holdings']
portfolio['turnover'] = turnover
# Time points of trades
portfolio['long_trades'] = long_pos.diff().fillna(0)
portfolio['short_trades'] = short_pos.diff().fillna(0)
portfolio['x_positions'] = x_positions
portfolio['y_positions'] = y_positions
return portfolio