Skip to content

Commit

Permalink
Add config option to exit position on last bar.
Browse files Browse the repository at this point in the history
Adds configuration option for exiting any open positions on the last bar of data available for a symbol. This is useful for automatically exiting positions when a symbol is delisted in the backtest data.

Also updates fill_price Callables to take an argument of symbol.
  • Loading branch information
edtechre committed Mar 16, 2023
1 parent 2df2001 commit 8f89571
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 11 deletions.
3 changes: 2 additions & 1 deletion docs/reference/pybroker.config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pybroker.config module
:show-inheritance:
:exclude-members: initial_cash, max_long_positions, max_short_positions,
buy_delay, sell_delay, bootstrap_samples, bootstrap_sample_size,
fee_mode, fee_amount, enable_fractional_shares
fee_mode, fee_amount, enable_fractional_shares, exit_on_last_bar,
exit_cover_fill_price, exit_sell_fill_price
21 changes: 19 additions & 2 deletions src/pybroker/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
with this program. If not, see <https://www.gnu.org/licenses/>.
"""

from .common import FeeMode
from .common import BarData, FeeMode, PriceType
from dataclasses import dataclass, field
from typing import Optional
from decimal import Decimal
from typing import Callable, Optional, Union


@dataclass(frozen=True)
Expand Down Expand Up @@ -52,6 +53,15 @@ class StrategyConfig:
Defaults to ``10_000``.
bootstrap_sample_size: Size of each random sample used to compute
bootstrap metrics. Defaults to ``1_000``.
exit_on_last_bar: Whether to automatically exit any open positions
on the last bar of data available for a symbol. Defaults to
``False``.
exit_cover_fill_price: Fill price for covering an open short position
when :attr:`.exit_on_last_bar` is ``True``. Defaults to
:attr:`pybroker.common.PriceType.MIDDLE`.
exit_sell_fill_price: Fill price for selling an open long position when
:attr:`.exit_on_last_bar` is ``True``. Defaults to
:attr:`pybroker.common.PriceType.MIDDLE`.
"""

initial_cash: float = field(default=100_000)
Expand All @@ -64,3 +74,10 @@ class StrategyConfig:
sell_delay: int = field(default=1)
bootstrap_samples: int = field(default=10_000)
bootstrap_sample_size: int = field(default=1_000)
exit_on_last_bar: bool = field(default=False)
exit_cover_fill_price: Union[
PriceType, Callable[[str, BarData], Union[int, float, Decimal]]
] = field(default=PriceType.MIDDLE)
exit_sell_fill_price: Union[
PriceType, Callable[[str, BarData], Union[int, float, Decimal]]
] = field(default=PriceType.MIDDLE)
8 changes: 4 additions & 4 deletions src/pybroker/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,14 @@ class ExecResult:
float,
Decimal,
PriceType,
Callable[[BarData], Union[int, float, Decimal]],
Callable[[str, BarData], Union[int, float, Decimal]],
]
sell_fill_price: Union[
int,
float,
Decimal,
PriceType,
Callable[[BarData], Union[int, float, Decimal]],
Callable[[str, BarData], Union[int, float, Decimal]],
]
score: Optional[float]
hold_bars: Optional[int]
Expand Down Expand Up @@ -551,7 +551,7 @@ def __init__(
float,
Decimal,
PriceType,
Callable[[BarData], Union[int, float, Decimal]],
Callable[[str, BarData], Union[int, float, Decimal]],
] = PriceType.MIDDLE
self.buy_shares: Optional[Union[int, float, Decimal]] = None
self.buy_limit_price: Optional[Union[int, float, Decimal]] = None
Expand All @@ -560,7 +560,7 @@ def __init__(
float,
Decimal,
PriceType,
Callable[[BarData], Union[int, float, Decimal]],
Callable[[str, BarData], Union[int, float, Decimal]],
] = PriceType.MIDDLE
self.sell_shares: Optional[Union[int, float, Decimal]] = None
self.sell_limit_price: Optional[Union[int, float, Decimal]] = None
Expand Down
22 changes: 22 additions & 0 deletions src/pybroker/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,28 @@ def _short(
)
return shares

def exit_position(
self,
date: np.datetime64,
symbol: str,
buy_fill_price: Decimal,
sell_fill_price: Decimal,
):
if symbol in self.long_positions:
self.sell(
date=date,
symbol=symbol,
shares=self.long_positions[symbol].shares,
fill_price=sell_fill_price,
)
if symbol in self.short_positions:
self.buy(
date=date,
symbol=symbol,
shares=self.short_positions[symbol].shares,
fill_price=buy_fill_price,
)

def capture_bar(self, date: np.datetime64, df: pd.DataFrame):
"""Captures portfolio state of the current bar.
Expand Down
81 changes: 78 additions & 3 deletions src/pybroker/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ def backtest_executions(
max_long_positions: Optional[int],
max_short_positions: Optional[int],
pos_size_handler: Optional[Callable[[PosSizeContext], None]],
exit_dates: Mapping[str, np.datetime64],
exit_buy_fill_price: Union[
PriceType, Callable[[str, BarData], Union[int, float, Decimal]]
] = PriceType.MIDDLE,
exit_sell_fill_price: Union[
PriceType, Callable[[str, BarData], Union[int, float, Decimal]]
] = PriceType.MIDDLE,
enable_fractional_shares: bool = False,
):
r"""Backtests a ``set`` of :class:`.Execution`\ s that implement
Expand Down Expand Up @@ -171,6 +178,11 @@ def backtest_executions(
held at a time. If ``None``, then unlimited.
pos_size_handler: :class:`Callable` that sets position sizes when
placing orders for buy and sell signals.
exit_dates: :class:`Mapping` of symbols to exit dates.
exit_buy_fill_price: Fill price for covering an open short position
for ``exit_dates``.
exit_sell_fill_price: Fill price for selling an open long position
for ``exit_dates``.
enable_fractional_shares: Whether to enable trading fractional
shares.
Expand All @@ -179,7 +191,6 @@ def backtest_executions(
"""
test_dates = test_data[DataCol.DATE.value].unique()
test_dates.sort()
sym_exec_dates: dict[str, frozenset] = {}
exec_symbols = tuple(
ExecSymbol(exec.id, sym)
for sym in test_data[DataCol.SYMBOL.value].unique()
Expand Down Expand Up @@ -235,6 +246,7 @@ def backtest_executions(
logger.backtest_executions_start(test_dates)
buy_results: deque[ExecResult] = deque()
sell_results: deque[ExecResult] = deque()
exit_syms: deque[str] = deque()
for i, date in enumerate(test_dates):
for sym in test_symbols:
if date not in sym_exec_dates[sym]:
Expand Down Expand Up @@ -285,6 +297,8 @@ def backtest_executions(
for exec_symbol in exec_symbols:
if date not in sym_exec_dates[exec_symbol.symbol]:
continue
if exit_dates and date == exit_dates[exec_symbol.symbol]:
exit_syms.append(exec_symbol.symbol)
result = exec_fns[exec_symbol.exec_id](
exec_ctx, sessions[exec_symbol], exec_symbol.symbol, date
)
Expand All @@ -310,10 +324,59 @@ def backtest_executions(
sched=sell_sched,
col_scope=col_scope,
)
while exit_syms:
self._exit_position(
portfolio=portfolio,
date=date,
symbol=exit_syms.popleft(),
exit_buy_fill_price=exit_buy_fill_price,
exit_sell_fill_price=exit_sell_fill_price,
df=test_data,
col_scope=col_scope,
sym_end_index=sym_end_index,
)
portfolio.incr_bars()
if i % 10 == 0 or i == len(test_dates) - 1:
logger.backtest_executions_loading(i + 1)

def _exit_position(
self,
portfolio: Portfolio,
date: np.datetime64,
symbol: str,
exit_buy_fill_price: Union[
PriceType, Callable[[str, BarData], Union[int, float, Decimal]]
],
exit_sell_fill_price: Union[
PriceType, Callable[[str, BarData], Union[int, float, Decimal]]
],
df: pd.DataFrame,
col_scope: ColumnScope,
sym_end_index: Mapping[str, int],
):
buy_fill_price = self._get_price(
symbol,
date,
exit_buy_fill_price,
df,
col_scope,
sym_end_index[symbol],
)
sell_fill_price = self._get_price(
symbol,
date,
exit_sell_fill_price,
df,
col_scope,
sym_end_index[symbol],
)
portfolio.exit_position(
date,
symbol,
buy_fill_price=buy_fill_price,
sell_fill_price=sell_fill_price,
)

def _set_pos_sizes(
self,
pos_size_handler: Callable[[PosSizeContext], None],
Expand Down Expand Up @@ -535,7 +598,7 @@ def _get_price(
int,
Decimal,
PriceType,
Callable[[BarData], Union[int, float, Decimal]],
Callable[[str, BarData], Union[int, float, Decimal]],
],
df: pd.DataFrame,
col_scope: ColumnScope,
Expand Down Expand Up @@ -566,7 +629,7 @@ def _get_price(
return to_decimal(price) # type: ignore[arg-type]
if callable(price):
bar_data = col_scope.bar_data_from_data_columns(symbol, end_index)
return to_decimal(price(bar_data))
return to_decimal(price(symbol, bar_data))
raise ValueError(f"Unknown price: {price_type}")


Expand Down Expand Up @@ -1207,6 +1270,15 @@ def _run_walkforward(
if execution.fn is not None
for sym in execution.symbols
}
exit_dates: dict[str, np.datetime64] = {}
if self._config.exit_on_last_bar:
exec_symbols = frozenset(
sym for exec in self._executions for sym in exec.symbols
)
for sym in exec_symbols:
exit_dates[sym] = df[df[DataCol.SYMBOL.value] == sym][
DataCol.DATE.value
].values[-1]
for train_idx, test_idx in self.walkforward_split(
df=df,
windows=windows,
Expand Down Expand Up @@ -1253,7 +1325,10 @@ def _run_walkforward(
max_long_positions=self._config.max_long_positions,
max_short_positions=self._config.max_short_positions,
pos_size_handler=self._pos_size_handler,
exit_dates=exit_dates,
enable_fractional_shares=self._fractional_shares_enabled(),
exit_buy_fill_price=self._config.exit_cover_fill_price,
exit_sell_fill_price=self._config.exit_sell_fill_price,
)

def _filter_dates(
Expand Down
78 changes: 78 additions & 0 deletions tests/test_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -1589,6 +1589,84 @@ def test_cover_when_zero_shares():
assert not len(portfolio.trades)


def test_exit_position():
portfolio = Portfolio(CASH)
portfolio.buy(DATE_1, SYMBOL_1, SHARES_1, FILL_PRICE_1, LIMIT_PRICE_1)
portfolio.sell(DATE_1, SYMBOL_2, SHARES_2, FILL_PRICE_3, LIMIT_PRICE_3)
assert len(portfolio.long_positions) == 1
assert SYMBOL_1 in portfolio.long_positions
assert len(portfolio.short_positions) == 1
assert SYMBOL_2 in portfolio.short_positions
portfolio.incr_bars()
portfolio.exit_position(
DATE_2, SYMBOL_1, buy_fill_price=0, sell_fill_price=FILL_PRICE_2
)
assert not portfolio.long_positions
assert len(portfolio.short_positions) == 1
assert SYMBOL_2 in portfolio.short_positions
assert len(portfolio.trades) == 1
long_pnl = (FILL_PRICE_2 - FILL_PRICE_1) * SHARES_1
assert_trade(
trade=portfolio.trades[0],
type="long",
symbol=SYMBOL_1,
entry_date=DATE_1,
exit_date=DATE_2,
entry=FILL_PRICE_1,
exit=FILL_PRICE_2,
shares=SHARES_1,
pnl=long_pnl,
return_pct=(FILL_PRICE_2 / FILL_PRICE_1 - 1) * 100,
bars=1,
pnl_per_bar=long_pnl,
agg_pnl=long_pnl,
)
assert len(portfolio.orders) == 3
assert_order(
order=portfolio.orders[-1],
date=DATE_2,
symbol=SYMBOL_1,
type="sell",
limit_price=None,
fill_price=FILL_PRICE_2,
shares=SHARES_1,
fees=0,
)
portfolio.exit_position(
DATE_2, SYMBOL_2, buy_fill_price=FILL_PRICE_4, sell_fill_price=0
)
assert not portfolio.long_positions
assert not portfolio.short_positions
assert len(portfolio.trades) == 2
short_pnl = (FILL_PRICE_3 - FILL_PRICE_4) * SHARES_2
assert_trade(
trade=portfolio.trades[-1],
type="short",
symbol=SYMBOL_2,
entry_date=DATE_1,
exit_date=DATE_2,
entry=FILL_PRICE_3,
exit=FILL_PRICE_4,
shares=SHARES_2,
pnl=short_pnl,
return_pct=(FILL_PRICE_3 / FILL_PRICE_4 - 1) * 100,
bars=1,
pnl_per_bar=short_pnl,
agg_pnl=short_pnl + long_pnl,
)
assert len(portfolio.orders) == 4
assert_order(
order=portfolio.orders[-1],
date=DATE_2,
symbol=SYMBOL_2,
type="buy",
limit_price=None,
fill_price=FILL_PRICE_4,
shares=SHARES_2,
fees=0,
)


def test_capture_bar():
portfolio = Portfolio(CASH)
close_1 = 102
Expand Down
Loading

0 comments on commit 8f89571

Please sign in to comment.