diff --git a/docker/Dockerfile b/docker/Dockerfile index d7aa453..1c65750 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,7 +25,7 @@ RUN curl -sSL https://install.python-poetry.org | python3 - \ WORKDIR /app/${PROJECT_DIR} COPY ${PROJECT_DIR}/pyproject.toml ${PROJECT_DIR}/poetry.lock ./ -COPY lib lib/ +COPY libs libs/ RUN poetry install --only main ################################################## diff --git a/libs/PyTrade b/libs/PyTrade index 1ec2f6b..7aedb92 160000 --- a/libs/PyTrade +++ b/libs/PyTrade @@ -1 +1 @@ -Subproject commit 1ec2f6beb635d062039f9a36deafbc791cc9c4b0 +Subproject commit 7aedb92e7c5d6cbb4009e8abfca2228155933a08 diff --git a/pytradebacktest/broker.py b/pytradebacktest/broker.py index 9768a5e..2e4f9b3 100644 --- a/pytradebacktest/broker.py +++ b/pytradebacktest/broker.py @@ -5,11 +5,12 @@ from pytrade.instruments import MINUTES_MAP, Granularity, Instrument from pytrade.interfaces.broker import IBroker from pytrade.interfaces.data import IInstrumentData -from pytrade.models import Order, Position, Trade +from pytrade.models import Order, Trade from pytradebacktest.data import MarketData from pytradebacktest.exceptions import OutOfMoneyError from pytradebacktest.order import OrderContext +from pytradebacktest.position import Position class BacktestBroker(IBroker): diff --git a/pytradebacktest/position.py b/pytradebacktest/position.py new file mode 100644 index 0000000..248fb1a --- /dev/null +++ b/pytradebacktest/position.py @@ -0,0 +1,62 @@ +import numpy as np +from pytrade.instruments import Instrument +from pytrade.interfaces.position import IPosition +from pytrade.models import Trade + + +class Position(IPosition): + """ + Currently held asset position, available as + `backtesting.backtesting.Strategy.position` within + `backtesting.backtesting.Strategy.next`. + Can be used in boolean contexts, e.g. + + if self.position: + ... # we have a position, either long or short + """ + + def __init__(self, instrument: Instrument, trades: list[Trade]): + self.__instrument = instrument + self.__trades = trades + + def __bool__(self): + return self.size != 0 + + @property + def trades(self): + return [ + trade for trade in self.__trades if trade.instrument == self.__instrument + ] + + @property + def size(self) -> float: + """Position size in units of asset. Negative if position is short.""" + return sum(trade.size for trade in self.trades) + + @property + def pl(self) -> float: + """Profit (positive) or loss (negative) of the current position in cash units.""" + return sum(trade.pl for trade in self.trades) + + @property + def pl_pct(self) -> float: + """Profit (positive) or loss (negative) of the current position in percent.""" + weights = np.abs([trade.size for trade in self.trades]) + weights = weights / weights.sum() + pl_pcts = np.array([trade.pl_pct for trade in self.trades]) + return (pl_pcts * weights).sum() + + @property + def is_long(self) -> bool: + """True if the position is long (position size is positive).""" + return self.size > 0 + + @property + def is_short(self) -> bool: + """True if the position is short (position size is negative).""" + return self.size < 0 + + def __repr__(self): + return ( + f"" + ) diff --git a/pytradebacktest/stats.py b/pytradebacktest/stats.py index 240ad6a..875bebf 100644 --- a/pytradebacktest/stats.py +++ b/pytradebacktest/stats.py @@ -258,7 +258,7 @@ def worst_trade_return(self): @property def avg_trade_return(self): - return self._geometric_mean(self.returns/100) * 100 + return self._geometric_mean(self.returns / 100) * 100 @property def max_trade_duration(self): diff --git a/tests/unit/test_backtest_position.py b/tests/unit/test_backtest_position.py new file mode 100644 index 0000000..542419c --- /dev/null +++ b/tests/unit/test_backtest_position.py @@ -0,0 +1,106 @@ +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pandas as pd +import pytest +from pandas import Timestamp +from pytrade.models import Trade + +from pytradebacktest.position import Position + + +def test_position(): + instrument_1 = "ABC" + instrument_2 = "GOOG" + + entry_price = 98.90 + last_price = 105 + time1 = Timestamp(datetime.now()) + exit_price = 110.50 + time2 = time1 + timedelta(days=1, hours=2) + goog_data = MagicMock() + goog_data.last_price = last_price + goog_df = pd.DataFrame( + {"Timestamp": [time1, time2], "Close": [entry_price, exit_price]} + ) + goog_df = goog_df.set_index("Timestamp") + goog_data.df = goog_df + + abc_data = MagicMock() + abc_data.last_price = last_price + abc_df = pd.DataFrame( + {"Timestamp": [time1, time2], "Close": [entry_price, exit_price]} + ) + abc_df = abc_df.set_index("Timestamp") + abc_data.df = abc_df + + abc_trades = [ + Trade(instrument_1, 100, 90, time1, abc_data), + Trade(instrument_1, 150, 85, time2, abc_data), + ] + goog_trades = [ + Trade(instrument_2, 100, 90, time1, goog_data), + Trade(instrument_2, 200, 100, time2, goog_data), + ] + trades = abc_trades + goog_trades + + position = Position(instrument_2, trades) + + assert len(position.trades) == 2 + assert position.size == 300 + assert position.pl == 2500 + trade1_pl_pct = (last_price / 90 - 1) * 100 + trade2_pl_pct = (last_price / 100 - 1) * 100 + position_pl_pct = ( + 0.3333333333333333 * trade1_pl_pct + 0.6666666666666666 * trade2_pl_pct + ) + assert position.pl_pct == position_pl_pct + assert position.is_long is True + assert position.is_short is False + + +def test_position_close(): + instrument_1 = "ABC" + instrument_2 = "GOOG" + + entry_price = 98.90 + last_price = 105 + time1 = Timestamp(datetime.now()) + exit_price = 110.50 + time2 = time1 + timedelta(days=1, hours=2) + goog_data = MagicMock() + goog_data.last_price = last_price + goog_df = pd.DataFrame( + {"Timestamp": [time1, time2], "Close": [entry_price, exit_price]} + ) + goog_df = goog_df.set_index("Timestamp") + goog_data.df = goog_df + + abc_data = MagicMock() + abc_data.last_price = last_price + abc_df = pd.DataFrame( + {"Timestamp": [time1, time2], "Close": [entry_price, exit_price]} + ) + abc_df = abc_df.set_index("Timestamp") + abc_data.df = abc_df + + abc_trades = [ + Trade(instrument_1, 100, 90, time1, abc_data), + Trade(instrument_1, 150, 85, time2, abc_data), + ] + trade_1 = Trade(instrument_2, 100, 90, time1, goog_data) + trade_2 = Trade(instrument_2, 200, 100, time1, goog_data) + goog_trades = [ + trade_1, + trade_2, + ] + trades = abc_trades + goog_trades + + position = Position("GOOG", trades) + + trade_1.close(exit_price, time2) + trade_2.close(exit_price, time2) + + assert position.size == 300 + assert position.pl == 4150 + assert position.pl_pct == pytest.approx(14.59, 1e-2)