Skip to content

Commit 4285602

Browse files
authored
Merge pull request freqtrade#11154 from freqtrade/feat/zip_backtest
Zip backtest results
2 parents 0032f9a + 6c94b75 commit 4285602

File tree

10 files changed

+314
-247
lines changed

10 files changed

+314
-247
lines changed

freqtrade/commands/analyze_commands.py

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,23 @@
11
import logging
2-
from pathlib import Path
32
from typing import Any
43

54
from freqtrade.enums import RunMode
6-
from freqtrade.exceptions import ConfigurationError, OperationalException
75

86

97
logger = logging.getLogger(__name__)
108

119

12-
def setup_analyze_configuration(args: dict[str, Any], method: RunMode) -> dict[str, Any]:
13-
"""
14-
Prepare the configuration for the entry/exit reason analysis module
15-
:param args: Cli args from Arguments()
16-
:param method: Bot running mode
17-
:return: Configuration
18-
"""
19-
from freqtrade.configuration import setup_utils_configuration
20-
21-
config = setup_utils_configuration(args, method)
22-
23-
no_unlimited_runmodes = {
24-
RunMode.BACKTEST: "backtesting",
25-
}
26-
if method in no_unlimited_runmodes.keys():
27-
from freqtrade.data.btanalysis import get_latest_backtest_filename
28-
29-
if "exportfilename" in config:
30-
if config["exportfilename"].is_dir():
31-
btfile = Path(get_latest_backtest_filename(config["exportfilename"]))
32-
signals_file = f"{config['exportfilename']}/{btfile.stem}_signals.pkl"
33-
else:
34-
if config["exportfilename"].exists():
35-
btfile = Path(config["exportfilename"])
36-
signals_file = f"{btfile.parent}/{btfile.stem}_signals.pkl"
37-
else:
38-
raise ConfigurationError(f"{config['exportfilename']} does not exist.")
39-
else:
40-
raise ConfigurationError("exportfilename not in config.")
41-
42-
if not Path(signals_file).exists():
43-
raise OperationalException(
44-
f"Cannot find latest backtest signals file: {signals_file}."
45-
"Run backtesting with `--export signals`."
46-
)
47-
48-
return config
49-
50-
5110
def start_analysis_entries_exits(args: dict[str, Any]) -> None:
5211
"""
5312
Start analysis script
5413
:param args: Cli args from Arguments()
5514
:return: None
5615
"""
16+
from freqtrade.configuration import setup_utils_configuration
5717
from freqtrade.data.entryexitanalysis import process_entry_exit_reasons
5818

5919
# Initialize configuration
60-
config = setup_analyze_configuration(args, RunMode.BACKTEST)
20+
config = setup_utils_configuration(args, RunMode.BACKTEST)
6121

6222
logger.info("Starting freqtrade in analysis mode")
6323

freqtrade/data/btanalysis.py

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
"""
44

55
import logging
6+
import zipfile
67
from copy import copy
78
from datetime import datetime, timezone
9+
from io import BytesIO, StringIO
810
from pathlib import Path
911
from typing import Any, Literal
1012

@@ -165,8 +167,16 @@ def load_backtest_stats(filename: Path | str) -> BacktestResultType:
165167
if not filename.is_file():
166168
raise ValueError(f"File {filename} does not exist.")
167169
logger.info(f"Loading backtest result from {filename}")
168-
with filename.open() as file:
169-
data = json_load(file)
170+
171+
if filename.suffix == ".zip":
172+
data = json_load(
173+
StringIO(
174+
load_file_from_zip(filename, filename.with_suffix(".json").name).decode("utf-8")
175+
)
176+
)
177+
else:
178+
with filename.open() as file:
179+
data = json_load(file)
170180

171181
# Legacy list format does not contain metadata.
172182
if isinstance(data, dict):
@@ -194,8 +204,10 @@ def load_and_merge_backtest_result(strategy_name: str, filename: Path, results:
194204

195205

196206
def _get_backtest_files(dirname: Path) -> list[Path]:
197-
# Weird glob expression here avoids including .meta.json files.
198-
return list(reversed(sorted(dirname.glob("backtest-result-*-[0-9][0-9].json"))))
207+
# Get both json and zip files separately and combine the results
208+
json_files = dirname.glob("backtest-result-*-[0-9][0-9]*.json")
209+
zip_files = dirname.glob("backtest-result-*-[0-9][0-9]*.zip")
210+
return list(reversed(sorted(list(json_files) + list(zip_files))))
199211

200212

201213
def _extract_backtest_result(filename: Path) -> list[BacktestHistoryEntryType]:
@@ -267,7 +279,11 @@ def get_backtest_market_change(filename: Path, include_ts: bool = True) -> pd.Da
267279
"""
268280
Read backtest market change file.
269281
"""
270-
df = pd.read_feather(filename)
282+
if filename.suffix == ".zip":
283+
data = load_file_from_zip(filename, f"{filename.stem}_market_change.feather")
284+
df = pd.read_feather(BytesIO(data))
285+
else:
286+
df = pd.read_feather(filename)
271287
if include_ts:
272288
df.loc[:, "__date_ts"] = df.loc[:, "date"].astype(np.int64) // 1000 // 1000
273289
return df
@@ -388,6 +404,93 @@ def load_backtest_data(filename: Path | str, strategy: str | None = None) -> pd.
388404
return df
389405

390406

407+
def load_file_from_zip(zip_path: Path, filename: str) -> bytes:
408+
"""
409+
Load a file from a zip file
410+
:param zip_path: Path to the zip file
411+
:param filename: Name of the file to load
412+
:return: Bytes of the file
413+
:raises: ValueError if loading goes wrong.
414+
"""
415+
try:
416+
with zipfile.ZipFile(zip_path) as zipf:
417+
try:
418+
with zipf.open(filename) as file:
419+
return file.read()
420+
except KeyError:
421+
logger.error(f"File {filename} not found in zip: {zip_path}")
422+
raise ValueError(f"File {filename} not found in zip: {zip_path}") from None
423+
except FileNotFoundError:
424+
raise ValueError(f"Zip file {zip_path} not found.")
425+
except zipfile.BadZipFile:
426+
logger.error(f"Bad zip file: {zip_path}.")
427+
raise ValueError(f"Bad zip file: {zip_path}.") from None
428+
429+
430+
def load_backtest_analysis_data(backtest_dir: Path, name: str):
431+
"""
432+
Load backtest analysis data either from a pickle file or from within a zip file
433+
:param backtest_dir: Directory containing backtest results
434+
:param name: Name of the analysis data to load (signals, rejected, exited)
435+
:return: Analysis data
436+
"""
437+
import joblib
438+
439+
if backtest_dir.is_dir():
440+
lbf = Path(get_latest_backtest_filename(backtest_dir))
441+
zip_path = backtest_dir / lbf
442+
else:
443+
zip_path = backtest_dir
444+
445+
if zip_path.suffix == ".zip":
446+
# Load from zip file
447+
analysis_name = f"{zip_path.stem}_{name}.pkl"
448+
data = load_file_from_zip(zip_path, analysis_name)
449+
if not data:
450+
return None
451+
loaded_data = joblib.load(BytesIO(data))
452+
453+
logger.info(f"Loaded {name} candles from zip: {str(zip_path)}:{analysis_name}")
454+
return loaded_data
455+
456+
else:
457+
# Load from separate pickle file
458+
if backtest_dir.is_dir():
459+
scpf = Path(backtest_dir, f"{zip_path.stem}_{name}.pkl")
460+
else:
461+
scpf = Path(backtest_dir.parent / f"{backtest_dir.stem}_{name}.pkl")
462+
463+
try:
464+
with scpf.open("rb") as scp:
465+
loaded_data = joblib.load(scp)
466+
logger.info(f"Loaded {name} candles: {str(scpf)}")
467+
return loaded_data
468+
except Exception:
469+
logger.exception(f"Cannot load {name} data from pickled results.")
470+
return None
471+
472+
473+
def load_rejected_signals(backtest_dir: Path):
474+
"""
475+
Load rejected signals from backtest directory
476+
"""
477+
return load_backtest_analysis_data(backtest_dir, "rejected")
478+
479+
480+
def load_signal_candles(backtest_dir: Path):
481+
"""
482+
Load signal candles from backtest directory
483+
"""
484+
return load_backtest_analysis_data(backtest_dir, "signals")
485+
486+
487+
def load_exit_signal_candles(backtest_dir: Path) -> dict[str, dict[str, pd.DataFrame]]:
488+
"""
489+
Load exit signal candles from backtest directory
490+
"""
491+
return load_backtest_analysis_data(backtest_dir, "exited")
492+
493+
391494
def analyze_trade_parallelism(results: pd.DataFrame, timeframe: str) -> pd.DataFrame:
392495
"""
393496
Find overlapping trades by expanding each trade once per period it was open

freqtrade/data/entryexitanalysis.py

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,25 @@
11
import logging
22
from pathlib import Path
33

4-
import joblib
54
import pandas as pd
65

76
from freqtrade.configuration import TimeRange
87
from freqtrade.constants import Config
98
from freqtrade.data.btanalysis import (
109
BT_DATA_COLUMNS,
11-
get_latest_backtest_filename,
1210
load_backtest_data,
1311
load_backtest_stats,
12+
load_exit_signal_candles,
13+
load_rejected_signals,
14+
load_signal_candles,
1415
)
15-
from freqtrade.exceptions import OperationalException
16+
from freqtrade.exceptions import ConfigurationError, OperationalException
1617
from freqtrade.util import print_df_rich_table
1718

1819

1920
logger = logging.getLogger(__name__)
2021

2122

22-
def _load_backtest_analysis_data(backtest_dir: Path, name: str):
23-
if backtest_dir.is_dir():
24-
scpf = Path(
25-
backtest_dir,
26-
Path(get_latest_backtest_filename(backtest_dir)).stem + "_" + name + ".pkl",
27-
)
28-
else:
29-
scpf = Path(backtest_dir.parent / f"{backtest_dir.stem}_{name}.pkl")
30-
31-
try:
32-
with scpf.open("rb") as scp:
33-
loaded_data = joblib.load(scp)
34-
logger.info(f"Loaded {name} candles: {str(scpf)}")
35-
except Exception as e:
36-
logger.error(f"Cannot load {name} data from pickled results: ", e)
37-
return None
38-
39-
return loaded_data
40-
41-
42-
def _load_rejected_signals(backtest_dir: Path):
43-
return _load_backtest_analysis_data(backtest_dir, "rejected")
44-
45-
46-
def _load_signal_candles(backtest_dir: Path):
47-
return _load_backtest_analysis_data(backtest_dir, "signals")
48-
49-
50-
def _load_exit_signal_candles(backtest_dir: Path) -> dict[str, dict[str, pd.DataFrame]]:
51-
return _load_backtest_analysis_data(backtest_dir, "exited")
52-
53-
5423
def _process_candles_and_indicators(
5524
pairlist, strategy_name, trades, signal_candles, date_col: str = "open_date"
5625
):
@@ -374,19 +343,21 @@ def process_entry_exit_reasons(config: Config):
374343
timerange = TimeRange.parse_timerange(
375344
None if config.get("timerange") is None else str(config.get("timerange"))
376345
)
377-
378-
backtest_stats = load_backtest_stats(config["exportfilename"])
346+
try:
347+
backtest_stats = load_backtest_stats(config["exportfilename"])
348+
except ValueError as e:
349+
raise ConfigurationError(e) from e
379350

380351
for strategy_name, results in backtest_stats["strategy"].items():
381352
trades = load_backtest_data(config["exportfilename"], strategy_name)
382353

383354
if trades is not None and not trades.empty:
384-
signal_candles = _load_signal_candles(config["exportfilename"])
385-
exit_signals = _load_exit_signal_candles(config["exportfilename"])
355+
signal_candles = load_signal_candles(config["exportfilename"])
356+
exit_signals = load_exit_signal_candles(config["exportfilename"])
386357

387358
rej_df = None
388359
if do_rejected:
389-
rejected_signals_dict = _load_rejected_signals(config["exportfilename"])
360+
rejected_signals_dict = load_rejected_signals(config["exportfilename"])
390361
rej_df = prepare_results(
391362
rejected_signals_dict,
392363
strategy_name,

freqtrade/misc.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@
1919
logger = logging.getLogger(__name__)
2020

2121

22+
def dump_json_to_file(file_obj: TextIO, data: Any) -> None:
23+
"""
24+
Dump JSON data into a file object
25+
:param file_obj: File object to write to
26+
:param data: JSON Data to save
27+
"""
28+
rapidjson.dump(data, file_obj, default=str, number_mode=rapidjson.NM_NATIVE)
29+
30+
2231
def file_dump_json(filename: Path, data: Any, is_zip: bool = False, log: bool = True) -> None:
2332
"""
2433
Dump JSON data into a file
@@ -35,32 +44,16 @@ def file_dump_json(filename: Path, data: Any, is_zip: bool = False, log: bool =
3544
logger.info(f'dumping json to "{filename}"')
3645

3746
with gzip.open(filename, "wt", encoding="utf-8") as fpz:
38-
rapidjson.dump(data, fpz, default=str, number_mode=rapidjson.NM_NATIVE)
47+
dump_json_to_file(fpz, data)
3948
else:
4049
if log:
4150
logger.info(f'dumping json to "{filename}"')
4251
with filename.open("w") as fp:
43-
rapidjson.dump(data, fp, default=str, number_mode=rapidjson.NM_NATIVE)
52+
dump_json_to_file(fp, data)
4453

4554
logger.debug(f'done json to "{filename}"')
4655

4756

48-
def file_dump_joblib(filename: Path, data: Any, log: bool = True) -> None:
49-
"""
50-
Dump object data into a file
51-
:param filename: file to create
52-
:param data: Object data to save
53-
:return:
54-
"""
55-
import joblib
56-
57-
if log:
58-
logger.info(f'dumping joblib to "{filename}"')
59-
with filename.open("wb") as fp:
60-
joblib.dump(data, fp)
61-
logger.debug(f'done joblib dump to "{filename}"')
62-
63-
6457
def json_load(datafile: TextIO) -> Any:
6558
"""
6659
load data with rapidjson

freqtrade/optimize/backtest_caching.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ def get_strategy_run_id(strategy) -> str:
4040
def get_backtest_metadata_filename(filename: Path | str) -> Path:
4141
"""Return metadata filename for specified backtest results file."""
4242
filename = Path(filename)
43-
return filename.parent / Path(f"{filename.stem}.meta{filename.suffix}")
43+
return filename.parent / Path(f"{filename.stem}.meta.json")

0 commit comments

Comments
 (0)