bug: long_only and short_only periodic returns don't sum up to long_short daily returns
andreas-vester opened this issue · comments
I created a simple test strategy (SMA crossover). I set it up so that long_only
, short_only
and both
can be analyzed.
When analyzing the periodic returns, I stumbled upon the fact that the sum of periodic long_only
and short_only
returns doesn't always equal the corresponding both
returns.
Here are some daily returns where the above assumption is true:
However, as soon as I introduce fees or slippage, the short_only
side doesn't correspond to the both
side (and we are not talking about rounding issues), while long_only
looks quite reasonable.
Am I missing something? Is this a bug / incorrect computation?
Here's the code to reproduce the issue:
import numpy as np
import pandas as pd
import pytest
import vectorbt as vbt
from datetime import datetime
def compute_vbt_pf(fee: float, slippage: float) -> vbt.Portfolio:
DIRECTIONS = ["long_only", "short_only", "both"]
symbols = ["SPY"]
start = datetime(2020, 12, 30)
end = datetime(2023, 10, 31)
prices = vbt.YFData.download(
symbols=symbols, start=start, end=end, tz_localize=None
)
close = prices.get("Close")
_close = close.vbt.tile(3)
_close.columns = DIRECTIONS
# compute strategy signals
sma = vbt.MA.run(close, 10)
signals_long = sma.close_crossed_above(sma.ma) * 1
signals_short = sma.close_crossed_below(sma.ma) * 1
signals_both = signals_long + (signals_short * (-1))
signals = pd.concat([signals_long, signals_short, signals_both], axis=1)
signals.columns = DIRECTIONS
signals.columns.name = "direction"
# compute trades / size
size = np.full(shape=signals.shape, fill_value=np.nan)
# long_only
size[signals["long_only"] == 1, 0] = 1
size[signals["short_only"] == 1, 0] = 0
# short_only
size[signals["long_only"] == 1, 1] = 0
size[signals["short_only"] == 1, 1] = 1
# both
size[:, 2] = size[:, 0] - size[:, 1]
vbt.settings["portfolio"]["fees"] = fee
vbt.settings["portfolio"]["slippage"] = slippage
# create portfolio object
return vbt.Portfolio.from_orders(
close=_close,
size=size,
size_type="target_percent",
price=_close,
direction=DIRECTIONS,
call_seq="auto",
cash_sharing=False,
)
@pytest.mark.parametrize("fee", [0.0, 0.002])
@pytest.mark.parametrize("slippage", [0.0, 0.05])
def test_daily_returns_sum_up(fee: float, slippage: float) -> None:
"""
Test if sum of daily "long_only" and "short_only" returns sum up to daily "both"
returns.
"""
pf = compute_vbt_pf(fee=fee, slippage=slippage)
rets = pf.returns()
np.testing.assert_array_almost_equal(
rets["long_only"] + rets["short_only"], rets["both"]
)
Fees and slippage aren't taken into account when target percentage is translated into number of shares in order to satisfy the 100% portfolio value requirement. Thus, you short buy slightly more than you can afford (you can see this when you print the allocation with pf.asset_value() / pf.value()
).
Thus, you short buy slightly more than you can afford
I don't fully understand this. Do you mind to elaborate?
Still, no matter how you treat fees/slippage in the first place, I feel that long_only
and short_only
returns should add up to both
returns.
Coming back to my example above (periods marked in yellow in the second dataframe), if I am only invested on the short_only
side, the return in these specific periods will solely be responsible for the overall (both
) portfolio return, won't it?
If my only portfolio position is to be short one asset and this asset looses 5% in a single period, then I expect my return on the short_only
side to be +5%. As this is my only position, the portfolio return should be equally +5%, shouldn't it?
@polakowo Any thoughts?