Skip to content

Commit 9480331

Browse files
authored
Merge pull request freqtrade#11548 from freqtrade/feat/config_to_btresults
Save config and Strategy to backtest result file
2 parents 7c5b2fd + 799ce4e commit 9480331

File tree

9 files changed

+108
-19
lines changed

9 files changed

+108
-19
lines changed

docs/backtesting.md

+14
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,20 @@ To save time, by default backtest will reuse a cached result from within the las
435435
To further analyze your backtest results, freqtrade will export the trades to file by default.
436436
You can then load the trades to perform further analysis as shown in the [data analysis](strategy_analysis_example.md#load-backtest-results-to-pandas-dataframe) backtesting section.
437437

438+
### Backtest output file
439+
440+
The output file freqtrade produces is a zip file containing the following files:
441+
442+
- The backtest report in json format
443+
- the market change data in feather format
444+
- a copy of the strategy file
445+
- a copy of the strategy parameters (if a parameter file was used)
446+
- a sanitized copy of the config file
447+
448+
This will ensure results are reproducible - under the assumption that the same data is available.
449+
450+
Only the strategy file and the config file are included in the zip file, eventual dependencies are not included.
451+
438452
## Assumptions made by backtesting
439453

440454
Since backtesting lacks some detailed information about what happens within a candle, it needs to take a few assumptions:

freqtrade/ft_types/backtest_result_type.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Any
1+
from copy import deepcopy
2+
from typing import Any, cast
23

34
from typing_extensions import TypedDict
45

@@ -15,11 +16,16 @@ class BacktestResultType(TypedDict):
1516

1617

1718
def get_BacktestResultType_default() -> BacktestResultType:
18-
return {
19-
"metadata": {},
20-
"strategy": {},
21-
"strategy_comparison": [],
22-
}
19+
return cast(
20+
BacktestResultType,
21+
deepcopy(
22+
{
23+
"metadata": {},
24+
"strategy": {},
25+
"strategy_comparison": [],
26+
}
27+
),
28+
)
2329

2430

2531
class BacktestHistoryEntryType(BacktestMetadataType):

freqtrade/optimize/backtesting.py

+1
Original file line numberDiff line numberDiff line change
@@ -1792,6 +1792,7 @@ def start(self) -> None:
17921792
dt_appendix,
17931793
market_change_data=combined_res,
17941794
analysis_results=self.analysis_results,
1795+
strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
17951796
)
17961797

17971798
# Results may be mixed up now. Sort them so they follow --strategy-list order.

freqtrade/optimize/optimize_reports/bt_storage.py

+28
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from pandas import DataFrame
88

9+
from freqtrade.configuration import sanitize_config
910
from freqtrade.constants import LAST_BT_RESULT_FN
1011
from freqtrade.enums.runmode import RunMode
1112
from freqtrade.ft_types import BacktestResultType
@@ -52,6 +53,7 @@ def store_backtest_results(
5253
*,
5354
market_change_data: DataFrame | None = None,
5455
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
56+
strategy_files: dict[str, str] | None = None,
5557
) -> Path:
5658
"""
5759
Stores backtest results and analysis data in a zip file, with metadata stored separately
@@ -85,6 +87,32 @@ def store_backtest_results(
8587
dump_json_to_file(stats_buf, stats_copy)
8688
zipf.writestr(json_filename.name, stats_buf.getvalue())
8789

90+
config_buf = StringIO()
91+
dump_json_to_file(config_buf, sanitize_config(config["original_config"]))
92+
zipf.writestr(f"{base_filename.stem}_config.json", config_buf.getvalue())
93+
94+
for strategy_name, strategy_file in (strategy_files or {}).items():
95+
# Store the strategy file and its parameters
96+
strategy_buf = BytesIO()
97+
strategy_path = Path(strategy_file)
98+
if not strategy_path.is_file():
99+
logger.warning(f"Strategy file '{strategy_path}' does not exist. Skipping.")
100+
continue
101+
with strategy_path.open("rb") as strategy_file_obj:
102+
strategy_buf.write(strategy_file_obj.read())
103+
strategy_buf.seek(0)
104+
zipf.writestr(f"{base_filename.stem}_{strategy_name}.py", strategy_buf.getvalue())
105+
strategy_params = strategy_path.with_suffix(".json")
106+
if strategy_params.is_file():
107+
strategy_params_buf = BytesIO()
108+
with strategy_params.open("rb") as strategy_params_obj:
109+
strategy_params_buf.write(strategy_params_obj.read())
110+
strategy_params_buf.seek(0)
111+
zipf.writestr(
112+
f"{base_filename.stem}_{strategy_name}.json",
113+
strategy_params_buf.getvalue(),
114+
)
115+
88116
# Add market change data if present
89117
if market_change_data is not None:
90118
market_change_name = f"{base_filename.stem}_market_change.feather"

freqtrade/optimize/optimize_reports/optimize_reports.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
calculate_sortino,
1919
calculate_sqn,
2020
)
21-
from freqtrade.ft_types import BacktestResultType
21+
from freqtrade.ft_types import BacktestResultType, get_BacktestResultType_default
2222
from freqtrade.util import decimals_per_coin, fmt_coin, get_dry_run_wallet
2323

2424

@@ -587,11 +587,7 @@ def generate_backtest_stats(
587587
:param max_date: Backtest end date
588588
:return: Dictionary containing results per strategy and a strategy summary.
589589
"""
590-
result: BacktestResultType = {
591-
"metadata": {},
592-
"strategy": {},
593-
"strategy_comparison": [],
594-
}
590+
result: BacktestResultType = get_BacktestResultType_default()
595591
market_change = calculate_market_change(btdata, "close")
596592
metadata = {}
597593
pairlist = list(btdata.keys())

freqtrade/rpc/api_server/api_backtest.py

+3
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ def __run_backtest_bg(btconfig: Config):
108108
ApiBG.bt["bt"].results,
109109
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
110110
market_change_data=combined_res,
111+
strategy_files={
112+
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
113+
},
111114
)
112115
ApiBG.bt["bt"].results["metadata"][strategy_name]["filename"] = str(fn.stem)
113116
ApiBG.bt["bt"].results["metadata"][strategy_name]["strategy"] = strategy_name

freqtrade/strategy/interface.py

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ class IStrategy(ABC, HyperStrategyMixin):
132132
stake_currency: str
133133
# container variable for strategy source code
134134
__source__: str = ""
135+
__file__: str = ""
135136

136137
# Definition of plot_config. See plotting documentation for more details.
137138
plot_config: dict = {}

tests/conftest.py

+1
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,7 @@ def get_default_conf(testdatadir):
652652
"trading_mode": "spot",
653653
"margin_mode": "",
654654
"candle_type_def": CandleType.SPOT,
655+
"original_config": {},
655656
}
656657
return configuration
657658

tests/optimize/test_optimize_reports.py

+46-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import re
3+
import shutil
34
from datetime import timedelta
45
from pathlib import Path
56
from shutil import copyfile
@@ -41,7 +42,7 @@
4142
from freqtrade.resolvers.strategy_resolver import StrategyResolver
4243
from freqtrade.util import dt_ts
4344
from freqtrade.util.datetime_helpers import dt_from_ts, dt_utc
44-
from tests.conftest import CURRENT_TEST_STRATEGY
45+
from tests.conftest import CURRENT_TEST_STRATEGY, log_has_re
4546
from tests.data.test_history import _clean_test_file
4647

4748

@@ -253,8 +254,9 @@ def test_store_backtest_results(testdatadir, mocker):
253254
dump_mock = mocker.patch("freqtrade.optimize.optimize_reports.bt_storage.file_dump_json")
254255
zip_mock = mocker.patch("freqtrade.optimize.optimize_reports.bt_storage.ZipFile")
255256
data = {"metadata": {}, "strategy": {}, "strategy_comparison": []}
256-
257-
store_backtest_results({"exportfilename": testdatadir}, data, "2022_01_01_15_05_13")
257+
store_backtest_results(
258+
{"exportfilename": testdatadir, "original_config": {}}, data, "2022_01_01_15_05_13"
259+
)
258260

259261
assert dump_mock.call_count == 2
260262
assert zip_mock.call_count == 1
@@ -264,17 +266,26 @@ def test_store_backtest_results(testdatadir, mocker):
264266
dump_mock.reset_mock()
265267
zip_mock.reset_mock()
266268
filename = testdatadir / "testresult.json"
267-
store_backtest_results({"exportfilename": filename}, data, "2022_01_01_15_05_13")
269+
store_backtest_results(
270+
{"exportfilename": filename, "original_config": {}}, data, "2022_01_01_15_05_13"
271+
)
268272
assert dump_mock.call_count == 2
269273
assert zip_mock.call_count == 1
270274
assert isinstance(dump_mock.call_args_list[0][0][0], Path)
271275
# result will be testdatadir / testresult-<timestamp>.json
272276
assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir / "testresult"))
273277

274278

275-
def test_store_backtest_results_real(tmp_path):
279+
def test_store_backtest_results_real(tmp_path, caplog):
276280
data = {"metadata": {}, "strategy": {}, "strategy_comparison": []}
277-
store_backtest_results({"exportfilename": tmp_path}, data, "2022_01_01_15_05_13")
281+
config = {
282+
"exportfilename": tmp_path,
283+
"original_config": {},
284+
}
285+
store_backtest_results(
286+
config, data, "2022_01_01_15_05_13", strategy_files={"DefStrat": "NoFile"}
287+
)
288+
assert log_has_re(r"Strategy file .* does not exist\. Skipping\.", caplog)
278289

279290
zip_file = tmp_path / "backtest-result-2022_01_01_15_05_13.zip"
280291
assert zip_file.is_file()
@@ -287,8 +298,19 @@ def test_store_backtest_results_real(tmp_path):
287298
fn = get_latest_backtest_filename(tmp_path)
288299
assert fn == "backtest-result-2022_01_01_15_05_13.zip"
289300

301+
strategy_test_dir = Path(__file__).parent.parent / "strategy" / "strats"
302+
303+
shutil.copy(strategy_test_dir / "strategy_test_v3.py", tmp_path)
304+
params_file = tmp_path / "strategy_test_v3.json"
305+
with params_file.open("w") as f:
306+
f.write("""{"strategy_name": "TurtleStrategyX5","params":{}}""")
307+
290308
store_backtest_results(
291-
{"exportfilename": tmp_path}, data, "2024_01_01_15_05_25", market_change_data=pd.DataFrame()
309+
config,
310+
data,
311+
"2024_01_01_15_05_25",
312+
market_change_data=pd.DataFrame(),
313+
strategy_files={"DefStrat": str(tmp_path / "strategy_test_v3.py")},
292314
)
293315
zip_file = tmp_path / "backtest-result-2024_01_01_15_05_25.zip"
294316
assert zip_file.is_file()
@@ -298,6 +320,22 @@ def test_store_backtest_results_real(tmp_path):
298320
with ZipFile(zip_file, "r") as zipf:
299321
assert "backtest-result-2024_01_01_15_05_25.json" in zipf.namelist()
300322
assert "backtest-result-2024_01_01_15_05_25_market_change.feather" in zipf.namelist()
323+
assert "backtest-result-2024_01_01_15_05_25_config.json" in zipf.namelist()
324+
# strategy file is copied to the zip file
325+
assert "backtest-result-2024_01_01_15_05_25_DefStrat.py" in zipf.namelist()
326+
# compare the content of the strategy file
327+
with zipf.open("backtest-result-2024_01_01_15_05_25_DefStrat.py") as strategy_file:
328+
strategy_content = strategy_file.read()
329+
with (strategy_test_dir / "strategy_test_v3.py").open("rb") as original_file:
330+
original_content = original_file.read()
331+
assert strategy_content == original_content
332+
assert "backtest-result-2024_01_01_15_05_25_DefStrat.py" in zipf.namelist()
333+
with zipf.open("backtest-result-2024_01_01_15_05_25_DefStrat.json") as pf:
334+
params_content = pf.read()
335+
with params_file.open("rb") as original_file:
336+
original_content = original_file.read()
337+
assert params_content == original_content
338+
301339
assert (tmp_path / LAST_BT_RESULT_FN).is_file()
302340

303341
# Last file reference should be updated
@@ -313,6 +351,7 @@ def test_write_read_backtest_candles(tmp_path):
313351
"exportfilename": tmp_path,
314352
"export": "signals",
315353
"runmode": "backtest",
354+
"original_config": {},
316355
}
317356
# test directory exporting
318357
sample_date = "2022_01_01_15_05_13"

0 commit comments

Comments
 (0)