diff --git a/pyalgotrade/stratanalyzer/returns.py b/pyalgotrade/stratanalyzer/returns.py index a4710774d..b2038452f 100644 --- a/pyalgotrade/stratanalyzer/returns.py +++ b/pyalgotrade/stratanalyzer/returns.py @@ -63,98 +63,104 @@ def getCumulativeReturns(self): return self.__cumRet -# Helper class to calculate returns and profit over a single instrument and a single position (not -# the whole portfolio). +# Helper class to calculate PnL and returns over a single instrument (not the whole portfolio). class PositionTracker(object): def __init__(self, instrumentTraits): self.__instrumentTraits = instrumentTraits self.reset() def reset(self): - self.__cash = 0.0 - self.__shares = 0 + self.__pnl = 0.0 + self.__avgPrice = 0.0 # Volume weighted average price per share. + self.__position = 0.0 self.__commissions = 0.0 - self.__costPerShare = 0.0 # Volume weighted average price per share. - self.__costBasis = 0.0 + self.__totalCommited = 0.0 # The total amount commited to this position. - def __update(self, quantity, price, commission): - assert(quantity != 0) + def getPosition(self): + return self.__position - if self.__shares == 0: - # Opening a new position - totalShares = quantity - self.__costPerShare = price - self.__costBasis = abs(quantity) * price - else: - totalShares = self.__instrumentTraits.roundQuantity(self.__shares + quantity) - if totalShares != 0: - prevDirection = math.copysign(1, self.__shares) - txnDirection = math.copysign(1, quantity) - - if prevDirection != txnDirection: - if abs(quantity) > abs(self.__shares): - # Going from long to short or the other way around. - # Update costs as a new position being opened. - self.__costPerShare = price - diff = self.__instrumentTraits.roundQuantity(self.__shares + quantity) - self.__costBasis = abs(diff) * price - else: - # Reducing the position. - pass - else: - # Increasing the current position. - # Calculate a volume weighted average price per share. - prevCost = self.__costPerShare * self.__shares - txnCost = quantity * price - self.__costPerShare = (prevCost + txnCost) / totalShares - self.__costBasis += abs(quantity) * price - else: - # Closing the position. - self.__costPerShare = 0.0 - - self.__cash += price * quantity * -1 - self.__commissions += commission - self.__shares = totalShares - - def getCash(self): - return self.__cash - self.__commissions - - def getShares(self): - return self.__shares - - def getCostPerShare(self): - # Returns the weighted average cost per share for the open position. - return self.__costPerShare + def getAvgPrice(self): + return self.__avgPrice def getCommissions(self): return self.__commissions - def getCostBasis(self): - return self.__costBasis + def getPnL(self, price=None, includeCommissions=True): + """ + Return the PnL that would result if closing the position a the given price. + Note that this will be different if commissions are used when the trade is executed. + """ - def getNetProfit(self, price=None, includeCommissions=True): - ret = self.__cash - if price is None: - price = self.__costPerShare - ret += price * self.__shares + ret = self.__pnl + if price: + ret += (price - self.__avgPrice) * self.__position if includeCommissions: ret -= self.__commissions return ret def getReturn(self, price=None, includeCommissions=True): ret = 0 - netProfit = self.getNetProfit(price, includeCommissions) - if self.__costBasis != 0: - ret = netProfit / float(self.__costBasis) + pnl = self.getPnL(price=price, includeCommissions=includeCommissions) + if self.__totalCommited != 0: + ret = pnl / float(self.__totalCommited) return ret - def buy(self, quantity, price, commission=0): - assert(quantity > 0) - self.__update(quantity, price, commission) + def __openNewPosition(self, quantity, price): + self.__avgPrice = price + self.__position = quantity + self.__totalCommited = self.__avgPrice * abs(self.__position) + + def __extendCurrentPosition(self, quantity, price): + newPosition = self.__instrumentTraits.roundQuantity(self.__position + quantity) + self.__avgPrice = (self.__avgPrice*abs(self.__position) + price*abs(quantity)) / abs(float(newPosition)) + self.__position = newPosition + self.__totalCommited = self.__avgPrice * abs(self.__position) + + def __reduceCurrentPosition(self, quantity, price): + # Check that we're closing or reducing partially + assert self.__instrumentTraits.roundQuantity(abs(self.__position) - abs(quantity)) >= 0 + pnl = (price - self.__avgPrice) * quantity * -1 + + self.__pnl += pnl + self.__position = self.__instrumentTraits.roundQuantity(self.__position + quantity) + if self.__position == 0: + self.__avgPrice = 0.0 + + def update(self, quantity, price, commission): + assert quantity != 0, "Invalid quantity" + assert price > 0, "Invalid price" + assert commission >= 0, "Invalid commission" + + if self.__position == 0: + self.__openNewPosition(quantity, price) + else: + # Are we extending the current position or going in the opposite direction ? + currPosDirection = math.copysign(1, self.__position) + tradeDirection = math.copysign(1, quantity) + + if currPosDirection == tradeDirection: + self.__extendCurrentPosition(quantity, price) + else: + # If we're going in the opposite direction we could be: + # 1: Partially reducing the current position. + # 2: Completely closing the current position. + # 3: Completely closing the current position and opening a new one in the opposite direction. + if abs(quantity) <= abs(self.__position): + self.__reduceCurrentPosition(quantity, price) + else: + newPos = self.__position + quantity + self.__reduceCurrentPosition(self.__position*-1, price) + self.__openNewPosition(newPos, price) + + self.__commissions += commission + + def buy(self, quantity, price, commission=0.0): + assert quantity > 0, "Invalid quantity" + self.update(quantity, price, commission) - def sell(self, quantity, price, commission=0): - assert(quantity > 0) - self.__update(quantity * -1, price, commission) + def sell(self, quantity, price, commission=0.0): + assert quantity > 0, "Invalid quantity" + self.update(quantity * -1, price, commission) class ReturnsAnalyzerBase(stratanalyzer.StrategyAnalyzer): diff --git a/pyalgotrade/stratanalyzer/trades.py b/pyalgotrade/stratanalyzer/trades.py index e70fb1944..6d09339cb 100644 --- a/pyalgotrade/stratanalyzer/trades.py +++ b/pyalgotrade/stratanalyzer/trades.py @@ -55,8 +55,8 @@ def __init__(self): def __updateTrades(self, posTracker): price = 0 # The price doesn't matter since the position should be closed. - assert(posTracker.getShares() == 0) - netProfit = posTracker.getNetProfit(price) + assert posTracker.getPosition() == 0 + netProfit = posTracker.getPnL(price) netReturn = posTracker.getReturn(price) if netProfit > 0: @@ -78,7 +78,7 @@ def __updateTrades(self, posTracker): posTracker.reset() def __updatePosTracker(self, posTracker, price, commission, quantity): - currentShares = posTracker.getShares() + currentShares = posTracker.getPosition() if currentShares > 0: # Current position is long if quantity > 0: # Increase long position diff --git a/pyalgotrade/strategy/position.py b/pyalgotrade/strategy/position.py index fb4917c21..f2475b5d3 100644 --- a/pyalgotrade/strategy/position.py +++ b/pyalgotrade/strategy/position.py @@ -276,7 +276,7 @@ def getPnL(self, includeCommissions=True): ret = 0 price = self.getLastPrice() if price is not None: - ret = self.__posTracker.getNetProfit(price, includeCommissions) + ret = self.__posTracker.getPnL(price=price, includeCommissions=includeCommissions) return ret def getNetProfit(self, includeCommissions=True): diff --git a/testcases/returns_analyzer_test.py b/testcases/returns_analyzer_test.py index 9b78f611a..20d663061 100644 --- a/testcases/returns_analyzer_test.py +++ b/testcases/returns_analyzer_test.py @@ -59,165 +59,164 @@ def testInvestopedia(self): class PosTrackerTestCase(common.TestCase): - def testBuyAndSellBreakEven(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 10) + self.assertEqual(posTracker.getAvgPrice(), 10) posTracker.sell(1, 10) - self.assertEqual(posTracker.getCash(), 0) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), 0) + # self.assertEqual(posTracker.getCash(), 0) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), 0) self.assertEqual(posTracker.getReturn(), 0) def testBuyAndSellBreakEvenWithCommission(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) - self.assertEqual(posTracker.getCash(), 0) + # self.assertEqual(posTracker.getCash(), 0) posTracker.buy(1, 10, 0.01) - self.assertEqual(posTracker.getCash(), -10.01) - self.assertEqual(posTracker.getCostPerShare(), 10) + # self.assertEqual(posTracker.getCash(), -10.01) + self.assertEqual(posTracker.getAvgPrice(), 10) posTracker.sell(1, 10.02, 0.01) - self.assertEqual(round(posTracker.getCash(), 2), 0) - self.assertEqual(posTracker.getCostPerShare(), 0) + # self.assertEqual(round(posTracker.getCash(), 2), 0) + self.assertEqual(posTracker.getAvgPrice(), 0) # We need to round to avoid floating point errors. # The same issue can be reproduced with this piece of code: # a = 10.02 - 10 # b = 0.02 # print a - b # print a - b == 0 - self.assertEqual(posTracker.getShares(), 0) - self.assertEqual(round(posTracker.getNetProfit(), 2), 0) + self.assertEqual(posTracker.getPosition(), 0) + self.assertEqual(round(posTracker.getPnL(), 2), 0) self.assertEqual(round(posTracker.getReturn(), 2), 0) def testBuyAndSellWin(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 10) + self.assertEqual(posTracker.getAvgPrice(), 10) posTracker.sell(1, 11) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), 1) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), 1) self.assertTrue(posTracker.getReturn() == 0.1) def testBuyAndSellInTwoTrades(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(2, 10) - self.assertEqual(posTracker.getCostPerShare(), 10) + self.assertEqual(posTracker.getAvgPrice(), 10) posTracker.sell(1, 11) - self.assertEqual(posTracker.getCostPerShare(), 10) - self.assertEqual(posTracker.getNetProfit(), 1) + self.assertEqual(posTracker.getAvgPrice(), 10) + self.assertEqual(posTracker.getPnL(), 1) self.assertEqual(posTracker.getReturn(), 0.05) posTracker.sell(1, 12) - self.assertEqual(posTracker.getNetProfit(), 3) + self.assertEqual(posTracker.getPnL(), 3) self.assertEqual(posTracker.getReturn(), 3/20.0) def testBuyAndSellMultipleEvals(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(2, 10) - self.assertEqual(posTracker.getCostPerShare(), 10) - self.assertEqual(posTracker.getNetProfit(), 0) - self.assertEqual(posTracker.getNetProfit(9), -2) - self.assertEqual(posTracker.getNetProfit(10), 0) - self.assertEqual(posTracker.getNetProfit(11), 2) + self.assertEqual(posTracker.getAvgPrice(), 10) + self.assertEqual(posTracker.getPnL(), 0) + self.assertEqual(posTracker.getPnL(price=9), -2) + self.assertEqual(posTracker.getPnL(price=10), 0) + self.assertEqual(posTracker.getPnL(price=11), 2) self.assertEqual(posTracker.getReturn(10), 0) - self.assertEqual(posTracker.getNetProfit(11), 2) + self.assertEqual(posTracker.getPnL(price=11), 2) self.assertEqual(round(posTracker.getReturn(11), 2), 0.1) - self.assertEqual(posTracker.getNetProfit(20), 20) + self.assertEqual(posTracker.getPnL(price=20), 20) self.assertEqual(posTracker.getReturn(20), 1) posTracker.sell(1, 11) - self.assertEqual(posTracker.getCostPerShare(), 10) - self.assertEqual(posTracker.getNetProfit(11), 2) + self.assertEqual(posTracker.getAvgPrice(), 10) + self.assertEqual(posTracker.getPnL(price=11), 2) self.assertEqual(posTracker.getReturn(11), 0.1) posTracker.sell(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), 1) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), 1) self.assertEqual(posTracker.getReturn(11), 0.05) def testSellAndBuyWin(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.sell(1, 13) - self.assertEqual(posTracker.getCostPerShare(), 13) - self.assertEqual(posTracker.getNetProfit(), 0) - self.assertEqual(posTracker.getNetProfit(10), 3) + self.assertEqual(posTracker.getAvgPrice(), 13) + self.assertEqual(posTracker.getPnL(), 0) + self.assertEqual(posTracker.getPnL(price=10), 3) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), 3) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), 3) self.assertEqual(round(posTracker.getReturn(), 9), round(0.23076923076923, 9)) def testSellAndBuyMultipleEvals(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.sell(2, 11) - self.assertEqual(posTracker.getCostPerShare(), 11) - self.assertEqual(posTracker.getNetProfit(10), 2) - self.assertEqual(posTracker.getNetProfit(11), 0) - self.assertEqual(posTracker.getNetProfit(12), -2) + self.assertEqual(posTracker.getAvgPrice(), 11) + self.assertEqual(posTracker.getPnL(price=10), 2) + self.assertEqual(posTracker.getPnL(price=11), 0) + self.assertEqual(posTracker.getPnL(price=12), -2) self.assertEqual(posTracker.getReturn(11), 0) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 11) - self.assertEqual(posTracker.getNetProfit(11), 1) + self.assertEqual(posTracker.getAvgPrice(), 11) + self.assertEqual(posTracker.getPnL(price=11), 1) self.assertEqual(round(posTracker.getReturn(11), 9), round(0.045454545, 9)) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), 2) - self.assertEqual(posTracker.getNetProfit(100), 2) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), 2) + self.assertEqual(posTracker.getPnL(price=100), 2) self.assertEqual(round(posTracker.getReturn(), 9), round(0.090909091, 9)) def testBuySellBuy(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 10) - self.assertEqual(posTracker.getNetProfit(9), -1) - self.assertEqual(posTracker.getNetProfit(), 0) - self.assertEqual(posTracker.getNetProfit(10), 0) - self.assertEqual(posTracker.getNetProfit(11), 1) + self.assertEqual(posTracker.getAvgPrice(), 10) + self.assertEqual(posTracker.getPnL(price=9), -1) + self.assertEqual(posTracker.getPnL(), 0) + self.assertEqual(posTracker.getPnL(price=10), 0) + self.assertEqual(posTracker.getPnL(price=11), 1) self.assertEqual(posTracker.getReturn(), 0) self.assertEqual(posTracker.getReturn(13), 0.3) # Closing the long position and short selling 1 @ $13. # The cost basis for the new position is $13. posTracker.sell(2, 13) - self.assertEqual(posTracker.getCostPerShare(), 13) - self.assertEqual(posTracker.getNetProfit(), 3) + self.assertEqual(posTracker.getAvgPrice(), 13) + self.assertEqual(posTracker.getPnL(), 3) self.assertEqual(round(posTracker.getReturn(), 8), 0.23076923) posTracker.buy(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), 6) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), 6) self.assertEqual(round(posTracker.getReturn(), 9), round(0.46153846153846, 9)) def testSellBuySell(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.sell(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 10) - self.assertEqual(posTracker.getNetProfit(), 0) + self.assertEqual(posTracker.getAvgPrice(), 10) + self.assertEqual(posTracker.getPnL(), 0) self.assertEqual(posTracker.getReturn(), 0) - self.assertEqual(posTracker.getNetProfit(13), -3) + self.assertEqual(posTracker.getPnL(price=13), -3) self.assertEqual(posTracker.getReturn(13), -0.3) # Closing the short position and going long 1 @ $13. # The cost basis for the new position is $13. posTracker.buy(2, 13) - self.assertEqual(posTracker.getCostPerShare(), 13) - self.assertEqual(posTracker.getNetProfit(), -3) + self.assertEqual(posTracker.getAvgPrice(), 13) + self.assertEqual(posTracker.getPnL(), -3) self.assertEqual(round(posTracker.getReturn(), 9), round(-0.23076923076923, 9)) posTracker.sell(1, 10) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), -6) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), -6) self.assertEqual(round(posTracker.getReturn(), 9), round(-0.46153846153846, 9)) def testBuyAndSellBreakEvenWithCommision(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(1, 10, 0.5) - self.assertEqual(posTracker.getCostPerShare(), 10) + self.assertEqual(posTracker.getAvgPrice(), 10) posTracker.sell(1, 11, 0.5) - self.assertEqual(posTracker.getNetProfit(includeCommissions=False), 1) - self.assertEqual(posTracker.getNetProfit(), 0) + self.assertEqual(posTracker.getPnL(includeCommissions=False), 1) + self.assertEqual(posTracker.getPnL(), 0) self.assertEqual(posTracker.getReturn(includeCommissions=False), 0.1) self.assertEqual(posTracker.getReturn(), 0) @@ -225,24 +224,20 @@ def testSeparateAndCombined(self): posA = returns.PositionTracker(broker.IntegerTraits()) posA.buy(11, 10) posA.sell(11, 30) - self.assertEqual(posA.getNetProfit(), 20*11) + self.assertEqual(posA.getPnL(), 20*11) self.assertEqual(posA.getReturn(), 2) - self.assertEqual(posA.getCostBasis(), 11*10) posB = returns.PositionTracker(broker.IntegerTraits()) posB.sell(100, 1.1) posB.buy(100, 1) - self.assertEqual(round(posB.getNetProfit(), 2), 100*0.1) + self.assertEqual(round(posB.getPnL(), 2), 100*0.1) self.assertEqual(round(posB.getReturn(), 2), 0.09) - self.assertEqual(posB.getCostBasis(), 100*1.1) combinedPos = returns.PositionTracker(broker.IntegerTraits()) combinedPos.buy(11, 10) combinedPos.sell(11, 30) - self.assertEqual(combinedPos.getCostBasis(), 11*10) combinedPos.sell(100, 1.1) combinedPos.buy(100, 1) - self.assertEqual(combinedPos.getCostBasis(), 100*1.1) self.assertEqual(round(combinedPos.getReturn(), 6), 2.090909) # The return of the combined position is less than the two returns combined # because when the second position gets opened the amount of cash not invested is greater @@ -252,27 +247,27 @@ def testSeparateAndCombined(self): def testProfitReturnsAndCost(self): posTracker = returns.PositionTracker(broker.IntegerTraits()) posTracker.buy(10, 1) - self.assertEqual(posTracker.getNetProfit(), 0) - self.assertEqual(posTracker.getCostPerShare(), 1) + self.assertEqual(posTracker.getPnL(), 0) + self.assertEqual(posTracker.getAvgPrice(), 1) self.assertEqual(posTracker.getCommissions(), 0) - self.assertEqual(posTracker.getCash(), -10) + # self.assertEqual(posTracker.getCash(), -10) posTracker.buy(20, 1, 10) - self.assertEqual(posTracker.getNetProfit(), -10) - self.assertEqual(posTracker.getCostPerShare(), 1) + self.assertEqual(posTracker.getPnL(), -10) + self.assertEqual(posTracker.getAvgPrice(), 1) self.assertEqual(posTracker.getCommissions(), 10) - self.assertEqual(posTracker.getCash(), -40) + # self.assertEqual(posTracker.getCash(), -40) posTracker.sell(30, 1) - self.assertEqual(posTracker.getCostPerShare(), 0) - self.assertEqual(posTracker.getNetProfit(), -10) - self.assertEqual(posTracker.getCash(), -10) + self.assertEqual(posTracker.getAvgPrice(), 0) + self.assertEqual(posTracker.getPnL(), -10) + # self.assertEqual(posTracker.getCash(), -10) self.assertEqual(posTracker.getCommissions(), 10) self.assertEqual(posTracker.getReturn(), -10/30.0) posTracker.buy(10, 1) - self.assertEqual(posTracker.getNetProfit(), -10) - self.assertEqual(posTracker.getCostPerShare(), 1) + self.assertEqual(posTracker.getPnL(), -10) + self.assertEqual(posTracker.getAvgPrice(), 1) class AnalyzerTestCase(common.TestCase): diff --git a/testcases/trades_analyzer_test.py b/testcases/trades_analyzer_test.py index e6825388c..a6954ac36 100644 --- a/testcases/trades_analyzer_test.py +++ b/testcases/trades_analyzer_test.py @@ -385,7 +385,7 @@ def testShort2(self): self.assertTrue(stratAnalyzer.getCount() == 1) self.assertTrue(stratAnalyzer.getEvenCount() == 0) - self.assertTrue(round(stratAnalyzer.getAll().mean(), 2) == -0.1) + self.assertEqual(round(stratAnalyzer.getAll().mean(), 2), -0.1) self.assertTrue(stratAnalyzer.getUnprofitableCount() == 1) self.assertTrue(round(stratAnalyzer.getLosses().mean(), 2) == -0.1)