diff --git a/.flake8 b/.flake8 index 5a02cc1..9019a79 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,5 @@ [flake8] exclude = investing_algorithm_framework/domain/utils/backtesting.py + investing_algorithm_framework/infrastructure/database/sql_alchemy.py examples \ No newline at end of file diff --git a/README.md b/README.md index 34c1688..f199d6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ +
+
+

Investing Algorithm Framework

+
+
+ +
+ Rapidly build and deploy quantitative strategies and trading bots +
+
+ +

+ View Docs + Getting Started +

+ +--- + [![Build](https://github.com/coding-kitties/investing-algorithm-framework/actions/workflows/publish.yml/badge.svg)](https://github.com/coding-kitties/investing-algorithm-framework/actions/workflows/publish.yml) [![Tests](https://github.com/coding-kitties/investing-algorithm-framework/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/coding-kitties/investing-algorithm-framework/actions/workflows/test.yml) @@ -6,10 +24,6 @@
[![GitHub stars](https://img.shields.io/github/stars/coding-kitties/investing-algorithm-framework.svg?style=social&label=Star&maxAge=1)](https://github.com/SeaQL/sea-orm/stargazers/) If you like what we do, consider starring, sharing and contributing! -# [Investing Algorithm Framework](https://github.com/coding-kitties/investing-algorithm-framework) - -The Investing Algorithm Framework is a Python framework that enables swift and elegant development of trading bots. - ## Sponsors @@ -22,7 +36,7 @@ The Investing Algorithm Framework is a Python framework that enables swift and e ## Features and planned features: -- [x] **Based on Python 3.9+**: Windows, macOS and Linux. +- [x] **Based on Python 3.10+**: Windows, macOS and Linux. - [x] **Documentation**: [Documentation](https://investing-algorithm-framework.com) - [x] **Persistence of portfolios, orders, positions and trades**: Persistence is achieved through sqlite. - [x] **Limit orders**: Create limit orders for buying and selling. @@ -33,7 +47,7 @@ The Investing Algorithm Framework is a Python framework that enables swift and e - [x] **Live trading**: Live trading. - [x] **Backtesting and performance analysis reports** [example](./examples/backtest_example) - [x] **Backtesting multiple algorithms with different backtest date ranges** [example](./examples/backtests_example) -- [x] **Backtest comparison and experiments**: Compare multiple backtests and run experiments. +- [x] **Backtesting and results evaluation**: Compare multiple backtests and run experiments. Save and load backtests. Save strategies as part of the backtest. [docs](https://investing-algorithm-framework.com/Getting%20Started/backtesting) - [x] **Order execution**: Currently support for a wide range of crypto exchanges through [ccxt](https://github.com/ccxt/ccxt) (Support for traditional asset brokers is planned). - [x] **Web API**: Rest API for interacting with your deployed trading bot - [x] **PyIndicators**: Works natively with [PyIndicators](https://github.com/coding-kitties/PyIndicators) for technical analysis on your Pandas and Polars dataframes. @@ -45,6 +59,38 @@ The Investing Algorithm Framework is a Python framework that enables swift and e - [ ] **AWS Lambda support (Planned)**: Stateless running for cloud function deployments in AWS. - [ ] **Azure App services support (Planned)**: deployments in Azure app services with Web UI. +## Quickstart + +1. First install the framework using `pip`. The Investing Algorithm Framework is hosted on [PyPi](https://pypi.org/project/Blankly/). + +```bash +$ pip install investing-algorithm-framework +``` + +2. Next, just run: + +```bash +$ investing-algorithm-framewor init +``` + +or if you want the web version: + +```bash +$ investing-algorithm-framework init --web +``` +> You can always change the app to the web version by changing the `app.py` file. + +The command will create the file `app.py` and an example script called `strategy.py`. + +From there, you start building your trading bot in the `strategy.py`. + +More information can be found on our [docs](https://docs.blankly.finance) + +> Make sure you leave the `app.py` file as is, as it is the entry point for the framework. +> You can change the `bot.py` file to your liking and add other files to the working directory. +> The framework will automatically pick up the files in the working directory. +``` + ## Example implementation The following algorithm connects to binance and buys BTC every 2 hours. @@ -262,14 +308,6 @@ app.add_portfolio_configuration( We are continuously working on improving the performance of the framework. If you have any suggestions, please let us know. -## How to install - -You can download the framework with pypi. - -```bash -pip install investing-algorithm-framework -``` - ## Installation for local development The framework is built with poetry. To install the framework for local development, you can run the following commands: diff --git a/examples/app.py b/examples/app.py deleted file mode 100644 index ee2d1cb..0000000 --- a/examples/app.py +++ /dev/null @@ -1,49 +0,0 @@ -from dotenv import load_dotenv - -from investing_algorithm_framework import create_app, PortfolioConfiguration, \ - TimeUnit, CCXTOHLCVMarketDataSource, Algorithm, \ - CCXTTickerMarketDataSource, MarketCredential, AzureBlobStorageStateHandler - -load_dotenv() - -# Define market data sources -# OHLCV data for candles -bitvavo_btc_eur_ohlcv_2h = CCXTOHLCVMarketDataSource( - identifier="BTC-ohlcv", - market="BITVAVO", - symbol="BTC/EUR", - time_frame="2h", - window_size=200 -) -# Ticker data for orders, trades and positions -bitvavo_btc_eur_ticker = CCXTTickerMarketDataSource( - identifier="BTC-ticker", - market="BITVAVO", - symbol="BTC/EUR", -) -app = create_app(state_handler=AzureBlobStorageStateHandler()) -app.add_market_data_source(bitvavo_btc_eur_ohlcv_2h) -algorithm = Algorithm() -app.add_market_credential(MarketCredential(market="bitvavo")) -app.add_portfolio_configuration( - PortfolioConfiguration( - market="bitvavo", - trading_symbol="EUR", - initial_balance=20 - ) -) -app.add_algorithm(algorithm) - -@algorithm.strategy( - # Run every two hours - time_unit=TimeUnit.HOUR, - interval=2, - # Specify market data sources that need to be passed to the strategy - market_data_sources=[bitvavo_btc_eur_ticker, "BTC-ohlcv"] -) -def perform_strategy(algorithm: Algorithm, market_data: dict): - # By default, ohlcv data is passed as polars df in the form of - # {"": } https://pola.rs/, - # call to_pandas() to convert to pandas - polars_df = market_data["BTC-ohlcv"] - print(f"I have access to {len(polars_df)} candles of ohlcv data") diff --git a/examples/backtest_example/run_backtest.py b/examples/backtest_example/run_backtest.py index 254b99b..4bd4ff1 100644 --- a/examples/backtest_example/run_backtest.py +++ b/examples/backtest_example/run_backtest.py @@ -2,14 +2,12 @@ from datetime import datetime import logging.config from datetime import datetime, timedelta +from investing_algorithm_framework import ( + CCXTOHLCVMarketDataSource, CCXTTickerMarketDataSource, PortfolioConfiguration, create_app, pretty_print_backtest, BacktestDateRange, TimeUnit, TradingStrategy, OrderSide, DEFAULT_LOGGING_CONFIG, Context +) +from pyindicators import ema, is_crossover, is_above, is_below, is_crossunder -from investing_algorithm_framework import CCXTOHLCVMarketDataSource, \ - CCXTTickerMarketDataSource, PortfolioConfiguration, \ - create_app, pretty_print_backtest, BacktestDateRange, TimeUnit, \ - TradingStrategy, OrderSide, DEFAULT_LOGGING_CONFIG, Context - -import tulipy as ti logging.config.dictConfig(DEFAULT_LOGGING_CONFIG) @@ -21,22 +19,22 @@ The strategy will also check if the fast moving average is above the trend moving average. If it is not above the trend moving average it will not buy. -It uses tulipy indicators to calculate the metrics. You need to +It uses pyindicators to calculate the metrics. You need to install this library in your environment to run this strategy. You can find instructions on how to install tulipy here: -https://tulipindicators.org/ or go directly to the pypi page: -https://pypi.org/project/tulipy/ +https://github.com/coding-kitties/PyIndicators or go directly +to the pypi page: https://pypi.org/project/PyIndicators/ """ bitvavo_btc_eur_ohlcv_2h = CCXTOHLCVMarketDataSource( - identifier="BTC/EUR-ohlcv", + identifier="BTC/EUR-ohlcv-2h", market="BINANCE", symbol="BTC/EUR", time_frame="2h", window_size=200 ) bitvavo_dot_eur_ohlcv_2h = CCXTOHLCVMarketDataSource( - identifier="DOT/EUR-ohlcv", + identifier="DOT/EUR-ohlch-2h", market="BINANCE", symbol="DOT/EUR", time_frame="2h", @@ -55,68 +53,73 @@ backtest_time_frame="2h", ) - -def is_below_trend(fast_series, slow_series): - return fast_series[-1] < slow_series[-1] - - -def is_above_trend(fast_series, slow_series): - return fast_series[-1] > slow_series[-1] - - -def is_crossover(fast, slow): - """ - Expect df to have columns: Date, ma_, ma_. - With the given date time it will check if the ma_ is a - crossover with the ma_ - """ - return fast[-2] <= slow[-2] and fast[-1] > slow[-1] - - -def is_crossunder(fast, slow): +class CrossOverStrategy(TradingStrategy): """ - Expect df to have columns: Date, ma_, ma_. - With the given date time it will check if the ma_ is a - crossover with the ma_ + A simple trading strategy that uses EMA crossovers to generate buy and + sell signals. The strategy uses a 50-period EMA and a 100-period EMA + to detect golden and death crosses. It also uses a 200-period EMA to + determine the overall trend direction. The strategy trades BTC/EUR + on a 2-hour timeframe. The strategy is designed to be used with the + Investing Algorithm Framework and uses the PyIndicators library + to calculate the EMAs and crossover signals. + + The strategy uses a trailing stop loss and take profit to manage + risk. The stop loss is set to 5% below the entry price and the + take profit is set to 10% above the entry price. The stop loss and + take profit are both trailing, meaning that they will move up + with the price when the price goes up. """ - return fast[-2] >= slow[-2] and fast[-1] < slow[-1] - - -class CrossOverStrategy(TradingStrategy): time_unit = TimeUnit.HOUR interval = 2 - market_data_sources = [ - bitvavo_dot_eur_ticker, - bitvavo_btc_eur_ticker, - bitvavo_dot_eur_ohlcv_2h, - bitvavo_btc_eur_ohlcv_2h - ] - symbols = ["BTC/EUR", "DOT/EUR"] - fast = 21 - slow = 75 - trend = 150 - stop_loss_percentage = 7 + symbol_pairs = ["BTC/EUR"] + market_data_sources = [bitvavo_btc_eur_ohlcv_2h, bitvavo_btc_eur_ticker] + fast = 50 + slow = 100 + trend = 200 + stop_loss_percentage = 2 + stop_loss_sell_size = 50 + take_profit_percentage = 8 + take_profit_sell_size = 50 def apply_strategy(self, context: Context, market_data): - for symbol in self.symbols: - target_symbol = symbol.split('/')[0] + for pair in self.symbol_pairs: + symbol = pair.split('/')[0] - if context.has_open_orders(target_symbol): + # Don't trade if there are open orders for the symbol + # This is important to avoid placing new orders while there are + # existing orders that are not yet filled + if context.has_open_orders(symbol): continue - df = market_data[f"{symbol}-ohlcv"] - ticker_data = market_data[f"{symbol}-ticker"] - fast = ti.sma(df['Close'].to_numpy(), self.fast) - slow = ti.sma(df['Close'].to_numpy(), self.slow) - trend = ti.sma(df['Close'].to_numpy(), self.trend) - price = ticker_data["bid"] - - if not context.has_position(target_symbol) \ - and is_crossover(fast, slow) \ - and is_above_trend(fast, trend): + ohlvc_data = market_data[f"{pair}-ohlcv-2h"] + # ticker_data = market_data[f"{symbol}-ticker"] + # Add fast, slow, and trend EMAs to the data + ohlvc_data = ema( + ohlvc_data, + source_column="Close", + period=self.fast, + result_column=f"ema_{self.fast}" + ) + ohlvc_data = ema( + ohlvc_data, + source_column="Close", + period=self.slow, + result_column=f"ema_{self.slow}" + ) + ohlvc_data = ema( + ohlvc_data, + source_column="Close", + period=self.trend, + result_column=f"ema_{self.trend}" + ) + + price = ohlvc_data["Close"][-1] + + if not context.has_position(symbol) \ + and self._is_buy_signal(ohlvc_data): order = context.create_limit_order( - target_symbol=target_symbol, + target_symbol=symbol, order_side=OrderSide.BUY, price=price, percentage_of_portfolio=25, @@ -126,31 +129,49 @@ def apply_strategy(self, context: Context, market_data): context.add_stop_loss( trade=trade, trade_risk_type="trailing", - percentage=5, - sell_percentage=50 + percentage=self.stop_loss_percentage, + sell_percentage=self.stop_loss_sell_size ) context.add_take_profit( trade=trade, - percentage=5, + percentage=self.take_profit_percentage, trade_risk_type="trailing", - sell_percentage=50 - ) - context.add_take_profit( - trade=trade, - percentage=10, - trade_risk_type="trailing", - sell_percentage=20 + sell_percentage=self.take_profit_sell_size ) - if context.has_position(target_symbol) \ - and is_below_trend(fast, slow): + if context.has_position(symbol) \ + and self._is_sell_signal(ohlvc_data): open_trades = context.get_open_trades( - target_symbol=target_symbol + target_symbol=symbol ) for trade in open_trades: context.close_trade(trade) + def _is_sell_signal(self, data): + return is_crossunder( + data, + first_column=f"ema_{self.fast}", + second_column=f"ema_{self.slow}", + number_of_data_points=2 + ) and is_below( + data, + first_column=f"ema_{self.fast}", + second_column=f"ema_{self.trend}", + ) + + def _is_buy_signal(self, data): + return is_crossover( + data=data, + first_column=f"ema_{self.fast}", + second_column=f"ema_{self.slow}", + number_of_data_points=2 + ) and is_above( + data=data, + first_column=f"ema_{self.fast}", + second_column=f"ema_{self.trend}", + ) + app = create_app(name="GoldenCrossStrategy") app.add_strategy(CrossOverStrategy) @@ -162,9 +183,7 @@ def apply_strategy(self, context: Context, market_data): # Add a portfolio configuration of 400 euro initial balance app.add_portfolio_configuration( PortfolioConfiguration( - market="BINANCE", - trading_symbol="EUR", - initial_balance=400, + market="BINANCE", trading_symbol="EUR", initial_balance=400, ) ) @@ -176,7 +195,9 @@ def apply_strategy(self, context: Context, market_data): end_date=end_date ) start_time = time.time() - backtest_report = app.run_backtest(backtest_date_range=date_range) + backtest_report = app.run_backtest( + backtest_date_range=date_range, save_in_memory_strategies=True + ) pretty_print_backtest(backtest_report) end_time = time.time() print(f"Execution Time: {end_time - start_time:.6f} seconds") diff --git a/investing_algorithm_framework/__init__.py b/investing_algorithm_framework/__init__.py index aa2c0fa..bf5c8c0 100644 --- a/investing_algorithm_framework/__init__.py +++ b/investing_algorithm_framework/__init__.py @@ -11,7 +11,8 @@ RESERVED_BALANCES, APP_MODE, AppMode, DATETIME_FORMAT, \ load_backtest_report, BacktestDateRange, convert_polars_to_pandas, \ DateRange, get_backtest_report, DEFAULT_LOGGING_CONFIG, \ - BacktestReport, TradeStatus, MarketDataType, TradeRiskType + BacktestReport, TradeStatus, MarketDataType, TradeRiskType, \ + APPLICATION_DIRECTORY from investing_algorithm_framework.infrastructure import \ CCXTOrderBookMarketDataSource, CCXTOHLCVMarketDataSource, \ CCXTTickerMarketDataSource, CSVOHLCVMarketDataSource, \ @@ -73,5 +74,6 @@ "TradeStatus", "MarketDataType", "TradeRiskType", - "Context" + "Context", + "APPLICATION_DIRECTORY" ] diff --git a/investing_algorithm_framework/app/algorithm.py b/investing_algorithm_framework/app/algorithm.py index 10fc80d..75a7719 100644 --- a/investing_algorithm_framework/app/algorithm.py +++ b/investing_algorithm_framework/app/algorithm.py @@ -22,7 +22,7 @@ class is responsible for managing the strategies and executing them in the correct order. Args: - name (str): The name of the algorithm + name (str): (Optional) The name of the algorithm description (str): The description of the algorithm context (dict): The context of the algorithm, for backtest references diff --git a/investing_algorithm_framework/app/app.py b/investing_algorithm_framework/app/app.py index 61250f4..a7b0d31 100644 --- a/investing_algorithm_framework/app/app.py +++ b/investing_algorithm_framework/app/app.py @@ -17,9 +17,9 @@ DATABASE_DIRECTORY_PATH, RESOURCE_DIRECTORY, ENVIRONMENT, Environment, \ SQLALCHEMY_DATABASE_URI, OperationalException, StateHandler, \ BACKTESTING_START_DATE, BACKTESTING_END_DATE, BacktestReport, \ - BACKTESTING_PENDING_ORDER_CHECK_INTERVAL, APP_MODE, MarketCredential, \ - AppMode, BacktestDateRange, DATABASE_DIRECTORY_NAME, \ - BACKTESTING_INITIAL_AMOUNT, MarketDataSource + APP_MODE, MarketCredential, AppMode, BacktestDateRange, \ + DATABASE_DIRECTORY_NAME, BACKTESTING_INITIAL_AMOUNT, \ + MarketDataSource, APPLICATION_DIRECTORY from investing_algorithm_framework.infrastructure import setup_sqlalchemy, \ create_all_tables from investing_algorithm_framework.services import OrderBacktestService, \ @@ -56,7 +56,7 @@ def __init__(self, state_handler=None, name=None): self._on_after_initialize_hooks = [] self._state_handler = state_handler self._name = name - self._algorithm = Algorithm() + self._algorithm = Algorithm(name=self.name) @property def algorithm(self) -> Algorithm: @@ -634,9 +634,11 @@ def run_backtest( self, backtest_date_range: BacktestDateRange, initial_amount=None, - pending_order_check_interval=None, output_directory=None, - algorithm: Algorithm = None + algorithm: Algorithm = None, + save_strategy=False, + save_in_memory_strategies: bool = False, + strategy_directory: str = None ) -> BacktestReport: """ Run a backtest for an algorithm. This method should be called when @@ -650,10 +652,13 @@ def run_backtest( portfolio will start with. algorithm: The algorithm to run a backtest for (instance of Algorithm) - pending_order_check_interval: str - pending_order_check_interval: - The interval at which to check pending orders (e.g. 1h, 1d, 1w) output_directory: str - The directory to write the backtest report to + save_strategy: bool - Whether to save the strategy + save_strategies_directory: bool - Whether to save the + strategies directory + strategies_directory_name: str - The name of the directory + that contains the strategies Returns: Instance of BacktestReport @@ -664,6 +669,32 @@ def run_backtest( if self.algorithm is None: raise OperationalException("No algorithm registered") + if save_strategy: + # Check if the strategies directory exists + if not save_in_memory_strategies: + + if strategy_directory is None: + strategy_directory = os.path.join( + self.config[APPLICATION_DIRECTORY], "strategies" + ) + else: + strategy_directory = os.path.join( + self.config[APPLICATION_DIRECTORY], strategy_directory + ) + + if not os.path.isdir(strategy_directory): + raise OperationalException( + "The backtest run is enabled with the " + "`include_strategy` flag but the strategies" + " directory: " + f"{strategy_directory} does not exist. " + "Please create the strategies directory or set " + "include_strategy to False. If you want to save the " + "strategies in memory, set save_in_memory_strategies " + "to True. This can be helpfull when running your " + "strategies in a notebook environment." + ) + # Add backtest configuration to the config self.set_config_with_dict({ ENVIRONMENT: Environment.BACKTEST.value, @@ -671,9 +702,6 @@ def run_backtest( BACKTESTING_END_DATE: backtest_date_range.end_date, DATABASE_NAME: "backtest-database.sqlite3", DATABASE_DIRECTORY_NAME: "backtest_databases", - BACKTESTING_PENDING_ORDER_CHECK_INTERVAL: ( - pending_order_check_interval - ), BACKTESTING_INITIAL_AMOUNT: initial_amount }) @@ -697,15 +725,14 @@ def run_backtest( initial_amount=initial_amount, backtest_date_range=backtest_date_range ) - config = self.container.configuration_service().get_config() - if output_directory is None: - output_directory = os.path.join( - config[RESOURCE_DIRECTORY], "backtest_reports" - ) - - backtest_service.write_report_to_json( - report=report, output_directory=output_directory + backtest_service.save_report( + report=report, + algorithm=self.algorithm, + output_directory=output_directory, + save_strategy=save_strategy, + save_in_memory_strategies=save_in_memory_strategies, + strategy_directory=strategy_directory ) return report @@ -713,10 +740,10 @@ def run_backtests( self, algorithms, initial_amount=None, - date_ranges: List[BacktestDateRange] = None, - pending_order_check_interval=None, + backtest_date_ranges: List[BacktestDateRange] = None, output_directory=None, - checkpoint=False + checkpoint=False, + save_strategy=False, ) -> List[BacktestReport]: """ Run a backtest for a set algorithm. This method should be called when @@ -724,11 +751,9 @@ def run_backtests( Args: Algorithms: List[Algorithm] - The algorithms to run backtests for - date_ranges: List[BacktestDateRange] - The date ranges to run the - backtests for + backtest_date_ranges: List[BacktestDateRange] - The date ranges + to run the backtests for initial_amount: The initial amount to start the backtest with. - pending_order_check_interval: str - The interval at which to check - pending orders output_directory: str - The directory to write the backtest report to. checkpoint: bool - Whether to checkpoint the backtest, @@ -738,6 +763,11 @@ def run_backtests( when running backtests for a large number of algorithms and date ranges where some of the backtests may fail and you want to re-run only the failed backtests. + save_strategy: bool - Whether to save the strategy as part + of the backtest report. You can only save in-memory strategies + when running multiple backtests. This is because we can't + differentiate between which folders belong to a specific + strategy. Returns List of BacktestReport intances @@ -745,8 +775,7 @@ def run_backtests( logger.info("Initializing backtests") reports = [] - for date_range in date_ranges: - date_range: BacktestDateRange = date_range + for date_range in backtest_date_ranges: print( f"{COLOR_YELLOW}Running backtests for date " f"range:{COLOR_RESET} {COLOR_GREEN}{date_range.name} " @@ -783,10 +812,7 @@ def run_backtests( BACKTESTING_START_DATE: date_range.start_date, BACKTESTING_END_DATE: date_range.end_date, DATABASE_NAME: "backtest-database.sqlite3", - DATABASE_DIRECTORY_NAME: "backtest_databases", - BACKTESTING_PENDING_ORDER_CHECK_INTERVAL: ( - pending_order_check_interval - ) + DATABASE_DIRECTORY_NAME: "backtest_databases" }) self.initialize_config() @@ -818,13 +844,12 @@ def run_backtests( if date_range.name is not None: report.date_range_name = date_range.name - if output_directory is None: - output_directory = os.path.join( - self.config[RESOURCE_DIRECTORY], "backtest_reports" - ) - - backtest_service.write_report_to_json( - report=report, output_directory=output_directory + backtest_service.save_report( + report=report, + algorithm=algorithm, + output_directory=output_directory, + save_strategy=save_strategy, + save_in_memory_strategies=True, ) reports.append(report) diff --git a/investing_algorithm_framework/cli/templates/app-web.py.template b/investing_algorithm_framework/cli/templates/app-web.py.template new file mode 100644 index 0000000..77e1bec --- /dev/null +++ b/investing_algorithm_framework/cli/templates/app-web.py.template @@ -0,0 +1,17 @@ +import logging.config +from dotenv import load_dotenv + +from investing_algorithm_framework import create_app, \ + DEFAULT_LOGGING_CONFIG, Algorithm +from strategies.strategy import MyTradingStrategy + +load_dotenv() +logging.config.dictConfig(DEFAULT_LOGGING_CONFIG) + +app = create_app(web=True) +algorithm = Algorithm(name="MyTradingBot") +algorithm.add_strategy(MyTradingStrategy) +app.add_algorithm(algorithm) + +if __name__ == "__main__": + app.run() diff --git a/investing_algorithm_framework/cli/templates/app.py.template b/investing_algorithm_framework/cli/templates/app.py.template index f5f5cde..c328765 100644 --- a/investing_algorithm_framework/cli/templates/app.py.template +++ b/investing_algorithm_framework/cli/templates/app.py.template @@ -21,4 +21,4 @@ algorithm.add_strategy(MyTradingStrategy) app.add_algorithm(algorithm) if __name__ == "__main__": - app.run() \ No newline at end of file + app.run() diff --git a/investing_algorithm_framework/cli/templates/market_data_providers.py.template b/investing_algorithm_framework/cli/templates/market_data_providers.py.template new file mode 100644 index 0000000..53c487e --- /dev/null +++ b/investing_algorithm_framework/cli/templates/market_data_providers.py.template @@ -0,0 +1,9 @@ +from investing_algorithm_framework import CCXTOHLCVMarketDataSource + +btc_eur_ohlcv_2h = CCXTOHLCVMarketDataSource( + identifier="BTC/EUR-ohlcv", + market="BINANCE", + symbol="BTC/EUR", + time_frame="2h", + window_size=200 +) diff --git a/investing_algorithm_framework/cli/templates/requirements.txt.template b/investing_algorithm_framework/cli/templates/requirements.txt.template index fc79b15..d054f28 100644 --- a/investing_algorithm_framework/cli/templates/requirements.txt.template +++ b/investing_algorithm_framework/cli/templates/requirements.txt.template @@ -1,2 +1,2 @@ -investing-algorithm-framework>=6.2.0 +investing-algorithm-framework>=6.2.1 pyindicators>=0.5.4 diff --git a/investing_algorithm_framework/cli/templates/run_backtest.py.template b/investing_algorithm_framework/cli/templates/run_backtest.py.template index 7b21d28..4b58b07 100644 --- a/investing_algorithm_framework/cli/templates/run_backtest.py.template +++ b/investing_algorithm_framework/cli/templates/run_backtest.py.template @@ -10,4 +10,4 @@ if __name__ == "__main__": end_date=datetime(2023, 12, 31), ) report = app.run_backtest(backtest_date_range=backtest_date_range) - pretty_print_backtest(report) \ No newline at end of file + pretty_print_backtest(report) diff --git a/investing_algorithm_framework/create_app.py b/investing_algorithm_framework/create_app.py index ddf1cb1..ef6fb49 100644 --- a/investing_algorithm_framework/create_app.py +++ b/investing_algorithm_framework/create_app.py @@ -1,9 +1,11 @@ +import os import logging +import inspect from dotenv import load_dotenv from .app import App from .dependency_container import setup_dependency_container -from .domain import AppMode +from .domain import AppMode, APPLICATION_DIRECTORY logger = logging.getLogger("investing_algorithm_framework") @@ -44,6 +46,10 @@ def create_app( if web: app.set_config("APP_MODE", AppMode.WEB.value) - logger.info("Investing algoritm framework app created") + # Add the application directory to the config + caller_frame = inspect.stack()[1] + caller_path = os.path.abspath(caller_frame.filename) + app.set_config(APPLICATION_DIRECTORY, caller_path) + logger.info("Investing algoritm framework app created") return app diff --git a/investing_algorithm_framework/domain/__init__.py b/investing_algorithm_framework/domain/__init__.py index d7a90b6..bf98e24 100644 --- a/investing_algorithm_framework/domain/__init__.py +++ b/investing_algorithm_framework/domain/__init__.py @@ -7,8 +7,8 @@ BACKTEST_DATA_DIRECTORY_NAME, TICKER_DATA_TYPE, OHLCV_DATA_TYPE, \ CURRENT_UTC_DATETIME, BACKTESTING_END_DATE, SYMBOLS, \ CCXT_DATETIME_FORMAT_WITH_TIMEZONE, RESERVED_BALANCES, \ - BACKTESTING_PENDING_ORDER_CHECK_INTERVAL, APP_MODE, \ - DATABASE_DIRECTORY_NAME, BACKTESTING_INITIAL_AMOUNT + APP_MODE, DATABASE_DIRECTORY_NAME, BACKTESTING_INITIAL_AMOUNT, \ + APPLICATION_DIRECTORY from .data_structures import PeekableQueue from .decimal_parsing import parse_decimal_to_string, parse_string_to_decimal from .exceptions import OperationalException, ApiException, \ @@ -100,7 +100,6 @@ "MarketService", "PeekableQueue", "BACKTESTING_END_DATE", - "BACKTESTING_PENDING_ORDER_CHECK_INTERVAL", "PositionSnapshot", "MarketCredentialService", "TradeStatus", @@ -128,5 +127,6 @@ "TradeRiskType", "TradeTakeProfit", "TradeStopLoss", - "StateHandler" + "StateHandler", + "APPLICATION_DIRECTORY" ] diff --git a/investing_algorithm_framework/domain/constants.py b/investing_algorithm_framework/domain/constants.py index f880a7b..2ee2c70 100644 --- a/investing_algorithm_framework/domain/constants.py +++ b/investing_algorithm_framework/domain/constants.py @@ -13,6 +13,7 @@ DEFAULT_DATABASE_NAME = "database" SYMBOLS = "SYMBOLS" +APPLICATION_DIRECTORY = "APP_DIR" RESOURCE_DIRECTORY = "RESOURCE_DIRECTORY" BACKTEST_DATA_DIRECTORY_NAME = "BACKTEST_DATA_DIRECTORY_NAME" LOG_LEVEL = 'LOG_LEVEL' @@ -69,8 +70,6 @@ BACKTESTING_INDEX_DATETIME = "BACKTESTING_INDEX_DATETIME" BACKTESTING_START_DATE = "BACKTESTING_START_DATE" BACKTESTING_END_DATE = "BACKTESTING_END_DATE" -BACKTESTING_PENDING_ORDER_CHECK_INTERVAL \ - = "BACKTESTING_PENDING_ORDER_CHECK_INTERVAL" BACKTESTING_INITIAL_AMOUNT = "BACKTESTING_INITIAL_AMOUNT" TICKER_DATA_TYPE = "TICKER" OHLCV_DATA_TYPE = "OHLCV" diff --git a/investing_algorithm_framework/services/backtesting/backtest_service.py b/investing_algorithm_framework/services/backtesting/backtest_service.py index f237096..5a42ba0 100644 --- a/investing_algorithm_framework/services/backtesting/backtest_service.py +++ b/investing_algorithm_framework/services/backtesting/backtest_service.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import re import os +import inspect import json import pandas as pd from dateutil import parser @@ -10,7 +11,8 @@ from investing_algorithm_framework.domain import BacktestReport, \ BACKTESTING_INDEX_DATETIME, TimeUnit, BacktestPosition, \ TradingDataType, OrderStatus, OperationalException, MarketDataSource, \ - OrderSide, SYMBOLS, BacktestDateRange, DATETIME_FORMAT_BACKTESTING + OrderSide, SYMBOLS, BacktestDateRange, DATETIME_FORMAT_BACKTESTING, \ + RESOURCE_DIRECTORY from investing_algorithm_framework.services.market_data_source_service import \ MarketDataSourceService @@ -21,6 +23,11 @@ r"backtest-end-date_\d{4}-\d{2}-\d{2}:\d{2}:\d{2}_" r"created-at_\d{4}-\d{2}-\d{2}:\d{2}:\d{2}\.json$" ) +BACKTEST_REPORT_DIRECTORY_PATTERN = ( + r"^report_\w+_backtest-start-date_\d{4}-\d{2}-\d{2}:\d{2}:\d{2}_" + r"backtest-end-date_\d{4}-\d{2}-\d{2}:\d{2}:\d{2}_" + r"created-at_\d{4}-\d{2}-\d{2}:\d{2}:\d{2}$" +) def validate_algorithm_name(name, illegal_chars=r"[\/:*?\"<>|]"): @@ -683,6 +690,108 @@ def _is_backtest_report(self, path: str) -> bool: return False + def save_report( + self, + report: BacktestReport, + algorithm, + output_directory: str, + save_strategy=False, + save_in_memory_strategies: bool = False, + strategy_directory: str = None + ) -> None: + """ + Function to save the backtest report to a file. If the + `save_in_memory_strategies` flag is set to True, the function + tries to get the strategy class defintion that are loaded in + memory and save them to the output directory(this is usefull + when experimenting in notebooks). Otherwise, it copies + the strategy directory to the output directory. + + Args: + report: BacktestReport - The backtest report to save + algorithm: Algorithm - The algorithm to save + output_directory: str - The directory to save the report in + save_in_memory_strategies: bool - Flag to save in-memory strategies + (default: False) + save_strateg + save_strategy_directory: bool - Flag to save strategy directory + (default: False) + strategy_directory (optional): str - Full path to + the strategy directory + + Returns: + None + """ + + if output_directory is None: + output_directory = os.path.join( + self._configuration_service.config[RESOURCE_DIRECTORY], + "backtest_reports" + ) + + output_directory = self.create_report_directory( + report, output_directory, algorithm.name + ) + + if save_strategy: + if save_in_memory_strategies: + strategys = algorithm.strategies + + for strategy in strategys: + self._save_strategy_class(strategy, output_directory) + else: + # Copy over all files in the strategy directory + # to the output directory + app_directory = self._configuration_service.config["APP_DIR"] + + if strategy_directory is None: + strategy_directory = os.path.join( + app_directory, "strategies" + ) + + if not os.path.exists(strategy_directory) or \ + not os.path.isdir(strategy_directory): + raise OperationalException( + "Default strategy directory 'strategies' does " + "not exist. If you have your strategies placed in " + "a different directory, please provide the " + "full path to the strategy directory as " + "the 'strategy_directory' argument." + ) + else: + + # Check if the strategy directory exists + if not os.path.exists(strategy_directory) or \ + not os.path.isdir(strategy_directory): + raise OperationalException( + f"Strategy directory {strategy_directory} " + "does not " + f"exist. Please make sure the directory exists." + ) + + strategy_directory_name = os.path.basename(strategy_directory) + strategy_files = os.listdir(strategy_directory) + output_strategy_directory = os.path.join( + output_directory, strategy_directory_name + ) + + if not os.path.exists(output_strategy_directory): + os.makedirs(output_strategy_directory) + + for file in strategy_files: + source_file = os.path.join(strategy_directory, file) + destination_file = os.path.join( + output_strategy_directory, file + ) + + if os.path.isfile(source_file): + # Copy the file to the output directory + with open(source_file, "rb") as src: + with open(destination_file, "wb") as dst: + dst.write(src.read()) + + self.write_report_to_json(report, output_directory) + def write_report_to_json( self, report: BacktestReport, output_directory: str ) -> None: @@ -690,21 +799,19 @@ def write_report_to_json( Function to write a backtest report to a JSON file. Args: - - report: BacktestReport + report: BacktestReport The backtest report to write to a file. - - output_directory: str + output_directory: str The directory to store the backtest report file. Returns: - - None + None """ if not os.path.exists(output_directory): os.makedirs(output_directory) - json_file_path = self.create_report_file_path( - report, output_directory, extension=".json" - ) + json_file_path = os.path.join(output_directory, "report.json") report_dict = report.to_dict() # Convert dictionary to JSON @@ -715,47 +822,132 @@ def write_report_to_json( json_file.write(json_data) @staticmethod - def create_report_name(report, output_directory, extension=".json"): - backtest_start_date = report.backtest_start_date \ - .strftime(DATETIME_FORMAT_BACKTESTING) - backtest_end_date = report.backtest_end_date \ - .strftime(DATETIME_FORMAT_BACKTESTING) + def create_report_directory_name(report) -> str: + """ + Function to create a directory name for a backtest report. + The directory name will be automatically generated based on the + algorithm name and creation date. + + Args: + report: BacktestReport - The backtest report to create a + directory for. + + Returns: + directory_name: str The directory name for the + backtest report file. + """ created_at = report.created_at.strftime(DATETIME_FORMAT_BACKTESTING) - file_path = os.path.join( - output_directory, - f"report_{report.name}_backtest-start-date_" - f"{backtest_start_date}_backtest-end-date_" - f"{backtest_end_date}_created-at_{created_at}{extension}" - ) - return file_path + directory_name = f"{report.name}_backtest_created-at_{created_at}" + return directory_name @staticmethod - def create_report_file_path( - report, output_directory, extension=".json" + def create_report_directory( + report, output_directory, algorithm_name ) -> str: """ - Function to create a file path for a backtest report. + Function to create a directory for a backtest report. + The directory name will be automatically generated based on the + backtest start date, end date, and creation date. + The directory will be created if it does not exist. Args: - - report: BacktestReport - The backtest report to create a file path for. - - output_directory: str - The directory to store the backtest report file. - - extension: str (default=".json") - optional - The file extension to use for the backtest report file. + report: BacktestReport - The backtest report to create a + directory for. + output_directory: str - The directory to store the backtest + report file. + algorithm_name: str - The name of the algorithm to + create a directory for. + Returns: - - file_path: str - The file path for the backtest report file. + directory_path: str The directory path for the + backtest report file. """ - backtest_start_date = report.backtest_start_date \ - .strftime(DATETIME_FORMAT_BACKTESTING) - backtest_end_date = report.backtest_end_date \ - .strftime(DATETIME_FORMAT_BACKTESTING) - created_at = report.created_at.strftime(DATETIME_FORMAT_BACKTESTING) - file_path = os.path.join( - output_directory, - f"report_{report.name}_backtest-start-date_" - f"{backtest_start_date}_backtest-end-date_" - f"{backtest_end_date}_created-at_{created_at}{extension}" - ) - return file_path + + directory_name = BacktestService.create_report_directory_name(report) + directory_path = os.path.join(output_directory, directory_name) + + if not os.path.exists(directory_path): + os.makedirs(directory_path) + + return directory_path + + def _save_strategy_class(self, strategy, output_directory): + """ + Save the strategy class to a file in the specified output directory. + + Args: + strategy: The strategy instance to save. + output_directory: The directory to save the + strategy class file. + + Returns: + None + """ + collected_imports = set() + class_definitions = [] + collected_imports = [] + class_definitions = [] + + cls = strategy.__class__ + file_path = inspect.getfile(cls) + + if os.path.exists(file_path): + + with open(file_path, "r") as f: + lines = f.readlines() + + class_started = False + class_code = [] + current_import = [] + + for line in lines: + stripped_line = line.strip() + + # Start collecting an import line + if stripped_line.startswith(("import ", "from ")): + current_import.append(line.rstrip()) + + # Handle single-line import directly + if not stripped_line.endswith(("\\", "(")): + collected_imports.append(" ".join(current_import)) + current_import = [] + + # Continue collecting multi-line imports + elif current_import: + current_import.append(line.rstrip()) + + # Stop when the multi-line import finishes + if not stripped_line.endswith(("\\", ",")): + collected_imports.append(" ".join(current_import)) + current_import = [] + + # Catch any unfinished import (just in case) + if current_import: + collected_imports.append(" ".join(current_import)) + + # Capture class definitions and functions + if stripped_line.startswith("class ") \ + or stripped_line.startswith("def "): + class_started = True + + if class_started: + class_code.append(line) + + if class_code: + class_definitions.append("".join(class_code)) + + class_name = strategy.__class__.__name__ + class_file_name = f"{class_name}.py" + filename = os.path.join(output_directory, class_file_name) + + # Save everything to a single file + with open(filename, "w") as f: + # Write unique imports at the top + for imp in collected_imports: + f.write(imp) + + f.write("\n\n") + + # Write class and function definitions + for class_def in class_definitions: + f.write(class_def + "\n\n") diff --git a/pyproject.toml b/pyproject.toml index 2c05ff1..8ef3521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ exclude = ["tests", "static", "examples", "docs"] [tool.poetry.dependencies] -python = ">=3.9" +python = ">=3.10" wrapt = ">=1.16.0" Flask = ">=2.3.2" Flask-Migrate = ">=2.6.0" diff --git a/tests/app/backtesting/test_backtest_report.py b/tests/app/backtesting/test_backtest_report.py index a96f091..0f77820 100644 --- a/tests/app/backtesting/test_backtest_report.py +++ b/tests/app/backtesting/test_backtest_report.py @@ -71,15 +71,16 @@ def test_report_json_creation(self): ) report = app.run_backtest( algorithm=algorithm, - backtest_date_range=backtest_date_range - ) - file_path = BacktestService.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") + backtest_date_range=backtest_date_range, + save_in_memory_strategies=True ) + dir_name = BacktestService.create_report_directory_name(report) + + path = os.path.join(self.resource_dir, "backtest_reports", dir_name) + # Check if the backtest report exists - self.assertTrue( - os.path.isfile(os.path.join(self.resource_dir, file_path)) - ) + self.assertTrue(os.path.isdir(path)) + def test_report_json_creation_with_multiple_strategies_with_id(self): """ @@ -109,12 +110,13 @@ def run_strategy(context, market_data): end_date=datetime.utcnow() ) report = app.run_backtest( - algorithm=algorithm, backtest_date_range=backtest_range - ) - file_path = BacktestService.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") + algorithm=algorithm, + backtest_date_range=backtest_range, + save_in_memory_strategies=True ) + dir_name = BacktestService.create_report_directory_name(report) + + path = os.path.join(self.resource_dir, "backtest_reports", dir_name) + # Check if the backtest report exists - self.assertTrue( - os.path.isfile(os.path.join(self.resource_dir, file_path)) - ) + self.assertTrue(os.path.isdir(path)) diff --git a/tests/app/backtesting/test_run_backtest.py b/tests/app/backtesting/test_run_backtest.py index 428be6c..6e10aab 100644 --- a/tests/app/backtesting/test_run_backtest.py +++ b/tests/app/backtesting/test_run_backtest.py @@ -50,7 +50,7 @@ def tearDown(self) -> None: for name in dirs: os.rmdir(os.path.join(root, name)) - def test_report_csv_creation(self): + def test_report_creation(self): """ Test if the backtest report is created as a CSV file """ @@ -72,17 +72,18 @@ def test_report_csv_creation(self): end_date=datetime.utcnow() ) report = app.run_backtest( - algorithm=algorithm, backtest_date_range=backtest_date_range - ) - file_path = BacktestService.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") + algorithm=algorithm, + backtest_date_range=backtest_date_range, + save_in_memory_strategies=True ) + dir_name = BacktestService.create_report_directory_name(report) + + path = os.path.join(self.resource_dir, "backtest_reports", dir_name) + # Check if the backtest report exists - self.assertTrue( - os.path.isfile(os.path.join(self.resource_dir, file_path)) - ) + self.assertTrue(os.path.isdir(path)) - def test_report_csv_creation_without_strategy_identifier(self): + def test_report_creation_without_strategy_identifier(self): """ Test if the backtest report is created as a CSV file when the strategy does not have an identifier @@ -106,54 +107,17 @@ def test_report_csv_creation_without_strategy_identifier(self): end_date=datetime.utcnow() ) report = app.run_backtest( - algorithm=algorithm, backtest_date_range=backtest_date_range - ) - file_path = BacktestService.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") - ) - # Check if the backtest report exists - self.assertTrue(os.path.isfile(file_path)) - - def test_report_csv_creation_with_multiple_strategies(self): - """ - Test if the backtest report is created as a CSV file - when there are multiple strategies - """ - app = create_app( - config={RESOURCE_DIRECTORY: self.resource_dir} - ) - strategy = TestStrategy() - strategy.strategy_id = None - algorithm = Algorithm() - algorithm.add_strategy(strategy) - - @algorithm.strategy() - def run_strategy(context, market_data): - pass - - app.add_portfolio_configuration( - PortfolioConfiguration( - market="bitvavo", - trading_symbol="EUR", - initial_balance=1000 - ) + algorithm=algorithm, + backtest_date_range=backtest_date_range, + save_in_memory_strategies=True ) + dir_name = BacktestService.create_report_directory_name(report) + path = os.path.join(self.resource_dir, "backtest_reports", dir_name) - self.assertEqual(2, len(algorithm.strategies)) - backtest_date_range = BacktestDateRange( - start_date=datetime.utcnow() - timedelta(days=1), - end_date=datetime.utcnow() - ) - report = app.run_backtest( - algorithm=algorithm, backtest_date_range=backtest_date_range - ) - file_path = BacktestService.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") - ) # Check if the backtest report exists - self.assertTrue(os.path.isfile(file_path)) + self.assertTrue(os.path.isdir(path)) - def test_report_csv_creation_with_multiple_strategies_with_id(self): + def test_report_creation_with_multiple_strategies_with_id(self): """ Test if the backtest report is created as a CSV file when there are multiple strategies with identifiers @@ -183,10 +147,11 @@ def run_strategy(context, market_data): ) report = app.run_backtest( algorithm=algorithm, - backtest_date_range=backtest_date_range - ) - file_path = BacktestService.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") + backtest_date_range=backtest_date_range, + save_in_memory_strategies=True ) + dir_name = BacktestService.create_report_directory_name(report) + path = os.path.join(self.resource_dir, "backtest_reports", dir_name) + # Check if the backtest report exists - self.assertTrue(os.path.isfile(file_path)) + self.assertTrue(os.path.isdir(path)) diff --git a/tests/app/backtesting/test_run_backtests.py b/tests/app/backtesting/test_run_backtests.py index f4a35e1..cb78c40 100644 --- a/tests/app/backtesting/test_run_backtests.py +++ b/tests/app/backtesting/test_run_backtests.py @@ -78,13 +78,14 @@ def test_run_backtests(self): ) reports = app.run_backtests( algorithms=[algorithm_one, algorithm_two, algorithm_three], - date_ranges=[backtest_date_range] + backtest_date_ranges=[backtest_date_range], ) backtest_service = app.container.backtest_service() # Check if the backtest reports exist for report in reports: - file_path = backtest_service.create_report_name( - report, os.path.join(self.resource_dir, "backtest_reports") + dir_name = backtest_service.create_report_directory_name(report) + path = os.path.join( + self.resource_dir, "backtest_reports", dir_name ) - self.assertTrue(os.path.isfile(file_path)) + self.assertTrue(os.path.isdir(path)) diff --git a/tests/app/backtesting/test_strategy_saving.py b/tests/app/backtesting/test_strategy_saving.py new file mode 100644 index 0000000..ae22432 --- /dev/null +++ b/tests/app/backtesting/test_strategy_saving.py @@ -0,0 +1,230 @@ +import os +from datetime import datetime, timedelta +from unittest import TestCase + +from investing_algorithm_framework import create_app, RESOURCE_DIRECTORY, \ + TradingStrategy, PortfolioConfiguration, TimeUnit, Algorithm, \ + BacktestDateRange +from investing_algorithm_framework.services import BacktestService + + +class TestStrategy(TradingStrategy): + strategy_id = "test_strategy" + time_unit = TimeUnit.MINUTE + interval = 1 + + def run_strategy(self, context, market_data): + pass + + +class Test(TestCase): + """ + Collection of tests for backtest report operations where + the tests resolve around the saving of strategies. + """ + def setUp(self) -> None: + self.resource_dir = os.path.abspath( + os.path.join( + os.path.join( + os.path.join( + os.path.join( + os.path.realpath(__file__), + os.pardir + ), + os.pardir + ), + os.pardir + ), + "resources" + ) + ) + + def tearDown(self) -> None: + database_dir = os.path.join( + self.resource_dir, "databases" + ) + + if os.path.exists(database_dir): + for root, dirs, files in os.walk(database_dir, topdown=False): + for name in files: + os.remove(os.path.join(root, name)) + for name in dirs: + os.rmdir(os.path.join(root, name)) + + def test_in_memory(self): + """ + Test if the backtest report contains the in-memory strategy. + The strategy should be saved as a python file called + .py. + """ + app = create_app( + config={"test": "test", RESOURCE_DIRECTORY: self.resource_dir} + ) + algorithm = Algorithm() + algorithm.add_strategy(TestStrategy()) + app.add_algorithm(algorithm) + app.add_portfolio_configuration( + PortfolioConfiguration( + market="bitvavo", + trading_symbol="EUR", + initial_balance=1000 + ) + ) + backtest_date_range = BacktestDateRange( + start_date=datetime.utcnow() - timedelta(days=1), + end_date=datetime.utcnow() + ) + report = app.run_backtest( + algorithm=algorithm, + backtest_date_range=backtest_date_range, + save_strategy=True, + save_in_memory_strategies=True + ) + report_directory = BacktestService.create_report_directory_name(report) + report_name = "report.json" + backtest_report_root_dir = os.path.join( + self.resource_dir, "backtest_reports" + ) + backtest_report_dir = os.path.join( + backtest_report_root_dir, report_directory + ) + + # Check if the backtest report root directory exists + self.assertTrue(os.path.isdir(backtest_report_root_dir)) + + # Check if the backtest report directory exists + self.assertTrue(os.path.isdir(backtest_report_dir)) + + # Check if the strategy file exists + strategy_file_path = os.path.join( + backtest_report_dir, "TestStrategy.py" + ) + self.assertTrue(os.path.isfile(strategy_file_path)) + + report_file_path = os.path.join( + backtest_report_dir, report_name + ) + # check if the report json file exists + self.assertTrue(os.path.isfile(report_file_path)) + + + def test_with_directory(self): + """ + Test if the strategy is saved when the strategy is specified through + a directory attribute. + """ + app = create_app( + config={"test": "test", RESOURCE_DIRECTORY: self.resource_dir} + ) + algorithm = Algorithm() + algorithm.add_strategy(TestStrategy()) + app.add_algorithm(algorithm) + app.add_portfolio_configuration( + PortfolioConfiguration( + market="bitvavo", + trading_symbol="EUR", + initial_balance=1000 + ) + ) + backtest_date_range = BacktestDateRange( + start_date=datetime.utcnow() - timedelta(days=1), + end_date=datetime.utcnow() + ) + strategy_directory = os.path.join( + self.resource_dir, "strategies_for_testing" + ) + report = app.run_backtest( + algorithm=algorithm, + backtest_date_range=backtest_date_range, + save_strategy=True, + strategy_directory=strategy_directory, + ) + report_directory = BacktestService.create_report_directory_name(report) + report_name = "report.json" + backtest_report_root_dir = os.path.join( + self.resource_dir, "backtest_reports" + ) + backtest_report_dir = os.path.join( + backtest_report_root_dir, report_directory + ) + + # Check if the backtest report root directory exists + self.assertTrue(os.path.isdir(backtest_report_root_dir)) + + # Check if the backtest report directory exists + self.assertTrue(os.path.isdir(backtest_report_dir)) + + # Check if the strategy file exists + strategy_one_file_path = os.path.join( + backtest_report_dir, "strategies_for_testing", "strategy_one.py" + ) + self.assertTrue(os.path.isfile(strategy_one_file_path)) + + strategy_two_file_path = os.path.join( + backtest_report_dir, "strategies_for_testing", "strategy_two.py" + ) + self.assertTrue(os.path.isfile(strategy_two_file_path)) + + def test_save_strategy_with_run_backtests(self): + """ + Test if the in-memory strategy is saved when the run_backtests method is called with save_strategy=True. + """ + app = create_app( + config={"test": "test", RESOURCE_DIRECTORY: self.resource_dir} + ) + algorithm_one = Algorithm() + algorithm_one.add_strategy(TestStrategy()) + + algorithm_two = Algorithm() + algorithm_two.add_strategy(TestStrategy()) + + app.add_portfolio_configuration( + PortfolioConfiguration( + market="bitvavo", + trading_symbol="EUR", + initial_balance=1000 + ) + ) + backtest_date_range = BacktestDateRange( + start_date=datetime.utcnow() - timedelta(days=1), + end_date=datetime.utcnow() + ) + strategy_directory = os.path.join( + self.resource_dir, "strategies_for_testing" + ) + reports = app.run_backtests( + algorithms=[algorithm_one, algorithm_two], + backtest_date_ranges=[backtest_date_range], + save_strategy=True, + ) + + backtest_report_root_dir = os.path.join( + self.resource_dir, "backtest_reports" + ) + + # Check if the backtest report root directory exists + self.assertTrue(os.path.isdir(backtest_report_root_dir)) + + for report in reports: + report_directory = BacktestService\ + .create_report_directory_name(report) + report_name = "report.json" + + report_directory = os.path.join( + backtest_report_root_dir, report_directory + ) + + # Check if the backtest report directory exists + self.assertTrue(os.path.isdir(report_directory)) + + # Check if the strategy file exists + strategy_file_path = os.path.join( + report_directory, "TestStrategy.py" + ) + self.assertTrue(os.path.isfile(strategy_file_path)) + + report_file_path = os.path.join( + report_directory, report_name + ) + # check if the report json file exists + self.assertTrue(os.path.isfile(report_file_path)) diff --git a/tests/resources/strategies_for_testing/__init__.py b/tests/resources/strategies_for_testing/__init__.py new file mode 100644 index 0000000..bc013e5 --- /dev/null +++ b/tests/resources/strategies_for_testing/__init__.py @@ -0,0 +1,7 @@ +from .strategy_one import StrategyOne +from .strategy_two import StrategyTwo + +__all__ = [ + "StrategyOne", + "StrategyTwo" +] diff --git a/tests/resources/strategies_for_testing/strategy_one.py b/tests/resources/strategies_for_testing/strategy_one.py new file mode 100644 index 0000000..82fbe4e --- /dev/null +++ b/tests/resources/strategies_for_testing/strategy_one.py @@ -0,0 +1,10 @@ +from investing_algorithm_framework import TradingStrategy, TimeUnit + + +class StrategyOne(TradingStrategy): + strategy_id = "strategy_one" + time_unit = TimeUnit.MINUTE + interval = 1 + + def run_strategy(self, context, market_data): + pass diff --git a/tests/resources/strategies_for_testing/strategy_two.py b/tests/resources/strategies_for_testing/strategy_two.py new file mode 100644 index 0000000..be38e2c --- /dev/null +++ b/tests/resources/strategies_for_testing/strategy_two.py @@ -0,0 +1,10 @@ +from investing_algorithm_framework import TradingStrategy, TimeUnit + + +class StrategyTwo(TradingStrategy): + strategy_id = "strategy_two" + time_unit = TimeUnit.MINUTE + interval = 1 + + def run_strategy(self, context, market_data): + pass