From 9c2fad69715541ebd315e4d486bc141acbb8e858 Mon Sep 17 00:00:00 2001 From: PyPI Poetry Publish Bot Date: Fri, 8 Aug 2025 18:58:34 +0000 Subject: [PATCH 01/11] Change version to v7.0.2 --- __init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index 1a5cf9c2..f864d5b2 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1 @@ -__version__='v7.0.1' +__version__='v7.0.2' diff --git a/pyproject.toml b/pyproject.toml index 31cb9328..ff3f060f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "investing-algorithm-framework" -version = "v7.0.1" +version = "v7.0.2" description = "A framework for creating trading bots" authors = ["MDUYN"] readme = "README.md" From 5474fab53ac3e784154d3440600fbd279f74b813 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Wed, 20 Aug 2025 11:31:33 +0600 Subject: [PATCH 02/11] Fix time unit exceptions --- .../domain/models/time_unit.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/investing_algorithm_framework/domain/models/time_unit.py b/investing_algorithm_framework/domain/models/time_unit.py index cf6aacba..5dae2043 100644 --- a/investing_algorithm_framework/domain/models/time_unit.py +++ b/investing_algorithm_framework/domain/models/time_unit.py @@ -1,8 +1,16 @@ from datetime import timedelta from enum import Enum +from investing_algorithm_framework.domain.exceptions import \ + OperationalException class TimeUnit(Enum): + """ + Enum class the represents a time unit such as + second, minute, hour or day. This can class + can be used to specify time specification within + the framework. + """ SECOND = "SECOND" MINUTE = "MINUTE" HOUR = "HOUR" @@ -18,7 +26,11 @@ def from_string(value: str): if value.upper() == entry.value: return entry - raise ValueError( + raise OperationalException( + f"Could not convert string {value} to time unit" + ) + + raise OperationalException( f"Could not convert value {value} to time unit," + " please make sure that the value is either of type string or" + f"TimeUnit. Its current type is {type(value)}" @@ -38,17 +50,21 @@ def from_ohlcv_data_file(file_path: str): TimeUnit: The extracted time unit. """ if not isinstance(file_path, str): - raise ValueError("File path must be a string.") + raise OperationalException( + "File path must be a string." + ) parts = file_path.split('_') if len(parts) < 2: - raise ValueError("File name does not contain a valid time unit.") + raise OperationalException( + "File name does not contain a valid time unit." + ) time_unit_str = parts[-1].split('.')[0].upper() try: return TimeUnit.from_string(time_unit_str) except ValueError: - raise ValueError( + raise OperationalException( f"Could not extract time unit from file name: {file_path}. " "Expected format 'symbol_timeunit.csv', " f"got '{time_unit_str}'." From 061b563cfc258c9ca4e5e9a598e97dd8fe985f9a Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Thu, 21 Aug 2025 08:24:56 +0200 Subject: [PATCH 03/11] Fix risk free rate reference --- .gitignore | 3 +- examples/backtest_example/run_backtest.ipynb | 597 ++++++++++++++++-- examples/tutorial/evaluation.ipynb | 468 +++++++++++++- examples/tutorial/exploration.ipynb | 14 +- investing_algorithm_framework/app/app.py | 43 +- .../domain/models/time_unit.py | 6 +- .../download_data.py | 18 +- .../infrastructure/data_providers/ccxt.py | 2 +- .../services/__init__.py | 2 + .../services/backtesting/backtest_service.py | 2 +- .../data_providers/data_provider_service.py | 12 +- .../services/metrics/__init__.py | 2 + .../services/metrics/generate.py | 4 +- .../services/metrics/sharpe_ratio.py | 13 +- .../services/metrics/sortino_ratio.py | 10 +- .../reporting/metrics/test_sortino_ratio.py | 5 +- tests/app/test_backtesting.py | 4 +- tests/resources/backtest_report/results.json | 6 +- .../test_algorithm_backtest/metrics.json | 12 +- .../test_algorithm_backtest/results.json | 10 +- tests/resources/test_base.py | 1 - ...st_run_backtest_with_pandas_datasources.py | 175 ++--- tests/test_download.py | 24 + 23 files changed, 1191 insertions(+), 242 deletions(-) create mode 100644 tests/test_download.py diff --git a/.gitignore b/.gitignore index 69740152..ccc4e13e 100644 --- a/.gitignore +++ b/.gitignore @@ -153,4 +153,5 @@ bumpversion.egg-info/ .vscode/ .logs venv -**/app_logs.log \ No newline at end of file +**/app_logs.log +/resources/ \ No newline at end of file diff --git a/examples/backtest_example/run_backtest.ipynb b/examples/backtest_example/run_backtest.ipynb index 2bc6c81f..3b648f3d 100644 --- a/examples/backtest_example/run_backtest.ipynb +++ b/examples/backtest_example/run_backtest.ipynb @@ -210,7 +210,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "2d48acc4", "metadata": {}, "outputs": [ @@ -4739,6 +4739,12 @@ } ], "height": 800, + "margin": { + "b": 50, + "l": 50, + "r": 50, + "t": 50 + }, "showlegend": false, "template": { "data": { @@ -5634,11 +5640,7 @@ " height=800,\n", " title_text=\"EMA Crossover Strategy Visualization\",\n", " showlegend=False,\n", - " xaxis=dict(\n", - " rangeslider=dict(visible=True), # This adds the horizontal slider\n", - " type='date'\n", - " ),\n", - " margin=dict(l=50, r=50, t=50, b=50),\n", + " margin=dict(l=50, r=50, t=50, b=50),\n", ")\n", "\n", "fig.show()" @@ -5655,42 +5657,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "36849215", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "b057ee9b74f04c00b757860d83c02cf0", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Preparing backtest data for all data sources: 0%| | 0/1 [00:00 8\u001b[0m \u001b[43mreport\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mshow\u001b[49m\u001b[43m(\u001b[49m\u001b[43mbrowser\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;28;43;01mTrue\u001b[39;49;00m\u001b[43m)\u001b[49m\n", - "File \u001b[0;32m~/Projects/LogicFunds/investing-algorithm-framework/investing_algorithm_framework/app/reporting/backtest_report.py:114\u001b[0m, in \u001b[0;36mBacktestReport.show\u001b[0;34m(self, browser)\u001b[0m\n\u001b[1;32m 108\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 109\u001b[0m \u001b[38;5;124;03mDisplay the HTML report in a Jupyter notebook cell.\u001b[39;00m\n\u001b[1;32m 110\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 112\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mhtml_report:\n\u001b[1;32m 113\u001b[0m \u001b[38;5;66;03m# If the HTML report is not created, create it\u001b[39;00m\n\u001b[0;32m--> 114\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_create_html_report\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 116\u001b[0m \u001b[38;5;66;03m# Save the html report to a tmp location\u001b[39;00m\n\u001b[1;32m 117\u001b[0m path \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m/tmp/backtest_report.html\u001b[39m\u001b[38;5;124m\"\u001b[39m\n", - "File \u001b[0;32m~/Projects/LogicFunds/investing-algorithm-framework/investing_algorithm_framework/app/reporting/backtest_report.py:228\u001b[0m, in \u001b[0;36mBacktestReport._create_html_report\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 224\u001b[0m \u001b[38;5;66;03m# Create HTML tables\u001b[39;00m\n\u001b[1;32m 225\u001b[0m key_metrics_table_html \u001b[38;5;241m=\u001b[39m create_html_key_metrics_table(\n\u001b[1;32m 226\u001b[0m metrics, results\n\u001b[1;32m 227\u001b[0m )\n\u001b[0;32m--> 228\u001b[0m trades_metrics_table_html \u001b[38;5;241m=\u001b[39m \u001b[43mcreate_html_trade_metrics_table\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 229\u001b[0m \u001b[43m \u001b[49m\u001b[43mmetrics\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mresults\u001b[49m\n\u001b[1;32m 230\u001b[0m \u001b[43m\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 231\u001b[0m time_metrics_table_html \u001b[38;5;241m=\u001b[39m create_html_time_metrics_table(\n\u001b[1;32m 232\u001b[0m metrics, results\n\u001b[1;32m 233\u001b[0m )\n\u001b[1;32m 234\u001b[0m trades_table_html \u001b[38;5;241m=\u001b[39m create_html_trades_table(results)\n", - "File \u001b[0;32m~/Projects/LogicFunds/investing-algorithm-framework/investing_algorithm_framework/app/reporting/tables/trade_metrics_table.py:78\u001b[0m, in \u001b[0;36mcreate_html_trade_metrics_table\u001b[0;34m(results, report)\u001b[0m\n\u001b[1;32m 76\u001b[0m copy_results[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mBest Trade Date\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mN/A\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 77\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m---> 78\u001b[0m copy_results[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mBest Trade\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00m\u001b[43mcopy_results\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mbest_trade\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mnet_gain\u001b[49m\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m.2f\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mreport\u001b[38;5;241m.\u001b[39mtrading_symbol\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m (\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mcopy_results[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mbest_trade\u001b[39m\u001b[38;5;124m'\u001b[39m]\u001b[38;5;241m.\u001b[39mnet_gain_percentage\u001b[38;5;132;01m:\u001b[39;00m\u001b[38;5;124m.2f\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m)%\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 79\u001b[0m copy_results[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mBest Trade Date\u001b[39m\u001b[38;5;124m'\u001b[39m] \u001b[38;5;241m=\u001b[39m copy_results[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mbest_trade_date\u001b[39m\u001b[38;5;124m'\u001b[39m]\u001b[38;5;241m.\u001b[39mstrftime(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m%\u001b[39m\u001b[38;5;124mY-\u001b[39m\u001b[38;5;124m%\u001b[39m\u001b[38;5;124mm-\u001b[39m\u001b[38;5;132;01m%d\u001b[39;00m\u001b[38;5;124m'\u001b[39m)\n\u001b[1;32m 81\u001b[0m worst_trade \u001b[38;5;241m=\u001b[39m copy_results[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mworst_trade\u001b[39m\u001b[38;5;124m'\u001b[39m]\n", - "\u001b[0;31mAttributeError\u001b[0m: 'dict' object has no attribute 'net_gain'" + "name": "stderr", + "output_type": "stream", + "text": [ + "Start date must be a UTC datetime object. Received: 2022-12-18 00:00:00\n", + "End date must be a UTC datetime object. Received: 2024-12-17 00:00:00\n" ] + }, + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + " Aluminium Smelting Strategy Report\n", + " \n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "

Backtest report: EMACrossoverRSIStrategy

\n", + "\n", + "
\n", + " \n", + " \n", + " \n", + " \n", + "\n", + "
\n", + "\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "
\n", + "

Time Metrics

\n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
MetricValue
Start Date2022-12-17 23:00:00+00:00
End Date2024-12-16 23:00:00+00:00
% Winning Months50.00%
% Winning Years100.00%
AVG Mo Return3.41%
AVG Mo Return (Losing Months)-2.97%
AVG Mo Return (Winning Months)8.30%
Best Month24.71% 2024-02-29 00:00:00+00:00
Worst Month-5.78% 2024-06-30 00:00:00+00:00
Best Year67.57% 2024-01-01
Worst Year26.47% 2023-01-01
\n", + "\n", + "
\n", + "
\n", + "

Performance Metrics

\n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
MetricValue
Total Return1119.17 (111.92%)
CAGR45.57%
Sharpe Ratio1.82
Sortino Ratio2.10
Profit Factor4.51
Calmar Ratio3.37
Annual Volatility20.14%
Max Drawdown13.53%
Max Drawdown Absolute225.44 EUR
Max Daily Drawdown13.53%
Max Drawdown Duration245 hours - 10 days
\n", + "\n", + "
\n", + "
\n", + "

Trade Metrics

\n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
MetricValue
Trades per Year3.00
Trade per Day0.01
Exposure Factor0.49
Trades Average Gain160.35 EUR 0.03%
Trades Average Loss-71.10 EUR -0.03%
Best Trade303.22 EUR
Best Trade Date2024-01-31 10:00:00+00:00
Worst Trade-75.20 EUR
Worst Trade Date2024-05-17 18:00:00+00:00
Average Trade Duration1436.33 hours
Number of Trades6
Win Rate60.00%
Win/Loss Ratio2.96
\n", + "\n", + "
\n", + "
\n", + "
\n", + "
\n", + " \n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
TradeNet GainEntry (Price, Date)Exit (Price, Date)Duration
1 BTC/EUR67.47 EUR (6.88%)23422.0 2023-03-15 06:00:002023-03-22 14:00:00176.00 hours
2 BTC/EUR-66.99 EUR (-6.40%)26673.0 2023-05-06 02:00:002023-06-05 06:00:00724.00 hours
3 BTC/EUR260.87 EUR (26.61%)25028.0 2023-09-18 18:00:002024-01-14 18:00:002832.00 hours
4 BTC/EUR303.22 EUR (24.53%)39961.0 2024-01-31 10:00:002024-03-05 14:00:00820.00 hours
5 BTC/EUR-75.20 EUR (-4.90%)61614.0 2024-05-17 18:00:002024-08-04 14:00:001892.00 hours
6 BTC/EUR1239.93 EUR (0.00%)54982.0 2024-09-17 10:00:00open2174.00 hours
\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + "
\n", + "

OHLCV Data Completeness Charts

\n", + " \n", + "
\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], "source": [ - "from investing_algorithm_framework import BacktestReport\n", - "\n", + "from investing_algorithm_framework import BacktestReport, Backtest\n", "\n", - "for backtest in backtests:\n", - " report = BacktestReport.open(\n", - " backtests=[backtest]\n", - " )\n", - " report.show(browser=True)" + "backtest = Backtest.open(\n", + " directory_path=BACKTEST_RESULTS_STORAGE_PATH\n", + ")\n", + "report = BacktestReport.open(\n", + " backtests=[backtest]\n", + ")\n", + "report.show(browser=True)" ] } ], diff --git a/examples/tutorial/evaluation.ipynb b/examples/tutorial/evaluation.ipynb index 67b4c90e..315a371c 100644 --- a/examples/tutorial/evaluation.ipynb +++ b/examples/tutorial/evaluation.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 7, "id": "5ba1016a", "metadata": {}, "outputs": [], @@ -24,7 +24,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "78e2c05e", "metadata": {}, "outputs": [], @@ -54,7 +54,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "id": "3ba0fcf5", "metadata": {}, "outputs": [], @@ -129,7 +129,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "ccef8135", "metadata": {}, "outputs": [ @@ -198,14 +198,14 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "afcff758", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "26cc2a1a0e00466c8951e73f84158b1a", + "model_id": "7191e05c7226412d96a2d33d2094c498", "version_major": 2, "version_minor": 0 }, @@ -219,7 +219,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "31389dafd191457888d8ffbcab58eef5", + "model_id": "9e72f24e407c4655ae04efed14c95f65", "version_major": 2, "version_minor": 0 }, @@ -233,7 +233,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "7e8d9e7165f947f8906376fbf067a062", + "model_id": "9660e3ee542244a2800c8b514347f902", "version_major": 2, "version_minor": 0 }, @@ -247,7 +247,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "782af82135474fa590b3695a29ff60be", + "model_id": "f45a2fac192141f595cda13762e9dcf8", "version_major": 2, "version_minor": 0 }, @@ -261,7 +261,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2a0282b7b3f847c1a52694b9e39d263d", + "model_id": "bf81871ee8a840ba9ec7feffbbb94451", "version_major": 2, "version_minor": 0 }, @@ -275,7 +275,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9da753511e7144fea41e9370ad1f75f1", + "model_id": "faf1d63f643e40fd8f7e7b1f44220e8a", "version_major": 2, "version_minor": 0 }, @@ -289,7 +289,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "92a55e7905ee4927a2fdea593455f3fe", + "model_id": "5d392ab8e61b4e67bf87abd783b24de1", "version_major": 2, "version_minor": 0 }, @@ -305,29 +305,63 @@ "output_type": "stream", "text": [ "Backtest for 1d \n", - "Final Balance: 1124.36 EUR\n", - "Total Return: 12436.41%\n", - "Sharpe Ratio: 0.32\n", - "Max Drawdown: 14.32%\n", - "\n", + " Final value: 1124.36\n", + " Total Return: 124.36 (12.44%)\n", + " Sharpe Ratio: 0.32\n", + " Sortino Ratio: 0.06\n", + " Max Drawdown: 14.32%\n", + " Win Rate: 100.0%\n", + " Profit Factor: inf\n", "Backtest for 1d \n", - "Final Balance: 1124.36 EUR\n", - "Total Return: 12436.41%\n", - "Sharpe Ratio: 0.32\n", - "Max Drawdown: 14.32%\n", - "\n", + " Final value: 1124.36\n", + " Total Return: 124.36 (12.44%)\n", + " Sharpe Ratio: 0.32\n", + " Sortino Ratio: 0.06\n", + " Max Drawdown: 14.32%\n", + " Win Rate: 100.0%\n", + " Profit Factor: inf\n", "Backtest for 1d \n", - "Final Balance: 1000.00 EUR\n", - "Total Return: 0.00%\n", - "Sharpe Ratio: nan\n", - "Max Drawdown: 0.00%\n", - "\n" + " Final value: 1000.00\n", + " Total Return: 0.00 (0.00%)\n", + " Sharpe Ratio: nan\n", + " Sortino Ratio: nan\n", + " Max Drawdown: 0.00%\n", + " Win Rate: 0.0%\n", + " Profit Factor: 0.00\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8599110ada914a4793fa781ef61b441d", + "model_id": "7523332daf7a47a9ab0647045d864248", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Preparing backtest data: 0%| | 0/10 [00:00 Union[pandas.DataFrame, polars.DataFrame]: """ Download market data from the specified source. This function diff --git a/investing_algorithm_framework/infrastructure/data_providers/ccxt.py b/investing_algorithm_framework/infrastructure/data_providers/ccxt.py index 493bc33c..72ccd051 100644 --- a/investing_algorithm_framework/infrastructure/data_providers/ccxt.py +++ b/investing_algorithm_framework/infrastructure/data_providers/ccxt.py @@ -142,7 +142,7 @@ def has_data( symbol=symbol, market=market, time_frame=data_source.time_frame, - storage_path=self.storage_path, + storage_path=data_source.storage_path, start_date=start_date, end_date=end_date ) diff --git a/investing_algorithm_framework/services/__init__.py b/investing_algorithm_framework/services/__init__.py index d2b95043..06aa7b6d 100644 --- a/investing_algorithm_framework/services/__init__.py +++ b/investing_algorithm_framework/services/__init__.py @@ -12,6 +12,7 @@ from .positions import PositionService, PositionSnapshotService from .repository_service import RepositoryService from .trade_service import TradeService +from .metrics import get_risk_free_rate_us __all__ = [ "OrderService", @@ -34,4 +35,5 @@ "PortfolioProviderLookup", "TradeOrderEvaluator", "DefaultTradeOrderEvaluator", + "get_risk_free_rate_us" ] diff --git a/investing_algorithm_framework/services/backtesting/backtest_service.py b/investing_algorithm_framework/services/backtesting/backtest_service.py index 4ee539b5..e4b5f3ab 100644 --- a/investing_algorithm_framework/services/backtesting/backtest_service.py +++ b/investing_algorithm_framework/services/backtesting/backtest_service.py @@ -323,7 +323,7 @@ def create_backtest( algorithm: The algorithm to create the backtest report for number_of_runs: The number of runs backtest_date_range: The backtest date range of the backtest - risk_free_rate: The risk-free rate to use in the calculations + risk_free_rate: The risk-free rate to use for the backtest metrics strategy_directory_path (optional, str): The path to the strategy directory diff --git a/investing_algorithm_framework/services/data_providers/data_provider_service.py b/investing_algorithm_framework/services/data_providers/data_provider_service.py index 68c75713..d7f6ad65 100644 --- a/investing_algorithm_framework/services/data_providers/data_provider_service.py +++ b/investing_algorithm_framework/services/data_providers/data_provider_service.py @@ -44,18 +44,18 @@ def add(self, data_provider: DataProvider): def register(self, data_source: DataSource) -> DataProvider: """ - Register an ohlcv data provider for a given market and symbol. + Register a data source in the DataProvider Index. - This method will also check if the data provider supports - the market. If no data provider is found for the market and symbol, - it will raise an ImproperlyConfigured exception. - If multiple data providers are found for the market and symbol, + This method will go over all data providers and select the + best matching data provider for the data source. + + If multiple data providers are found for the data source, it will sort them by priority and pick the best one. Args: data_source (DataSource): The data source to register the - ohlcv data provider for. + data provider for. Returns: None diff --git a/investing_algorithm_framework/services/metrics/__init__.py b/investing_algorithm_framework/services/metrics/__init__.py index 2f0d21ce..953590b0 100644 --- a/investing_algorithm_framework/services/metrics/__init__.py +++ b/investing_algorithm_framework/services/metrics/__init__.py @@ -27,6 +27,7 @@ from .win_rate import get_win_rate, get_win_loss_ratio from .calmar_ratio import get_calmar_ratio from .generate import create_backtest_metrics +from .risk_free_rate import get_risk_free_rate_us __all__ = [ "get_annual_volatility", @@ -81,4 +82,5 @@ "create_backtest_metrics", "get_growth", "get_growth_percentage", + "get_risk_free_rate_us" ] diff --git a/investing_algorithm_framework/services/metrics/generate.py b/investing_algorithm_framework/services/metrics/generate.py index 811f10bd..9d6fca2f 100644 --- a/investing_algorithm_framework/services/metrics/generate.py +++ b/investing_algorithm_framework/services/metrics/generate.py @@ -57,7 +57,9 @@ def create_backtest_metrics( backtest_results.portfolio_snapshots, risk_free_rate=risk_free_rate ), - sortino_ratio=get_sortino_ratio(backtest_results.portfolio_snapshots), + sortino_ratio=get_sortino_ratio( + backtest_results.portfolio_snapshots, risk_free_rate=risk_free_rate + ), profit_factor=get_profit_factor(backtest_results.trades), calmar_ratio=get_calmar_ratio(backtest_results.portfolio_snapshots), annual_volatility=get_annual_volatility( diff --git a/investing_algorithm_framework/services/metrics/sharpe_ratio.py b/investing_algorithm_framework/services/metrics/sharpe_ratio.py index 4b43ba02..906f5ea8 100644 --- a/investing_algorithm_framework/services/metrics/sharpe_ratio.py +++ b/investing_algorithm_framework/services/metrics/sharpe_ratio.py @@ -53,12 +53,11 @@ from investing_algorithm_framework.domain import PortfolioSnapshot from .mean_daily_return import get_mean_daily_return -from .risk_free_rate import get_risk_free_rate_us from .standard_deviation import get_daily_returns_std def get_sharpe_ratio( - snapshots: List[PortfolioSnapshot], risk_free_rate: Optional[float] = None, + snapshots: List[PortfolioSnapshot], risk_free_rate: float, ) -> float: """ Calculate the Sharpe Ratio from a backtest report using daily or @@ -79,9 +78,6 @@ def get_sharpe_ratio( mean_daily_return = get_mean_daily_return(snapshots) std_daily_return = get_daily_returns_std(snapshots) - if risk_free_rate is None: - risk_free_rate = get_risk_free_rate_us() - if std_daily_return == 0: return float('nan') # Avoid division by zero @@ -92,21 +88,18 @@ def get_sharpe_ratio( def get_rolling_sharpe_ratio( - snapshots: List[PortfolioSnapshot], risk_free_rate: Optional[float] = None + snapshots: List[PortfolioSnapshot], risk_free_rate: float ) -> List[Tuple[float, datetime]]: """ Calculate the rolling Sharpe Ratio over a 365-day window. Args: snapshots (List[PortfolioSnapshot]): Time-sorted list of snapshots. - risk_free_rate (float, optional): Annualized risk-free rate (e.g., 0.03 for 3%). + risk_free_rate (float): Annualized risk-free rate (e.g., 0.03 for 3%). Returns: List[Tuple[float, datetime]]: List of (sharpe_ratio, snapshot_date). """ - if risk_free_rate is None: - risk_free_rate = get_risk_free_rate_us() - data = [(s.created_at, s.total_value) for s in snapshots] df = pd.DataFrame(data, columns=["created_at", "total_value"]) df['created_at'] = pd.to_datetime(df['created_at']) diff --git a/investing_algorithm_framework/services/metrics/sortino_ratio.py b/investing_algorithm_framework/services/metrics/sortino_ratio.py index 447a200f..86667de6 100644 --- a/investing_algorithm_framework/services/metrics/sortino_ratio.py +++ b/investing_algorithm_framework/services/metrics/sortino_ratio.py @@ -30,7 +30,7 @@ def get_sortino_ratio( - snapshots: List[PortfolioSnapshot], risk_free_rate: Optional[float] = None, + snapshots: List[PortfolioSnapshot], risk_free_rate: float ) -> float: """ Calculate the Sortino Ratio for a given report. @@ -46,9 +46,8 @@ def get_sortino_ratio( Args: snapshots (List[PortfolioSnapshot]): List of portfolio snapshots from the backtest report. - risk_free_rate (float, optional): Annual risk-free rate as a decimal - (e.g., 0.047 for 4.7%). If not provided, defaults to the US risk-free - rate. + risk_free_rate (float): Annual risk-free rate as a decimal + (e.g., 0.047 for 4.7%). Returns: float: The Sortino Ratio. @@ -61,9 +60,6 @@ def get_sortino_ratio( mean_daily_return = get_mean_daily_return(snapshots) std_downside_daily_return = get_downside_std_of_daily_returns(snapshots) - if risk_free_rate is None: - risk_free_rate = get_risk_free_rate_us() - if std_downside_daily_return == 0: return float('nan') # or 0.0, depending on preference diff --git a/tests/app/reporting/metrics/test_sortino_ratio.py b/tests/app/reporting/metrics/test_sortino_ratio.py index eef63ca0..3414e721 100644 --- a/tests/app/reporting/metrics/test_sortino_ratio.py +++ b/tests/app/reporting/metrics/test_sortino_ratio.py @@ -27,7 +27,10 @@ def create_report(self, prices, start_date): def test_no_snapshots(self): report = MagicMock() report.get_snapshots.return_value = [] - self.assertEqual(get_sortino_ratio(report.get_snapshots()), float("inf")) + self.assertEqual( + get_sortino_ratio(report.get_snapshots(), risk_free_rate=0.027), + float("inf") + ) # def test_single_snapshot(self): # report = MagicMock() diff --git a/tests/app/test_backtesting.py b/tests/app/test_backtesting.py index fc8f076e..e7596b67 100644 --- a/tests/app/test_backtesting.py +++ b/tests/app/test_backtesting.py @@ -47,7 +47,8 @@ def test_backtest_with_initial_amount(self): app.add_algorithm(algorithm) report = app.run_backtest( backtest_date_range=date_range, - initial_amount=1000 + initial_amount=1000, + risk_free_rate=0.027 ) self.assertEqual(report.backtest_results.initial_unallocated, 1000) self.assertEqual(report.backtest_results.growth, 0) @@ -78,6 +79,7 @@ def test_backtest_with_initial_balance(self): app.add_algorithm(algorithm) report = app.run_backtest( backtest_date_range=date_range, + risk_free_rate=0.027 ) self.assertEqual(report.backtest_results.initial_unallocated, 500) self.assertEqual(report.backtest_results.growth, 0) diff --git a/tests/resources/backtest_report/results.json b/tests/resources/backtest_report/results.json index 90ae7962..5d84cc76 100644 --- a/tests/resources/backtest_report/results.json +++ b/tests/resources/backtest_report/results.json @@ -1,8 +1,8 @@ { "name": "test", "backtest_date_range_identifier": "Test Backtest Date Range", - "backtest_start_date": "2023-08-07 04:00:00", - "backtest_end_date": "2023-12-01 21:00:00", + "backtest_start_date": "2023-08-07 05:00:00", + "backtest_end_date": "2023-12-01 23:00:00", "number_of_runs": 1000, "symbols": [], "number_of_days": 116, @@ -34,5 +34,5 @@ "total_value": 1200 } ], - "created_at": "2025-08-08 18:45:20" + "created_at": "2025-08-21 06:16:46" } \ No newline at end of file diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/metrics.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/metrics.json index 574d5255..c3e8c95f 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/metrics.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/metrics.json @@ -1,14 +1,14 @@ { - "backtest_start_date": "2023-12-01T21:00:00+00:00", - "backtest_end_date": "2023-12-02T21:00:00+00:00", + "backtest_start_date": "2023-12-01T23:00:00+00:00", + "backtest_end_date": "2023-12-02T23:00:00+00:00", "equity_curve": [ [ 1000.0, - "2023-12-01T21:00:00+00:00" + "2023-12-01T23:00:00+00:00" ], [ 1000.0, - "2023-12-02T21:00:00+00:00" + "2023-12-02T23:00:00+00:00" ] ], "total_net_gain": 0.0, @@ -33,11 +33,11 @@ "drawdown_series": [ [ 0.0, - "2023-12-01T21:00:00+00:00" + "2023-12-01T23:00:00+00:00" ], [ 0.0, - "2023-12-02T21:00:00+00:00" + "2023-12-02T23:00:00+00:00" ] ], "max_drawdown": 0.0, diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json index 5fd560ab..be6feda8 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json @@ -1,8 +1,8 @@ { "name": "TestStrategy", "backtest_date_range_identifier": null, - "backtest_start_date": "2023-12-01 21:00:00", - "backtest_end_date": "2023-12-02 21:00:00", + "backtest_start_date": "2023-12-01 23:00:00", + "backtest_end_date": "2023-12-02 23:00:00", "number_of_runs": 1441, "symbols": [], "number_of_days": 1, @@ -25,14 +25,14 @@ "portfolio_snapshots": [ { "net_size": 1000.0, - "created_at": "2023-12-01 21:00:00", + "created_at": "2023-12-01 23:00:00", "total_value": 1000.0 }, { "net_size": 1000.0, - "created_at": "2023-12-02 21:00:00", + "created_at": "2023-12-02 23:00:00", "total_value": 1000.0 } ], - "created_at": "2025-08-08 18:45:07" + "created_at": "2025-08-21 06:16:37" } \ No newline at end of file diff --git a/tests/resources/test_base.py b/tests/resources/test_base.py index 42d10388..e62d8ff7 100644 --- a/tests/resources/test_base.py +++ b/tests/resources/test_base.py @@ -211,7 +211,6 @@ def create_app(self): self.iaf_app.initialize_portfolios() if self.initial_orders is not None: - print(self.initial_orders) for order in self.initial_orders: created_order = self.app.context.create_order( target_symbol=order.get_target_symbol(), diff --git a/tests/scenarios/event_backtests/test_run_backtest_with_pandas_datasources.py b/tests/scenarios/event_backtests/test_run_backtest_with_pandas_datasources.py index c75f6d29..889c4ce5 100644 --- a/tests/scenarios/event_backtests/test_run_backtest_with_pandas_datasources.py +++ b/tests/scenarios/event_backtests/test_run_backtest_with_pandas_datasources.py @@ -1,86 +1,89 @@ -# import time -# import os -# from datetime import datetime, timedelta, timezone -# from unittest import TestCase -# import pandas as pd -# import polars as pl -# -# from investing_algorithm_framework import create_app, BacktestDateRange, \ -# Algorithm, RESOURCE_DIRECTORY, PandasOHLCVDataProvider, \ -# convert_polars_to_pandas -# from tests.resources.strategies_for_testing.strategy_v1 import \ -# CrossOverStrategyV1 -# -# -# class Test(TestCase): -# -# def test_run(self): -# """ -# """ -# start_time = time.time() -# # RESOURCE_DIRECTORY should point to three directories up from this file -# # to the resources directory -# # Get the parent directory of the current file -# -# resource_directory = os.path.abspath( -# os.path.join(os.path.dirname(__file__), '..', '..', 'resources') -# ) -# csv_file_path = f"{resource_directory}/market_data_sources_for_testing" \ -# "/OHLCV_BTC-EUR_BINANCE_2h_2023-08-07-07-59_2023-12-02-00-00.csv" -# -# config = {RESOURCE_DIRECTORY: resource_directory} -# app = create_app(name="GoldenCrossStrategy", config=config) -# app.add_market(market="BITVAVO", trading_symbol="EUR", initial_balance=400) -# end_date = datetime(2023, 12, 2, tzinfo=timezone.utc) -# start_date = end_date - timedelta(days=99) -# date_range = BacktestDateRange( -# start_date=start_date, end_date=end_date -# ) -# algorithm = Algorithm() -# strategy = CrossOverStrategyV1() -# -# # Join the path to the CSV file -# dataframe = pl.read_csv(csv_file_path) -# dataframe = convert_polars_to_pandas(dataframe, add_index=False) -# data_provider = PandasOHLCVDataProvider( -# data_provider_identifier="BTC/EUR-ohlcv-2h", -# dataframe=dataframe, -# market="BITVAVO", -# symbol="BTC/EUR", -# time_frame="2h", -# window_size=200 -# ) -# app.add_data_provider(data_provider, priority=1) -# algorithm.add_strategy(strategy) -# backtest_report = app.run_backtest( -# backtest_date_range=date_range, algorithm=algorithm -# ) -# self.assertAlmostEqual( -# backtest_report.backtest_metrics.growth, 5.9, delta=0.5 -# ) -# self.assertAlmostEqual( -# backtest_report.backtest_metrics.growth_percentage, 0.0149, delta=0.001 -# ) -# self.assertEqual( -# backtest_report.backtest_results.initial_unallocated, 400 -# ) -# self.assertEqual( -# backtest_report.backtest_results.trading_symbol, "EUR" -# ) -# self.assertAlmostEqual( -# backtest_report.backtest_metrics.total_net_gain, 5.9, delta=0.5 -# ) -# self.assertAlmostEqual( -# backtest_report.backtest_metrics.total_net_gain_percentage, 0.0149, delta=0.001 -# ) -# end_time = time.time() -# elapsed_time = end_time - start_time -# print(f"Test completed in {elapsed_time:.2f} seconds") -# -# snapshots = backtest_report.backtest_results.get_portfolio_snapshots() -# # Check that the first two snapshots created at are the same -# # as the start date of the backtest -# self.assertEqual( -# snapshots[0].created_at.replace(tzinfo=timezone.utc), -# start_date.replace(tzinfo=timezone.utc) -# ) +import time +import os +from datetime import datetime, timedelta, timezone +from unittest import TestCase +import polars as pl + +from investing_algorithm_framework import create_app, BacktestDateRange, \ + Algorithm, RESOURCE_DIRECTORY, PandasOHLCVDataProvider, \ + convert_polars_to_pandas +from tests.resources.strategies_for_testing.strategy_v1 import \ + CrossOverStrategyV1 + + +class Test(TestCase): + + def test_run(self): + """ + """ + start_time = time.time() + # RESOURCE_DIRECTORY should point to three directories up from this file + # to the resources directory + # Get the parent directory of the current file + + resource_directory = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', '..', 'resources') + ) + csv_file_path = f"{resource_directory}/market_data_sources_for_testing" \ + "/OHLCV_BTC-EUR_BINANCE_2h_2023-08-07-07-59_2023-12-02-00-00.csv" + + config = {RESOURCE_DIRECTORY: resource_directory} + app = create_app(name="GoldenCrossStrategy", config=config) + app.add_market(market="BITVAVO", trading_symbol="EUR", initial_balance=400) + end_date = datetime(2023, 12, 2, tzinfo=timezone.utc) + start_date = end_date - timedelta(days=99) + date_range = BacktestDateRange( + start_date=start_date, end_date=end_date + ) + algorithm = Algorithm() + strategy = CrossOverStrategyV1() + + # Join the path to the CSV file + dataframe = pl.read_csv(csv_file_path) + dataframe = convert_polars_to_pandas(dataframe, add_index=False) + data_provider = PandasOHLCVDataProvider( + data_provider_identifier="BTC/EUR-ohlcv-2h", + dataframe=dataframe, + market="BITVAVO", + symbol="BTC/EUR", + time_frame="2h", + window_size=200 + ) + app.add_data_provider(data_provider, priority=1) + algorithm.add_strategy(strategy) + backtest_report = app.run_backtest( + backtest_date_range=date_range, algorithm=algorithm, risk_free_rate=0.027 + ) + self.assertAlmostEqual( + backtest_report.backtest_results.growth, 5.9, delta=0.5 + ) + self.assertAlmostEqual( + backtest_report.backtest_results.growth_percentage, 1.49, delta=0.1 + ) + self.assertEqual( + backtest_report.backtest_results.initial_unallocated, 400 + ) + self.assertEqual( + backtest_report.backtest_results.trading_symbol, "EUR" + ) + self.assertAlmostEqual( + backtest_report.backtest_metrics.total_net_gain, + 5.96, + delta=0.5 + ) + self.assertAlmostEqual( + backtest_report.backtest_metrics.total_net_gain_percentage, + 0.0149, + delta=0.1 + ) + end_time = time.time() + elapsed_time = end_time - start_time + print(f"Test completed in {elapsed_time:.2f} seconds") + + snapshots = backtest_report.backtest_results.get_portfolio_snapshots() + # Check that the first two snapshots created at are the same + # as the start date of the backtest + self.assertEqual( + snapshots[0].created_at.replace(tzinfo=timezone.utc), + start_date.replace(tzinfo=timezone.utc) + ) diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 00000000..c2ae1e1c --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path +from unittest import TestCase +from datetime import datetime, timezone + +from investing_algorithm_framework import download + + + +class TestDownload(TestCase): + + def test_download_data_with_already_existing_data(self): + storage_path = Path(__file__).parent / "resources" / "data" + data = download( + symbol="BTC/EUR", + market="BITVAVO", + data_type="OHLCV", + time_frame="2h", + start_date=datetime(2023, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2023, 12, 31, tzinfo=timezone.utc), + storage_path=str(storage_path) + ) + self.assertIsNotNone(data) + self.assertNotEqual(len(data), 0) From d259af2c1ba4201d1ea4e8bbeceec0d3b1d1a6d8 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Thu, 21 Aug 2025 08:28:58 +0200 Subject: [PATCH 04/11] Fix flake8 warnings --- investing_algorithm_framework/download_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/investing_algorithm_framework/download_data.py b/investing_algorithm_framework/download_data.py index 387a1f9d..4b925285 100644 --- a/investing_algorithm_framework/download_data.py +++ b/investing_algorithm_framework/download_data.py @@ -1,4 +1,3 @@ -import os from pathlib import Path from dateutil import parser from datetime import timezone, datetime From 8ad075bd9ba98a8c811988742d5fb91882d7016e Mon Sep 17 00:00:00 2001 From: PyPI Poetry Publish Bot Date: Thu, 21 Aug 2025 06:38:17 +0000 Subject: [PATCH 05/11] Change version to v7.0.3 --- __init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index f864d5b2..e6946ad3 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1 @@ -__version__='v7.0.2' +__version__='v7.0.3' diff --git a/pyproject.toml b/pyproject.toml index ff3f060f..f1eb19e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "investing-algorithm-framework" -version = "v7.0.2" +version = "v7.0.3" description = "A framework for creating trading bots" authors = ["MDUYN"] readme = "README.md" From f26d98d72b89fff6a30e5afd4535109c9f5149a2 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Fri, 22 Aug 2025 10:09:58 +0200 Subject: [PATCH 06/11] Support multiple vector backtests --- investing_algorithm_framework/app/app.py | 112 ++++++- investing_algorithm_framework/app/strategy.py | 10 +- tests/resources/backtest_report/results.json | 2 +- .../test_algorithm_backtest/results.json | 2 +- .../test_multiple_vectorized_backtests.py | 292 ++++++++++++++++++ tests/test_download.py | 1 - 6 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 tests/scenarios/vectorized_backtests/test_multiple_vectorized_backtests.py diff --git a/investing_algorithm_framework/app/app.py b/investing_algorithm_framework/app/app.py index cf257012..7e9e8769 100644 --- a/investing_algorithm_framework/app/app.py +++ b/investing_algorithm_framework/app/app.py @@ -381,6 +381,10 @@ def initialize_data_sources( None """ logger.info("Initializing data sources") + + if data_sources is None or len(data_sources) == 0: + return + data_provider_service = self.container.data_provider_service() data_provider_service.reset() @@ -415,6 +419,10 @@ def initialize_data_sources_backtest( None """ logger.info("Initializing data sources for backtest") + + if data_sources is None or len(data_sources) == 0: + return + data_provider_service = self.container.data_provider_service() data_provider_service.reset() @@ -900,18 +908,90 @@ def run_backtest( return backtest + def run_vector_backtests( + self, + backtest_date_range: BacktestDateRange, + initial_amount, + strategies: List[TradingStrategy], + snapshot_interval: SnapshotInterval = SnapshotInterval.DAILY, + risk_free_rate: Optional[float] = None, + skip_data_sources_initialization: bool = False + ): + """ + Run vectorized backtests for a set of strategies. The provided + set of strategies need to have their 'buy_signal_vectorized' and + 'sell_signal_vectorized' methods implemented to support vectorized + backtesting. + + Args: + backtest_date_range: The date range to run the backtest for + (instance of BacktestDateRange) + initial_amount: The initial amount to start the backtest with. + This will be the amount of trading currency that the backtest + portfolio will start with. + strategies (List[TradingStrategy]): List of strategy objects + that need to be backtested. Each strategy should implement + the 'buy_signal_vectorized' and 'sell_signal_vectorized' + methods to support vectorized backtesting. + snapshot_interval (SnapshotInterval): The snapshot + interval to use for the backtest. This is used to determine + how often the portfolio snapshot should be taken during the + backtest. The default is TRADE_CLOSE, which means that the + portfolio snapshot will be taken at the end of each trade. + risk_free_rate (Optional[float]): The risk-free rate to use for + the backtest. This is used to calculate the Sharpe ratio + and other performance metrics. If not provided, the default + risk-free rate will be tried to be fetched from the + US Treasury website. + skip_data_sources_initialization (bool): Whether to skip the + initialization of data sources. This is useful when the data + sources are already initialized, and you want to skip the + initialization step. This will speed up the backtesting + process, but make sure that the data sources are already + initialized before calling this method. + + Returns: + List[Backtest]: List of Backtest instances for each strategy + that was backtested. + """ + backtests = [] + data_sources = [] + + for strategy in strategies: + data_sources.extend(strategy.data_sources) + + if not skip_data_sources_initialization: + self.initialize_data_sources_backtest( + data_sources, backtest_date_range + ) + + for strategy in tqdm(strategies): + backtests.append( + self.run_vector_backtest( + backtest_date_range=backtest_date_range, + initial_amount=initial_amount, + strategy=strategy, + snapshot_interval=snapshot_interval, + risk_free_rate=risk_free_rate, + skip_data_sources_initialization=True + ) + ) + + return backtests + def run_vector_backtest( self, backtest_date_range: BacktestDateRange, initial_amount, - strategy, + strategy: TradingStrategy, snapshot_interval: SnapshotInterval = SnapshotInterval.DAILY, metadata: Optional[Dict[str, str]] = None, risk_free_rate: Optional[float] = None, + skip_data_sources_initialization: bool = False ) -> Backtest: """ - Run a vectorized backtest for an algorithm. The provided algorithm - or set of strategies need to have their 'buy_signal_vectorized' and + Run vectorized backtests for a strategy. The provided + strategy needs to have its 'buy_signal_vectorized' and 'sell_signal_vectorized' methods implemented to support vectorized backtesting. @@ -937,6 +1017,12 @@ def run_vector_backtest( backtest report. This can be used to store additional information about the backtest, such as the author, version, parameters or any other relevant information. + skip_data_sources_initialization (bool): Whether to skip the + initialization of data sources. This is useful when the data + sources are already initialized, and you want to skip the + initialization step. This will speed up the backtesting + process, but make sure that the data sources are already + initialized before calling this method. Returns: Backtest: Instance of Backtest @@ -947,9 +1033,11 @@ def run_vector_backtest( snapshot_interval=snapshot_interval, initial_amount=initial_amount ) - self.initialize_data_sources_backtest( - strategy.data_sources, backtest_date_range - ) + + if not skip_data_sources_initialization: + self.initialize_data_sources_backtest( + strategy.data_sources, backtest_date_range + ) if risk_free_rate is None: logger.info("No risk free rate provided, retrieving it...") @@ -971,7 +1059,17 @@ def run_vector_backtest( initial_amount=initial_amount, risk_free_rate=risk_free_rate ) - backtest.metadata = metadata if metadata is not None else {} + + # Add the metadata to the backtest + if metadata is None: + + if strategy.metadata is not None: + backtest.metadata = {} + else: + backtest.metadata = strategy.metadata + else: + backtest.metadata = metadata + return backtest def run_backtests( diff --git a/investing_algorithm_framework/app/strategy.py b/investing_algorithm_framework/app/strategy.py index ea77f3c2..ca5985b4 100644 --- a/investing_algorithm_framework/app/strategy.py +++ b/investing_algorithm_framework/app/strategy.py @@ -27,8 +27,12 @@ class TradingStrategy: sources to use for the strategy. The data sources will be used to indentify data providers that will be called to gather data and pass to the strategy before its run. + metadata (optional): Dict[str, Any] - a dictionary + containing metadata about the strategy. This can be used to + store additional information about the strategy, such as its + author, version, description, params etc. """ - time_unit: str = None + time_unit: TimeUnit = None interval: int = None worker_id: str = None strategy_id: str = None @@ -36,6 +40,7 @@ class TradingStrategy: data_sources: List[DataSource] = None traces = None context: Context = None + metadata: Dict[str, Any] = None def __init__( self, @@ -43,6 +48,7 @@ def __init__( time_unit=None, interval=None, data_sources=None, + metadata=None, worker_id=None, decorated=None ): @@ -65,6 +71,8 @@ def __init__( if data_sources is not None: self.data_sources = data_sources + self.metadata = metadata if metadata is not None else {} + if decorated is not None: self.decorated = decorated diff --git a/tests/resources/backtest_report/results.json b/tests/resources/backtest_report/results.json index 5d84cc76..a1adf3f1 100644 --- a/tests/resources/backtest_report/results.json +++ b/tests/resources/backtest_report/results.json @@ -34,5 +34,5 @@ "total_value": 1200 } ], - "created_at": "2025-08-21 06:16:46" + "created_at": "2025-08-22 08:08:54" } \ No newline at end of file diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json index be6feda8..85736622 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json @@ -34,5 +34,5 @@ "total_value": 1000.0 } ], - "created_at": "2025-08-21 06:16:37" + "created_at": "2025-08-22 08:08:44" } \ No newline at end of file diff --git a/tests/scenarios/vectorized_backtests/test_multiple_vectorized_backtests.py b/tests/scenarios/vectorized_backtests/test_multiple_vectorized_backtests.py new file mode 100644 index 00000000..fd3b2a31 --- /dev/null +++ b/tests/scenarios/vectorized_backtests/test_multiple_vectorized_backtests.py @@ -0,0 +1,292 @@ +import os +import time +import pandas as pd +from datetime import datetime, timedelta, timezone +from unittest import TestCase +from typing import Dict, Any, List + +from pyindicators import ema, rsi, crossover, crossunder, macd + +from investing_algorithm_framework import TradingStrategy, DataSource, \ + TimeUnit, DataType, create_app, BacktestDateRange, \ + Algorithm, RESOURCE_DIRECTORY, SnapshotInterval + + + + +class RSIEMACrossoverStrategy(TradingStrategy): + time_unit = TimeUnit.HOUR + interval = 2 + + def __init__( + self, + time_unit: TimeUnit, + interval: int, + market: str, + rsi_time_frame: str, + rsi_period: int, + rsi_overbought_threshold, + rsi_oversold_threshold, + ema_time_frame, + ema_short_period, + ema_long_period, + ema_cross_lookback_window: int = 10 + ): + self.rsi_time_frame = rsi_time_frame + self.rsi_period = rsi_period + self.rsi_result_column = f"rsi_{self.rsi_period}" + self.rsi_overbought_threshold = rsi_overbought_threshold + self.rsi_oversold_threshold = rsi_oversold_threshold + self.ema_time_frame = ema_time_frame + self.ema_short_result_column = f"ema_{ema_short_period}" + self.ema_long_result_column = f"ema_{ema_long_period}" + self.ema_crossunder_result_column = "ema_crossunder" + self.ema_crossover_result_column = "ema_crossover" + self.ema_short_period = ema_short_period + self.ema_long_period = ema_long_period + self.ema_cross_lookback_window = ema_cross_lookback_window + data_sources = [] + + data_sources.append( + DataSource( + identifier=f"rsi_data", + data_type=DataType.OHLCV, + time_frame=self.rsi_time_frame, + market=market, + symbol="BTC/EUR", + pandas=True + ) + ) + data_sources.append( + DataSource( + identifier=f"ema_data", + data_type=DataType.OHLCV, + time_frame=self.ema_time_frame, + market=market, + symbol="BTC/EUR", + pandas=True + ) + ) + + super().__init__(data_sources=data_sources, time_unit=time_unit, interval=interval) + + def prepare_indicators( + self, + rsi_data, + ema_data + ): + ema_data = ema( + ema_data, + period=self.ema_short_period, + source_column="Close", + result_column=self.ema_short_result_column + ) + ema_data = ema( + ema_data, + period=self.ema_long_period, + source_column="Close", + result_column=self.ema_long_result_column + ) + # Detect crossover (short EMA crosses above long EMA) + ema_data = crossover( + ema_data, + first_column=self.ema_short_result_column, + second_column=self.ema_long_result_column, + result_column=self.ema_crossover_result_column + ) + # Detect crossunder (short EMA crosses below long EMA) + ema_data = crossunder( + ema_data, + first_column=self.ema_short_result_column, + second_column=self.ema_long_result_column, + result_column=self.ema_crossunder_result_column + ) + rsi_data = rsi( + rsi_data, + period=self.rsi_period, + source_column="Close", + result_column=self.rsi_result_column + ) + + return ema_data, rsi_data + + def buy_signal_vectorized(self, data: Dict[str, Any]) -> pd.Series: + ema_data_identifier = "ema_data" + rsi_data_identifier = "rsi_data" + ema_data, rsi_data = self.prepare_indicators( + data[ema_data_identifier].copy(), + data[rsi_data_identifier].copy() + ) + + # crossover confirmed + ema_crossover_confirmed = ( + ema_data[self.ema_crossover_result_column] + .rolling(window=self.ema_cross_lookback_window) + .sum() > 0 + ) + + # use only RSI column + rsi_oversold = rsi_data[self.rsi_result_column] \ + < self.rsi_oversold_threshold + + buy_signal = rsi_oversold & ema_crossover_confirmed + return buy_signal.fillna(False).astype(bool) + + def sell_signal_vectorized(self, data: Dict[str, Any]) -> pd.Series: + """ + Generate sell signals based on the moving average crossover. + + Args: + data (pd.DataFrame): DataFrame containing OHLCV data. + + Returns: + pd.Series: Series of sell signals (1 for sell, 0 for no action). + """ + ema_data_identifier = "ema_data" + rsi_data_identifier = "rsi_data" + ema_data, rsi_data = self.prepare_indicators( + data[ema_data_identifier].copy(), + data[rsi_data_identifier].copy() + ) + + # Confirmed by crossover between short-term EMA and long-term EMA + # within a given lookback window + ema_crossunder_confirmed = ema_data[self.ema_crossunder_result_column] \ + .rolling( + window=self.ema_cross_lookback_window).sum() > 0 + + # use only RSI column + rsi_overbought = rsi_data[self.rsi_result_column] \ + >= self.rsi_overbought_threshold + + # Combine both conditions + sell_signal = rsi_overbought & ema_crossunder_confirmed + sell_signal = sell_signal.fillna(False).astype(bool) + return sell_signal + +class Test(TestCase): + + def test_run(self): + """ + """ + start_time = time.time() + # RESOURCE_DIRECTORY should always point to the parent directory/resources + # Resource directory should point to /tests/resources + # Resource directory is two levels up from the current file + resource_directory = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'resources' + ) + config = {RESOURCE_DIRECTORY: resource_directory} + app = create_app(name="GoldenCrossStrategy", config=config) + app.add_market( + market="BITVAVO", trading_symbol="EUR", initial_balance=400 + ) + end_date = datetime(2023, 12, 2, tzinfo=timezone.utc) + start_date = end_date - timedelta(days=400) + date_range = BacktestDateRange( + start_date=start_date, end_date=end_date + ) + strategies = [ + RSIEMACrossoverStrategy( + time_unit=TimeUnit.HOUR, + interval=2, + market="BITVAVO", + rsi_time_frame="2h", + rsi_period=14, + rsi_overbought_threshold=70, + rsi_oversold_threshold=30, + ema_time_frame="2h", + ema_short_period=50, + ema_long_period=200, + ema_cross_lookback_window=10 + ), + RSIEMACrossoverStrategy( + time_unit=TimeUnit.HOUR, + interval=2, + market="BITVAVO", + rsi_time_frame="2h", + rsi_period=14, + rsi_overbought_threshold=70, + rsi_oversold_threshold=30, + ema_time_frame="2h", + ema_short_period=50, + ema_long_period=150, + ema_cross_lookback_window=10 + ) + ] + backtests = app.run_vector_backtests( + initial_amount=1000, + backtest_date_range=date_range, + strategies=strategies, + snapshot_interval=SnapshotInterval.DAILY + ) + end_time = time.time() + elapsed_time = end_time - start_time + print(f"Test completed in {elapsed_time:.2f} seconds") + self.assertEqual(2, len(backtests)) + + def test_run_without_data_sources_initialization(self): + start_time = time.time() + # RESOURCE_DIRECTORY should always point to the parent directory/resources + # Resource directory should point to /tests/resources + # Resource directory is two levels up from the current file + resource_directory = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'resources' + ) + config = {RESOURCE_DIRECTORY: resource_directory} + app = create_app(name="GoldenCrossStrategy", config=config) + app.add_market( + market="BITVAVO", trading_symbol="EUR", initial_balance=400 + ) + end_date = datetime(2023, 12, 2, tzinfo=timezone.utc) + start_date = end_date - timedelta(days=400) + date_range = BacktestDateRange( + start_date=start_date, end_date=end_date + ) + strategies = [ + RSIEMACrossoverStrategy( + time_unit=TimeUnit.HOUR, + interval=2, + market="BITVAVO", + rsi_time_frame="2h", + rsi_period=14, + rsi_overbought_threshold=70, + rsi_oversold_threshold=30, + ema_time_frame="2h", + ema_short_period=50, + ema_long_period=200, + ema_cross_lookback_window=10 + ), + RSIEMACrossoverStrategy( + time_unit=TimeUnit.HOUR, + interval=2, + market="BITVAVO", + rsi_time_frame="2h", + rsi_period=14, + rsi_overbought_threshold=70, + rsi_oversold_threshold=30, + ema_time_frame="2h", + ema_short_period=50, + ema_long_period=150, + ema_cross_lookback_window=10 + ) + ] + data_sources = [] + + for strategy in strategies: + data_sources.extend(strategy.data_sources) + + app.initialize_data_sources_backtest( + data_sources=data_sources, backtest_date_range=date_range + ) + backtests = app.run_vector_backtests( + initial_amount=1000, + backtest_date_range=date_range, + strategies=strategies, + snapshot_interval=SnapshotInterval.DAILY, + skip_data_sources_initialization=True + ) + end_time = time.time() + elapsed_time = end_time - start_time + print(f"Test completed in {elapsed_time:.2f} seconds") + self.assertEqual(2, len(backtests)) diff --git a/tests/test_download.py b/tests/test_download.py index c2ae1e1c..c9300a49 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -1,4 +1,3 @@ -import os from pathlib import Path from unittest import TestCase from datetime import datetime, timezone From 8fa03165bc65f8617fe5c8f228352f1c007ae3a1 Mon Sep 17 00:00:00 2001 From: PyPI Poetry Publish Bot Date: Fri, 22 Aug 2025 08:25:47 +0000 Subject: [PATCH 07/11] Change version to v7.1.0 --- __init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index e6946ad3..f1485fa8 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1 @@ -__version__='v7.0.3' +__version__='v7.1.0' diff --git a/pyproject.toml b/pyproject.toml index f1eb19e7..68eb31b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "investing-algorithm-framework" -version = "v7.0.3" +version = "v7.1.0" description = "A framework for creating trading bots" authors = ["MDUYN"] readme = "README.md" From 2fee0312207c5f14511176841822cdcf791e8b05 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Fri, 22 Aug 2025 13:29:11 +0200 Subject: [PATCH 08/11] Fix metadata initialization --- investing_algorithm_framework/__init__.py | 4 +-- investing_algorithm_framework/app/__init__.py | 4 +-- .../app/algorithm/algorithm.py | 4 ++- .../app/analysis/__init__.py | 4 +-- investing_algorithm_framework/app/app.py | 27 +++++++++++++++++-- investing_algorithm_framework/app/strategy.py | 2 +- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/investing_algorithm_framework/__init__.py b/investing_algorithm_framework/__init__.py index 0f0b5132..b03d993e 100644 --- a/investing_algorithm_framework/__init__.py +++ b/investing_algorithm_framework/__init__.py @@ -5,7 +5,7 @@ pretty_print_orders, pretty_print_backtest, select_backtest_date_ranges, \ get_equity_curve_with_drawdown_chart, \ get_rolling_sharpe_ratio_chart, rank_results, \ - get_monthly_returns_heatmap_chart, defaults_ranking_weights, \ + get_monthly_returns_heatmap_chart, create_weights, \ get_yearly_returns_bar_chart, get_entry_and_exit_signals, \ get_ohlcv_data_completeness_chart from .domain import ApiException, \ @@ -159,7 +159,7 @@ "get_yearly_returns_bar_chart", "get_ohlcv_data_completeness_chart", "rank_results", - "defaults_ranking_weights", + "create_weights", "get_entry_and_exit_signals", "get_growth", "get_growth_percentage" diff --git a/investing_algorithm_framework/app/__init__.py b/investing_algorithm_framework/app/__init__.py index 2da553e3..667b32a1 100644 --- a/investing_algorithm_framework/app/__init__.py +++ b/investing_algorithm_framework/app/__init__.py @@ -14,7 +14,7 @@ get_yearly_returns_bar_chart, \ get_ohlcv_data_completeness_chart, get_entry_and_exit_signals from .analysis import select_backtest_date_ranges, rank_results, \ - defaults_ranking_weights + create_weights __all__ = [ @@ -39,6 +39,6 @@ "get_yearly_returns_bar_chart", "get_ohlcv_data_completeness_chart", "rank_results", - "defaults_ranking_weights", + "create_weights", "get_entry_and_exit_signals" ] diff --git a/investing_algorithm_framework/app/algorithm/algorithm.py b/investing_algorithm_framework/app/algorithm/algorithm.py index 63311b68..03eacc59 100644 --- a/investing_algorithm_framework/app/algorithm/algorithm.py +++ b/investing_algorithm_framework/app/algorithm/algorithm.py @@ -33,7 +33,8 @@ def __init__( strategies=None, tasks: List = None, data_sources: List[DataSource] = None, - on_strategy_run_hooks=None + on_strategy_run_hooks=None, + metadata=None ): self._name = name self._context = {} @@ -46,6 +47,7 @@ def __init__( self._tasks = [] self._data_sources = [] self._on_strategy_run_hooks = [] + self.metadata = metadata if data_sources is not None: self._data_sources = data_sources diff --git a/investing_algorithm_framework/app/analysis/__init__.py b/investing_algorithm_framework/app/analysis/__init__.py index 1e96b1bc..038ab76b 100644 --- a/investing_algorithm_framework/app/analysis/__init__.py +++ b/investing_algorithm_framework/app/analysis/__init__.py @@ -1,8 +1,8 @@ from .backtest_data_ranges import select_backtest_date_ranges -from .ranking import rank_results, defaults_ranking_weights +from .ranking import rank_results, create_weights __all__ = [ "select_backtest_date_ranges", "rank_results", - "defaults_ranking_weights" + "create_weights" ] diff --git a/investing_algorithm_framework/app/app.py b/investing_algorithm_framework/app/app.py index 7e9e8769..c40d2611 100644 --- a/investing_algorithm_framework/app/app.py +++ b/investing_algorithm_framework/app/app.py @@ -898,7 +898,18 @@ def run_backtest( risk_free_rate=risk_free_rate, ) - backtest.metadata = metadata if metadata is not None else {} + # Add the metadata to the backtest + if metadata is None: + + if strategy.metadata is not None: + backtest.metadata = strategy.metadata + elif algorithm.metadata is not None: + backtest.metadata = algorithm.metadata + else: + backtest.metadata = {} + else: + backtest.metadata = metadata + self.cleanup_backtest_resources() if save and directory: @@ -965,6 +976,18 @@ def run_vector_backtests( data_sources, backtest_date_range ) + if risk_free_rate is None: + logger.info("No risk free rate provided, retrieving it...") + risk_free_rate = get_risk_free_rate_us() + + if risk_free_rate is None: + raise OperationalException( + "Could not retrieve risk free rate for backtest metrics." + "Please provide a risk free as an argument when running " + "your backtest or make sure you have an internet " + "connection" + ) + for strategy in tqdm(strategies): backtests.append( self.run_vector_backtest( @@ -1063,7 +1086,7 @@ def run_vector_backtest( # Add the metadata to the backtest if metadata is None: - if strategy.metadata is not None: + if strategy.metadata is None: backtest.metadata = {} else: backtest.metadata = strategy.metadata diff --git a/investing_algorithm_framework/app/strategy.py b/investing_algorithm_framework/app/strategy.py index ca5985b4..ab4e10b8 100644 --- a/investing_algorithm_framework/app/strategy.py +++ b/investing_algorithm_framework/app/strategy.py @@ -71,7 +71,7 @@ def __init__( if data_sources is not None: self.data_sources = data_sources - self.metadata = metadata if metadata is not None else {} + self.metadata = metadata if decorated is not None: self.decorated = decorated From b89901f1653fabf4d9570c3d8afd46b249a2ac44 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Fri, 22 Aug 2025 13:29:31 +0200 Subject: [PATCH 09/11] Change weights calculation --- .../app/analysis/ranking.py | 178 +++++++++++++++--- .../services/metrics/win_rate.py | 6 +- 2 files changed, 152 insertions(+), 32 deletions(-) diff --git a/investing_algorithm_framework/app/analysis/ranking.py b/investing_algorithm_framework/app/analysis/ranking.py index 7ea3cff6..e4419bd5 100644 --- a/investing_algorithm_framework/app/analysis/ranking.py +++ b/investing_algorithm_framework/app/analysis/ranking.py @@ -1,58 +1,178 @@ -from investing_algorithm_framework.domain import Backtest +default_weights = { + # Profitability + "total_net_gain": 3.0, + "total_net_loss": 0.0, + "total_return": 0.0, + "avg_return_per_trade": 0.0, -defaults_ranking_weights = { - "total_net_gain": 2.0, + # Risk-adjusted returns "sharpe_ratio": 1.0, "sortino_ratio": 1.0, - "win_rate": 1.0, "profit_factor": 1.0, - "max_drawdown": -1.0, # negative weight to penalize high drawdown - "max_drawdown_duration": -0.5, # penalize long drawdown periods - "number_of_trades": 0.5, + + # Risk + "max_drawdown": -2.0, + "max_drawdown_duration": -0.5, + + # Trading activity + "number_of_trades": 2.0, + "win_rate": 3.0, + + # Exposure "exposure_factor": 0.5, + "exposure_ratio": 0.0, + "exposure_time": 0.0, } -def compute_score(metrics: dict, weights: dict) -> float: +def normalize(value, min_val, max_val): + """ + Normalize a value to a range [0, 1]. + + Args: + value (float): The value to normalize. + min_val (float): The minimum value of the range. + max_val (float): The maximum value of the range. + + Returns: + float: The normalized value. + """ + if max_val == min_val: + return 0 + return (value - min_val) / (max_val - min_val) + + +def compute_score(metrics, weights, ranges): + """ + Compute a weighted score for the given metrics. + + Args: + metrics: The metrics to evaluate. + weights: The weights to apply to each metric. + ranges: The min/max ranges for each metric. + + Returns: + float: The computed score. + """ score = 0 for key, weight in weights.items(): - # Metrics are attributes to the backtest if not hasattr(metrics, key): continue - - # Get the value of the metric value = getattr(metrics, key) - try: - score += weight * value - except TypeError: - continue # skip if value is not a number + if key in ranges: + value = normalize(value, ranges[key][0], ranges[key][1]) + score += weight * value return score -def rank_results( - backtests: list[Backtest], weights=defaults_ranking_weights -) -> list[Backtest]: +def create_weights( + focus: str = "balanced", + gain: float = 3.0, + win_rate: float = 3.0, + trades: float = 2.0, + custom_weights: dict | None = None, +) -> dict: + """ + Utility to generate weights dicts for ranking backtests. + + This function does not assign weights to every possible performance + metric. Instead, it focuses on a curated subset of commonly relevant + ones (profitability, win rate, trade frequency, and risk-adjusted returns). + The rationale is to avoid overfitting ranking logic to noisy or redundant + statistics (e.g., monthly return breakdowns, best/worst trade), while + keeping the weighting system simple and interpretable. + Users who need fine-grained control can pass `custom_weights` to fully + override defaults. + + Args: + focus (str): One of [ + "balanced", "profit", "frequency", "risk_adjusted" + ]. + gain (float): Weight for total_net_gain (default only). + win_rate (float): Weight for win_rate (default only). + trades (float): Weight for number_of_trades (default only). + custom_weights (dict): Full override for weights (all metrics). + If provided, it takes precedence over presets. + + Returns: + dict: A dictionary of weights for ranking backtests. """ - Rank backtests based on their metrics and the provided weights. - The default weights are defined in `defaults_ranking_weights`. - Please note that the weights should be adjusted based on the - specific analysis needs. You can modify the `weights` parameter - to include or exclude metrics as needed and reuse - the `defaults_ranking_weights` as a starting point. + # default / balanced + base = { + "total_net_gain": gain, + "win_rate": win_rate, + "number_of_trades": trades, + "sharpe_ratio": 1.0, + "sortino_ratio": 1.0, + "profit_factor": 1.0, + "max_drawdown": -2.0, + "max_drawdown_duration": -0.5, + "total_net_loss": 0.0, + "total_return": 0.0, + "avg_return_per_trade": 0.0, + "exposure_factor": 0.5, + "exposure_ratio": 0.0, + "exposure_time": 0.0, + } + # apply presets + if focus == "profit": + base.update({ + "total_net_gain": 5.0, + "win_rate": 2.0, + "number_of_trades": 1.0, + }) + elif focus == "frequency": + base.update({ + "number_of_trades": 4.0, + "win_rate": 2.0, + "total_net_gain": 2.0, + }) + elif focus == "risk_adjusted": + base.update({ + "sharpe_ratio": 3.0, + "sortino_ratio": 3.0, + "max_drawdown": -3.0, + }) + + # if full custom dict is given → override everything + if custom_weights is not None: + base = {**base, **custom_weights} + + return base + + +def rank_results(backtests, focus=None, weights=None): + """ + Rank backtest results based on specified focus and weights. Args: - backtests (list[Backtest]): List of Backtest objects to rank. - weights (dict): Weights for each metric to compute the score. + backtests (list): List of backtest results to rank. + focus (str, optional): Focus for ranking. If None, + uses default weights. Options: "balanced", "profit", + "frequency", "risk_adjusted". + weights (dict, optional): Custom weights for ranking metrics. + If None, uses default weights based on focus. Returns: - list[Backtest]: List of Backtest objects sorted by - their computed score. + list: Sorted list of backtests based on computed scores. """ + + if weights is None: + weights = create_weights(focus=focus) + + # First compute metric ranges for normalization + ranges = {} + for key in weights: + values = [getattr(bt.backtest_metrics, key, None) for bt in backtests] + values = [v for v in values if isinstance(v, (int, float))] + if values: + ranges[key] = (min(values), max(values)) + return sorted( backtests, - key=lambda bt: compute_score(bt.backtest_metrics, weights), + key=lambda bt: compute_score(bt.backtest_metrics, weights, ranges), reverse=True ) diff --git a/investing_algorithm_framework/services/metrics/win_rate.py b/investing_algorithm_framework/services/metrics/win_rate.py index 885502b6..e1212023 100644 --- a/investing_algorithm_framework/services/metrics/win_rate.py +++ b/investing_algorithm_framework/services/metrics/win_rate.py @@ -42,7 +42,7 @@ def get_win_rate(trades: List[Trade]) -> float: The percentage of trades that are profitable. Formula: - Win Rate = (Number of Profitable Trades / Total Number of Trades) * 100 + Win Rate = Number of Profitable Trades / Total Number of Trades Example: If 60 out of 100 trades are profitable, the win rate is 60%. @@ -50,7 +50,7 @@ def get_win_rate(trades: List[Trade]) -> float: trades (List[Trade]): List of trades from the backtest report. Returns: - float: The win rate as a percentage (e.g., 75.0 for 75% win rate). + float: The win rate as a percentage (e.g., o.75 for 75% win rate). """ trades = [ trade for trade in trades if TradeStatus.CLOSED.equals(trade.status) @@ -61,7 +61,7 @@ def get_win_rate(trades: List[Trade]) -> float: if total_trades == 0: return 0.0 - return (positive_trades / total_trades) * 100.0 + return positive_trades / total_trades def get_win_loss_ratio(trades: List[Trade]) -> float: From 4371dc10b91c96f2512c46c45e84c4a6fec9d027 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Fri, 22 Aug 2025 13:50:54 +0200 Subject: [PATCH 10/11] Fix metadata reference --- investing_algorithm_framework/app/app.py | 4 +--- tests/resources/backtest_report/results.json | 2 +- .../test_algorithm_backtest/results.json | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/investing_algorithm_framework/app/app.py b/investing_algorithm_framework/app/app.py index c40d2611..300000df 100644 --- a/investing_algorithm_framework/app/app.py +++ b/investing_algorithm_framework/app/app.py @@ -901,9 +901,7 @@ def run_backtest( # Add the metadata to the backtest if metadata is None: - if strategy.metadata is not None: - backtest.metadata = strategy.metadata - elif algorithm.metadata is not None: + if algorithm.metadata is not None: backtest.metadata = algorithm.metadata else: backtest.metadata = {} diff --git a/tests/resources/backtest_report/results.json b/tests/resources/backtest_report/results.json index a1adf3f1..21702134 100644 --- a/tests/resources/backtest_report/results.json +++ b/tests/resources/backtest_report/results.json @@ -34,5 +34,5 @@ "total_value": 1200 } ], - "created_at": "2025-08-22 08:08:54" + "created_at": "2025-08-22 11:35:37" } \ No newline at end of file diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json index 85736622..93f82571 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/results.json @@ -34,5 +34,5 @@ "total_value": 1000.0 } ], - "created_at": "2025-08-22 08:08:44" + "created_at": "2025-08-22 11:35:28" } \ No newline at end of file From 01cccee1b04b1430d468138be4efd665fbeb14da Mon Sep 17 00:00:00 2001 From: PyPI Poetry Publish Bot Date: Fri, 22 Aug 2025 12:15:44 +0000 Subject: [PATCH 11/11] Change version to v7.2.0 --- __init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__init__.py b/__init__.py index f1485fa8..08142982 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1 @@ -__version__='v7.1.0' +__version__='v7.2.0' diff --git a/pyproject.toml b/pyproject.toml index 68eb31b1..2fbc88eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "investing-algorithm-framework" -version = "v7.1.0" +version = "v7.2.0" description = "A framework for creating trading bots" authors = ["MDUYN"] readme = "README.md"