{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "Notebook for the article [Stop Loss, Trailing Stop, or Take Profit? 2 Million Backtests Shed Light](https://polakowo.medium.com/stop-loss-trailing-stop-or-take-profit-2-million-backtests-shed-light-dde23bda40be)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from vectorbtpro import *\n", "# whats_imported()\n", "\n", "import ipywidgets\n", "\n", "vbt.settings.set_theme('dark')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "seed = 42\n", "symbols = [\n", " \"BTC-USD\", \"ETH-USD\", \"XRP-USD\", \"BCH-USD\", \"LTC-USD\", \n", " \"BNB-USD\", \"EOS-USD\", \"XLM-USD\", \"XMR-USD\", \"ADA-USD\"\n", "]\n", "start_date = vbt.utc_timestamp(\"2018-01-01\")\n", "end_date = vbt.utc_timestamp(\"2021-01-01\")\n", "time_delta = end_date - start_date\n", "window_len = vbt.timedelta(\"180d\")\n", "window_cnt = 400\n", "exit_types = [\"SL\", \"TS\", \"TP\", \"Random\", \"Holding\"]\n", "step = 0.01\n", "stops = np.arange(step, 1 + step, step)\n", "\n", "vbt.settings.wrapping[\"freq\"] = \"d\"\n", "vbt.settings.plotting[\"layout\"][\"template\"] = \"vbt_dark\"\n", "vbt.settings.portfolio[\"init_cash\"] = 100.\n", "\n", "print(pd.Series({\n", " \"Start date\": start_date,\n", " \"End date\": end_date,\n", " \"Time period (days)\": time_delta.days,\n", " \"Assets\": len(symbols),\n", " \"Window length\": window_len,\n", " \"Windows\": window_cnt,\n", " \"Exit types\": len(exit_types),\n", " \"Stop values\": len(stops),\n", " \"Tests per asset\": window_cnt * len(stops) * len(exit_types),\n", " \"Tests per window\": len(symbols) * len(stops) * len(exit_types),\n", " \"Tests per exit type\": len(symbols) * window_cnt * len(stops),\n", " \"Tests per stop type and value\": len(symbols) * window_cnt,\n", " \"Tests total\": len(symbols) * window_cnt * len(stops) * len(exit_types)\n", "}))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "cols = [\"Open\", \"Low\", \"High\", \"Close\", \"Volume\"]\n", "yfdata = vbt.YFData.pull(symbols, start=start_date, end=end_date)\n", "\n", "print(yfdata.data.keys())\n", "print(yfdata.data[\"BTC-USD\"].shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "yfdata.plot(symbol=\"BTC-USD\").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "ohlcv = yfdata.concat()\n", "\n", "print(ohlcv.keys())\n", "print(ohlcv[\"Open\"].shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "splitter = vbt.Splitter.from_n_rolling(\n", " ohlcv[\"Open\"].index, \n", " n=window_cnt,\n", " length=window_len.days\n", ")\n", "\n", "split_ohlcv = {}\n", "for k, v in ohlcv.items():\n", " split_ohlcv[k] = splitter.take(v, into=\"reset_stacked\")\n", "print(split_ohlcv[\"Open\"].shape)\n", "\n", "split_indexes = splitter.take(ohlcv[\"Open\"].index)\n", "print(split_indexes)\n", "print(split_indexes[10])" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(split_ohlcv[\"Open\"].columns)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "entries = pd.DataFrame.vbt.signals.empty_like(split_ohlcv[\"Open\"])\n", "entries.iloc[0, :] = True\n", "\n", "print(entries.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# We use OHLCSTX instead of built-in stop-loss in Portfolio.from_signals\n", "# because we want to analyze signals before simulation + it's easier to construct param grids\n", "# For reality check, run the same setup using Portfolio.from_signals alone\n", "\n", "sl_ohlcstx = vbt.OHLCSTX.run(\n", " entries, \n", " entry_price=split_ohlcv[\"Close\"], \n", " open=split_ohlcv[\"Open\"], \n", " high=split_ohlcv[\"High\"], \n", " low=split_ohlcv[\"Low\"], \n", " close=split_ohlcv[\"Close\"], \n", " sl_stop=list(stops),\n", " stop_type=None\n", ")\n", "sl_exits = sl_ohlcstx.exits.copy()\n", "sl_price = sl_ohlcstx.close.copy()\n", "sl_price[sl_exits] = sl_ohlcstx.stop_price\n", "del sl_ohlcstx\n", "\n", "print(sl_exits.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "tsl_ohlcstx = vbt.OHLCSTX.run(\n", " entries, \n", " entry_price=split_ohlcv[\"Close\"], \n", " open=split_ohlcv[\"Open\"], \n", " high=split_ohlcv[\"High\"], \n", " low=split_ohlcv[\"Low\"], \n", " close=split_ohlcv[\"Close\"], \n", " tsl_stop=list(stops),\n", " stop_type=None\n", ")\n", "tsl_exits = tsl_ohlcstx.exits.copy()\n", "tsl_price = tsl_ohlcstx.close.copy()\n", "tsl_price[tsl_exits] = tsl_ohlcstx.stop_price\n", "del tsl_ohlcstx\n", "\n", "print(tsl_exits.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "tp_ohlcstx = vbt.OHLCSTX.run(\n", " entries, \n", " entry_price=split_ohlcv[\"Close\"], \n", " open=split_ohlcv[\"Open\"], \n", " high=split_ohlcv[\"High\"], \n", " low=split_ohlcv[\"Low\"], \n", " close=split_ohlcv[\"Close\"], \n", " tp_stop=list(stops),\n", " stop_type=None\n", ")\n", "tp_exits = tp_ohlcstx.exits.copy()\n", "tp_price = tp_ohlcstx.close.copy()\n", "tp_price[tp_exits] = tp_ohlcstx.stop_price\n", "del tp_ohlcstx\n", "\n", "print(tp_exits.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def rename_stop_level(df):\n", " return df.vbt.rename_levels({\n", " \"ohlcstx_sl_stop\": \"stop_value\",\n", " \"ohlcstx_tsl_stop\": \"stop_value\",\n", " \"ohlcstx_tp_stop\": \"stop_value\"\n", " }, strict=False)\n", "\n", "sl_exits = rename_stop_level(sl_exits)\n", "tsl_exits = rename_stop_level(tsl_exits)\n", "tp_exits = rename_stop_level(tp_exits)\n", "\n", "sl_price = rename_stop_level(sl_price)\n", "tsl_price = rename_stop_level(tsl_price)\n", "tp_price = rename_stop_level(tp_price)\n", "\n", "print(sl_exits.columns)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(pd.Series({\n", " \"SL\": sl_exits.vbt.signals.total().mean(),\n", " \"TS\": tsl_exits.vbt.signals.total().mean(),\n", " \"TP\": tp_exits.vbt.signals.total().mean()\n", "}, name=\"avg_num_signals\"))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def groupby_stop_value(df):\n", " return df.vbt.signals.total().groupby(\"stop_value\").mean()\n", "\n", "pd.DataFrame({\n", " \"Stop Loss\": groupby_stop_value(sl_exits),\n", " \"Trailing Stop\": groupby_stop_value(tsl_exits),\n", " \"Take Profit\": groupby_stop_value(tp_exits)\n", "}).vbt.plot(\n", " xaxis_title=\"Stop value\", \n", " yaxis_title=\"Avg number of signals\"\n", ").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "sl_exits.iloc[-1, :] = True\n", "tsl_exits.iloc[-1, :] = True\n", "tp_exits.iloc[-1, :] = True\n", "\n", "sl_exits = sl_exits.vbt.signals.first_after(entries)\n", "tsl_exits = tsl_exits.vbt.signals.first_after(entries)\n", "tp_exits = tp_exits.vbt.signals.first_after(entries)\n", "\n", "print(pd.Series({\n", " \"SL\": sl_exits.vbt.signals.total().mean(),\n", " \"TS\": tsl_exits.vbt.signals.total().mean(),\n", " \"TP\": tp_exits.vbt.signals.total().mean()\n", "}, name=\"avg_num_signals\"))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hold_exits = pd.DataFrame.vbt.signals.empty_like(sl_exits)\n", "hold_exits.iloc[-1, :] = True\n", "hold_price = vbt.broadcast_to(split_ohlcv[\"Close\"], sl_price)\n", "\n", "print(hold_exits.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "rand_exits = hold_exits.vbt.shuffle(seed=seed)\n", "rand_price = hold_price\n", "\n", "print(rand_exits.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "exits = pd.DataFrame.vbt.concat(\n", " sl_exits, \n", " tsl_exits, \n", " tp_exits, \n", " rand_exits, \n", " hold_exits, \n", " keys=pd.Index(exit_types, name=\"exit_type\")\n", ")\n", "del sl_exits\n", "del tsl_exits\n", "del tp_exits\n", "del rand_exits\n", "del hold_exits\n", "\n", "print(exits.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "price = pd.DataFrame.vbt.concat(\n", " sl_price, \n", " tsl_price, \n", " tp_price, \n", " rand_price, \n", " hold_price, \n", " keys=pd.Index(exit_types, name=\"exit_type\")\n", ")\n", "del sl_price\n", "del tsl_price\n", "del tp_price\n", "del rand_price\n", "del hold_price\n", "\n", "print(price.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(exits.columns)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(exits.vbt.getsize())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(price.vbt.getsize())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "avg_distance = entries.vbt.signals.between_ranges(target=exits)\\\n", " .duration.mean()\\\n", " .groupby([\"exit_type\", \"stop_value\"])\\\n", " .mean()\\\n", " .unstack(level=\"exit_type\")\n", "\n", "print(avg_distance.mean())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "avg_distance[exit_types].vbt.plot(\n", " xaxis_title=\"Stop value\", \n", " yaxis_title=\"Avg distance to entry\"\n", ").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "%%time\n", "pf = vbt.Portfolio.from_signals(\n", " split_ohlcv[\"Close\"], \n", " entries, \n", " exits, \n", " price=price\n", ")\n", "\n", "print(len(pf.orders))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "total_return = pf.total_return\n", "del pf\n", "\n", "print(total_return.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import gc\n", "\n", "total_returns = []\n", "for i in vbt.ProgressBar(range(len(exit_types))):\n", " chunk_mask = exits.columns.get_level_values(\"exit_type\") == exit_types[i]\n", " chunk_pf = vbt.Portfolio.from_signals(\n", " split_ohlcv[\"Close\"], \n", " entries, \n", " exits.loc[:, chunk_mask],\n", " price=price.loc[:, chunk_mask]\n", " )\n", " total_returns.append(chunk_pf.total_return)\n", " \n", " del chunk_pf\n", " gc.collect()\n", " \n", "total_return = pd.concat(total_returns)\n", "\n", "print(total_return.shape)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "return_by_type = total_return.unstack(level=\"exit_type\")[exit_types]\n", "\n", "print(return_by_type[\"Holding\"].describe(percentiles=[]))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "purple_color = vbt.settings[\"plotting\"][\"color_schema\"][\"purple\"]\n", "return_by_type[\"Holding\"].vbt.histplot(\n", " xaxis_title=\"Total return\",\n", " xaxis_tickformat=\".2%\",\n", " yaxis_title=\"Count\",\n", " trace_kwargs=dict(marker_color=purple_color)\n", ").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(pd.DataFrame({\n", " \"Mean\": return_by_type.mean(),\n", " \"Median\": return_by_type.median(),\n", " \"Std\": return_by_type.std(),\n", "}))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "return_by_type.vbt.boxplot(\n", " trace_kwargs=dict(boxpoints=False),\n", " yaxis_title=\"Total return\",\n", " yaxis_tickformat=\".2%\"\n", ").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print((return_by_type > 0).mean().rename(\"win_rate\"))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "init_cash = vbt.settings.portfolio[\"init_cash\"]\n", "\n", "def get_expectancy(return_by_type, level_name):\n", " grouped = return_by_type.groupby(level_name, axis=0)\n", " win_rate = grouped.apply(lambda x: (x > 0).mean())\n", " avg_win = grouped.apply(lambda x: init_cash * x[x > 0].mean())\n", " avg_win = avg_win.fillna(0)\n", " avg_loss = grouped.apply(lambda x: init_cash * x[x < 0].mean())\n", " avg_loss = avg_loss.fillna(0)\n", " return win_rate * avg_win - (1 - win_rate) * np.abs(avg_loss)\n", " \n", "expectancy_by_stop = get_expectancy(return_by_type, \"stop_value\")\n", "\n", "print(expectancy_by_stop.mean())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "expectancy_by_stop.vbt.plot(\n", " xaxis_title=\"Stop value\", \n", " yaxis_title=\"Expectancy\"\n", ").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "return_values = np.sort(return_by_type[\"Holding\"].values)\n", "idxs = np.ceil(np.linspace(0, len(return_values) - 1, 21)).astype(int)\n", "bins = return_values[idxs][:-1]\n", "\n", "def bin_return(return_by_type):\n", " classes = pd.cut(return_by_type[\"Holding\"], bins=bins, right=True)\n", " new_level = np.array(classes.apply(lambda x: x.right))\n", " new_level = pd.Index(new_level, name=\"bin_right\")\n", " return return_by_type.vbt.add_levels(new_level, axis=0)\n", "\n", "binned_return_by_type = bin_return(return_by_type)\n", "\n", "expectancy_by_bin = get_expectancy(binned_return_by_type, \"bin_right\")\n", "\n", "expectancy_by_bin.vbt.plot(\n", " trace_kwargs=dict(mode=\"lines\"),\n", " xaxis_title=\"Total return of holding\",\n", " xaxis_tickformat=\".2%\",\n", " yaxis_title=\"Expectancy\"\n", ").show_svg()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "range_starts = pd.DatetimeIndex(list(map(lambda x: x[0], split_indexes)))\n", "range_ends = pd.DatetimeIndex(list(map(lambda x: x[-1], split_indexes)))\n", "\n", "symbol_lvl = return_by_type.index.get_level_values(\"symbol\")\n", "split_lvl = return_by_type.index.get_level_values(\"split\")\n", "range_start_lvl = range_starts[split_lvl]\n", "range_end_lvl = range_ends[split_lvl]\n", "\n", "asset_multi_select = ipywidgets.SelectMultiple(\n", " options=symbols,\n", " value=symbols,\n", " rows=len(symbols),\n", " description=\"Symbols\"\n", ")\n", "dates = np.unique(yfdata.wrapper.index)\n", "date_range_slider = ipywidgets.SelectionRangeSlider(\n", " options=dates,\n", " index=(0, len(dates)-1),\n", " orientation=\"horizontal\",\n", " readout=False,\n", " continuous_update=False\n", ")\n", "range_start_label = ipywidgets.Label()\n", "range_end_label = ipywidgets.Label()\n", "metric_dropdown = ipywidgets.Dropdown(\n", " options=[\"Mean\", \"Median\", \"Win Rate\", \"Expectancy\"],\n", " value=\"Expectancy\"\n", ")\n", "stop_scatter = vbt.Scatter(\n", " trace_names=exit_types,\n", " x_labels=stops, \n", " xaxis_title=\"Stop value\", \n", " yaxis_title=\"Expectancy\"\n", ")\n", "stop_scatter_img = ipywidgets.Image(\n", " format=\"png\",\n", " width=stop_scatter.fig.layout.width,\n", " height=stop_scatter.fig.layout.height\n", ")\n", "bin_scatter = vbt.Scatter(\n", " trace_names=exit_types,\n", " x_labels=expectancy_by_bin.index, \n", " trace_kwargs=dict(mode=\"lines\"),\n", " xaxis_title=\"Total return of holding\",\n", " xaxis_tickformat=\"%\",\n", " yaxis_title=\"Expectancy\"\n", ")\n", "bin_scatter_img = ipywidgets.Image(\n", " format=\"png\",\n", " width=bin_scatter.fig.layout.width,\n", " height=bin_scatter.fig.layout.height\n", ")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def update_scatter(*args, **kwargs):\n", " _symbols = asset_multi_select.value\n", " _from = date_range_slider.value[0]\n", " _to = date_range_slider.value[1]\n", " _metric_name = metric_dropdown.value\n", " \n", " range_mask = (range_start_lvl >= _from) & (range_end_lvl <= _to)\n", " asset_mask = symbol_lvl.isin(_symbols)\n", " filt = return_by_type[range_mask & asset_mask]\n", " \n", " filt_binned = bin_return(filt)\n", " if _metric_name == \"Mean\":\n", " filt_metric = filt.groupby(\"stop_value\").mean()\n", " filt_bin_metric = filt_binned.groupby(\"bin_right\").mean()\n", " elif _metric_name == \"Median\":\n", " filt_metric = filt.groupby(\"stop_value\").median()\n", " filt_bin_metric = filt_binned.groupby(\"bin_right\").median()\n", " elif _metric_name == \"Win Rate\":\n", " filt_metric = (filt > 0).groupby(\"stop_value\").mean()\n", " filt_bin_metric = (filt_binned > 0).groupby(\"bin_right\").mean()\n", " elif _metric_name == \"Expectancy\":\n", " filt_metric = get_expectancy(filt, \"stop_value\")\n", " filt_bin_metric = get_expectancy(filt_binned, \"bin_right\")\n", " \n", " stop_scatter.fig.update_layout(yaxis_title=_metric_name)\n", " stop_scatter.update(filt_metric)\n", " stop_scatter_img.value = stop_scatter.fig.to_image(format=\"png\")\n", " \n", " bin_scatter.fig.update_layout(yaxis_title=_metric_name)\n", " bin_scatter.update(filt_bin_metric)\n", " bin_scatter_img.value = bin_scatter.fig.to_image(format=\"png\")\n", " \n", " range_start_label.value = np.datetime_as_string(_from.to_datetime64(), unit=\"D\")\n", " range_end_label.value = np.datetime_as_string(_to.to_datetime64(), unit=\"D\")\n", " \n", "asset_multi_select.observe(update_scatter, names=\"value\")\n", "date_range_slider.observe(update_scatter, names=\"value\")\n", "metric_dropdown.observe(update_scatter, names=\"value\")\n", "update_scatter()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "dashboard = ipywidgets.VBox([\n", " asset_multi_select,\n", " ipywidgets.HBox([\n", " range_start_label,\n", " date_range_slider,\n", " range_end_label\n", " ]),\n", " metric_dropdown,\n", " stop_scatter_img,\n", " bin_scatter_img\n", "])\n", "dashboard" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "dashboard.close()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.12" }, "widgets": { "application/vnd.jupyter.widget-state+json": { "state": {}, "version_major": 2, "version_minor": 0 } } }, "nbformat": 4, "nbformat_minor": 4 }