2022-03-20 10:09:33 +01:00 | | | from pathlib import Path
|
2022-03-27 19:22:02 +02:00 | | | import click
|
| | | from tqdm import tqdm
|
2022-07-17 21:32:45 +02:00 | | |
|
| | | import numpy as np
|
2022-03-20 10:09:33 +01:00 | | | import pandas as pd
|
| | |
|
2022-03-27 19:22:02 +02:00 | | | from sklearn.metrics import (precision_recall_curve, PrecisionRecallDisplay, RocCurveDisplay)
|
2022-03-20 10:09:33 +01:00 | | | from sklearn.model_selection import ParameterGrid
|
| | |
|
| | | from service.App import *
|
2023-09-02 10:44:14 +02:00 | | | from common.utils import *
|
2025-06-15 12:17:00 +02:00 | | | from common.model_store import *
|
2025-04-17 17:48:45 +02:00 | | | from common.backtesting import *
|
2023-12-22 16:36:28 +01:00 | | | from common.generators import generate_feature_set
|
2022-03-20 10:09:33 +01:00 | | |
|
| | | """
|
2023-12-22 16:36:28 +01:00 | | | The script is intended for finding best trade parameters for a certain trade algorithm
|
| | | by executing trade simulation (backtesting) for all specified parameters.
|
| | | It performs exhaustive search in the space of all specified parameters by computing
|
| | | trade performance and then choosing the parameters with the highest profit (or maybe
|
| | | using other selection criteria like stability of the results or minimum allowed losses etc.)
|
2022-03-27 19:22:02 +02:00 | | |
|
| | | Notes:
|
2023-12-22 16:36:28 +01:00 | | | - The optimization is based on certain trade algorithm. This means that a trade algorithm
|
| | | is a parameter for this script. Different trade algorithms have different trade logics and
|
| | | also have different parameters. Currently, the script works with a very simple threshold-based
|
| | | trade algorithm: if some score is higher than the threshold (parameter) then buy, if it is lower
|
| | | than another threshold then sell. There is also a version with two thresholds for two scores.
|
| | | - The script consumes the results of signal script but it then varies parameters of one entry
|
| | | responsible for generation of trade signals. It then measures performance.
|
2022-03-20 10:09:33 +01:00 | | | """
|
| | |
|
2023-12-22 16:36:28 +01:00 | | |
|
2022-03-20 10:09:33 +01:00 | | | @click.command()
|
| | | @click.option('--config_file', '-c', type=click.Path(), default='', help='Configuration file name')
|
| | | def main(config_file):
|
| | | load_config(config_file)
|
2025-07-26 19:40:49 +02:00 | | | config = App.config
|
2022-03-20 10:09:33 +01:00 | | |
|
2025-07-26 19:40:49 +02:00 | | | App.model_store = ModelStore(config)
|
2025-06-15 12:17:00 +02:00 | | | App.model_store.load_models()
|
| | |
|
2025-07-26 19:40:49 +02:00 | | | time_column = config["time_column"]
|
2022-07-17 21:32:45 +02:00 | | |
|
| | | now = datetime.now()
|
| | |
|
2025-07-26 19:40:49 +02:00 | | | symbol = config["symbol"]
|
| | | data_path = Path(config["data_folder"]) / symbol
|
2022-03-20 10:09:33 +01:00 | | |
|
| | | #
|
2023-02-20 18:39:28 +01:00 | | | # Load data with (rolling) label point-wise predictions and signals generated
|
2022-03-20 10:09:33 +01:00 | | | #
|
2025-07-26 19:40:49 +02:00 | | | file_path = data_path / config.get("signal_file_name")
|
2022-07-17 21:32:45 +02:00 | | | if not file_path.exists():
|
| | | print(f"ERROR: Input file does not exist: {file_path}")
|
2022-03-20 10:09:33 +01:00 | | | return
|
| | |
|
2023-02-20 18:39:28 +01:00 | | | print(f"Loading signals from input file: {file_path}")
|
2024-03-16 14:06:50 +01:00 | | | if file_path.suffix == ".parquet":
|
| | | df = pd.read_parquet(file_path)
|
| | | elif file_path.suffix == ".csv":
|
2025-07-26 19:40:49 +02:00 | | | df = pd.read_csv(file_path, parse_dates=[time_column], date_format="ISO8601")
|
2024-03-16 14:06:50 +01:00 | | | else:
|
2025-04-17 17:22:55 +02:00 | | | print(f"ERROR: Unknown extension of the input file '{file_path.suffix}'. Only 'csv' and 'parquet' are supported")
|
2024-03-16 14:06:50 +01:00 | | | return
|
| | |
|
2023-02-20 18:39:28 +01:00 | | | print(f"Signals loaded. Length: {len(df)}. Width: {len(df.columns)}")
|
2022-03-20 10:09:33 +01:00 | | |
|
2023-09-02 10:44:14 +02:00 | | | #
|
| | | # Limit the source data
|
| | | #
|
2025-07-26 19:40:49 +02:00 | | | simulate_config = config["simulate_model"]
|
2023-09-02 10:44:14 +02:00 | | |
|
2025-04-19 11:48:06 +02:00 | | | data_start = simulate_config.get("data_start", None)
|
2025-04-17 17:36:48 +02:00 | | | data_end = simulate_config.get("data_end", None)
|
2023-09-02 10:44:14 +02:00 | | |
|
2025-04-19 11:48:06 +02:00 | | | if data_start:
|
| | | if isinstance(data_start, str):
|
| | | df = df[ df[time_column] >= data_start ]
|
| | | elif isinstance(data_start, int):
|
| | | df = df.iloc[data_start:]
|
| | |
|
| | | if data_end:
|
| | | if isinstance(data_end, str):
|
| | | df = df[ df[time_column] < data_end ]
|
| | | elif isinstance(data_end, int):
|
| | | df = df.iloc[:-data_end]
|
| | |
|
2022-08-06 10:05:33 +02:00 | | | df = df.reset_index(drop=True)
|
2022-04-15 11:46:19 +02:00 | | |
|
2023-09-02 11:42:12 +02:00 | | | print(f"Input data size {len(df)} records. Range: [{df.iloc[0][time_column]}, {df.iloc[-1][time_column]}]")
|
| | |
|
2023-03-03 18:32:17 +01:00 | | | #
|
| | | # Load signal train parameters
|
| | | #
|
2025-04-17 17:36:48 +02:00 | | | parameter_grid = simulate_config.get("grid")
|
| | | direction = simulate_config.get("direction", "")
|
2025-04-19 11:48:06 +02:00 | | | if direction not in ['long', 'short']:
|
| | | raise ValueError(f"Unknown value of {direction} in signal train model. Only 'long' or 'short' are possible.")
|
2025-04-17 17:36:48 +02:00 | | | topn_to_store = simulate_config.get("topn_to_store", 10)
|
2023-03-03 18:32:17 +01:00 | | |
|
2023-12-22 16:36:28 +01:00 | | | # Evaluate strings to produce lists with ranges of parameters
|
2023-08-06 13:30:22 +02:00 | | | if isinstance(parameter_grid.get("buy_signal_threshold"), str):
|
| | | parameter_grid["buy_signal_threshold"] = eval(parameter_grid.get("buy_signal_threshold"))
|
| | | if isinstance(parameter_grid.get("buy_signal_threshold_2"), str):
|
| | | parameter_grid["buy_signal_threshold_2"] = eval(parameter_grid.get("buy_signal_threshold_2"))
|
| | | if isinstance(parameter_grid.get("sell_signal_threshold"), str):
|
| | | parameter_grid["sell_signal_threshold"] = eval(parameter_grid.get("sell_signal_threshold"))
|
| | | if isinstance(parameter_grid.get("sell_signal_threshold_2"), str):
|
| | | parameter_grid["sell_signal_threshold_2"] = eval(parameter_grid.get("sell_signal_threshold_2"))
|
2023-03-03 18:32:17 +01:00 | | |
|
2023-12-22 16:36:28 +01:00 | | | # If necessary, disable sell parameters in grid search - they will be set from the buy parameters
|
2025-04-17 17:36:48 +02:00 | | | if simulate_config.get("buy_sell_equal"):
|
2023-08-06 13:30:22 +02:00 | | | parameter_grid["sell_signal_threshold"] = [None]
|
| | | parameter_grid["sell_signal_threshold_2"] = [None]
|
2022-08-14 16:54:29 +02:00 | | |
|
2025-08-24 12:28:22 +02:00 | | | months_in_simulation = (df[time_column].iloc[-1] - df[time_column].iloc[0]) / timedelta(days=365/12)
|
| | |
|
2023-12-22 16:36:28 +01:00 | | | #
|
| | | # Find the generator, the parameters of which will be varied
|
| | | #
|
2025-04-17 17:36:48 +02:00 | | | generator_name = simulate_config.get("signal_generator")
|
2025-07-26 19:40:49 +02:00 | | | signal_generator = next((ss for ss in config.get("signal_sets", []) if ss.get('generator') == generator_name), None)
|
2023-12-22 16:36:28 +01:00 | | | if not signal_generator:
|
| | | raise ValueError(f"Signal generator '{generator_name}' not found among all 'signal_sets'")
|
| | |
|
2022-03-27 19:22:02 +02:00 | | | performances = list()
|
2023-08-06 13:30:22 +02:00 | | | for parameters in tqdm(ParameterGrid([parameter_grid]), desc="MODELS"):
|
| | |
|
2022-03-27 19:22:02 +02:00 | | | #
|
2023-02-20 18:39:28 +01:00 | | | # If equal parameters, then derive the sell parameter from the buy parameter
|
2022-03-27 19:22:02 +02:00 | | | #
|
2025-04-17 17:36:48 +02:00 | | | if simulate_config.get("buy_sell_equal"):
|
2023-08-06 13:30:22 +02:00 | | | parameters["sell_signal_threshold"] = -parameters["buy_signal_threshold"]
|
2023-02-20 18:39:28 +01:00 | | | #signal_model["sell_slope_threshold"] = -signal_model["buy_slope_threshold"]
|
2023-08-06 13:30:22 +02:00 | | | if parameters.get("buy_signal_threshold_2") is not None:
|
| | | parameters["sell_signal_threshold_2"] = -parameters["buy_signal_threshold_2"]
|
2022-03-20 10:09:33 +01:00 | | |
|
2023-03-11 14:37:33 +01:00 | | | #
|
2023-12-22 16:36:28 +01:00 | | | # Set new parameters of the signal generator
|
2023-03-11 14:37:33 +01:00 | | | #
|
2023-12-22 16:36:28 +01:00 | | | signal_generator["config"]["parameters"].update(parameters)
|
2023-03-11 14:37:33 +01:00 | | |
|
2023-02-11 17:50:07 +01:00 | | | #
|
2023-12-22 16:36:28 +01:00 | | | # Execute the signal generator with new parameters by producing new signal columns
|
2023-02-11 17:50:07 +01:00 | | | #
|
2025-07-26 19:40:49 +02:00 | | | df, new_features = generate_feature_set(df, signal_generator, config, App.model_store, last_rows=0)
|
2022-06-26 17:32:41 +02:00 | | |
|
2022-03-27 19:22:02 +02:00 | | | #
|
2023-03-11 14:37:33 +01:00 | | | # Simulate trade and compute performance using close price and two boolean signals
|
2022-03-27 19:22:02 +02:00 | | | # Add a pair of two dicts: performance dict and model parameters dict
|
| | | #
|
2023-12-22 16:36:28 +01:00 | | |
|
| | | # These boolean columns are used for performance measurement. Alternatively, they are in trade_signal_model
|
| | | buy_signal_column = signal_generator["config"]["names"][0]
|
| | | sell_signal_column = signal_generator["config"]["names"][1]
|
| | |
|
| | | # Perform backtesting
|
| | | performance, long_performance, short_performance = simulated_trade_performance(
|
| | | df,
|
| | | buy_signal_column, sell_signal_column,
|
| | | 'close'
|
| | | )
|
2022-03-20 10:09:33 +01:00 | | |
|
2023-09-02 10:29:47 +02:00 | | | if direction == "long":
|
| | | performance = long_performance
|
| | | elif direction == "short":
|
| | | performance = short_performance
|
| | |
|
2025-04-19 11:48:06 +02:00 | | | # Add monthly numbers
|
| | | performance["#transactions/M"] = round(performance["#transactions"] / months_in_simulation, 2)
|
| | | performance["profit/M"] = round(performance["profit"] / months_in_simulation, 2)
|
| | | performance["%profit/M"] = round(performance["%profit"] / months_in_simulation, 2)
|
2022-08-14 16:54:29 +02:00 | | |
|
2022-03-27 19:22:02 +02:00 | | | performances.append(dict(
|
2023-08-06 13:30:22 +02:00 | | | model=parameters,
|
2025-04-19 11:48:06 +02:00 | | | performance=performance,
|
| | | #performance={k: performance[k] for k in ['profit_percent_per_month', 'profitable', 'profit_percent_per_transaction', 'transaction_no_per_month']},
|
2022-03-27 19:22:02 +02:00 | | | ))
|
2022-03-20 10:09:33 +01:00 | | |
|
| | | #
|
2022-03-27 19:22:02 +02:00 | | | # Flatten
|
2022-03-20 10:09:33 +01:00 | | | #
|
| | |
|
2022-03-27 19:22:02 +02:00 | | | # Sort
|
2025-04-19 11:48:06 +02:00 | | | performances = sorted(performances, key=lambda x: x['performance']['%profit/M'], reverse=True)
|
2023-09-02 10:29:47 +02:00 | | | performances = performances[:topn_to_store]
|
2022-03-27 19:22:02 +02:00 | | |
|
| | | # Column names (from one record)
|
| | | keys = list(performances[0]['model'].keys()) + \
|
2023-09-02 10:29:47 +02:00 | | | list(performances[0]['performance'].keys())
|
2022-03-20 10:09:33 +01:00 | | |
|
| | | lines = []
|
2022-03-27 19:22:02 +02:00 | | | for p in performances:
|
| | | record = list(p['model'].values()) + \
|
2023-09-02 10:29:47 +02:00 | | | list(p['performance'].values())
|
| | | #list(p['long_performance'].values()) + \
|
| | | #list(p['short_performance'].values())
|
2025-04-19 11:48:06 +02:00 | | | #record = [f"{v:.2f}" if isinstance(v, float) else str(v) for v in record]
|
| | | record_str = ",".join(str(v) for v in record)
|
2022-03-27 19:22:02 +02:00 | | | lines.append(record_str)
|
2022-03-20 10:09:33 +01:00 | | |
|
| | | #
|
| | | # Store simulation parameters and performance
|
| | | #
|
2025-07-26 19:40:49 +02:00 | | | out_file_name = config.get("signal_models_file_name")
|
| | | out_path = (data_path / out_file_name).with_suffix(".txt").resolve()
|
2022-03-20 10:09:33 +01:00 | | |
|
2022-03-27 19:22:02 +02:00 | | | if out_path.is_file():
|
2022-03-20 10:09:33 +01:00 | | | add_header = False
|
| | | else:
|
| | | add_header = True
|
2022-03-27 19:22:02 +02:00 | | | with open(out_path, "a+") as f:
|
2022-03-20 10:09:33 +01:00 | | | if add_header:
|
2022-03-27 19:22:02 +02:00 | | | f.write(",".join(keys) + "\n")
|
2022-03-20 10:09:33 +01:00 | | | #f.writelines(lines)
|
2022-07-17 21:32:45 +02:00 | | | f.write("\n".join(lines) + "\n\n")
|
2022-03-20 10:09:33 +01:00 | | |
|
2022-03-27 19:22:02 +02:00 | | | print(f"Simulation results stored in: {out_path}. Lines: {len(lines)}.")
|
| | |
|
2022-07-17 21:32:45 +02:00 | | | elapsed = datetime.now() - now
|
| | | print(f"Finished simulation in {str(elapsed).split('.')[0]}")
|
2022-03-20 10:09:33 +01:00 | | |
|
| | |
|
| | | if __name__ == '__main__':
|
| | | main()
|