From 579c392cdde3edebe141e375c9fd09e5bc94fb9c Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 5 Feb 2025 10:33:38 -0500 Subject: [PATCH 1/6] Fixing Sl/TP orders being left in order queue --- pytradebacktest/broker.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pytradebacktest/broker.py b/pytradebacktest/broker.py index 6a8845a..088ad78 100644 --- a/pytradebacktest/broker.py +++ b/pytradebacktest/broker.py @@ -169,7 +169,8 @@ def _process_contingent_order(self, ctx: OrderContext): closed = self._reduce_trade( trade, _order_size, ctx.entry_price, ctx.entry_time ) - if closed: + # IF we closed, it removes SL and TP + if not closed: self.orders.remove(order) def _process_market_order(self, ctx: OrderContext): @@ -280,9 +281,9 @@ def close_trades(self): def _close_trade(self, trade: Trade, price: float, timestamp: Timestamp): self.trades.remove(trade) - if trade.sl: + if trade.sl != None: self.orders.remove(trade.sl) - if trade.tp: + if trade.tp != None: self.orders.remove(trade.tp) trade.close(price, timestamp) From e8d0bf8c4aeb0e40dd3c53f8e4bd51a1dcec557c Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 5 Feb 2025 10:34:16 -0500 Subject: [PATCH 2/6] Fixing CSV loader to use columsn from resampled tick data --- pytradebacktest/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytradebacktest/data.py b/pytradebacktest/data.py index 8f460ea..78c3009 100644 --- a/pytradebacktest/data.py +++ b/pytradebacktest/data.py @@ -102,8 +102,8 @@ def load(self) -> list[InstrumentData]: _sources = [] for source in self.sources: - df = load_csv(source.path, parse_dates=["Timestamp"]) - df = df.set_index("Timestamp") + df = load_csv(source.path, parse_dates=["datetime"]) + df = df.set_index("datetime") df.replace("", np.nan, inplace=True) df.dropna(inplace=True) instrument_data = InstrumentData(source.instrument, source.granularity, df) From 7163b498db4d2b4eedfffc23a6e688cfd6399a20 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 5 Feb 2025 19:55:34 -0500 Subject: [PATCH 3/6] Adding initial plotting implementation --- .vscode/settings.json | 1 + lib/PyTrade | 2 +- poetry.lock | 83 ++++++++++++++++- pyproject.toml | 8 +- pytradebacktest/backtest.py | 37 +++++--- pytradebacktest/broker.py | 4 +- pytradebacktest/main.py | 0 pytradebacktest/plot.py | 105 ++++++++++++++++++++++ tests/unit/assets/EURGBP-2024-05_1Min.csv | 2 +- tests/unit/assets/EURGBP-2024-05_5Min.csv | 2 +- tests/unit/assets/EURJPY-2024-05_1Min.csv | 2 +- tests/unit/assets/EURJPY-2024-05_5Min.csv | 2 +- tests/unit/assets/EURUSD-2024-05_1Min.csv | 2 +- tests/unit/assets/EURUSD-2024-05_5Min.csv | 2 +- tests/unit/assets/GBPUSD-2024-05_1Min.csv | 2 +- tests/unit/assets/GBPUSD-2024-05_5Min.csv | 2 +- tests/unit/assets/GOOG.csv | 2 +- tests/unit/test_backtest_broker.py | 80 +++++++++++++---- tests/unit/test_backtest_indicator.py | 2 +- tests/unit/test_backtest_plot.py | 12 +++ tests/unit/test_backtest_strategy.py | 18 ++-- tests/unit/test_utils.py | 2 +- 22 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 pytradebacktest/main.py create mode 100644 pytradebacktest/plot.py create mode 100644 tests/unit/test_backtest_plot.py diff --git a/.vscode/settings.json b/.vscode/settings.json index def288c..80005db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, + "debugpy.debugJustMyCode": false, "python.terminal.activateEnvironment": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.analysis.exclude": [ diff --git a/lib/PyTrade b/lib/PyTrade index ed1b101..9443a58 160000 --- a/lib/PyTrade +++ b/lib/PyTrade @@ -1 +1 @@ -Subproject commit ed1b1018c69f0e05aec33e046e724f8804d37d74 +Subproject commit 9443a58c606b6b6d9520634bc5d1899787d59964 diff --git a/poetry.lock b/poetry.lock index bf01e6f..3f519db 100644 --- a/poetry.lock +++ b/poetry.lock @@ -571,6 +571,32 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "narwhals" +version = "1.24.1" +description = "Extremely lightweight compatibility layer between dataframe libraries" +optional = false +python-versions = ">=3.8" +files = [ + {file = "narwhals-1.24.1-py3-none-any.whl", hash = "sha256:d8983fe14851c95d60576ddca37c094bd4ed24ab9ea98396844fb20ad9aaf184"}, + {file = "narwhals-1.24.1.tar.gz", hash = "sha256:b09b8253d945f23cdb683a84685abf3afb9f96114d89e9f35dc876e143f65007"}, +] + +[package.extras] +core = ["duckdb", "pandas", "polars", "pyarrow", "pyarrow-stubs"] +cudf = ["cudf (>=24.10.0)"] +dask = ["dask[dataframe] (>=2024.8)"] +dev = ["covdefaults", "hypothesis", "pre-commit", "pytest", "pytest-cov", "pytest-env", "pytest-randomly", "typing-extensions"] +docs = ["black", "duckdb", "jinja2", "markdown-exec[ansi]", "mkdocs", "mkdocs-autorefs", "mkdocs-material", "mkdocstrings[python]", "pandas", "polars (>=1.0.0)", "pyarrow"] +duckdb = ["duckdb (>=1.0)"] +extra = ["scikit-learn"] +ibis = ["ibis-framework (>=6.0.0)", "packaging", "pyarrow-hotfix", "rich"] +modin = ["modin"] +pandas = ["pandas (>=0.25.3)"] +polars = ["polars (>=0.20.3)"] +pyarrow = ["pyarrow (>=11.0.0)"] +pyspark = ["pyspark (>=3.5.0)"] + [[package]] name = "numpy" version = "1.26.4" @@ -854,6 +880,24 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.11.2)"] +[[package]] +name = "plotly" +version = "6.0.0" +description = "An open-source, interactive data visualization library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "plotly-6.0.0-py3-none-any.whl", hash = "sha256:f708871c3a9349a68791ff943a5781b1ec04de7769ea69068adcd9202e57653a"}, + {file = "plotly-6.0.0.tar.gz", hash = "sha256:c4aad38b8c3d65e4a5e7dd308b084143b9025c2cc9d5317fc1f1d30958db87d3"}, +] + +[package.dependencies] +narwhals = ">=1.15.1" +packaging = "*" + +[package.extras] +express = ["numpy"] + [[package]] name = "pluggy" version = "1.5.0" @@ -869,6 +913,24 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "progressbar2" +version = "4.5.0" +description = "A Python Progressbar library to provide visual (yet text based) progress to long running operations." +optional = false +python-versions = ">=3.8" +files = [ + {file = "progressbar2-4.5.0-py3-none-any.whl", hash = "sha256:625c94a54e63915b3959355e6d4aacd63a00219e5f3e2b12181b76867bf6f628"}, + {file = "progressbar2-4.5.0.tar.gz", hash = "sha256:6662cb624886ed31eb94daf61e27583b5144ebc7383a17bae076f8f4f59088fb"}, +] + +[package.dependencies] +python-utils = ">=3.8.1" + +[package.extras] +docs = ["sphinx (>=1.8.5)", "sphinx-autodoc-typehints (>=1.6.0)"] +tests = ["dill (>=0.3.6)", "flake8 (>=3.7.7)", "freezegun (>=0.3.11)", "pytest (>=4.6.9)", "pytest-cov (>=2.6.1)", "pytest-mypy", "pywin32", "sphinx (>=1.8.5)"] + [[package]] name = "pycodestyle" version = "2.12.1" @@ -977,6 +1039,25 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-utils" +version = "3.9.1" +description = "Python Utils is a module with some convenient utilities not included with the standard Python install" +optional = false +python-versions = ">=3.9.0" +files = [ + {file = "python_utils-3.9.1-py2.py3-none-any.whl", hash = "sha256:0273d7363c7ad4b70999b2791d5ba6b55333d6f7a4e4c8b6b39fb82b5fab4613"}, + {file = "python_utils-3.9.1.tar.gz", hash = "sha256:eb574b4292415eb230f094cbf50ab5ef36e3579b8f09e9f2ba74af70891449a0"}, +] + +[package.dependencies] +typing_extensions = ">3.10.0.2" + +[package.extras] +docs = ["mock", "python-utils", "sphinx"] +loguru = ["loguru"] +tests = ["blessings", "loguru", "loguru-mypy", "mypy-ipython", "pyright", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mypy", "ruff", "sphinx", "types-setuptools"] + [[package]] name = "pytrade" version = "0.1.0" @@ -1224,4 +1305,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "04e1fdcc5d2c4cd28ed0b8a4c2965cb10ed8de054d32d92915de51710b706bdb" +content-hash = "7598843080ad7b5a656df7873bbe96d6a1162cefc58f6aad9e2785fc63cc20f0" diff --git a/pyproject.toml b/pyproject.toml index b004115..447917c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ pytrade = {path = "lib/PyTrade", develop = true} pandas = "^2.2.2" pytest-asyncio = "^0.25.1" autoflake = "^2.3.1" +plotly = "^6.0.0" +progressbar2 = "^4.5.0" [tool.poetry.group.dev.dependencies] @@ -52,4 +54,8 @@ omit = [ [tool.pytest.ini_options] addopts = ["--import-mode=importlib"] -pythonpath = [ "." ] \ No newline at end of file +pythonpath = [ "." ] + +[[tool.mypy.overrides]] +module = ["plotly.*"] +ignore_missing_imports = true \ No newline at end of file diff --git a/pytradebacktest/backtest.py b/pytradebacktest/backtest.py index e1c9303..5c490ca 100644 --- a/pytradebacktest/backtest.py +++ b/pytradebacktest/backtest.py @@ -1,11 +1,14 @@ from typing import Type import pandas as pd +from progressbar import ProgressBar from pytrade.indicator import Indicator +from pytrade.instruments import Granularity from pytrade.strategy import FxStrategy from pytradebacktest.broker import BacktestBroker from pytradebacktest.data import MarketData +from pytradebacktest.plot import plot from pytradebacktest.stats import Stats @@ -38,24 +41,38 @@ def increment_indicator(self): Indicator._update = increment_indicator - broker = BacktestBroker(self.data, self.cash, self.comission, self.margin) + self.broker = BacktestBroker(self.data, self.cash, self.comission, self.margin) - strategy = self.kstrategy(broker, self.data) + strategy = self.kstrategy(self.broker, self.data) strategy.init() - while self.data.next(): + with ProgressBar(max_value=len(self.data), redirect_stdout=True) as bar: + while self.data.next(): - broker.next() - strategy.next() + self.broker.next() + strategy.next() + + bar.next() # Close any open trades: - broker.close_trades() + self.broker.close_trades() # Call broker one last time to clean up any outstanding orders from strategy - broker.next() + self.broker.next() # Claculate results/stats - equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values - return Stats(broker.closed_trades, equity, self.data, strategy) + equity = pd.Series(self.broker._equity).bfill().fillna(self.broker._cash).values + return Stats(self.broker.closed_trades, equity, self.data, strategy) def plot(self): - pass + instruments = set([t.instrument for t in self.broker.closed_trades]) + for instrument in instruments: + goog_data = self.data.get(instrument, Granularity.M1) + trades = [ + trade + for trade in self.broker.closed_trades + if trade.instrument == instrument + ] + equity_df = pd.DataFrame( + self.broker._equity, index=self.data._market_index, columns=["Equity"] + ) + plot(goog_data, equity_df, trades) diff --git a/pytradebacktest/broker.py b/pytradebacktest/broker.py index 088ad78..6f0ae57 100644 --- a/pytradebacktest/broker.py +++ b/pytradebacktest/broker.py @@ -281,9 +281,9 @@ def close_trades(self): def _close_trade(self, trade: Trade, price: float, timestamp: Timestamp): self.trades.remove(trade) - if trade.sl != None: + if trade.sl is not None: self.orders.remove(trade.sl) - if trade.tp != None: + if trade.tp is not None: self.orders.remove(trade.tp) trade.close(price, timestamp) diff --git a/pytradebacktest/main.py b/pytradebacktest/main.py new file mode 100644 index 0000000..e69de29 diff --git a/pytradebacktest/plot.py b/pytradebacktest/plot.py new file mode 100644 index 0000000..47be6f2 --- /dev/null +++ b/pytradebacktest/plot.py @@ -0,0 +1,105 @@ +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from pytrade.interfaces.data import IInstrumentData +from pytrade.models import Trade + +# from pytradebacktest.data import MarketData + + +def plot(data: IInstrumentData, equity: pd.DataFrame, trades: list[Trade]): + + fig = make_subplots( + rows=2, cols=1, row_heights=[0.2, 0.8], subplot_titles=("Equity", "Trades") + ) + + _plot_equity(fig, equity) + _plot_ohlc(fig, data) + _plot_trades(fig, data, trades) + + fig.show() + + +def _plot_equity(fig: go.Figure, equity: pd.DataFrame): + fig.add_trace( + go.Scatter(x=equity.index, y=equity["Equity"], mode="lines", name="Equity"), + row=1, + col=1, + ) + + +def _plot_ohlc(fig: go.Figure, data: IInstrumentData): + df = data.df + ohlc = go.Candlestick( + x=df.index, open=df["open"], high=df["high"], low=df["low"], close=df["close"] + ) + + fig.add_trace(ohlc, row=2, col=1) + fig.update_layout(xaxis2_rangeslider_visible=False) + + +def _plot_trades(fig: go.Figure, data: IInstrumentData, trades: list[Trade]): + for trade in trades: + # Add entry points + fig.add_trace( + go.Scatter( + x=[trade.entry_time], + y=[trade.entry_price], + mode="markers", + marker=dict( + size=10, + symbol="triangle-up" if trade.is_long else "triangle-down", + color="green" if trade.is_long else "red", + line=dict(color="black", width=1), + ), + hovertemplate=f""" +Date:%{{x}}
+Entry:%{{y}}
+Exit:{trade.exit_price}
+Size:{trade.size}
+{("SL: {trade.sl.stop}
" if trade.sl is not None else "")} +{("TP: {trade.tp.limit}
" if trade.tp is not None else "")} +P/L:{trade.pl} +""", + ), + row=2, + col=1, + ) + + # Add line to exit + fig.add_trace( + go.Scatter( + x=[trade.entry_time, trade.exit_time], + y=[trade.entry_price, trade.exit_price], + mode="lines", + line=dict(color="green" if trade.pl > 0 else "red"), + ), + row=2, + col=1, + ) + + if trade.sl is not None: + # Add stop loss line + fig.add_trace( + go.Scatter( + x=[trade.entry_time, trade.exit_time], + y=[trade.sl.stop, trade.sl.stop], + mode="lines", + line=dict(color="red", dash="dashdot"), + ), + row=2, + col=1, + ) + + if trade.tp is not None: + # Add take profit line + fig.add_trace( + go.Scatter( + x=[trade.entry_time, trade.exit_time], + y=[trade.tp.limit, trade.tp.limit], + mode="lines", + line=dict(color="green", dash="dashdot"), + ), + row=2, + col=1, + ) diff --git a/tests/unit/assets/EURGBP-2024-05_1Min.csv b/tests/unit/assets/EURGBP-2024-05_1Min.csv index 6106104..dd9decf 100644 --- a/tests/unit/assets/EURGBP-2024-05_1Min.csv +++ b/tests/unit/assets/EURGBP-2024-05_1Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,0.85401,0.85405,0.85397,0.85403 2024-05-01 00:01:00,0.85402,0.85404,0.85392,0.85403 2024-05-01 00:02:00,0.85397,0.85403,0.85396,0.854 diff --git a/tests/unit/assets/EURGBP-2024-05_5Min.csv b/tests/unit/assets/EURGBP-2024-05_5Min.csv index 802923c..f92941d 100644 --- a/tests/unit/assets/EURGBP-2024-05_5Min.csv +++ b/tests/unit/assets/EURGBP-2024-05_5Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,0.85401,0.85405,0.85391,0.85396 2024-05-01 00:05:00,0.85396,0.85397,0.85389,0.85392 2024-05-01 00:10:00,0.85391,0.85397,0.85386,0.85396 diff --git a/tests/unit/assets/EURJPY-2024-05_1Min.csv b/tests/unit/assets/EURJPY-2024-05_1Min.csv index 0b4286b..5a2accc 100644 --- a/tests/unit/assets/EURJPY-2024-05_1Min.csv +++ b/tests/unit/assets/EURJPY-2024-05_1Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,168.263,168.274,168.201,168.229 2024-05-01 00:01:00,168.247,168.261,168.217,168.222 2024-05-01 00:02:00,168.222,168.242,168.191,168.213 diff --git a/tests/unit/assets/EURJPY-2024-05_5Min.csv b/tests/unit/assets/EURJPY-2024-05_5Min.csv index 681c392..4cafad5 100644 --- a/tests/unit/assets/EURJPY-2024-05_5Min.csv +++ b/tests/unit/assets/EURJPY-2024-05_5Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,168.263,168.274,168.177,168.226 2024-05-01 00:05:00,168.24,168.285,168.2,168.256 2024-05-01 00:10:00,168.274,168.275,168.201,168.242 diff --git a/tests/unit/assets/EURUSD-2024-05_1Min.csv b/tests/unit/assets/EURUSD-2024-05_1Min.csv index b0a3cae..f6a08f4 100644 --- a/tests/unit/assets/EURUSD-2024-05_1Min.csv +++ b/tests/unit/assets/EURUSD-2024-05_1Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,1.06657,1.06672,1.06655,1.0667 2024-05-01 00:01:00,1.06665,1.06672,1.06655,1.06663 2024-05-01 00:02:00,1.06655,1.06666,1.06655,1.06663 diff --git a/tests/unit/assets/EURUSD-2024-05_5Min.csv b/tests/unit/assets/EURUSD-2024-05_5Min.csv index 93b2b42..c1c931a 100644 --- a/tests/unit/assets/EURUSD-2024-05_5Min.csv +++ b/tests/unit/assets/EURUSD-2024-05_5Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,1.06657,1.06672,1.06647,1.0667 2024-05-01 00:05:00,1.06666,1.06671,1.06652,1.06663 2024-05-01 00:10:00,1.06656,1.06665,1.06633,1.06643 diff --git a/tests/unit/assets/GBPUSD-2024-05_1Min.csv b/tests/unit/assets/GBPUSD-2024-05_1Min.csv index 0acc55d..6f1f3a4 100644 --- a/tests/unit/assets/GBPUSD-2024-05_1Min.csv +++ b/tests/unit/assets/GBPUSD-2024-05_1Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,1.24883,1.24896,1.24878,1.24893 2024-05-01 00:01:00,1.24894,1.24904,1.24883,1.24888 2024-05-01 00:02:00,1.24884,1.24894,1.24884,1.24889 diff --git a/tests/unit/assets/GBPUSD-2024-05_5Min.csv b/tests/unit/assets/GBPUSD-2024-05_5Min.csv index c2b25c4..00ef5ae 100644 --- a/tests/unit/assets/GBPUSD-2024-05_5Min.csv +++ b/tests/unit/assets/GBPUSD-2024-05_5Min.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close +datetime,open,high,low,close 2024-05-01 00:00:00,1.24883,1.24906,1.24878,1.24901 2024-05-01 00:05:00,1.24904,1.24911,1.24891,1.24902 2024-05-01 00:10:00,1.24903,1.24906,1.24868,1.24872 diff --git a/tests/unit/assets/GOOG.csv b/tests/unit/assets/GOOG.csv index eed8d1c..f44bddd 100644 --- a/tests/unit/assets/GOOG.csv +++ b/tests/unit/assets/GOOG.csv @@ -1,4 +1,4 @@ -Timestamp,Open,High,Low,Close,Volume +datetime,open,high,low,close,volume 2004-08-19,100,104.06,95.96,100.34,22351900 2004-08-20,101.01,109.08,100.5,108.31,11428600 2004-08-23,110.75,113.48,109.05,109.4,9137200 diff --git a/tests/unit/test_backtest_broker.py b/tests/unit/test_backtest_broker.py index 942bcbc..6d0f5b8 100644 --- a/tests/unit/test_backtest_broker.py +++ b/tests/unit/test_backtest_broker.py @@ -23,7 +23,7 @@ def test_fill_market_order(iterations, test_stock_universe: MarketData): assert len(broker.orders) == 0 assert len(broker.trades) == 1 trade = broker.trades[0] - assert trade.entry_price == goog_data.Open.iloc[iterations - 1] + assert trade.entry_price == goog_data.open.iloc[iterations - 1] def test_market_order_not_enough_equity(test_stock_universe: MarketData): @@ -45,12 +45,12 @@ def test_stop_order_conversion(price_index, buy, test_stock_universe: MarketData broker = BacktestBroker(test_stock_universe, 100000, 0, 1, False, False, False) price_timestamp = ( - goog_data[:price_index].High.idxmax() + goog_data[:price_index].high.idxmax() if buy - else goog_data[:price_index].Low.idxmin() + else goog_data[:price_index].low.idxmin() ) price_idx = goog_data.index.get_loc(price_timestamp) - price = goog_data.High.iloc[price_idx] if buy else goog_data.Low.iloc[price_idx] + price = goog_data.high.iloc[price_idx] if buy else goog_data.low.iloc[price_idx] stop = price - 0.01 if buy else price + 0.01 # Set to 1 cent past price size = 100 if buy else -100 broker.order(Order("GOOG", size, stop=stop)) @@ -80,12 +80,12 @@ def test_limit_order_conversion(price_index, buy, test_stock_universe: MarketDat broker = BacktestBroker(test_stock_universe, 100000, 0, 1, False, False, False) price_timestamp = ( - goog_data[:price_index].Low.idxmin() + goog_data[:price_index].low.idxmin() if buy - else goog_data[:price_index].High.idxmax() + else goog_data[:price_index].high.idxmax() ) price_idx = goog_data.index.get_loc(price_timestamp) - price = goog_data.Low.iloc[price_idx] if buy else goog_data.High.iloc[price_idx] + price = goog_data.low.iloc[price_idx] if buy else goog_data.high.iloc[price_idx] limit = price + 0.01 if buy else price - 0.01 # Set to 1 cent past price size = 100 if buy else -100 broker.order(Order("GOOG", size, limit=limit)) @@ -115,16 +115,16 @@ def test_stop_loss_order(index, buy, test_stock_universe: MarketData): broker = BacktestBroker(test_stock_universe, 100000, 0, 1, False, False, False) stop_timestmap = ( - goog_data[index:].Low.idxmin() if buy else goog_data[index:].High.idxmax() + goog_data[index:].low.idxmin() if buy else goog_data[index:].high.idxmax() ) stop_idx = goog_data.index.get_loc(stop_timestmap) stop_price = ( - goog_data.Low.iloc[stop_idx] + 0.01 + goog_data.low.iloc[stop_idx] + 0.01 if buy - else goog_data.High.iloc[stop_idx] - 0.01 + else goog_data.high.iloc[stop_idx] - 0.01 ) size = 100 if buy else -100 - entry_price = goog_data.Open.iloc[index] + entry_price = goog_data.open.iloc[index] for i in range(index): test_stock_universe.next() @@ -162,16 +162,16 @@ def test_take_profit_order(index, buy, test_stock_universe: MarketData): broker = BacktestBroker(test_stock_universe, 100000, 0, 1, False, False, False) limit_timestmap = ( - goog_data[index:].High.idxmax() if buy else goog_data[index:].Low.idxmin() + goog_data[index:].high.idxmax() if buy else goog_data[index:].low.idxmin() ) limit_idx = goog_data.index.get_loc(limit_timestmap) limit_price = ( - goog_data.High.iloc[limit_idx] - 0.01 + goog_data.high.iloc[limit_idx] - 0.01 if buy - else goog_data.Low.iloc[limit_idx] + 0.01 + else goog_data.low.iloc[limit_idx] + 0.01 ) size = 100 if buy else -100 - entry_price = goog_data.Open.iloc[index] + entry_price = goog_data.open.iloc[index] for i in range(index): test_stock_universe.next() @@ -254,6 +254,56 @@ def test_change_position(test_stock_universe: MarketData): assert len(goog_position.trades) == 1 +def test_close_and_open_oppposite_position(test_stock_universe: MarketData): + broker = BacktestBroker(test_stock_universe, 10000, 0, 1, False, False, False) + goog_position = broker.get_position("GOOG") + + broker.order(Order("GOOG", 100)) + test_stock_universe.next() + broker.next() + + assert len(broker.orders) == 0 + assert len(broker.trades) == 1 + + assert goog_position.is_long is True + assert goog_position.size == 100 + assert len(goog_position.trades) == 1 + + broker.close_position("GOOG") + broker.order(Order("GOOG", -100)) + test_stock_universe.next() + broker.next() + + assert goog_position.is_long is False + assert goog_position.size == -100 + assert len(goog_position.trades) == 1 + + +def test_close_and_open_oppposite_position_with_sl_tp(test_stock_universe: MarketData): + broker = BacktestBroker(test_stock_universe, 10000, 0, 1, False, False, False) + goog_position = broker.get_position("GOOG") + + broker.order(Order("GOOG", 100, stop_loss_on_fill=80, take_profit_on_fill=140)) + test_stock_universe.next() + broker.next() + + assert len(broker.orders) == 2 + assert len(broker.trades) == 1 + + assert goog_position.is_long is True + assert goog_position.size == 100 + assert len(goog_position.trades) == 1 + + broker.close_position("GOOG") + broker.order(Order("GOOG", -100)) + test_stock_universe.next() + broker.next() + + assert goog_position.is_long is False + assert goog_position.size == -100 + assert len(goog_position.trades) == 1 + + def test_reduce_position(test_stock_universe: MarketData): broker = BacktestBroker(test_stock_universe, 10000, 0, 1, False, False, False) goog_position = broker.get_position("GOOG") diff --git a/tests/unit/test_backtest_indicator.py b/tests/unit/test_backtest_indicator.py index ee53609..9999fa4 100644 --- a/tests/unit/test_backtest_indicator.py +++ b/tests/unit/test_backtest_indicator.py @@ -8,7 +8,7 @@ class OpenIndicator(Indicator): def _run(self, *args, **kwargs): - return self._data.df.Open + return self._data.df.open def test_indicator_values(data: np.ndarray, indicator: Indicator): diff --git a/tests/unit/test_backtest_plot.py b/tests/unit/test_backtest_plot.py new file mode 100644 index 0000000..5a45713 --- /dev/null +++ b/tests/unit/test_backtest_plot.py @@ -0,0 +1,12 @@ +import pytest + +# from pytradebacktest.backtest import Backtest +# from tests.unit.resources.indicators import SmaCross + + +@pytest.mark.asyncio +async def test_plot_ohlc(test_stock_universe): + # test = Backtest(test_stock_universe, SmaCross, 10000) + # stats = await test.run() + + pass diff --git a/tests/unit/test_backtest_strategy.py b/tests/unit/test_backtest_strategy.py index 8ed81b7..9b74c53 100644 --- a/tests/unit/test_backtest_strategy.py +++ b/tests/unit/test_backtest_strategy.py @@ -1,9 +1,9 @@ from unittest.mock import patch +import pandas as pd import pytest from pytrade.indicator import Indicator from pytrade.instruments import CandleSubscription, FxInstrument, Granularity -from pytrade.interfaces.data import IInstrumentData from pytrade.strategy import FxStrategy from pytradebacktest.broker import BacktestBroker @@ -68,16 +68,16 @@ def increment_indicator(self): strategy = BacktestStrategy(broker, test_fx_universe) strategy.init() - m1_data: IInstrumentData = test_fx_universe.get( + m1_data: pd.DataFrame = test_fx_universe.get( BACKTEST_INSTRUMENT, Granularity.M1 ).df.copy() - m5_data: IInstrumentData = test_fx_universe.get( + m5_data: pd.DataFrame = test_fx_universe.get( BACKTEST_INSTRUMENT, Granularity.M5 ).df.copy() indicator_data = {"eurusd_m1": m1_data, "eurusd_m5": m5_data} expected_indicator_values = { - "eurusd_m1": m1_data.Open > m1_data.Close, - "eurusd_m5": m5_data.Open > m5_data.Close, + "eurusd_m1": m1_data.open > m1_data.close, + "eurusd_m5": m5_data.open > m5_data.close, } _indicators = { @@ -119,16 +119,16 @@ def increment_indicator(self): strategy = BacktestStrategy(broker, test_fx_universe) strategy.init() - test_data = test_fx_universe.get( + test_data: pd.DataFrame = test_fx_universe.get( BACKTEST_INSTRUMENT, BACKTEST_GRANULARITY ).df.copy() while test_fx_universe.next(): strategy.next() - expected_buy_calls = test_data[test_data.Open <= test_data.Close].count().Open - expected_sell_calls = test_data[test_data.Open > test_data.Close].count().Open - assert expected_buy_calls + expected_sell_calls == test_data.count().Open + expected_buy_calls = test_data[test_data.open <= test_data.close].count().open + expected_sell_calls = test_data[test_data.open > test_data.close].count().open + assert expected_buy_calls + expected_sell_calls == test_data.count().open assert mock_buy.call_count == expected_buy_calls assert mock_sell.call_count == expected_sell_calls diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index b62ff86..564cddc 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -13,5 +13,5 @@ ], ) def test_load_csvs(path, count): - df = load_csv(path, parse_dates=["Timestamp"]) + df = load_csv(path, parse_dates=["datetime"]) assert df.shape[0] == count From cb18b25eb1effb4d43f131bde619f99750387e41 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 5 Feb 2025 19:56:22 -0500 Subject: [PATCH 4/6] Bump pytrde --- lib/PyTrade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/PyTrade b/lib/PyTrade index 9443a58..48ae071 160000 --- a/lib/PyTrade +++ b/lib/PyTrade @@ -1 +1 @@ -Subproject commit 9443a58c606b6b6d9520634bc5d1899787d59964 +Subproject commit 48ae071e7d25e844abf36292d2ea21b35dc510a3 From 1296e2d518ee110947310556dd4c7ed008d09585 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 5 Feb 2025 20:00:51 -0500 Subject: [PATCH 5/6] Fix stats to account for non fractional pl_pct --- lib/PyTrade | 2 +- pytradebacktest/stats.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/PyTrade b/lib/PyTrade index 48ae071..00ec0d6 160000 --- a/lib/PyTrade +++ b/lib/PyTrade @@ -1 +1 @@ -Subproject commit 48ae071e7d25e844abf36292d2ea21b35dc510a3 +Subproject commit 00ec0d657b3476b8f27d0c9285ad9f1205140339 diff --git a/pytradebacktest/stats.py b/pytradebacktest/stats.py index bb1a0b5..240ad6a 100644 --- a/pytradebacktest/stats.py +++ b/pytradebacktest/stats.py @@ -250,15 +250,15 @@ def win_rate(self): @property def best_trade_return(self): - return self.returns.max() * 100 + return self.returns.max() @property def worst_trade_return(self): - return self.returns.min() * 100 + return self.returns.min() @property def avg_trade_return(self): - return self._geometric_mean(self.returns) * 100 + return self._geometric_mean(self.returns/100) * 100 @property def max_trade_duration(self): @@ -275,7 +275,7 @@ def profit_factor(self): @property def expectancy(self): - return self.returns.mean() * 100 + return self.returns.mean() @property def SQN(self): From d668939b604c06497c1db4a51ef25ad2aff041c1 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 5 Feb 2025 20:03:14 -0500 Subject: [PATCH 6/6] Bump pytrade --- lib/PyTrade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/PyTrade b/lib/PyTrade index 00ec0d6..ef0faea 160000 --- a/lib/PyTrade +++ b/lib/PyTrade @@ -1 +1 @@ -Subproject commit 00ec0d657b3476b8f27d0c9285ad9f1205140339 +Subproject commit ef0faeac292d271e97b19c159ac3acc0b166522b