diff --git a/README.md b/README.md index d6af6b91..35863e43 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ The following example connects to Binance and buys BTC every 2 hours. import logging.config from dotenv import load_dotenv -from investing_algorithm_framework import create_app, PortfolioConfiguration, \ - TimeUnit, CCXTOHLCVMarketDataSource, Context, CCXTTickerMarketDataSource, \ - MarketCredential, DEFAULT_LOGGING_CONFIG, Algorithm, Context +from investing_algorithm_framework import create_app, TimeUnit, \ + CCXTOHLCVMarketDataSource, CCXTTickerMarketDataSource, \ + DEFAULT_LOGGING_CONFIG, Algorithm, Context load_dotenv() logging.config.dictConfig(DEFAULT_LOGGING_CONFIG) @@ -93,17 +93,15 @@ bitvavo_btc_eur_ticker = CCXTTickerMarketDataSource( market="BITVAVO", symbol="BTC/EUR", ) -app = create_app() # Bitvavo market credentials are read from .env file, or you can # set them manually as params -app.add_market_credential(MarketCredential(market="bitvavo")) -app.add_portfolio_configuration( - PortfolioConfiguration( - market="bitvavo", trading_symbol="EUR", initial_balance=40 - ) +app = create_app() +app.add_market( + market="BITVAVO", + trading_symbol="EUR", + initial_balance=100 ) - algorithm = Algorithm(name="test_algorithm") # Define a strategy for the algorithm that will run every 10 seconds @@ -338,8 +336,8 @@ This will ensure that interested parties can give valuable feedback on the featu ## 📬 Support -* Slack Community -* Reddit Community +* [Reddit Community](https://www.reddit.com/r/InvestingBots/) +* [Discord Community](https://discord.gg/dQsRmGZP") ## 🏆 Acknowledgements diff --git a/examples/backtest_example/run_backtest.py b/examples/backtest_example/run_backtest.py index 404bce95..3e7c399b 100644 --- a/examples/backtest_example/run_backtest.py +++ b/examples/backtest_example/run_backtest.py @@ -1,13 +1,14 @@ -import time -from datetime import datetime import logging.config +import time 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, create_app, + pretty_print_backtest, BacktestDateRange, TimeUnit, TradingStrategy, + OrderSide, DEFAULT_LOGGING_CONFIG, Context +) logging.config.dictConfig(DEFAULT_LOGGING_CONFIG) diff --git a/examples/test.py b/examples/test.py deleted file mode 100644 index c4da331d..00000000 --- a/examples/test.py +++ /dev/null @@ -1,16 +0,0 @@ -from investing_algorithm_framework import download - - -if __name__ == "__main__": - data = download( - symbol="BTC/USDT", - market="binance", - data_type="ohlcv", - start_date="2023-01-01", - end_date="2023-10-01", - window_size=200, - pandas=True, - save=True, - storage_dir="./data" - ) - print(data) diff --git a/investing_algorithm_framework/__init__.py b/investing_algorithm_framework/__init__.py index 425311b0..c76387ec 100644 --- a/investing_algorithm_framework/__init__.py +++ b/investing_algorithm_framework/__init__.py @@ -12,7 +12,8 @@ load_backtest_report, BacktestDateRange, convert_polars_to_pandas, \ DateRange, get_backtest_report, DEFAULT_LOGGING_CONFIG, \ BacktestReport, TradeStatus, MarketDataType, TradeRiskType, \ - APPLICATION_DIRECTORY + APPLICATION_DIRECTORY, pretty_print_orders, pretty_print_trades, \ + pretty_print_positions, DataSource from investing_algorithm_framework.infrastructure import \ CCXTOrderBookMarketDataSource, CCXTOHLCVMarketDataSource, \ CCXTTickerMarketDataSource, CSVOHLCVMarketDataSource, \ @@ -77,5 +78,9 @@ "TradeRiskType", "Context", "APPLICATION_DIRECTORY", - "download" + "download", + "pretty_print_orders", + "pretty_print_trades", + "pretty_print_positions", + "DataSource", ] diff --git a/investing_algorithm_framework/app/app.py b/investing_algorithm_framework/app/app.py index c70ac815..b91fe2d4 100644 --- a/investing_algorithm_framework/app/app.py +++ b/investing_algorithm_framework/app/app.py @@ -19,9 +19,10 @@ BACKTESTING_START_DATE, BACKTESTING_END_DATE, BacktestReport, \ APP_MODE, MarketCredential, AppMode, BacktestDateRange, \ DATABASE_DIRECTORY_NAME, BACKTESTING_INITIAL_AMOUNT, \ - MarketDataSource, APPLICATION_DIRECTORY, PortfolioConfiguration + MarketDataSource, APPLICATION_DIRECTORY, PortfolioConfiguration, \ + PortfolioProvider, OrderExecutor, ImproperlyConfigured from investing_algorithm_framework.infrastructure import setup_sqlalchemy, \ - create_all_tables + create_all_tables, CCXTOrderExecutor, CCXTPortfolioProvider from investing_algorithm_framework.services import OrderBacktestService, \ BacktestMarketDataSourceService, BacktestPortfolioService, \ MarketDataSourceService, MarketCredentialService @@ -211,6 +212,8 @@ def initialize(self): None """ logger.info("Initializing app") + self._initialize_default_order_executors() + self._initialize_default_portfolio_providers() if self.algorithm is None: raise OperationalException("No algorithm registered") @@ -240,18 +243,20 @@ def initialize(self): portfolio_snap_service = self.container \ .portfolio_snapshot_service() market_cred_service = self.container.market_credential_service() + portfolio_provider_lookup = \ + self.container.portfolio_provider_lookup() # Override the portfolio service with the backtest # portfolio service self.container.portfolio_service.override( BacktestPortfolioService( configuration_service=configuration_service, market_credential_service=market_cred_service, - market_service=self.container.market_service(), position_service=self.container.position_service(), order_service=self.container.order_service(), portfolio_repository=self.container.portfolio_repository(), portfolio_configuration_service=portfolio_conf_service, - portfolio_snapshot_service=portfolio_snap_service + portfolio_snapshot_service=portfolio_snap_service, + portfolio_provider_lookup=portfolio_provider_lookup ) ) @@ -284,7 +289,7 @@ def initialize(self): OrderBacktestService( trade_service=self.container.trade_service(), order_repository=self.container.order_repository(), - position_repository=self.container.position_repository(), + position_service=self.container.position_service(), portfolio_repository=self.container.portfolio_repository(), portfolio_configuration_service=portfolio_conf_service, portfolio_snapshot_service=portfolio_snap_service, @@ -350,74 +355,7 @@ def initialize(self): ) self._initialize_web() - # Initialize all portfolios that are registered - portfolio_configuration_service = self.container \ - .portfolio_configuration_service() - portfolio_service = self.container.portfolio_service() - - # Throw an error if no portfolios are configured - if portfolio_configuration_service.count() == 0: - raise OperationalException("No portfolios configured") - - if Environment.BACKTEST.equals(config[ENVIRONMENT]): - initial_backtest_amount = config.get( - BACKTESTING_INITIAL_AMOUNT, None - ) - - for portfolio_configuration \ - in portfolio_configuration_service.get_all(): - - if not portfolio_service.exists( - {"identifier": portfolio_configuration.identifier} - ): - portfolio = ( - portfolio_service.create_portfolio_from_configuration( - portfolio_configuration, - initial_amount=initial_backtest_amount, - ) - ) - else: - synced_portfolios = [] - - for portfolio_configuration \ - in portfolio_configuration_service.get_all(): - - if not portfolio_service.exists( - {"identifier": portfolio_configuration.identifier} - ): - portfolio = portfolio_service\ - .create_portfolio_from_configuration( - portfolio_configuration - ) - - portfolios = portfolio_service.get_all() - - for portfolio in portfolios: - - if portfolio not in synced_portfolios: - self.sync(portfolio) - - def sync(self, portfolio): - """ - Sync the portfolio with the exchange. This method should be called - before running the algorithm. It syncs the portfolio with the - exchange by syncing the unallocated balance, positions, orders, and - trades. - - Args: - portfolio (Portfolio): The portfolio to sync - - Returns: - None - """ - logger.info(f"Syncing portfolio {portfolio.identifier}") - portfolio_sync_service = self.container.portfolio_sync_service() - - # Sync unallocated balance - portfolio_sync_service.sync_unallocated(portfolio) - - # Sync all orders from exchange with current order history - portfolio_sync_service.sync_orders(portfolio) + self._initialize_portfolios() def run( self, @@ -476,7 +414,7 @@ def run( self._state_handler.load(config[RESOURCE_DIRECTORY]) self.initialize() - logger.info("Initialization complete") + logger.info("App initialization complete") # Run all on_initialize hooks for hook in self._on_initialize_hooks: @@ -1020,3 +958,267 @@ def add_market( secret_key=secret_key ) self.add_market_credential(market_credential) + + def add_order_executor(self, order_executor): + """ + Function to add an order executor to the app. The order executor + should be an instance of OrderExecutor. + + Args: + order_executor: Instance of OrderExecutor + + Returns: + None + """ + + if inspect.isclass(order_executor): + order_executor = order_executor() + + if not isinstance(order_executor, OrderExecutor): + raise OperationalException( + "Order executor should be an instance of OrderExecutor" + ) + + order_executor_lookup = self.container.order_executor_lookup() + order_executor_lookup.add_order_executor( + order_executor=order_executor + ) + + def get_order_executors(self): + """ + Function to get all order executors from the app. This method + should be called when you want to get all order executors. + + Returns: + List of OrderExecutor instances + """ + order_executor_lookup = self.container.order_executor_lookup() + return order_executor_lookup.get_all() + + def add_portfolio_provider(self, portfolio_provider): + """ + Function to add a portfolio provider to the app. The portfolio + provider should be an instance of PortfolioProvider. + + Args: + portfolio_provider: Instance of PortfolioProvider + + Returns: + None + """ + + if inspect.isclass(portfolio_provider): + portfolio_provider = portfolio_provider() + + if not isinstance(portfolio_provider, PortfolioProvider): + raise OperationalException( + "Portfolio provider should be an instance of " + "PortfolioProvider" + ) + + portfolio_provider_lookup = self.container.portfolio_provider_lookup() + portfolio_provider_lookup.add_portfolio_provider( + portfolio_provider=portfolio_provider + ) + + def get_portfolio_providers(self): + """ + Function to get all portfolio providers from the app. This method + should be called when you want to get all portfolio providers. + + Returns: + List of PortfolioProvider instances + """ + portfolio_provider_lookup = self.container.portfolio_provider_lookup() + return portfolio_provider_lookup.get_all() + + def _initialize_portfolios(self): + """ + Function to initialize the portfolios. This function will + first check if the app is running in backtest mode or not. If it is + running in backtest mode, it will create the portfolios with the + initial amount specified in the config. If it is not running in + backtest mode, it will check if there are + + """ + logger.info("Initializing portfolios") + config = self.config + + portfolio_configuration_service = self.container \ + .portfolio_configuration_service() + portfolio_service = self.container.portfolio_service() + + # Throw an error if no portfolios are configured + if portfolio_configuration_service.count() == 0: + raise OperationalException("No portfolios configured") + + if Environment.BACKTEST.equals(config[ENVIRONMENT]): + logger.info("Setting up backtest portfolios") + initial_backtest_amount = config.get( + BACKTESTING_INITIAL_AMOUNT, None + ) + + for portfolio_configuration \ + in portfolio_configuration_service.get_all(): + + if not portfolio_service.exists( + {"identifier": portfolio_configuration.identifier} + ): + portfolio = ( + portfolio_service.create_portfolio_from_configuration( + portfolio_configuration, + initial_amount=initial_backtest_amount, + ) + ) + else: + # Check if there are already existing portfolios + portfolios = portfolio_service.get_all() + portfolio_configurations = portfolio_configuration_service\ + .get_all() + + if len(portfolios) > 0: + + # Check if there are matching portfolio configurations + for portfolio in portfolios: + logger.info( + f"Checking if there is an matching portfolio " + "configuration " + f"for portfolio {portfolio.identifier}" + ) + portfolio_configuration = \ + portfolio_configuration_service.get( + portfolio.market + ) + + if portfolio_configuration is None: + raise ImproperlyConfigured( + f"No matching portfolio configuration found for " + f"existing portfolio {portfolio.market}, " + f"please make sure that you have configured your " + f"app with the right portfolio configurations " + f"for the existing portfolios." + f"If you want to create a new portfolio, please " + f"remove the existing database (WARNING!!: this " + f"will remove all existing history of your " + f"trading bot.)" + ) + + # Check if the portfolio configuration is still inline + # with the initial balance + + if portfolio_configuration.initial_balance != \ + portfolio.initial_balance: + logger.warning( + "The initial balance of the portfolio " + "configuration is different from the existing " + "portfolio. Checking if the existing portfolio " + "can be updated..." + ) + + portfolio_provider_lookup = \ + self.container.portfolio_provider_lookup() + # Register a portfolio provider for the portfolio + portfolio_provider_lookup \ + .register_portfolio_provider_for_market( + portfolio_configuration.market + ) + initial_balance = portfolio_configuration\ + .initial_balance + + if initial_balance != portfolio.initial_balance: + raise ImproperlyConfigured( + "The initial balance of the portfolio " + "configuration is different then that of " + "the existing portfolio. Please make sure " + "that the initial balance of the portfolio " + "configuration is the same as that of the " + "existing portfolio. " + f"Existing portfolio initial balance: " + f"{portfolio.initial_balance}, " + f"Portfolio configuration initial balance: " + f"{portfolio_configuration.initial_balance}" + "If this is intentional, please remove " + "the database and re-run the app. " + "WARNING!!: this will remove all existing " + "history of your trading bot." + ) + + portfolio_provider_lookup = \ + self.container.portfolio_provider_lookup() + order_executor_lookup = self.container.order_executor_lookup() + + # Register portfolio providers and order executors + for portfolio_configuration in portfolio_configurations: + + # Register a portfolio provider for the portfolio + portfolio_provider_lookup\ + .register_portfolio_provider_for_market( + portfolio_configuration.market + ) + + # Register an order executor for the portfolio + order_executor_lookup.register_order_executor_for_market( + portfolio_configuration.market + ) + + market_credential = \ + self._market_credential_service.get( + portfolio_configuration.market + ) + + if market_credential is None: + raise ImproperlyConfigured( + f"No market credential found for existing " + f"portfolio {portfolio_configuration.market} " + "with market " + "Cannot initialize portfolio configuration." + ) + + if not portfolio_service.exists( + {"identifier": portfolio_configuration.identifier} + ): + portfolio_service.create_portfolio_from_configuration( + portfolio_configuration + ) + + logger.info("Portfolio configurations complete") + logger.info("Syncing portfolios") + portfolio_service = self.container.portfolio_service() + portfolio_sync_service = self.container.portfolio_sync_service() + + for portfolio in portfolio_service.get_all(): + logger.info(f"Syncing portfolio {portfolio.identifier}") + portfolio_sync_service.sync_unallocated(portfolio) + portfolio_sync_service.sync_orders(portfolio) + + def _initialize_default_portfolio_providers(self): + """ + Function to initialize the default portfolio providers. + This function will create a default portfolio provider for + each market that is configured in the app. The default portfolio + provider will be used to create portfolios for the app. + + Returns: + None + """ + logger.info("Adding default portfolio providers") + portfolio_provider_lookup = self.container.portfolio_provider_lookup() + portfolio_provider_lookup.add_portfolio_provider( + CCXTPortfolioProvider() + ) + + def _initialize_default_order_executors(self): + """ + Function to initialize the default order executors. + This function will create a default order executor for + each market that is configured in the app. The default order + executor will be used to create orders for the app. + + Returns: + None + """ + logger.info("Adding default order executors") + order_executor_lookup = self.container.order_executor_lookup() + order_executor_lookup.add_order_executor( + CCXTOrderExecutor() + ) diff --git a/investing_algorithm_framework/app/context.py b/investing_algorithm_framework/app/context.py index 6b539491..7d9d1519 100644 --- a/investing_algorithm_framework/app/context.py +++ b/investing_algorithm_framework/app/context.py @@ -74,7 +74,7 @@ def create_order( execute=True, validate=True, sync=True - ): + ) -> Order: """ Function to create an order. This function will create an order and execute it if the execute parameter is set to True. If the @@ -157,7 +157,7 @@ def create_limit_order( execute=True, validate=True, sync=True - ): + ) -> Order: """ Function to create a limit order. This function will create a limit order and execute it if the execute parameter is set to True. If the @@ -237,6 +237,10 @@ def create_limit_order( "parameter must be specified." ) + logger.info( + f"Creating limit order: {target_symbol} " + f"{order_side} {amount} @ {price}" + ) order_data = { "target_symbol": target_symbol, "price": price, @@ -274,7 +278,7 @@ def get_portfolio(self, market=None) -> Portfolio: if market is None: return self.portfolio_service.get_all()[0] - return self.portfolio_service.find({{"market": market}}) + return self.portfolio_service.find({"market": market}) def get_portfolios(self): """ @@ -676,32 +680,53 @@ def get_position_percentage_of_portfolio_by_net_size( return (position.cost / net_size) * 100 def close_position( - self, symbol, market=None, identifier=None, precision=None - ): + self, position=None, symbol=None, portfolio=None, precision=None + ) -> Order: """ Function to close a position. This function will close a position by creating a market order to sell the position. If the precision parameter is specified, the amount of the order will be rounded down to the specified precision. - Parameters: - symbol: The symbol of the asset - market: The market of the asset - identifier: The identifier of the portfolio - precision: The precision of the amount + Args: + position (Optional): The position to close + symbol (Optional): The symbol of the asset + portfolio (Optional): The portfolio where the position is located + precision (Optional): The precision of the amount Returns: - None + Order: The order created to close the position """ - portfolio = self.portfolio_service.find( - {"market": market, "identifier": identifier} - ) - position = self.position_service.find( - {"portfolio": portfolio.id, "symbol": symbol} - ) + query_params = {} + + if position is None and (symbol is None and portfolio is None): + raise OperationalException( + "Either position or symbol and portfolio parameters must " + "be specified to close a position." + ) + + if position is not None: + query_params["id"] = position.id + query_params["symbol"] = position.symbol + + if symbol is not None: + query_params["symbol"] = symbol + + if portfolio is not None: + query_params["portfolio"] = portfolio.id + + position = self.position_service.find(query_params) + portfolio = self.portfolio_service.get(position.portfolio_id) if position.get_amount() == 0: - return + logger.warning("Cannot close position. Amount is 0.") + return None + + if position.get_symbol() == portfolio.get_trading_symbol(): + raise OperationalException( + "Cannot close position. The position is the same as the " + "trading symbol of the portfolio." + ) for order in self.order_service \ .get_all( @@ -712,11 +737,17 @@ def close_position( ): self.order_service.cancel_order(order) - symbol = f"{symbol.upper()}/{portfolio.trading_symbol.upper()}" + target_symbol = position.get_symbol() + symbol = f"{target_symbol.upper()}/{portfolio.trading_symbol.upper()}" ticker = self.market_data_source_service.get_ticker( - symbol=symbol, market=market + symbol=symbol, market=portfolio.market ) - self.create_limit_order( + logger.info( + f"Closing position {position.symbol} " + f"with amount {position.get_amount()} " + f"at price {ticker['bid']}" + ) + return self.create_limit_order( target_symbol=position.symbol, amount=position.get_amount(), order_side=OrderSide.SELL.value, @@ -844,6 +875,100 @@ def has_open_buy_orders(self, target_symbol, identifier=None, market=None): query_params["status"] = OrderStatus.OPEN.value return self.order_service.exists(query_params) + def get_sell_orders(self, target_symbol, identifier=None, market=None): + query_params = {} + + if identifier is not None: + portfolio = self.portfolio_service.find( + {"identifier": identifier} + ) + query_params["portfolio"] = portfolio.id + + if market is not None: + portfolio = self.portfolio_service.find( + {"market": market} + ) + query_params["portfolio"] = portfolio.id + + query_params["target_symbol"] = target_symbol + query_params["order_side"] = OrderSide.SELL.value + return self.order_service.get_all(query_params) + + def get_open_orders( + self, target_symbol=None, identifier=None, market=None + ) -> List[Order]: + """ + Function to get all open orders. This function will return all + open orders that match the specified query parameters. + + Args: + target_symbol (str): the symbol of the asset + identifier (str): the identifier of the portfolio + market (str): the market of the asset + + Returns: + List[Order]: A list of open orders that match the query parameters + """ + query_params = {} + + if identifier is not None: + portfolio = self.portfolio_service.find( + {"identifier": identifier} + ) + query_params["portfolio"] = portfolio.id + + if market is not None: + portfolio = self.portfolio_service.find( + {"market": market} + ) + query_params["portfolio"] = portfolio.id + + if target_symbol is not None: + query_params["target_symbol"] = target_symbol + + query_params["status"] = OrderStatus.OPEN.value + return self.order_service.get_all(query_params) + + def get_closed_orders( + self, target_symbol=None, identifier=None, market=None, order_side=None + ) -> List[Order]: + """ + Function to get all closed orders. This function will return all + closed orders that match the specified query parameters. + + Args: + target_symbol (str): the symbol of the asset + identifier (str): the identifier of the portfolio + market (str): the market of the asset + order_side (str): the side of the order + + Returns: + List[Order]: A list of closed orders that + match the query parameters + """ + query_params = {} + + if identifier is not None: + portfolio = self.portfolio_service.find( + {"identifier": identifier} + ) + query_params["portfolio"] = portfolio.id + + if order_side is not None: + query_params["order_side"] = order_side + + if market is not None: + portfolio = self.portfolio_service.find( + {"market": market} + ) + query_params["portfolio"] = portfolio.id + + if target_symbol is not None: + query_params["target_symbol"] = target_symbol + + query_params["status"] = OrderStatus.CLOSED.value + return self.order_service.get_all(query_params) + def has_open_sell_orders(self, target_symbol, identifier=None, market=None): query_params = {} @@ -1025,6 +1150,33 @@ def count_trades( return self.trade_service.count(query_params) + def get_pending_trades( + self, target_symbol=None, market=None + ) -> List[Trade]: + """ + Function to get all pending trades. This function will return all + pending trades that match the specified query parameters. If the + target_symbol parameter is specified, the pending trades with the + specified target symbol will be returned. If the market parameter + is specified, the pending trades with the specified market will be + returned. + + Args: + target_symbol: The symbol of the asset + market: The market of the asset + + Returns: + List[Trade]: A list of pending trades that match + the query parameters + """ + return self.trade_service.get_all( + { + "status": TradeStatus.CREATED.value, + "target_symbol": target_symbol, + "market": market + } + ) + def get_open_trades(self, target_symbol=None, market=None) -> List[Trade]: """ Function to get all open trades. This function will return all @@ -1157,7 +1309,7 @@ def close_trade(self, trade, precision=None) -> None: if TradeStatus.CLOSED.equals(trade.status): raise OperationalException("Trade already closed.") - if trade.remaining <= 0: + if trade.available_amount <= 0: raise OperationalException("Trade has no amount to close.") position_id = trade.orders[0].position_id @@ -1165,7 +1317,7 @@ def close_trade(self, trade, precision=None) -> None: position = self.position_service.find( {"portfolio": portfolio.id, "symbol": trade.target_symbol} ) - amount = trade.remaining + amount = trade.available_amount if precision is not None: amount = RoundingService.round_down(amount, precision) @@ -1183,6 +1335,7 @@ def close_trade(self, trade, precision=None) -> None: symbol=trade.symbol, market=portfolio.market ) + logger.info(f"Closing trade {trade.id} {trade.symbol}") self.order_service.create( { "portfolio_id": portfolio.id, diff --git a/investing_algorithm_framework/app/strategy.py b/investing_algorithm_framework/app/strategy.py index ce13e129..98007f33 100644 --- a/investing_algorithm_framework/app/strategy.py +++ b/investing_algorithm_framework/app/strategy.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Dict, Any import pandas as pd from datetime import datetime, timezone @@ -76,7 +76,8 @@ def __init__( # Check if time_unit is None if self.time_unit is None: raise OperationalException( - f"Time unit not set for strategy instance {self.strategy_id}" + f"Time unit attribute not set for " + f"strategy instance {self.strategy_id}" ) # Check if interval is None @@ -89,7 +90,31 @@ def __init__( self._context = None self._last_run = None - def run_strategy(self, context, market_data): + def run_strategy(self, context: Context, market_data: Dict[str, Any]): + """ + Main function for running your strategy. This function will be called + by the framework when the trigger of your strategy is met. + + During execution of this function, the context and market data + will be passed to the function. The context is an instance of + the Context class, this class has various methods to do operations + with your portfolio, orders, trades, positions and other components. + + The market data is a dictionary containing all the data retrieved + from the specified data sources. + + Args: + context (Context): The context of the strategy. This is an instance + of the Context class, this class has various methods to do + operations with your portfolio, orders, trades, positions and + other components. + market_data (Dict[str, Any]): The data for the strategy. + This is a dictionary containing all the data retrieved from the + specified data sources. + + Returns: + None + """ self.context = context config = self.context.get_config() diff --git a/investing_algorithm_framework/cli/initialize_app.py b/investing_algorithm_framework/cli/initialize_app.py index 06f4085b..8fd3d104 100644 --- a/investing_algorithm_framework/cli/initialize_app.py +++ b/investing_algorithm_framework/cli/initialize_app.py @@ -309,4 +309,3 @@ def command(path=None, app_type="default", replace=False): os.path.join(path, "README.md"), replace=replace ) - print("App initialized successfully.") diff --git a/investing_algorithm_framework/create_app.py b/investing_algorithm_framework/create_app.py index ef6fb497..7dc9af2b 100644 --- a/investing_algorithm_framework/create_app.py +++ b/investing_algorithm_framework/create_app.py @@ -23,6 +23,7 @@ def create_app( config (dict): Configuration dictionary web (bool): Whether to create a web app state_handler (StateHandler): State handler for the app + name (str): Name of the app Returns: App: App instance diff --git a/investing_algorithm_framework/dependency_container.py b/investing_algorithm_framework/dependency_container.py index 6f6e3262..30a9eb97 100644 --- a/investing_algorithm_framework/dependency_container.py +++ b/investing_algorithm_framework/dependency_container.py @@ -11,7 +11,8 @@ PositionService, PortfolioService, StrategyOrchestratorService, \ PortfolioConfigurationService, MarketDataSourceService, BacktestService, \ ConfigurationService, PortfolioSnapshotService, PositionSnapshotService, \ - MarketCredentialService, TradeService, PortfolioSyncService + MarketCredentialService, TradeService, PortfolioSyncService, \ + OrderExecutorLookup, PortfolioProviderLookup def setup_dependency_container(app, modules=None, packages=None): @@ -34,8 +35,14 @@ class DependencyContainer(containers.DeclarativeContainer): MarketCredentialService ) order_repository = providers.Factory(SQLOrderRepository) + order_executor_lookup = providers.ThreadSafeSingleton( + OrderExecutorLookup + ) order_metadata_repository = providers.Factory(SQLOrderMetadataRepository) position_repository = providers.Factory(SQLPositionRepository) + portfolio_provider_lookup = providers.ThreadSafeSingleton( + PortfolioProviderLookup, + ) portfolio_repository = providers.Factory(SQLPortfolioRepository) position_snapshot_repository = providers.Factory( SQLPositionSnapshotRepository @@ -72,12 +79,6 @@ class DependencyContainer(containers.DeclarativeContainer): portfolio_repository=portfolio_repository, position_repository=position_repository, ) - position_service = providers.Factory( - PositionService, - repository=position_repository, - market_service=market_service, - market_credential_service=market_credential_service, - ) trade_service = providers.Factory( TradeService, order_repository=order_repository, @@ -90,28 +91,34 @@ class DependencyContainer(containers.DeclarativeContainer): market_data_source_service=market_data_source_service, order_metadata_repository=order_metadata_repository, ) + position_service = providers.Factory( + PositionService, + portfolio_repository=portfolio_repository, + repository=position_repository, + ) order_service = providers.Factory( OrderService, configuration_service=configuration_service, order_repository=order_repository, portfolio_repository=portfolio_repository, - position_repository=position_repository, - market_service=market_service, + position_service=position_service, market_credential_service=market_credential_service, portfolio_configuration_service=portfolio_configuration_service, portfolio_snapshot_service=portfolio_snapshot_service, trade_service=trade_service, + order_executor_lookup=order_executor_lookup, + portfolio_provider_lookup=portfolio_provider_lookup ) portfolio_service = providers.Factory( PortfolioService, configuration_service=configuration_service, market_credential_service=market_credential_service, - market_service=market_service, order_service=order_service, position_service=position_service, portfolio_repository=portfolio_repository, portfolio_configuration_service=portfolio_configuration_service, portfolio_snapshot_service=portfolio_snapshot_service, + portfolio_provider_lookup=portfolio_provider_lookup ) portfolio_sync_service = providers.Factory( PortfolioSyncService, @@ -123,6 +130,7 @@ class DependencyContainer(containers.DeclarativeContainer): portfolio_configuration_service=portfolio_configuration_service, market_credential_service=market_credential_service, market_service=market_service, + portfolio_provider_lookup=portfolio_provider_lookup, ) strategy_orchestrator_service = providers.Factory( StrategyOrchestratorService, diff --git a/investing_algorithm_framework/domain/__init__.py b/investing_algorithm_framework/domain/__init__.py index 192475cb..fc26ed1d 100644 --- a/investing_algorithm_framework/domain/__init__.py +++ b/investing_algorithm_framework/domain/__init__.py @@ -19,7 +19,8 @@ BacktestReport, PortfolioSnapshot, StrategyProfile, \ BacktestPosition, Trade, MarketCredential, PositionSnapshot, \ BacktestReportsEvaluation, AppMode, BacktestDateRange, DateRange, \ - MarketDataType, TradeRiskType, TradeTakeProfit, TradeStopLoss + MarketDataType, TradeRiskType, TradeTakeProfit, TradeStopLoss, \ + DataSource from .services import TickerMarketDataSource, OrderBookMarketDataSource, \ OHLCVMarketDataSource, BacktestMarketDataSource, MarketDataSource, \ MarketService, MarketCredentialService, AbstractPortfolioSyncService, \ @@ -28,13 +29,15 @@ from .strategy import Strategy from .utils import random_string, append_dict_as_row_to_csv, \ add_column_headers_to_csv, get_total_amount_of_rows, \ - load_backtest_report, convert_polars_to_pandas, \ + load_backtest_report, convert_polars_to_pandas, random_number, \ csv_to_list, StoppableThread, pretty_print_backtest_reports_evaluation, \ pretty_print_backtest, load_csv_into_dict, load_backtest_reports, \ get_backtest_report, pretty_print_positions, pretty_print_trades, \ pretty_print_orders from .metrics import get_price_efficiency_ratio from .data_provider import DataProvider +from .order_executor import OrderExecutor +from .portfolio_provider import PortfolioProvider __all__ = [ "OrderStatus", @@ -133,5 +136,9 @@ "pretty_print_trades", "pretty_print_orders", "DataProvider", - "NetworkError" + "NetworkError", + "DataSource", + "OrderExecutor", + "PortfolioProvider", + "random_number" ] diff --git a/investing_algorithm_framework/domain/data_provider.py b/investing_algorithm_framework/domain/data_provider.py index b528cbb3..aa0a7606 100644 --- a/investing_algorithm_framework/domain/data_provider.py +++ b/investing_algorithm_framework/domain/data_provider.py @@ -43,12 +43,12 @@ def __init__( self, data_type: str, symbol: str = None, + market: str = None, markets: list = None, priority: int = 0, time_frame=None, window_size=None, storage_path=None, - market_credentials: List = None, ): """ Initializes the DataProvider with data type, symbols, and markets. @@ -63,11 +63,12 @@ def __init__( self.time_frame = TimeFrame.from_value(time_frame) self.symbol = symbol + self.market = market self.markets = markets self.priority = priority self.window_size = window_size self.storage_path = storage_path - self.market_credentials = market_credentials + self._market_credentials = None @property def data_type(self): @@ -85,6 +86,20 @@ def time_frame(self): def time_frame(self, value): self._time_frame = TimeFrame.from_value(value) + @property + def market_credentials(self): + """ + Returns the market credentials for the data provider. + """ + return self._market_credentials + + @market_credentials.setter + def market_credentials(self, value: List): + """ + Sets the market credentials for the data provider. + """ + self._market_credentials = value + def get_credential(self, market: str): """ Returns the credentials for the given market. @@ -114,7 +129,7 @@ def has_data( start_date: datetime = None, end_date: datetime = None, window_size=None, - ) -> None: + ) -> bool: """ Checks if the data provider has data for the given parameters. """ diff --git a/investing_algorithm_framework/domain/models/__init__.py b/investing_algorithm_framework/domain/models/__init__.py index e67bb8c6..3a96bc5d 100644 --- a/investing_algorithm_framework/domain/models/__init__.py +++ b/investing_algorithm_framework/domain/models/__init__.py @@ -15,6 +15,7 @@ from .trading_time_frame import TradingTimeFrame from .date_range import DateRange from .market_data_type import MarketDataType +from .data_source import DataSource __all__ = [ "OrderStatus", @@ -45,4 +46,5 @@ "TradeStopLoss", "TradeTakeProfit", "TradeRiskType", + "DataSource" ] diff --git a/investing_algorithm_framework/domain/models/backtesting/backtest_report.py b/investing_algorithm_framework/domain/models/backtesting/backtest_report.py index b1241692..bc0ce4eb 100644 --- a/investing_algorithm_framework/domain/models/backtesting/backtest_report.py +++ b/investing_algorithm_framework/domain/models/backtesting/backtest_report.py @@ -218,15 +218,25 @@ def percentage_negative_trades(self, value): @property def number_of_trades_closed(self): - return self._number_of_trades_closed + closed_trades = self.get_trades( + trade_status=TradeStatus.CLOSED.value + ) + return len(closed_trades) @number_of_trades_closed.setter def number_of_trades_closed(self, value): self._number_of_trades_closed = value + @property + def number_of_trades(self): + return len(self._trades) + @property def number_of_trades_open(self): - return self._number_of_trades_open + open_trades = self.get_trades( + trade_status=TradeStatus.OPEN.value + ) + return len(open_trades) @number_of_trades_open.setter def number_of_trades_open(self, value): diff --git a/investing_algorithm_framework/domain/models/data_source.py b/investing_algorithm_framework/domain/models/data_source.py new file mode 100644 index 00000000..b5fd1d71 --- /dev/null +++ b/investing_algorithm_framework/domain/models/data_source.py @@ -0,0 +1,21 @@ +class DataSource: + """ + Base class for data sources. + """ + + def __init__( + self, + data_type: str, + symbol: str, + market: str, + time_frame: str, + window_size: int, + key: str, name: str + ): + self.name = name + self.key = key + self.data_type = data_type + self.symbol = symbol + self.market = market + self.time_frame = time_frame + self.window_size = window_size diff --git a/investing_algorithm_framework/domain/models/order/order.py b/investing_algorithm_framework/domain/models/order/order.py index 046a6606..667a4945 100644 --- a/investing_algorithm_framework/domain/models/order/order.py +++ b/investing_algorithm_framework/domain/models/order/order.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime, timezone from dateutil.parser import parse @@ -23,8 +24,8 @@ def __init__( self, order_type, order_side, - status, amount, + status=OrderStatus.CREATED.value, target_symbol=None, trading_symbol=None, price=None, @@ -59,6 +60,15 @@ def __init__( if status is None: raise OperationalException("Status is not set") + self.created_at = created_at + self.updated_at = updated_at + + if self.created_at is None: + self.created_at = datetime.now(tz=timezone.utc) + + if self.updated_at is None: + self.updated_at = datetime.now(tz=timezone.utc) + self.external_id = external_id self.price = price self.order_side = OrderSide.from_value(order_side).value @@ -66,8 +76,6 @@ def __init__( self.status = OrderStatus.from_value(status).value self.position_id = position_id self.amount = amount - self.created_at = created_at - self.updated_at = updated_at self.filled = filled self.remaining = remaining self.fee = fee @@ -166,7 +174,7 @@ def set_filled(self, filled): def get_remaining(self): if self.remaining is None: - return self.amount + return self.get_amount() - self.get_filled() return self.remaining @@ -180,7 +188,8 @@ def set_fee(self, order_fee): self.fee = order_fee def get_symbol(self): - return self.get_target_symbol() + "/" + self.get_trading_symbol() + return (self.get_target_symbol().upper() + "/" + + self.get_trading_symbol().upper()) def get_available_amount(self): diff --git a/investing_algorithm_framework/domain/models/trade/trade.py b/investing_algorithm_framework/domain/models/trade/trade.py index 323423e0..07cc4ad2 100644 --- a/investing_algorithm_framework/domain/models/trade/trade.py +++ b/investing_algorithm_framework/domain/models/trade/trade.py @@ -26,22 +26,23 @@ class Trade(BaseModel): buy order can be closed by multiple sell orders. Attributes: - id (int): the id of the trade - orders (List[Order]): the orders of the trade - target_symbol (str): the target symbol of the trade - trading_symbol (str): the trading symbol of the trade - closed_at (datetime): the datetime when the trade was closed - opened_at (datetime): the datetime when the trade was opened - open_price (float): the open price of the trade - amount (float): the amount of the trade - remaining (float): the remaining amount of the trade - net_gain (float): the net gain of the trade - last_reported_price (float): the last reported price of the trade - created_at (datetime): the datetime when the trade was created - updated_at (datetime): the datetime when the trade was last updated - status (str): the status of the trade - stop_losses (List[TradeStopLoss]): the stop losses of the trade - take_profits (List[TradeTakeProfit]): the take profits of the trade + orders: str, the id of the buy order + target_symbol: str, the target symbol of the trade + trading_symbol: str, the trading symbol of the trade + closed_at: datetime, the datetime when the trade was closed + amount: float, the amount of the trade + available_amount: float, the available amount of the trade + remaining: float, the remaining amount that is not filled by the + buy order that opened the trade. + filled_amount: float, the filled amount of the trade by the buy + order that opened the trade. + net_gain: float, the net gain of the trade + last_reported_price: float, the last reported price of the trade + last_reported_price_datetime: datetime, the datetime when the last + reported price was reported + created_at: datetime, the datetime when the trade was created + updated_at: datetime, the datetime when the trade was last updated + status: str, the status of the trade """ def __init__( @@ -54,8 +55,10 @@ def __init__( opened_at, open_price, amount, + available_amount, cost, remaining, + filled_amount, status, net_gain=0, last_reported_price=None, @@ -74,8 +77,10 @@ def __init__( self.opened_at = opened_at self.open_price = open_price self.amount = amount + self.available_amount = available_amount self.cost = cost self.remaining = remaining + self.filled_amount = filled_amount self.net_gain = net_gain self.last_reported_price = last_reported_price self.last_reported_price_datetime = last_reported_price_datetime @@ -397,6 +402,8 @@ def from_dict(data): open_price=data["open_price"], opened_at=opened_at, closed_at=closed_at, + filled_amount=data.get("filled_amount", 0), + available_amount=data.get("available_amount", 0), remaining=data.get("remaining", 0), net_gain=data.get("net_gain", 0), last_reported_price=data.get("last_reported_price"), @@ -414,6 +421,7 @@ def __repr__(self): trading_symbol=self.trading_symbol, status=self.status, amount=self.amount, + available_amount=self.available_amount, filled_amount=self.filled_amount, remaining=self.remaining, open_price=self.open_price, diff --git a/investing_algorithm_framework/domain/models/trade/trade_stop_loss.py b/investing_algorithm_framework/domain/models/trade/trade_stop_loss.py index b688bde5..58cf4048 100644 --- a/investing_algorithm_framework/domain/models/trade/trade_stop_loss.py +++ b/investing_algorithm_framework/domain/models/trade/trade_stop_loss.py @@ -56,7 +56,7 @@ def __init__( sell_percentage: float = 100, active: bool = True, sell_prices: str = None, - sell_price_dates: str = None, + sell_dates: str = None, high_water_mark_date: str = None, ): self.trade_id = trade_id @@ -72,7 +72,7 @@ def __init__( self.sold_amount = 0 self.active = active self.sell_prices = sell_prices - self.sell_price_dates = sell_price_dates + self.sell_dates = sell_dates def update_with_last_reported_price(self, current_price: float, date): """ @@ -140,7 +140,7 @@ def get_sell_amount(self) -> float: sell percentage and the remaining amount of the trade. Keep in mind the moment the take profit triggers, the remaining amount of the trade is used to calculate the sell amount. - If the remaining amount is smaller then the trade amount, the + If the remaining amount is smaller than the trade amount, the trade stop loss stays active. The client that uses the trade stop loss is responsible for setting the trade stop loss to inactive. @@ -155,6 +155,61 @@ def get_sell_amount(self) -> float: return self.sell_amount - self.sold_amount + def add_sell_price(self, price: float, date: str): + """ + Function to add a sell price to the list of sell prices. + The sell price is added to the list of sell prices and the + date is added to the list of sell dates. + + Args: + price: float - the price at which the trade was sold + date: str - the date at which the trade was sold + + Returns: + None + """ + if self.sell_prices is None: + self.sell_prices = str(price) + self.sell_dates = str(date) + else: + self.sell_prices += f", {price}" + self.sell_dates += f", {date}" + + def remove_sell_price(self, price: float, date: str): + """ + Function to remove a sell price from the list of sell prices. + The sell price is removed from the list of sell prices and the + date is removed from the list of sell dates. + + Args: + price: float - the price at which the trade was sold + date: str - the date at which the trade was sold + + Returns: + None + """ + if self.sell_prices is not None: + + # Split the sell prices into a list and convert to float + sell_prices_list = self.sell_prices.split(", ") + sell_prices_list = [float(p) for p in sell_prices_list] + + if price in sell_prices_list: + sell_prices_list.remove(price) + self.sell_prices = ", ".join(sell_prices_list) + + if self.sell_prices == "": + self.sell_prices = None + + # Split the sell dates into a list + sell_dates_list = self.sell_dates.split(", ") + if date in sell_dates_list: + sell_dates_list.remove(date) + self.sell_dates = ", ".join(sell_dates_list) + else: + self.sell_prices = None + self.sell_dates = None + def to_dict(self, datetime_format=None): return { "trade_id": self.trade_id, diff --git a/investing_algorithm_framework/domain/models/trade/trade_take_profit.py b/investing_algorithm_framework/domain/models/trade/trade_take_profit.py index c9aeeda4..189437c6 100644 --- a/investing_algorithm_framework/domain/models/trade/trade_take_profit.py +++ b/investing_algorithm_framework/domain/models/trade/trade_take_profit.py @@ -56,7 +56,7 @@ def __init__( sell_percentage: float = 100, active: bool = True, sell_prices: str = None, - sell_price_dates: str = None, + sell_dates: str = None, high_water_mark_date: str = None, ): self.trade_id = trade_id @@ -72,7 +72,7 @@ def __init__( self.sold_amount = 0 self.active = active self.sell_prices = sell_prices - self.sell_price_dates = sell_price_dates + self.sell_dates = sell_dates def update_with_last_reported_price(self, current_price: float, date): """ @@ -158,7 +158,7 @@ def has_triggered(self, current_price: float = None) -> bool: if current_price < self.take_profit_price: return True - # Increase the high water mark and take profit price + # Increase the high watermark and take profit price elif current_price > self.high_water_mark: self.high_water_mark = current_price new_take_profit_price = self.high_water_mark * \ @@ -178,7 +178,7 @@ def get_sell_amount(self) -> float: sell percentage and the remaining amount of the trade. Keep in mind the moment the take profit triggers, the remaining amount of the trade is used to calculate the sell amount. - If the remaining amount is smaller then the trade amount, the + If the remaining amount is smaller than the trade amount, the trade stop loss stays active. The client that uses the trade stop loss is responsible for setting the trade stop loss to inactive. @@ -193,6 +193,61 @@ def get_sell_amount(self) -> float: return self.sell_amount - self.sold_amount + def add_sell_price(self, price: float, date: str): + """ + Function to add a sell price to the list of sell prices. + The sell price is added to the list of sell prices and the + date is added to the list of sell dates. + + Args: + price: float - the price at which the trade was sold + date: str - the date at which the trade was sold + + Returns: + None + """ + if self.sell_prices is None: + self.sell_prices = str(price) + self.sell_dates = str(date) + else: + self.sell_prices += f", {price}" + self.sell_dates += f", {date}" + + def remove_sell_price(self, price: float, date: str): + """ + Function to remove a sell price from the list of sell prices. + The sell price is removed from the list of sell prices and the + date is removed from the list of sell dates. + + Args: + price: float - the price at which the trade was sold + date: str - the date at which the trade was sold + + Returns: + None + """ + if self.sell_prices is not None: + + # Split the sell prices into a list and convert to float + sell_prices_list = self.sell_prices.split(", ") + sell_prices_list = [float(p) for p in sell_prices_list] + + if price in sell_prices_list: + sell_prices_list.remove(price) + self.sell_prices = ", ".join(sell_prices_list) + + if self.sell_prices == "": + self.sell_prices = None + + # Split the sell dates into a list + sell_dates_list = self.sell_dates.split(", ") + if date in sell_dates_list: + sell_dates_list.remove(date) + self.sell_dates = ", ".join(sell_dates_list) + else: + self.sell_prices = None + self.sell_dates = None + def to_dict(self, datetime_format=None): return { "trade_id": self.trade_id, diff --git a/investing_algorithm_framework/domain/order_executor.py b/investing_algorithm_framework/domain/order_executor.py index 8f788d01..7196d5b9 100644 --- a/investing_algorithm_framework/domain/order_executor.py +++ b/investing_algorithm_framework/domain/order_executor.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod +from investing_algorithm_framework.domain import Order + class OrderExecutor(ABC): """ @@ -7,56 +9,78 @@ class OrderExecutor(ABC): responsible for executing orders in a trading algorithm. Attributes: - market (str): The market in which the order will be executed - + _priority (int): The priority of the order executor compared to + other order executors. The lower the number, the higher the + priority. The framework will use this priority when searching + for an order executor for a specific market. """ + def __init__(self, priority=1): + self._priority = priority + @property - def market_credentials(self): + def priority(self): """ - Returns the market credentials for the order executor. - - Returns: - dict: A dictionary containing the market credentials. + Returns the priority of the order executor. """ - return self._market_credentials + return self._priority - @market_credentials.setter - def market_credentials(self, credentials): + @abstractmethod + def execute_order(self, portfolio, order, market_credential) -> Order: """ - Sets the market credentials for the order executor. + Executes an order for a given portfolio. The order executor should + create an order on the exchange or broker and return an order object + that reflects the order on the exchange or broker. This should be + done by setting the external_id of the order to the id of the order + on the exchange or broker. + + !Important: This function should not throw an exception if the order + is not successfully executed. Instead, it should return an order + instance with the status set to OrderStatus.FAILED. Args: - value (dict): A dictionary containing the market credentials. + order: The order to be executed + portfolio: The portfolio in which the order will be executed + market_credential: The market credential to use for the order + + Returns: + Order: Instance of the executed order. The order instance + should copy the id of the order that has been provided as a """ - self._market_credentials = credentials + raise NotImplementedError( + "Subclasses must implement this method." + ) @abstractmethod - def execute_order(self, order): + def cancel_order(self, portfolio, order, market_credential) -> Order: """ - Executes an order. + Cancels an order for a given portfolio. The order executor should + cancel the order on the exchange or broker and return an order + object that reflects the order on the exchange or broker. Args: - order: The order to be executed - + order: The order to be canceled + portfolio: The portfolio in which the order was executed + market_credential: The market credential to use for the order Returns: - Order: Instance of the executed order. + Order: Instance of the canceled order. """ raise NotImplementedError( "Subclasses must implement this method." ) @abstractmethod - def cancel_order(self, order): + def supports_market(self, market): """ - Cancels an order. + Checks if the order executor supports the given market. Args: - order: The order to be canceled + market: The market to check Returns: - Order: Instance of the canceled order. + bool: True if the order executor supports the market, False + otherwise. """ raise NotImplementedError( "Subclasses must implement this method." diff --git a/investing_algorithm_framework/domain/portfolio_provider.py b/investing_algorithm_framework/domain/portfolio_provider.py index 13bb473f..873b9802 100644 --- a/investing_algorithm_framework/domain/portfolio_provider.py +++ b/investing_algorithm_framework/domain/portfolio_provider.py @@ -1,5 +1,8 @@ +from typing import Union from abc import ABC, abstractmethod +from investing_algorithm_framework.domain import Order, Position + class PortfolioProvider(ABC): """ @@ -7,56 +10,80 @@ class PortfolioProvider(ABC): is responsible for managing and providing access to trading portfolios. Attributes: - portfolio_id (str): The unique identifier for the portfolio. - user_id (str): The unique identifier for the user associated - with the portfolio. - balance (float): The current balance of the portfolio. - assets (dict): A dictionary containing the assets in the - portfolio and their quantities. + priority (int): The priority of the portfolio provider compared to + other portfolio providers. The lower the number, the higher the + priority. The framework will use this priority when searching + for a portfolio provider for a specific symbol or market. """ - @abstractmethod - def get_order(self, order_id: str): - """ - Fetches an order by its ID. + def __init__(self, priority=1): + self._priority = priority - Args: - order_id (str): The unique identifier for the order. - - Returns: - Order: The order object. + @property + def priority(self): + """ + Returns the priority of the portfolio provider. """ - pass + return self._priority @abstractmethod - def get_orders(self): + def get_order( + self, portfolio, order, market_credential + ) -> Union[Order, None]: """ - Fetches all orders in the portfolio. + Function to get an order from the exchange or broker. The returned + should be an order object that reflects the current state of the + order on the exchange or broker. + + !IMPORTANT: This function should return None if the order is + not found or if the order is not available on the + exchange or broker. Please do not throw an exception if the + order is not found. + + Args: + portfolio: Portfolio object + order: Order object from the database + market_credential: Market credential object Returns: - List[Order]: A list of order objects. + Order: Order object reflecting the order on the exchange or broker """ - pass + raise NotImplementedError("Subclasses must implement this method.") @abstractmethod - def get_position(self, position_id: str): + def get_position( + self, portfolio, symbol, market_credential + ) -> Union[Position, None]: """ - Fetches a position by its ID. + Function to get the position for a given symbol in the portfolio. + The returned position should be an object that reflects the current + state of the position on the exchange or broker. + + !IMPORTANT: This function should return None if the position is + not found or if the position is not available on the + exchange or broker. Please do not throw an exception if the + position is not found. Args: - position_id (str): The unique identifier for the position. + portfolio: Portfolio object + symbol: Symbol object + market_credential: MarketCredential object Returns: - Position: The position object. + float: Position for the given symbol in the portfolio """ - pass + raise NotImplementedError("Subclasses must implement this method.") @abstractmethod - def get_positions(self): + def supports_market(self, market) -> bool: """ - Fetches all positions in the portfolio. + Function to check if the market is supported by the portfolio + provider. + + Args: + market: Market object Returns: - List[Position]: A list of position objects. + bool: True if the market is supported, False otherwise """ - pass + raise NotImplementedError("Subclasses must implement this method.") diff --git a/investing_algorithm_framework/domain/services/portfolios/portfolio_sync_service.py b/investing_algorithm_framework/domain/services/portfolios/portfolio_sync_service.py index c6784683..291f6f62 100644 --- a/investing_algorithm_framework/domain/services/portfolios/portfolio_sync_service.py +++ b/investing_algorithm_framework/domain/services/portfolios/portfolio_sync_service.py @@ -5,11 +5,5 @@ class AbstractPortfolioSyncService: def sync_unallocated(self, portfolio): pass - def sync_positions(self, portfolio): - pass - def sync_orders(self, portfolio): pass - - def sync_trades(self, portfolio): - pass diff --git a/investing_algorithm_framework/domain/utils/__init__.py b/investing_algorithm_framework/domain/utils/__init__.py index 514b523f..5f1ba6c1 100644 --- a/investing_algorithm_framework/domain/utils/__init__.py +++ b/investing_algorithm_framework/domain/utils/__init__.py @@ -4,7 +4,7 @@ pretty_print_orders from .csv import get_total_amount_of_rows, append_dict_as_row_to_csv, \ add_column_headers_to_csv, csv_to_list, load_csv_into_dict -from .random import random_string +from .random import random_string, random_number from .stoppable_thread import StoppableThread from .synchronized import synchronized from .polars import convert_polars_to_pandas @@ -13,6 +13,7 @@ 'synchronized', 'StoppableThread', 'random_string', + 'random_number', 'get_total_amount_of_rows', 'append_dict_as_row_to_csv', 'add_column_headers_to_csv', diff --git a/investing_algorithm_framework/domain/utils/backtesting.py b/investing_algorithm_framework/domain/utils/backtesting.py index 7ecb80cf..c80a9133 100644 --- a/investing_algorithm_framework/domain/utils/backtesting.py +++ b/investing_algorithm_framework/domain/utils/backtesting.py @@ -185,28 +185,39 @@ def get_stop_loss_price(take_profit): if trade.stop_losses is not None: for stop_loss in trade.stop_losses: - - selection += [ - { - "symbol": trade.symbol, - "target_symbol": trade.target_symbol, - "trading_symbol": trade.trading_symbol, - "status": get_status(stop_loss), - "trade_id": stop_loss.trade_id, - "trade_risk_type": stop_loss.trade_risk_type, - "percentage": stop_loss.percentage, - "open_price": stop_loss.open_price, - "sell_percentage": stop_loss.sell_percentage, - "high_water_mark": stop_loss.high_water_mark, - "high_water_mark_date": \ - stop_loss.high_water_mark_date, - "stop_loss_price": stop_loss.stop_loss_price, - "sell_amount": stop_loss.sell_amount, - "sold_amount": stop_loss.sold_amount, - "active": stop_loss.active, - "sell_prices": stop_loss.sell_prices - } for stop_loss in trade.stop_losses - ] + data = { + "symbol": trade.symbol, + "target_symbol": trade.target_symbol, + "trading_symbol": trade.trading_symbol, + "status": get_status(stop_loss), + "trade_id": stop_loss.trade_id, + "trade_risk_type": stop_loss.trade_risk_type, + "percentage": stop_loss.percentage, + "open_price": stop_loss.open_price, + "sell_percentage": stop_loss.sell_percentage, + "stop_loss_price": stop_loss.stop_loss_price, + "sell_amount": stop_loss.sell_amount, + "sold_amount": stop_loss.sold_amount, + "active": stop_loss.active, + } + + if hasattr(stop_loss, "sell_prices"): + data["sell_prices"] = stop_loss.sell_prices + else: + data["sell_prices"] = None + + if hasattr(stop_loss, "high_water_mark"): + data["high_water_mark"] = stop_loss.high_water_mark + + if hasattr(stop_loss, "high_water_mark_date"): + data["high_water_mark_date"] = \ + stop_loss.high_water_mark_date + else: + data["high_water_mark_date"] = None + else: + data["high_water_mark"] = None + data["high_water_mark_date"] = None + selection.append(data) stop_loss_table["Trade (Trade id)"] = [ f"{stop_loss['symbol'] + ' (' + str(stop_loss['trade_id']) + ')'}" @@ -230,7 +241,7 @@ def get_stop_loss_price(take_profit): f"{float(stop_loss['open_price']):.{price_precision}f} {stop_loss['trading_symbol']}" for stop_loss in selection if stop_loss['open_price'] is not None ] stop_loss_table["Sell price's"] = [ - f"{stop_loss['sell_prices']}" for stop_loss in selection + f"{stop_loss['sell_prices']}" for stop_loss in selection if stop_loss['sell_prices'] is not None ] stop_loss_table["High water mark"] = [ f"{get_high_water_mark(stop_loss)}" for stop_loss in selection @@ -323,8 +334,9 @@ def get_status(take_profit): for trade in trades: if trade.take_profits is not None: - selection += [ - { + + for take_profit in trade.take_profits: + data = { "symbol": trade.symbol, "target_symbol": trade.target_symbol, "trading_symbol": trade.trading_symbol, @@ -334,16 +346,30 @@ def get_status(take_profit): "percentage": take_profit.percentage, "open_price": take_profit.open_price, "sell_percentage": take_profit.sell_percentage, - "high_water_mark": take_profit.high_water_mark, - "high_water_mark_date": \ - take_profit.high_water_mark_date, "take_profit_price": take_profit.take_profit_price, "sell_amount": take_profit.sell_amount, "sold_amount": take_profit.sold_amount, - "active": take_profit.active, - "sell_prices": take_profit.sell_prices - } for take_profit in trade.take_profits - ] + "active": take_profit.active + } + + if hasattr(take_profit, "sell_prices"): + data["sell_prices"] = take_profit.sell_prices + else: + data["sell_prices"] = None + + if hasattr(take_profit, "high_water_mark"): + data["high_water_mark"] = take_profit.high_water_mark + + if hasattr(take_profit, "high_water_mark_date"): + data["high_water_mark_date"] = \ + take_profit.high_water_mark_date + else: + data["high_water_mark_date"] = None + else: + data["high_water_mark"] = None + data["high_water_mark_date"] = None + + selection.append(data) take_profit_table["Trade (Trade id)"] = [ f"{stop_loss['symbol'] + ' (' + str(stop_loss['trade_id']) + ')'}" @@ -925,7 +951,6 @@ def pretty_print_backtest( Returns: None """ - ascii_art = f""" :%%%#+- .=*#%%% {COLOR_GREEN}Backtest report{COLOR_RESET} *%%%%%%%+------=*%%%%%%%- {COLOR_GREEN}---------------------------{COLOR_RESET} @@ -936,14 +961,15 @@ def pretty_print_backtest( .:-=*%%%%. {COLOR_PURPLE}+={COLOR_RESET} .%%# {COLOR_PURPLE}-+.-{COLOR_RESET}%%%%=-:.. {COLOR_YELLOW}Number of orders:{COLOR_RESET}{COLOR_GREEN} {backtest_report.number_of_orders}{COLOR_RESET} .:=+#%%%%%*###%%%%#*+#%%%%%%*+-: {COLOR_YELLOW}Initial balance:{COLOR_RESET}{COLOR_GREEN} {backtest_report.initial_unallocated}{COLOR_RESET} +%%%%%%%%%%%%%%%%%%%= {COLOR_YELLOW}Final balance:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.total_value):.{price_precision}f}{COLOR_RESET} - :++ .=#%%%%%%%%%%%%%*- {COLOR_YELLOW}Total net gain:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.total_net_gain):.{price_precision}f} {float(backtest_report.total_net_gain_percentage):.{percentage_precision}}%{COLOR_RESET} + :++ .=#%%%%%%%%%%%%%*- {COLOR_YELLOW}Total net gain:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.total_net_gain):.{price_precision}f} {float(backtest_report.total_net_gain_percentage):.{percentage_precision}f}%{COLOR_RESET} :++: :+%%%%%%#-. {COLOR_YELLOW}Growth:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.growth):.{price_precision}f} {float(backtest_report.growth_rate):.{percentage_precision}f}%{COLOR_RESET} + :++: .%%%%%#= {COLOR_YELLOW}Number of trades:{COLOR_RESET}{COLOR_GREEN} {backtest_report.number_of_trades}{COLOR_RESET} :++: .%%%%%#= {COLOR_YELLOW}Number of trades closed:{COLOR_RESET}{COLOR_GREEN} {backtest_report.number_of_trades_closed}{COLOR_RESET} :++: .#%%%%%#*= {COLOR_YELLOW}Number of trades open(end of backtest):{COLOR_RESET}{COLOR_GREEN} {backtest_report.number_of_trades_open}{COLOR_RESET} - :++- :%%%%%%%%%+= {COLOR_YELLOW}Percentage positive trades:{COLOR_RESET}{COLOR_GREEN} {backtest_report.percentage_positive_trades}%{COLOR_RESET} - .++- -%%%%%%%%%%%+= {COLOR_YELLOW}Percentage negative trades:{COLOR_RESET}{COLOR_GREEN} {backtest_report.percentage_negative_trades}%{COLOR_RESET} + :++- :%%%%%%%%%+= {COLOR_YELLOW}Percentage positive trades:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.percentage_positive_trades):.{percentage_precision}f}%{COLOR_RESET} + .++- -%%%%%%%%%%%+= {COLOR_YELLOW}Percentage negative trades:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.percentage_negative_trades):.{percentage_precision}f}%{COLOR_RESET} .++- .%%%%%%%%%%%%%+= {COLOR_YELLOW}Average trade size:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.average_trade_size):.{price_precision}f} {backtest_report.trading_symbol}{COLOR_RESET} - .++- *%%%%%%%%%%%%%*+: {COLOR_YELLOW}Average trade duration:{COLOR_RESET}{COLOR_GREEN} {backtest_report.average_trade_duration} hours{COLOR_RESET} + .++- *%%%%%%%%%%%%%*+: {COLOR_YELLOW}Average trade duration:{COLOR_RESET}{COLOR_GREEN} {float(backtest_report.average_trade_duration):.{0}f} hours{COLOR_RESET} .++- %%%%%%%%%%%%%%#+= =++........:::%%%%%%%%%%%%%%*+- .=++++++++++**#%%%%%%%%%%%%%++. diff --git a/investing_algorithm_framework/domain/utils/random.py b/investing_algorithm_framework/domain/utils/random.py index 940eaa94..596af312 100644 --- a/investing_algorithm_framework/domain/utils/random.py +++ b/investing_algorithm_framework/domain/utils/random.py @@ -3,6 +3,16 @@ def random_string(n, spaces: bool = False): + """ + Function to generate a random string of n characters. + + Args: + n: number of characters + spaces: if True, include spaces in the string + + Returns: + str: Random string of n characters + """ if spaces: return ''.join( @@ -10,3 +20,22 @@ def random_string(n, spaces: bool = False): ) return ''.join(random.choice(string.ascii_lowercase) for _ in range(n)) + + +def random_number(n, variable_size: bool = False): + """ + Function to generate a random number of n digits. + + Args: + n: number of digits + variable_size: if True, the number of digits will be variable + between 1 and n + + Returns: + int: Random number of n digits + """ + + if variable_size: + n = random.randint(1, n) + + return int(''.join(random.choice(string.digits) for _ in range(n))) diff --git a/investing_algorithm_framework/download_data.py b/investing_algorithm_framework/download_data.py index a51d4862..f4c093fe 100644 --- a/investing_algorithm_framework/download_data.py +++ b/investing_algorithm_framework/download_data.py @@ -34,6 +34,10 @@ def download( pandas (bool): Whether to return the data as a pandas DataFrame. save (bool): Whether to save the downloaded data. storage_path (str): The directory to save the downloaded data. + time_frame (str): The time frame for the data download. + date (str): The date for the data download. + window_size (int): The size of the data window. + pandas (bool): Whether to return the data as a pandas DataFrame. Returns:w None diff --git a/investing_algorithm_framework/infrastructure/__init__.py b/investing_algorithm_framework/infrastructure/__init__.py index f449d741..18b2daa1 100644 --- a/investing_algorithm_framework/infrastructure/__init__.py +++ b/investing_algorithm_framework/infrastructure/__init__.py @@ -14,6 +14,8 @@ from .services import PerformanceService, CCXTMarketService, \ AzureBlobStorageStateHandler from .data_providers import CCXTDataProvider, get_default_data_providers +from .order_executors import CCXTOrderExecutor +from .portfolio_providers import CCXTPortfolioProvider __all__ = [ "create_all_tables", @@ -48,5 +50,7 @@ "SQLTradeStopLossRepository", "SQLOrderMetadataRepository", "CCXTDataProvider", - "get_default_data_providers" + "CCXTOrderExecutor", + "CCXTPortfolioProvider", + "get_default_data_providers", ] diff --git a/investing_algorithm_framework/infrastructure/data_providers/csv.py b/investing_algorithm_framework/infrastructure/data_providers/csv.py new file mode 100644 index 00000000..698a1d60 --- /dev/null +++ b/investing_algorithm_framework/infrastructure/data_providers/csv.py @@ -0,0 +1,257 @@ +import polars +from datetime import datetime +from investing_algorithm_framework.domain import DataProvider, \ + TradingDataType, OperationalException + + +class CSVOHLCVDataProvider(DataProvider): + """ + Implementation of Data Provider for OHLCV data. + """ + def __init__( + self, + file_path: str, + symbol: str, + time_frame: str, + market: str = None, + priority: int = 0, + window_size=None, + storage_path=None, + ): + """ + Initialize the CSV Data Provider. + + Args: + file_path (str): Path to the CSV file. + """ + + super().__init__( + data_type=TradingDataType.OHLCV.value, + symbol=symbol, + market=market, + markets=[], + priority=priority, + time_frame=time_frame, + window_size=window_size, + storage_path=storage_path, + ) + self.file_path = file_path + self._start_date_data_source = None + self._end_date_data_source = None + self.data = None + + def has_data( + self, + data_type: str = None, + symbol: str = None, + market: str = None, + time_frame: str = None, + start_date: datetime = None, + end_date: datetime = None, + window_size=None + ) -> bool: + + if symbol == self.symbol and market == self.market and \ + data_type == self.data_type and time_frame == self.time_frame: + return True + + return False + + def get_data( + self, + data_type: str = None, + date: datetime = None, + symbol: str = None, + market: str = None, + time_frame: str = None, + start_date: datetime = None, + end_date: datetime = None, + storage_path=None, + window_size=None, + pandas=False + ): + + if self.data is None: + self._load_data(self.file_path) + + if start_date is None and end_date is None: + return self.data + + if end_date is not None and start_date is not None: + + if end_date < start_date: + raise OperationalException( + f"End date {end_date} is before the start date " + f"{start_date}" + ) + + if start_date > self._end_date_data_source: + return polars.DataFrame() + + df = self.data + df = df.filter( + (df['Datetime'] >= start_date) + & (df['Datetime'] <= end_date) + ) + return df + + if start_date is not None: + + if start_date < self._start_date_data_source: + return polars.DataFrame() + + if start_date > self._end_date_data_source: + return polars.DataFrame() + + df = self.data + df = df.filter( + (df['Datetime'] >= start_date) + ) + df = df.head(self.window_size) + return df + + if end_date is not None: + + if end_date < self._start_date_data_source: + return polars.DataFrame() + + if end_date > self._end_date_data_source: + return polars.DataFrame() + + df = self.data + df = df.filter( + (df['Datetime'] <= end_date) + ) + df = df.tail(self.window_size) + return df + + return self.data + + def pre_pare_backtest_data( + self, + backtest_start_date, + backtest_end_date, + symbol: str = None, + market: str = None, + time_frame: str = None, + window_size=None + ) -> None: + + if symbol is not None: + return + + if self.data is None: + self._load_data(self.file_path) + + if backtest_start_date < self._start_date_data_source: + raise OperationalException( + f"Backtest start date {backtest_start_date} is before the " + f"start date {self._start_date_data_source}" + ) + + if backtest_end_date > self._end_date_data_source: + raise OperationalException( + f"Backtest end date {backtest_end_date} is after the " + f"end date {self._end_date_data_source}" + ) + + def get_backtest_data( + self, + date: datetime = None, + symbol: str = None, + market: str = None, + time_frame: str = None, + backtest_start_date: datetime = None, + backtest_end_date: datetime = None, + window_size=None, + pandas=False + ) -> None: + + if self.data is None: + self._load_data(self.file_path) + + if backtest_start_date is None and backtest_end_date is None: + return self.data + + if backtest_start_date is not None and backtest_end_date is not None: + + if backtest_end_date < backtest_start_date: + raise OperationalException( + f"Backtest end date {backtest_end_date} is before the " + f"start date {backtest_start_date}" + ) + + if backtest_start_date > self._end_date_data_source: + return polars.DataFrame() + + df = self.data + df = df.filter( + (df['Datetime'] >= backtest_start_date) + & (df['Datetime'] <= backtest_end_date) + ) + return df + + if backtest_start_date is not None: + + if backtest_start_date < self._start_date_data_source: + return polars.DataFrame() + + if backtest_start_date > self._end_date_data_source: + return polars.DataFrame() + + df = self.data + df = df.filter( + (df['Datetime'] >= backtest_start_date) + ) + df = df.head(self.window_size) + return df + + if backtest_end_date is not None: + + if backtest_end_date < self._start_date_data_source: + return polars.DataFrame() + + if backtest_end_date > self._end_date_data_source: + return polars.DataFrame() + + df = self.data + df = df.filter( + (df['Datetime'] <= backtest_end_date) + ) + df = df.tail(self.window_size) + return df + + return self.data + + def _load_data(self, storage_path): + self._columns = [ + "Datetime", "Open", "High", "Low", "Close", "Volume" + ] + + df = polars.read_csv(storage_path) + + # Check if all column names are in the csv file + if not all(column in df.columns for column in self._columns): + # Identify missing columns + missing_columns = [column for column in self._columns if + column not in df.columns] + raise OperationalException( + f"Csv file {storage_path} does not contain " + f"all required ohlcv columns. " + f"Missing columns: {missing_columns}" + ) + + self.data = polars.read_csv( + storage_path, + schema_overrides={"Datetime": polars.Datetime}, + low_memory=True + ).with_columns( + polars.col("Datetime").cast( + polars.Datetime(time_unit="ms", time_zone="UTC") + ) + ) + + first_row = self.data.head(1) + last_row = self.data.tail(1) + self._start_date_data_source = first_row["Datetime"][0] + self._end_date_data_source = last_row["Datetime"][0] diff --git a/investing_algorithm_framework/infrastructure/models/order/order.py b/investing_algorithm_framework/infrastructure/models/order/order.py index 8b1fc966..3074ce7e 100644 --- a/investing_algorithm_framework/infrastructure/models/order/order.py +++ b/investing_algorithm_framework/infrastructure/models/order/order.py @@ -31,16 +31,16 @@ class SQLOrder(Order, SQLBaseModel, SQLAlchemyModelExtension): ) price = Column(Float) amount = Column(Float) - remaining = Column(Float, default=0) - filled = Column(Float, default=0) + remaining = Column(Float, default=None) + filled = Column(Float, default=None) cost = Column(Float, default=0) - status = Column(String) + status = Column(String, default=OrderStatus.CREATED.value) position_id = Column(Integer, ForeignKey('positions.id')) position = relationship("SQLPosition", back_populates="orders") created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow) order_fee = Column(Float, default=None) - order_fee_currency = Column(String) + order_fee_currency = Column(String, default=None) order_fee_rate = Column(Float, default=None) sell_order_metadata_id = Column(Integer, ForeignKey('orders.id')) order_metadata = relationship( diff --git a/investing_algorithm_framework/infrastructure/models/order/order_metadata.py b/investing_algorithm_framework/infrastructure/models/order/order_metadata.py index fd415a72..cd9242cb 100644 --- a/investing_algorithm_framework/infrastructure/models/order/order_metadata.py +++ b/investing_algorithm_framework/infrastructure/models/order/order_metadata.py @@ -36,3 +36,9 @@ def __init__( self.take_profit_id = take_profit_id self.amount = amount self.amount_pending = amount_pending + + def __repr__(self): + return f"" diff --git a/investing_algorithm_framework/infrastructure/models/portfolio/sql_portfolio.py b/investing_algorithm_framework/infrastructure/models/portfolio/sql_portfolio.py index 8a252b0c..980e0c3c 100644 --- a/investing_algorithm_framework/infrastructure/models/portfolio/sql_portfolio.py +++ b/investing_algorithm_framework/infrastructure/models/portfolio/sql_portfolio.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean from sqlalchemy import UniqueConstraint @@ -31,8 +31,8 @@ class SQLPortfolio(Portfolio, SQLBaseModel, SQLAlchemyModelExtension): lazy="dynamic", cascade="all,delete", ) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) - updated_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False) + updated_at = Column(DateTime, nullable=False) initialized = Column(Boolean, nullable=False, default=False) __table_args__ = ( @@ -67,7 +67,10 @@ def __init__( identifier = market if created_at is None: - created_at = datetime.utcnow() + created_at = datetime.now(tz=timezone.utc) + + if updated_at is None: + updated_at = datetime.now(tz=timezone.utc) super().__init__( trading_symbol=trading_symbol, diff --git a/investing_algorithm_framework/infrastructure/models/trades/trade.py b/investing_algorithm_framework/infrastructure/models/trades/trade.py index 7a258f90..39a286b2 100644 --- a/investing_algorithm_framework/infrastructure/models/trades/trade.py +++ b/investing_algorithm_framework/infrastructure/models/trades/trade.py @@ -25,23 +25,23 @@ class SQLTrade(Trade, SQLBaseModel, SQLAlchemyModelExtension): buy order can be closed by multiple sell orders. Attributes: - * orders: str, the id of the buy order - * target_symbol: str, the target symbol of the trade - * trading_symbol: str, the trading symbol of the trade - * closed_at: datetime, the datetime when the trade was closed - * remaining: float, the remaining amount of the trade - * net_gain: float, the net gain of the trade - * last_reported_price: float, the last reported price of the trade - * last_reported_price_datetime: datetime, the datetime when the last - reported price was reported - * high_water_mark: float, the high water mark of the trade - * high_water_mark_datetime: datetime, the datetime when the high water - mark was reported - * created_at: datetime, the datetime when the trade was created - * updated_at: datetime, the datetime when the trade was last updated - * status: str, the status of the trade - * stop_loss_percentage: float, the stop loss percentage of the trade - * trailing_stop_loss_percentage: float, the trailing stop loss percentage + orders: str, the id of the buy order + target_symbol: str, the target symbol of the trade + trading_symbol: str, the trading symbol of the trade + closed_at: datetime, the datetime when the trade was closed + amount: float, the amount of the trade + available_amount: float, the available amount of the trade + remaining: float, the remaining amount that is not filled by the + buy order that opened the trade. + filled_amount: float, the filled amount of the trade by the buy + order that opened the trade. + net_gain: float, the net gain of the trade + last_reported_price: float, the last reported price of the trade + last_reported_price_datetime: datetime, the datetime when the last + reported price was reported + created_at: datetime, the datetime when the trade was created + updated_at: datetime, the datetime when the trade was last updated + status: str, the status of the trade """ __tablename__ = "trades" @@ -58,6 +58,7 @@ class SQLTrade(Trade, SQLBaseModel, SQLAlchemyModelExtension): opened_at = Column(DateTime, default=None) open_price = Column(Float, default=None) amount = Column(Float, default=None) + available_amount = Column(Float, default=None) filled_amount = Column(Float, default=None) remaining = Column(Float, default=None) net_gain = Column(Float, default=0) @@ -88,6 +89,7 @@ def __init__( trading_symbol, opened_at, amount, + available_amount, filled_amount, remaining, status=TradeStatus.CREATED.value, @@ -109,6 +111,7 @@ def __init__( self.trading_symbol = trading_symbol self.closed_at = closed_at self.amount = amount + self.available_amount = available_amount self.filled_amount = filled_amount self.remaining = remaining self.net_gain = net_gain diff --git a/investing_algorithm_framework/infrastructure/order_executors/__init__.py b/investing_algorithm_framework/infrastructure/order_executors/__init__.py new file mode 100644 index 00000000..71f9a481 --- /dev/null +++ b/investing_algorithm_framework/infrastructure/order_executors/__init__.py @@ -0,0 +1,19 @@ +from .ccxt_order_executor import CCXTOrderExecutor + + +def get_default_order_executors(): + """ + Function to get the default order executors. + + Returns: + list: List of default order executors. + """ + return [ + CCXTOrderExecutor(), + ] + + +__all__ = [ + 'CCXTOrderExecutor', + 'get_default_order_executors', +] diff --git a/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py b/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py new file mode 100644 index 00000000..353a1866 --- /dev/null +++ b/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py @@ -0,0 +1,156 @@ +from logging import getLogger + +import ccxt + +from investing_algorithm_framework.domain import OrderExecutor, \ + OperationalException, Order, OrderStatus, OrderSide, OrderType + +logger = getLogger("investing_algorithm_framework") + + +class CCXTOrderExecutor(OrderExecutor): + """ + CCXTOrderExecutor is a class that implements the OrderExecutor + interface for executing orders using the CCXT library. + """ + + def execute_order(self, portfolio, order, market_credential) -> Order: + """ + Executes an order for a given portfolio on a CCXT exchange. + + Args: + order: The order to be executed + portfolio: The portfolio in which the order will be executed + market_credential: The market credential to use for the order + + Returns: + Order: Instance of the executed order. The order instance + should copy the id of the order that has been provided as a + """ + market = portfolio.market + exchange = self.initialize_exchange(market, market_credential) + symbol = order.get_symbol() + amount = order.get_amount() + price = order.get_price() + order_type = order.get_order_type() + order_side = order.get_order_side() + + try: + if OrderType.LIMIT.equals(order_type): + if OrderSide.BUY.equals(order_side): + + # Check if the exchange supports the + # createLimitBuyOrder method + if not hasattr(exchange, "createLimitBuyOrder"): + raise OperationalException( + f"Exchange {market} does not support " + f"functionality createLimitBuyOrder" + ) + + # Create a limit buy order + external_order = exchange.createLimitBuyOrder( + symbol, amount, price, + ) + else: + # Check if the exchange supports + # the createLimitSellOrder method + if not hasattr(exchange, "createLimitSellOrder"): + raise OperationalException( + f"Exchange {market} does not support " + f"functionality createLimitSellOrder" + ) + + # Create a limit sell order + external_order = exchange.createLimitSellOrder( + symbol, amount, price, + ) + else: + raise OperationalException( + f"Order type {order_type} not supported " + f"by CCXT OrderExecutor" + ) + + external_order = Order.from_ccxt_order(external_order) + external_order.id = order.id + return external_order + except Exception as e: + logger.exception(e) + raise OperationalException("Could not create limit buy order") + + def cancel_order(self, portfolio, order, market_credential) -> Order: + """ + Cancels an order for a given portfolio on a CCXT exchange. + + Args: + order: The order to be canceled + portfolio: The portfolio in which the order was executed + market_credential: The market credential to use for the order + + Returns: + Order: Instance of the canceled order. + """ + market = portfolio.market + exchange = self.initialize_exchange(market, market_credential) + + if not exchange.has['cancelOrder']: + raise OperationalException( + f"Exchange {market} does not support " + f"functionality cancelOrder" + ) + + try: + exchange.cancelOrder( + order.get_external_id(), + f"{order.get_target_symbol()}/{order.get_trading_symbol()}" + ) + order.status = OrderStatus.CANCELED.value + return order + except Exception as e: + logger.exception(e) + raise OperationalException("Could not cancel order") + + @staticmethod + def initialize_exchange(market, market_credential): + """ + Function to initialize the exchange for the market. + + Args: + market (str): The market to initialize the exchange for + market_credential (MarketCredential): The market credential to use + for the exchange + + Returns: + + """ + market = market.lower() + + if not hasattr(ccxt, market): + raise OperationalException( + f"No ccxt exchange for market id {market}" + ) + + exchange_class = getattr(ccxt, market) + + if exchange_class is None: + raise OperationalException( + f"No market service found for market id {market}" + ) + + exchange = exchange_class({ + 'apiKey': market_credential.api_key, + 'secret': market_credential.secret_key, + }) + return exchange + + def supports_market(self, market): + """ + Function to check if the market is supported by the portfolio + provider. + + Args: + market: Market object + + Returns: + bool: True if the market is supported, False otherwise + """ + return hasattr(ccxt, market.lower()) diff --git a/investing_algorithm_framework/infrastructure/portfolio_providers/__init__.py b/investing_algorithm_framework/infrastructure/portfolio_providers/__init__.py new file mode 100644 index 00000000..b689ad3c --- /dev/null +++ b/investing_algorithm_framework/infrastructure/portfolio_providers/__init__.py @@ -0,0 +1,19 @@ +from .ccxt_portfolio_provider import CCXTPortfolioProvider + + +def get_default_portfolio_providers(): + """ + Function to get the default portfolio providers. + + Returns: + list: List of default portfolio providers. + """ + return [ + CCXTPortfolioProvider(), + ] + + +__all__ = [ + "CCXTPortfolioProvider", + "get_default_portfolio_providers", +] diff --git a/investing_algorithm_framework/infrastructure/portfolio_providers/ccxt_portfolio_provider.py b/investing_algorithm_framework/infrastructure/portfolio_providers/ccxt_portfolio_provider.py new file mode 100644 index 00000000..cc847865 --- /dev/null +++ b/investing_algorithm_framework/infrastructure/portfolio_providers/ccxt_portfolio_provider.py @@ -0,0 +1,144 @@ +import ccxt +from logging import getLogger +from typing import Union + +from investing_algorithm_framework.domain import PortfolioProvider, \ + OperationalException, Order, Position + + +logger = getLogger("investing_algorithm_framework") + + +class CCXTPortfolioProvider(PortfolioProvider): + """ + Implementation of Portfolio Provider for CCXT. + """ + + def get_order( + self, portfolio, order, market_credential + ) -> Union[Order, None]: + """ + Method to check if there are any pending orders for the portfolio. + This method will retrieve the open orders from the exchange and + check if there are any pending orders for the portfolio. + + !IMPORTANT: This function should return None if the order is + not found or if the order is not available on the + exchange or broker. Please do not throw an exception if the + order is not found. + + Args: + portfolio: Portfolio object + order: Order object from the database + market_credential: Market credential object + + Returns: + None + """ + exchange = self.initialize_exchange( + portfolio.market, market_credential + ) + + if not exchange.has['fetchOrder']: + raise OperationalException( + f"Market service {portfolio.market} does not support " + f"functionality get_order" + ) + + symbol = order.get_symbol() + + try: + external_order = exchange.fetchOrder(order.external_id, symbol) + external_order = Order.from_ccxt_order(external_order) + external_order.id = order.id + return external_order + except Exception as e: + logger.exception(e) + raise OperationalException("Could not retrieve order") + + def get_position( + self, portfolio, symbol, market_credential + ) -> Union[Position, None]: + """ + Function to get the position for a given symbol in the portfolio. + The returned position should be an object that reflects the current + state of the position on the exchange or broker. + + !IMPORTANT: This function should return None if the position is + not found or if the position is not available on the + exchange or broker. Please do not throw an exception if the + position is not found. + + Args: + portfolio (Portfolio): Portfolio object + symbol (str): Symbol object + market_credential (MarketCredential): MarketCredential object + + Returns: + Position: Position for the given symbol in the portfolio + """ + + exchange = self.initialize_exchange( + portfolio.market, market_credential + ) + + if not exchange.has['fetchBalance']: + raise OperationalException( + f"Market service {portfolio.market} does not support " + f"functionality get_balance" + ) + + try: + amount = exchange.fetchBalance()["free"] + + if symbol not in amount: + return None + + return Position( + symbol=symbol, + amount=amount[symbol], + cost=0, + portfolio_id=portfolio.id + ) + except Exception as e: + logger.exception(e) + raise OperationalException( + f"Please make sure you have " + f"registered a valid market credential " + f"object to the app: {str(e)}" + ) + + @staticmethod + def initialize_exchange(market, market_credential): + market = market.lower() + + if not hasattr(ccxt, market): + raise OperationalException( + f"No ccxt exchange for market id {market}" + ) + + exchange_class = getattr(ccxt, market) + + if exchange_class is None: + raise OperationalException( + f"No market service found for market id {market}" + ) + + exchange = exchange_class({ + 'apiKey': market_credential.api_key, + 'secret': market_credential.secret_key, + }) + return exchange + + def supports_market(self, market): + """ + Function to check if the market is supported by the portfolio + provider. + + Args: + market: Market object + + Returns: + bool: True if the market is supported, False otherwise + """ + return hasattr(ccxt, market.lower()) diff --git a/investing_algorithm_framework/infrastructure/repositories/order_repository.py b/investing_algorithm_framework/infrastructure/repositories/order_repository.py index 0a2112e2..3462dcda 100644 --- a/investing_algorithm_framework/infrastructure/repositories/order_repository.py +++ b/investing_algorithm_framework/infrastructure/repositories/order_repository.py @@ -10,6 +10,7 @@ class SQLOrderRepository(Repository): DEFAULT_NOT_FOUND_MESSAGE = "The requested order was not found" def _apply_query_params(self, db, query, query_params): + id_query_param = self.get_query_param("id", query_params) external_id_query_param = self.get_query_param( "external_id", query_params ) @@ -34,6 +35,9 @@ def _apply_query_params(self, db, query, query_params): "order_by_created_at_asc", query_params ) + if id_query_param: + query = query.filter_by(id=id_query_param) + if portfolio_query_param is not None: portfolio = db.query(SQLPortfolio).filter_by( id=portfolio_query_param diff --git a/investing_algorithm_framework/infrastructure/repositories/position_repository.py b/investing_algorithm_framework/infrastructure/repositories/position_repository.py index 5d77bafd..f8e4c2ec 100644 --- a/investing_algorithm_framework/infrastructure/repositories/position_repository.py +++ b/investing_algorithm_framework/infrastructure/repositories/position_repository.py @@ -9,6 +9,7 @@ class SQLPositionRepository(Repository): DEFAULT_NOT_FOUND_MESSAGE = "Position not found" def _apply_query_params(self, db, query, query_params): + id_query_param = self.get_query_param("id", query_params) amount_query_param = self.get_query_param("amount", query_params) symbol_query_param = self.get_query_param("symbol", query_params) portfolio_query_param = self.get_query_param("portfolio", query_params) @@ -22,6 +23,9 @@ def _apply_query_params(self, db, query, query_params): ) order_id_query_param = self.get_query_param("order_id", query_params) + if id_query_param: + query = query.filter_by(id=id_query_param) + if amount_query_param: query = query.filter( cast(SQLPosition.amount, Float) == amount_query_param diff --git a/investing_algorithm_framework/infrastructure/repositories/repository.py b/investing_algorithm_framework/infrastructure/repositories/repository.py index 1785354c..c9896387 100644 --- a/investing_algorithm_framework/infrastructure/repositories/repository.py +++ b/investing_algorithm_framework/infrastructure/repositories/repository.py @@ -5,7 +5,7 @@ from sqlalchemy.exc import SQLAlchemyError from werkzeug.datastructures import MultiDict -from investing_algorithm_framework.domain import ApiException, \ +from investing_algorithm_framework.domain import OperationalException, \ DEFAULT_PAGE_VALUE, DEFAULT_PER_PAGE_VALUE from investing_algorithm_framework.infrastructure.database import Session @@ -18,18 +18,21 @@ class Repository(ABC): DEFAULT_PER_PAGE = DEFAULT_PER_PAGE_VALUE DEFAULT_PAGE = DEFAULT_PAGE_VALUE - def create(self, data): + def create(self, data, save=True): + created_object = self.base_class(**data) - with Session() as db: - try: - created_object = self.base_class(**data) - db.add(created_object) - db.commit() - return self.get(created_object.id) - except SQLAlchemyError as e: - logger.error(e) - db.rollback() - raise ApiException("Error creating object") + if save: + with Session() as db: + try: + db.add(created_object) + db.commit() + return self.get(created_object.id) + except SQLAlchemyError as e: + logger.error(e) + db.rollback() + raise OperationalException("Error creating object") + + return created_object def update(self, object_id, data): @@ -43,7 +46,7 @@ def update(self, object_id, data): except SQLAlchemyError as e: logger.error(e) db.rollback() - raise ApiException("Error updating object") + raise OperationalException("Error updating object") def update_all(self, query_params, data): @@ -63,7 +66,7 @@ def update_all(self, query_params, data): except SQLAlchemyError as e: logger.error(e) db.rollback() - raise ApiException("Error updating object") + raise OperationalException("Error updating object") def delete(self, object_id): @@ -76,13 +79,13 @@ def delete(self, object_id): except SQLAlchemyError as e: logger.error(e) db.rollback() - raise ApiException("Error deleting object") + raise OperationalException("Error deleting object") def delete_all(self, query_params): with Session() as db: if query_params is None: - raise ApiException("No parameters are required") + raise OperationalException("No parameters are required") try: query_set = db.query(self.base_class) @@ -97,7 +100,7 @@ def delete_all(self, query_params): except SQLAlchemyError as e: logger.error(e) db.rollback() - raise ApiException("Error deleting all objects") + raise OperationalException("Error deleting all objects") def get_all(self, query_params=None): query_params = MultiDict(query_params) @@ -111,7 +114,7 @@ def get_all(self, query_params=None): return query_set.all() except SQLAlchemyError as e: logger.error(e) - raise ApiException("Error getting all objects") + raise OperationalException("Error getting all objects") def get(self, object_id): @@ -120,8 +123,8 @@ def get(self, object_id): .first() if not match: - raise ApiException( - self.DEFAULT_NOT_FOUND_MESSAGE, status_code=404 + raise OperationalException( + self.DEFAULT_NOT_FOUND_MESSAGE ) return match @@ -146,12 +149,12 @@ def exists(self, query_params): return query.first() is not None except SQLAlchemyError as e: logger.error(e) - raise ApiException("Error checking if object exists") + raise OperationalException("Error checking if object exists") def find(self, query_params): if query_params is None or len(query_params) == 0: - raise ApiException("Find requires query parameters") + raise OperationalException("Find requires query parameters") with Session() as db: try: @@ -160,12 +163,12 @@ def find(self, query_params): result = query.first() if result is None: - raise ApiException(self.DEFAULT_NOT_FOUND_MESSAGE) + raise OperationalException(self.DEFAULT_NOT_FOUND_MESSAGE) return result except SQLAlchemyError as e: logger.error(e) - raise ApiException(self.DEFAULT_NOT_FOUND_MESSAGE) + raise OperationalException(self.DEFAULT_NOT_FOUND_MESSAGE) def count(self, query_params=None): @@ -176,7 +179,7 @@ def count(self, query_params=None): return query.count() except SQLAlchemyError as e: logger.error(e) - raise ApiException("Error counting objects") + raise OperationalException("Error counting objects") def normalize_query_param(self, value): """ @@ -197,7 +200,7 @@ def is_query_param_present(self, key, params, throw_exception=False): if not throw_exception: return False - raise ApiException(f"{key} is not specified") + raise OperationalException(f"{key} is not specified") else: return True @@ -248,17 +251,25 @@ def get_query_param(self, key, params, default=None, many=False): return new_selection - def save(self, object): + def save(self, object_to_save): + """ + Save an object to the database with SQLAlchemy. + Args: + object_to_save: instance of the object to save. + + Returns: + Object: The saved object. + """ with Session() as db: try: - db.add(object) + db.add(object_to_save) db.commit() - return self.get(object.id) + return self.get(object_to_save.id) except SQLAlchemyError as e: logger.error(e) db.rollback() - raise ApiException("Error saving object") + raise OperationalException("Error saving object") def save_objects(self, objects): @@ -271,4 +282,4 @@ def save_objects(self, objects): except SQLAlchemyError as e: logger.error(e) db.rollback() - raise ApiException("Error saving objects") + raise OperationalException("Error saving objects") diff --git a/investing_algorithm_framework/services/__init__.py b/investing_algorithm_framework/services/__init__.py index 3ad70f49..c0f3fe84 100644 --- a/investing_algorithm_framework/services/__init__.py +++ b/investing_algorithm_framework/services/__init__.py @@ -3,12 +3,12 @@ from .market_credential_service import MarketCredentialService from .market_data_source_service import MarketDataSourceService, \ BacktestMarketDataSourceService, DataProviderService -from .order_service import OrderService, OrderBacktestService +from .order_service import OrderService, OrderBacktestService, \ + OrderExecutorLookup from .portfolios import PortfolioService, BacktestPortfolioService, \ PortfolioConfigurationService, PortfolioSyncService, \ - PortfolioSnapshotService -from .position_service import PositionService -from .position_snapshot_service import PositionSnapshotService + PortfolioSnapshotService, PortfolioProviderLookup +from .positions import PositionService, PositionSnapshotService from .repository_service import RepositoryService from .strategy_orchestrator_service import StrategyOrchestratorService from .trade_service import TradeService @@ -31,5 +31,8 @@ "BacktestMarketDataSourceService", "BacktestPortfolioService", "TradeService", - "DataProviderService" + "DataProviderService", + "OrderExecutorLookup", + "PortfolioServiceV2", + "PortfolioProviderLookup", ] diff --git a/investing_algorithm_framework/services/backtesting/backtest_service.py b/investing_algorithm_framework/services/backtesting/backtest_service.py index 5a42ba0a..d9be48cc 100644 --- a/investing_algorithm_framework/services/backtesting/backtest_service.py +++ b/investing_algorithm_framework/services/backtesting/backtest_service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import re import os import inspect @@ -362,7 +362,7 @@ def create_backtest_report( backtest_date_range=backtest_date_range, initial_unallocated=initial_unallocated, trading_symbol=portfolio.trading_symbol, - created_at=datetime.utcnow(), + created_at=datetime.now(tz=timezone.utc) ) backtest_report.number_of_runs = number_of_runs backtest_report.number_of_orders = self._order_service.count({ diff --git a/investing_algorithm_framework/services/market_data_source_service/data_provider_service.py b/investing_algorithm_framework/services/market_data_source_service/data_provider_service.py index c572a70d..7418559a 100644 --- a/investing_algorithm_framework/services/market_data_source_service/data_provider_service.py +++ b/investing_algorithm_framework/services/market_data_source_service/data_provider_service.py @@ -147,6 +147,65 @@ def get_data( pandas=pandas, ) + def get_backtest_data( + self, + symbol: str, + data_type: str, + market: str = None, + backtest_index_date: datetime = None, + time_frame: str = None, + start_date: datetime = None, + end_date: datetime = None, + storage_path=None, + window_size=None, + pandas=False, + save: bool = False, + ): + + """ + Function to get backtest data from the data provider. + + Args: + symbol (str): The symbol to get data for. + market (str): The market to get data from. + time_frame (str): The time frame to get data for. + start_date (datetime): The start date for the data. + end_date (datetime): The end date for the data. + storage_path (str): The path to store the data. + window_size (int): The size of the data window. + pandas (bool): Whether to return the data as a pandas DataFrame. + + Returns: + DataFrame: The backtest data for the given symbol and market. + """ + data_provider = self.data_provider_index.find_data_provider( + symbol=symbol, + market=market, + time_frame=time_frame, + ) + + if data_provider is None: + self._throw_no_data_provider_exception( + { + "symbol": symbol, + "market": market, + "data_type": data_type, + "time_frame": time_frame, + } + ) + + return data_provider.get_backtest_data( + symbol=symbol, + market=market, + time_frame=time_frame, + backtest_index_date=backtest_index_date, + start_date=start_date, + end_date=end_date, + storage_path=storage_path, + window_size=window_size, + pandas=pandas, + ) + def _throw_no_data_provider_exception(self, params): """ Raise an exception if no data provider is found for the given params diff --git a/investing_algorithm_framework/services/market_data_source_service/market_data_source_service.py b/investing_algorithm_framework/services/market_data_source_service/market_data_source_service.py index 4fce0ecb..64982b07 100644 --- a/investing_algorithm_framework/services/market_data_source_service/market_data_source_service.py +++ b/investing_algorithm_framework/services/market_data_source_service/market_data_source_service.py @@ -119,7 +119,6 @@ def get_data_for_strategy(self, strategy): the keys being the identifier of the market data sources """ identifiers = [] - if strategy.market_data_sources is not None: for market_data_source in strategy.market_data_sources: @@ -165,12 +164,10 @@ def get_data_for_strategy(self, strategy): return market_data def get_data(self, identifier): - for market_data_source in self._market_data_sources: if market_data_source.get_identifier() == identifier: config = self._configuration_service.get_config() - config = self._configuration_service.get_config() date = config.get("DATE_TIME", None) @@ -198,7 +195,6 @@ def get_data(self, identifier): # Add metadata to the data if isinstance(market_data_source, OHLCVMarketDataSource): result["type"] = MarketDataType.OHLCV - time_frame = market_data_source.time_frame if time_frame is not None: @@ -236,7 +232,6 @@ def get_ticker_market_data_source(self, symbol, market=None): if self.market_data_sources is not None: for market_data_source in self._market_data_sources: if isinstance(market_data_source, TickerMarketDataSource): - if market is not None: if market_data_source.market.upper() == market.upper()\ and market_data_source.symbol.upper() \ diff --git a/investing_algorithm_framework/services/order_service/__init__.py b/investing_algorithm_framework/services/order_service/__init__.py index 7fe02a60..154c603e 100644 --- a/investing_algorithm_framework/services/order_service/__init__.py +++ b/investing_algorithm_framework/services/order_service/__init__.py @@ -1,7 +1,9 @@ from .order_backtest_service import OrderBacktestService from .order_service import OrderService +from .order_executor_lookup import OrderExecutorLookup __all__ = [ "OrderService", - "OrderBacktestService" + "OrderBacktestService", + "OrderExecutorLookup", ] diff --git a/investing_algorithm_framework/services/order_service/order_backtest_service.py b/investing_algorithm_framework/services/order_service/order_backtest_service.py index 56b443fc..548f1d33 100644 --- a/investing_algorithm_framework/services/order_service/order_backtest_service.py +++ b/investing_algorithm_framework/services/order_service/order_backtest_service.py @@ -17,7 +17,7 @@ def __init__( self, order_repository, trade_service, - position_repository, + position_service, portfolio_repository, portfolio_configuration_service, portfolio_snapshot_service, @@ -27,7 +27,7 @@ def __init__( super(OrderService, self).__init__(order_repository) self.trade_service = trade_service self.order_repository = order_repository - self.position_repository = position_repository + self.position_service = position_service self.portfolio_repository = portfolio_repository self.portfolio_configuration_service = portfolio_configuration_service self.portfolio_snapshot_service = portfolio_snapshot_service @@ -58,17 +58,13 @@ def create(self, data, execute=True, validate=True, sync=True) -> Order: return super(OrderBacktestService, self)\ .create(data, execute, validate, sync) - def execute_order(self, order_id, portfolio): - order = self.get(order_id) - order = self.update( - order_id, - { - "status": OrderStatus.OPEN.value, - "remaining": order.remaining, - "updated_at": self.configuration_service - .config[BACKTESTING_INDEX_DATETIME] - } - ) + def execute_order(self, order, portfolio): + order.status = OrderStatus.OPEN.value + order.remaining = order.get_amount() + order.filled = 0 + order.updated_at = self.configuration_service.config[ + BACKTESTING_INDEX_DATETIME + ] return order def check_pending_orders(self, market_data): diff --git a/investing_algorithm_framework/services/order_service/order_executor_lookup.py b/investing_algorithm_framework/services/order_service/order_executor_lookup.py new file mode 100644 index 00000000..02d63004 --- /dev/null +++ b/investing_algorithm_framework/services/order_service/order_executor_lookup.py @@ -0,0 +1,110 @@ +from collections import defaultdict +from typing import List + +from investing_algorithm_framework.domain import OrderExecutor, \ + ImproperlyConfigured + + +class OrderExecutorLookup: + """ + Efficient lookup for order executors based on market in O(1) time. + + Attributes: + order_executors (List[OrderExecutor]): List of order executors + order_executor_lookup (dict): Dictionary to store the lookup + for order executors based on market. + """ + def __init__(self, order_executors=[]): + self.order_executors = order_executors + + # These will be our lookup tables + self.order_executor_lookup = defaultdict() + + def add_order_executor(self, order_executor: OrderExecutor): + """ + Add an order executor to the lookup. + + Args: + order_executor (OrderExecutor): The order executor to be added. + + Returns: + None + """ + self.order_executors.append(order_executor) + + def register_order_executor_for_market(self, market) -> None: + """ + Register an order executor for a specific market. + This method will create a lookup table for efficient access to + order executors based on market. It will use the + order executors that are currently registered in the + order_executors list. The lookup table will be a dictionary + where the key is the market and the value is the portfolio provider. + + This method will also check if the portfolio provider supports + the market. If no portfolio provider is found for the market, + it will raise an ImproperlyConfigured exception. + + If multiple order executors are found for the market, + it will sort them by priority and pick the best one. + + Args: + market: + + Returns: + None + """ + matches = [] + + for order_executor in self.order_executors: + + if order_executor.supports_market(market): + matches.append(order_executor) + + if len(matches) == 0: + raise ImproperlyConfigured( + f"No portfolio provider found for market " + f"{market}. Cannot configure portfolio." + f" Please make sure that you have registered a portfolio " + f"provider for the market you are trying to use" + ) + + # Sort by priority and pick the best one + best_provider = sorted(matches, key=lambda x: x.priority)[0] + self.order_executor_lookup[market] = best_provider + + def get_order_executor(self, market: str): + """ + Get the order executor for a specific market. + This method will return the order executor for the market + that was registered in the lookup table. If no order executor + was found for the market, it will return None. + + Args: + market: + + Returns: + OrderExecutor: The order executor for the market. + """ + return self.order_executor_lookup.get(market, None) + + def get_all(self) -> List[OrderExecutor]: + """ + Get all order executors. + This method will return all order executors that are currently + registered in the order_executors list. + + Returns: + List[OrderExecutor]: A list of all order executors. + """ + return self.order_executors + + def reset(self): + """ + Function to reset the order executor lookup table + + Returns: + None + """ + self.order_executor_lookup = defaultdict() + self.order_executors = [] diff --git a/investing_algorithm_framework/services/order_service/order_service.py b/investing_algorithm_framework/services/order_service/order_service.py index 02a50014..a77d76a5 100644 --- a/investing_algorithm_framework/services/order_service/order_service.py +++ b/investing_algorithm_framework/services/order_service/order_service.py @@ -1,10 +1,11 @@ import logging from datetime import datetime +from typing import List from dateutil.tz import tzutc from investing_algorithm_framework.domain import OrderType, OrderSide, \ - OperationalException, OrderStatus, MarketService, Order + OperationalException, OrderStatus, Order, OrderExecutor, random_number from investing_algorithm_framework.services.repository_service \ import RepositoryService @@ -12,29 +13,81 @@ class OrderService(RepositoryService): + """ + Service to manage orders. This service will use the provided + order executors to execute the orders. The order service is + responsible for creating, updating, canceling and deleting orders. + + Attributes: + configuration_service (ConfigurationService): The service + responsible for managing configurations. + order_repository (OrderRepository): The repository + responsible for managing orders. + position_service (PositionService): The service + responsible for managing positions. + portfolio_repository (PortfolioRepository): The repository + responsible for managing portfolios. + portfolio_configuration_service (PortfolioConfigurationService): + service responsible for managing portfolio configurations. + portfolio_snapshot_service (PortfolioSnapshotService): + service responsible for managing portfolio snapshots. + market_credential_service (MarketCredentialService): + service responsible for managing market credentials. + trade_service (TradeService): The service responsible for + managing trades. + """ def __init__( self, configuration_service, order_repository, - market_service: MarketService, - position_repository, + position_service, portfolio_repository, portfolio_configuration_service, portfolio_snapshot_service, market_credential_service, trade_service, + order_executor_lookup, + portfolio_provider_lookup ): super(OrderService, self).__init__(order_repository) self.configuration_service = configuration_service self.order_repository = order_repository - self.market_service: MarketService = market_service - self.position_repository = position_repository + self.position_service = position_service self.portfolio_repository = portfolio_repository self.portfolio_configuration_service = portfolio_configuration_service self.portfolio_snapshot_service = portfolio_snapshot_service self.market_credential_service = market_credential_service self.trade_service = trade_service + self._order_executors = None + self._order_executor_lookup = order_executor_lookup + self._portfolio_provider_lookup = portfolio_provider_lookup + + @property + def order_executors(self) -> List[OrderExecutor]: + """ + Returns the order executors for the order service. + """ + return self._order_executors + + @order_executors.setter + def order_executors(self, value) -> None: + """ + Sets the order executors for the order service. + """ + self._order_executors = value + + def get_order_executor(self, market) -> OrderExecutor: + """ + Returns the order executor for the given market. + + Args: + market (str): The market for which to get the order executor. + + Returns: + OrderExecutor: The order executor for the given market. + """ + return self._order_executor_lookup.get_order_executor(market) def create(self, data, execute=True, validate=True, sync=True) -> Order: """ @@ -113,9 +166,6 @@ def create(self, data, execute=True, validate=True, sync=True) -> Order: trades = data.get("trades", []) stop_losses = data.get("stop_losses", []) take_profits = data.get("take_profits", []) - filled = data.get("filled", 0) - remaining = data.get("remaining", 0) - amount = data.get("amount", 0) if "filled" in data: del data["filled"] @@ -137,22 +187,25 @@ def create(self, data, execute=True, validate=True, sync=True) -> Order: del data["portfolio_id"] symbol = data["target_symbol"] + data["id"] = self._create_order_id() + + order = self.repository.create(data, save=False) if validate: self.validate_order(data, portfolio) - # Get the position + if execute: + order = self.execute_order(order, portfolio) + position = self._create_position_if_not_exists(symbol, portfolio) - data["position_id"] = position.id - data["remaining"] = data["amount"] - data["status"] = OrderStatus.CREATED.value - order = self.order_repository.create(data) + order.position_id = position.id + order = self.order_repository.save(order) order_id = order.id created_at = order.created_at order_side = order.order_side if OrderSide.SELL.equals(order_side): - # Create order metadata if their is a key in the data + # Create order metadata if there is a key in the data # for trades, stop_losses or take_profits self.trade_service.create_order_metadata_with_trade_context( sell_order=order, @@ -171,36 +224,6 @@ def create(self, data, execute=True, validate=True, sync=True) -> Order: self._sync_portfolio_with_created_sell_order(order) self.create_snapshot(portfolio.id, created_at=created_at) - - if sync: - - if filled or remaining: - - if filled == 0: - filled = amount - remaining - - if remaining == 0: - remaining = amount - filled - - status = OrderStatus.OPEN.value - - if filled == amount: - status = OrderStatus.CLOSED.value - - order = self.update( - order_id, - { - "filled": filled, - "remaining": remaining, - "status": status - } - ) - - if execute: - portfolio.configuration = self.portfolio_configuration_service\ - .get(portfolio.identifier) - self.execute_order(order_id, portfolio) - order = self.get(order_id) return order @@ -218,6 +241,7 @@ def update(self, object_id, data): data: dict - the data to update the order with the following format: { + "amount": float, "filled" (optional): float, "remaining" (optional): float, "status" (optional): str, @@ -227,20 +251,13 @@ def update(self, object_id, data): Order: Order object that has been updated """ previous_order = self.order_repository.get(object_id) - trading_symbol_position = self.position_repository.find( - { - "id": previous_order.position_id, - "symbol": previous_order.trading_symbol - } - ) - portfolio = self.portfolio_repository.get( - trading_symbol_position.portfolio_id - ) + position = self.position_service.get(previous_order.position_id) + portfolio = self.portfolio_repository.get(position.portfolio_id) new_order = self.order_repository.update(object_id, data) filled_difference = new_order.get_filled() \ - previous_order.get_filled() - if filled_difference: + if filled_difference > 0: if OrderSide.BUY.equals(new_order.get_order_side()): self._sync_with_buy_order_filled(previous_order, new_order) else: @@ -276,57 +293,39 @@ def update(self, object_id, data): self.create_snapshot(portfolio.id, created_at=created_at) return new_order - def execute_order(self, order_id, portfolio): - order = self.get(order_id) + def execute_order(self, order, portfolio) -> Order: + """ + Function to execute an order. The function will execute the order + with a matching order executor. The function will also update + the order attributes with the external order attributes. - try: - if OrderType.LIMIT.equals(order.get_order_type()): - - if OrderSide.BUY.equals(order.get_order_side()): - external_order = self.market_service\ - .create_limit_buy_order( - target_symbol=order.get_target_symbol(), - trading_symbol=order.get_trading_symbol(), - amount=order.get_amount(), - price=order.get_price(), - market=portfolio.get_market() - ) - else: - external_order = self.market_service\ - .create_limit_sell_order( - target_symbol=order.get_target_symbol(), - trading_symbol=order.get_trading_symbol(), - amount=order.get_amount(), - price=order.get_price(), - market=portfolio.get_market() - ) - else: - if OrderSide.BUY.equals(order.get_order_side()): - raise OperationalException( - "Market buy order not supported" - ) - else: - external_order = self.market_service\ - .create_market_sell_order( - target_symbol=order.get_target_symbol(), - trading_symbol=order.get_trading_symbol(), - amount=order.get_amount(), - market=portfolio.get_market() - ) - - data = external_order.to_dict() - data["status"] = OrderStatus.OPEN.value - data["updated_at"] = datetime.now(tz=tzutc()) - return self.update(order_id, data) - except Exception as e: - logger.error("Error executing order: {}".format(e)) - return self.update( - order_id, - { - "status": OrderStatus.REJECTED.value, - "updated_at": datetime.now(tz=tzutc()) - } - ) + Args: + order: Order object representing the order to be executed + portfolio: Portfolio object representing the portfolio in which + + Returns: + order: Order object representing the executed order + """ + logger.info( + f"Executing order {order.get_symbol()} with " + f"amount {order.get_amount()} " + f"and price {order.get_price()}" + ) + + order_executor = self.get_order_executor(portfolio.market) + market_credential = self.market_credential_service.get( + portfolio.market + ) + external_order = order_executor.execute_order( + portfolio, order, market_credential + ) + logger.info(f"Executed order: {external_order.to_dict()}") + order.set_external_id(external_order.get_external_id()) + order.set_status(external_order.get_status()) + order.set_filled(external_order.get_filled()) + order.set_remaining(external_order.get_remaining()) + order.updated_at = datetime.now(tz=tzutc()) + return order def validate_order(self, order_data, portfolio): @@ -338,10 +337,13 @@ def validate_order(self, order_data, portfolio): if OrderType.LIMIT.equals(order_data["order_type"]): self.validate_limit_order(order_data, portfolio) else: - self.validate_market_order(order_data, portfolio) + raise OperationalException( + f"Order type {order_data['order_type']} is not supported" + ) def validate_sell_order(self, order_data, portfolio): - if not self.position_repository.exists( + + if not self.position_service.exists( { "symbol": order_data["target_symbol"], "portfolio": portfolio.id @@ -351,7 +353,7 @@ def validate_sell_order(self, order_data, portfolio): "Can't add sell order to non existing position" ) - position = self.position_repository\ + position = self.position_service\ .find( { "symbol": order_data["target_symbol"], @@ -386,13 +388,20 @@ def validate_limit_order(self, order_data, portfolio): if OrderSide.SELL.equals(order_data["order_side"]): amount = order_data["amount"] - position = self.position_repository\ + position = self.position_service\ .find( { "portfolio": portfolio.id, "symbol": order_data["target_symbol"] } ) + + if amount <= 0: + raise OperationalException( + f"Order amount: {amount} {position.symbol}, is " + f"less or equal to 0" + ) + if amount > position.get_amount(): raise OperationalException( f"Order amount: {amount} {position.symbol}, is " @@ -401,7 +410,7 @@ def validate_limit_order(self, order_data, portfolio): ) else: total_price = order_data["amount"] * order_data["price"] - unallocated_position = self.position_repository\ + unallocated_position = self.position_service\ .find( { "portfolio": portfolio.id, @@ -425,48 +434,10 @@ def validate_limit_order(self, order_data, portfolio): f"{portfolio.trading_symbol} of the portfolio" ) - def validate_market_order(self, order_data, portfolio): - - if OrderSide.BUY.equals(order_data["order_side"]): - - if "amount" not in order_data: - raise OperationalException( - f"Market order needs an amount specified in the trading " - f"symbol {order_data['trading_symbol']}" - ) - - if order_data['amount'] > portfolio.unallocated: - raise OperationalException( - f"Market order amount " - f"{order_data['amount']}" - f"{portfolio.trading_symbol.upper()} is larger then " - f"unallocated {portfolio.unallocated} " - f"{portfolio.trading_symbol.upper()}" - ) - else: - position = self.position_repository\ - .find( - { - "symbol": order_data["target_symbol"], - "portfolio": portfolio.id - } - ) - - if position is None: - raise OperationalException( - "Can't add market sell order to non existing position" - ) - - if order_data['amount'] > position.get_amount(): - raise OperationalException( - "Sell order amount larger then position size" - ) - def check_pending_orders(self, portfolio=None): """ Function to check if """ - if portfolio is not None: pending_orders = self.get_all( { @@ -478,71 +449,91 @@ def check_pending_orders(self, portfolio=None): pending_orders = self.get_all({"status": OrderStatus.OPEN.value}) for order in pending_orders: - position = self.position_repository.get(order.position_id) + position = self.position_service.get(order.position_id) portfolio = self.portfolio_repository.get(position.portfolio_id) - external_order = self.market_service\ - .get_order(order, market=portfolio.get_market()) + portfolio_provider = self._portfolio_provider_lookup\ + .get_portfolio_provider(portfolio.market) + market_credential = self.market_credential_service.get( + portfolio.market + ) + logger.info( + f"Checking {order.get_order_side()} order {order.get_id()} " + f"with external id: {order.get_external_id()} " + f"at market {portfolio.market}" + ) + external_order = portfolio_provider.get_order( + portfolio, order, market_credential + ) self.update(order.id, external_order.to_dict()) def _create_position_if_not_exists(self, symbol, portfolio): - if not self.position_repository.exists( + if not self.position_service.exists( {"portfolio": portfolio.id, "symbol": symbol} ): - self.position_repository \ + self.position_service \ .create({"portfolio_id": portfolio.id, "symbol": symbol}) - position = self.position_repository \ + position = self.position_service \ .find({"portfolio": portfolio.id, "symbol": symbol}) else: - position = self.position_repository \ + position = self.position_service \ .find({"portfolio": portfolio.id, "symbol": symbol}) return position def _sync_portfolio_with_created_buy_order(self, order): - position = self.position_repository.get(order.position_id) - portfolio = self.portfolio_repository.get(position.portfolio_id) - size = order.get_amount() * order.get_price() - trading_symbol_position = self.position_repository.find( - { - "portfolio": portfolio.id, - "symbol": portfolio.trading_symbol - } + """ + Function to sync the portfolio and positions with a created buy order. + + Args: + order: the order object representing the buy order + + Returns: + None + """ + self.position_service.update_positions_with_created_buy_order( + order ) - portfolio = self.portfolio_repository.update( + position = self.position_service.get(order.position_id) + portfolio = self.portfolio_repository.get(position.portfolio_id) + size = order.get_size() + self.portfolio_repository.update( portfolio.id, {"unallocated": portfolio.get_unallocated() - size} ) - position = self.position_repository.update( - trading_symbol_position.id, - { - "amount": trading_symbol_position.get_amount() - size - } - ) def _sync_portfolio_with_created_sell_order(self, order): """ Function to sync the portfolio with a created sell order. The function will subtract the amount of the order from the position and - the trade amount. + the trade amount. If the sell order is already filled, then + the function will also update the portfolio and the + trading symbol position. The portfolio will not be updated because the sell order has not been filled. Args: order: Order object representing the sell order - stop_loss_ids: List of stop loss order ids - take_profit_ids: List of take profit order ids Returns: None """ - position = self.position_repository.get(order.position_id) - self.position_repository.update( - position.id, - { - "amount": position.get_amount() - order.get_amount() - } + self.position_service.update_positions_with_created_sell_order( + order ) + filled = order.get_filled() + + if filled > 0: + position = self.position_service.get(order.position_id) + portfolio = self.portfolio_repository.get(position.portfolio_id) + size = filled * order.get_price() + self.portfolio_repository.update( + portfolio.id, + { + "unallocated": portfolio.get_unallocated() + size + } + ) + def cancel_order(self, order): self.check_pending_orders() order = self.order_repository.get(order.id) @@ -552,28 +543,38 @@ def cancel_order(self, order): if OrderStatus.OPEN.equals(order.status): portfolio = self.portfolio_repository\ .find({"position": order.position_id}) - self.market_service.cancel_order( - order, market=portfolio.get_market() + market_credential = self.market_credential_service.get( + portfolio.market ) + order_executor = self.get_order_executor(portfolio.market) + order = order_executor\ + .cancel_order(portfolio, order, market_credential) + self.update(order.id, order.to_dict()) def _sync_with_buy_order_filled(self, previous_order, current_order): + """ + Function to sync the portfolio, position and trades with the + filled buy order. + + Args: + previous_order: the previous order object + current_order: the current order object + + Returns: + None + """ + logger.info("Syncing portfolio with filled buy order") filled_difference = current_order.get_filled() - \ - previous_order.get_filled() + previous_order.get_filled() filled_size = filled_difference * current_order.get_price() if filled_difference <= 0: return - # Update position - position = self.position_repository.get(current_order.position_id) - - self.position_repository.update( - position.id, - { - "amount": position.get_amount() + filled_difference, - "cost": position.get_cost() + filled_size, - } + self.position_service.update_positions_with_buy_order_filled( + current_order, filled_difference ) + position = self.position_service.get(current_order.position_id) # Update portfolio portfolio = self.portfolio_repository.get(position.portfolio_id) @@ -591,15 +592,34 @@ def _sync_with_buy_order_filled(self, previous_order, current_order): ) def _sync_with_sell_order_filled(self, previous_order, current_order): + """ + Function to sync the portfolio, position and trades with the + filled sell order. The function will update the portfolio and + position with the filled amount of the order. The function will + also update the trades with the filled amount of the order. + + Args: + previous_order: Order object representing the previous order + current_order: Order object representing the current order + + Returns: + None + """ filled_difference = current_order.get_filled() - \ - previous_order.get_filled() + previous_order.get_filled() filled_size = filled_difference * current_order.get_price() if filled_difference <= 0: return + logger.info( + f"Syncing portfolio with filled sell " + f"order {current_order.get_id()} with filled amount " + f"{filled_difference}" + ) + # Get position - position = self.position_repository.get(current_order.position_id) + position = self.position_service.get(current_order.position_id) # Update the portfolio portfolio = self.portfolio_repository.get(position.portfolio_id) @@ -613,13 +633,13 @@ def _sync_with_sell_order_filled(self, previous_order, current_order): ) # Update the trading symbol position - trading_symbol_position = self.position_repository.find( + trading_symbol_position = self.position_service.find( { "symbol": portfolio.trading_symbol, "portfolio": portfolio.id } ) - self.position_repository.update( + self.position_service.update( trading_symbol_position.id, { "amount": @@ -627,6 +647,18 @@ def _sync_with_sell_order_filled(self, previous_order, current_order): } ) + # Update the position if the amount has changed + if current_order.amount != previous_order.amount: + difference = current_order.amount - previous_order.amount + cost = difference * current_order.get_price() + self.position_service.update( + position.id, + { + "amount": position.get_amount() - difference, + "cost": position.get_cost() - cost + } + ) + self.trade_service.update_trade_with_filled_sell_order( filled_difference, current_order ) @@ -649,13 +681,13 @@ def _sync_with_buy_order_cancelled(self, order): ) # Add the remaining amount to the trading symbol position - trading_symbol_position = self.position_repository.find( + trading_symbol_position = self.position_service.find( { "symbol": portfolio.trading_symbol, "portfolio": portfolio.id } ) - self.position_repository.update( + self.position_service.update( trading_symbol_position.id, { "amount": trading_symbol_position.get_amount() + remaining @@ -666,8 +698,8 @@ def _sync_with_sell_order_cancelled(self, order): remaining = order.get_amount() - order.get_filled() # Add the remaining back to the position - position = self.position_repository.get(order.position_id) - self.position_repository.update( + position = self.position_service.get(order.position_id) + self.position_service.update( position.id, { "amount": position.get_amount() + remaining @@ -693,13 +725,13 @@ def _sync_with_buy_order_failed(self, order): ) # Add the remaining amount to the trading symbol position - trading_symbol_position = self.position_repository.find( + trading_symbol_position = self.position_service.find( { "symbol": portfolio.trading_symbol, "portfolio": portfolio.id } ) - self.position_repository.update( + self.position_service.update( trading_symbol_position.id, { "amount": trading_symbol_position.get_amount() + remaining @@ -710,8 +742,8 @@ def _sync_with_sell_order_failed(self, order): remaining = order.get_amount() - order.get_filled() # Add the remaining back to the position - position = self.position_repository.get(order.position_id) - self.position_repository.update( + position = self.position_service.get(order.position_id) + self.position_service.update( position.id, { "amount": position.get_amount() + remaining @@ -738,13 +770,13 @@ def _sync_with_buy_order_expired(self, order): ) # Add the remaining amount to the trading symbol position - trading_symbol_position = self.position_repository.find( + trading_symbol_position = self.position_service.find( { "symbol": portfolio.trading_symbol, "portfolio": portfolio.id } ) - self.position_repository.update( + self.position_service.update( trading_symbol_position.id, { "amount": trading_symbol_position.get_amount() + remaining @@ -755,8 +787,8 @@ def _sync_with_sell_order_expired(self, order): remaining = order.get_amount() - order.get_filled() # Add the remaining back to the position - position = self.position_repository.get(order.position_id) - self.position_repository.update( + position = self.position_service.get(order.position_id) + self.position_service.update( position.id, { "amount": position.get_amount() + remaining @@ -783,13 +815,13 @@ def _sync_with_buy_order_rejected(self, order): ) # Add the remaining amount to the trading symbol position - trading_symbol_position = self.position_repository.find( + trading_symbol_position = self.position_service.find( { "symbol": portfolio.trading_symbol, "portfolio": portfolio.id } ) - self.position_repository.update( + self.position_service.update( trading_symbol_position.id, { "amount": trading_symbol_position.get_amount() + remaining @@ -800,8 +832,8 @@ def _sync_with_sell_order_rejected(self, order): remaining = order.get_amount() - order.get_filled() # Add the remaining back to the position - position = self.position_repository.get(order.position_id) - self.position_repository.update( + position = self.position_service.get(order.position_id) + self.position_service.update( position.id, { "amount": position.get_amount() + remaining @@ -836,3 +868,23 @@ def create_snapshot(self, portfolio_id, created_at=None): created_orders=created_orders, created_at=created_at ) + + def _create_order_id(self): + """ + Function to create a new order id. The function will + create a new order id and return it. + + Returns: + int: The new order id + """ + + unique = False + order_id = None + + while not unique: + order_id = random_number(12) + + if not self.repository.exists({"id": order_id}): + unique = True + + return order_id diff --git a/investing_algorithm_framework/services/portfolios/__init__.py b/investing_algorithm_framework/services/portfolios/__init__.py index dbe947b1..f1535353 100644 --- a/investing_algorithm_framework/services/portfolios/__init__.py +++ b/investing_algorithm_framework/services/portfolios/__init__.py @@ -3,6 +3,7 @@ from .portfolio_service import PortfolioService from .portfolio_snapshot_service import PortfolioSnapshotService from .portfolio_sync_service import PortfolioSyncService +from .portfolio_provider_lookup import PortfolioProviderLookup __all__ = [ "PortfolioConfigurationService", @@ -10,5 +11,6 @@ "PortfolioSnapshotService", "PortfolioService", "PortfolioSnapshotService", - "BacktestPortfolioService" + "BacktestPortfolioService", + "PortfolioProviderLookup" ] diff --git a/investing_algorithm_framework/services/portfolios/portfolio_configuration_service.py b/investing_algorithm_framework/services/portfolios/portfolio_configuration_service.py index 04526708..e9b1f14f 100644 --- a/investing_algorithm_framework/services/portfolios/portfolio_configuration_service.py +++ b/investing_algorithm_framework/services/portfolios/portfolio_configuration_service.py @@ -30,8 +30,14 @@ def get(self, identifier): None ) - if portfolio_configuration is None: - raise ApiException('Portfolio configuration not found', 404) + # if portfolio_configuration is None: + # raise ApiException( + # f'Portfolio configuration not ' + # f'found for {identifier}' + # " Please make sure that you have registered a portfolio " + # "configuration for the portfolio you are trying to use", + # 404 + # ) return portfolio_configuration diff --git a/investing_algorithm_framework/services/portfolios/portfolio_provider_lookup.py b/investing_algorithm_framework/services/portfolios/portfolio_provider_lookup.py new file mode 100644 index 00000000..b0e8efeb --- /dev/null +++ b/investing_algorithm_framework/services/portfolios/portfolio_provider_lookup.py @@ -0,0 +1,108 @@ +import logging +from collections import defaultdict +from typing import List, Union + +from investing_algorithm_framework.domain import ImproperlyConfigured, \ + PortfolioProvider + +logger = logging.getLogger("investing_algorithm_framework") + + +class PortfolioProviderLookup: + """ + Efficient lookup for portfolio providers based on market in O(1) time. + """ + def __init__(self): + self.portfolio_providers = [] + + # These will be our lookup tables + self.portfolio_provider_lookup = defaultdict() + + def add_portfolio_provider(self, portfolio_provider: PortfolioProvider): + """ + Add a portfolio provider to the lookup table. + + Args: + portfolio_provider (PortfolioProvider): The portfolio provider + to be added. + + Returns: + None + """ + self.portfolio_providers.append(portfolio_provider) + + def register_portfolio_provider_for_market(self, market) -> None: + """ + Register a portfolio provider for a specific market. + This method will create a lookup table for efficient access to + portfolio providers based on market. It will use the + portfolio providers that are currently registered in the + portfolio_providers list. The lookup table will be a dictionary + where the key is the market and the value is the portfolio provider. + + This method will also check if the portfolio provider supports + the market. If no portfolio provider is found for the market, + it will raise an ImproperlyConfigured exception. + + If multiple portfolio providers are found for the market, + it will sort them by priority and pick the best one. + + Args: + market: + + Returns: + None + """ + matches = [] + + for portfolio_provider in self.portfolio_providers: + + if portfolio_provider.supports_market(market): + matches.append(portfolio_provider) + + if len(matches) == 0: + raise ImproperlyConfigured( + f"No portfolio provider found for market " + f"{market}. Cannot configure portfolio." + f" Please make sure that you have registered a portfolio " + f"provider for the market you are trying to use" + ) + + # Sort by priority and pick the best one + best_provider = sorted(matches, key=lambda x: x.priority)[0] + self.portfolio_provider_lookup[market] = best_provider + + def get_portfolio_provider(self, market) -> Union[PortfolioProvider, None]: + """ + Get the portfolio provider for a specific market. + This method will return the portfolio provider for the given market. + If no portfolio provider is found, it will return None. + + Args: + market: + + Returns: + PortfolioProvider: The portfolio provider for the given market. + """ + return self.portfolio_provider_lookup.get(market, None) + + def get_all(self) -> List[PortfolioProvider]: + """ + Get all portfolio providers. + This method will return all portfolio providers that are currently + registered in the portfolio_providers list. + + Returns: + List[PortfolioProvider]: A list of all portfolio providers. + """ + return self.portfolio_providers + + def reset(self): + """ + Function to reset the order executor lookup table + + Returns: + None + """ + self.portfolio_provider_lookup = defaultdict() + self.portfolio_providers = [] diff --git a/investing_algorithm_framework/services/portfolios/portfolio_service.py b/investing_algorithm_framework/services/portfolios/portfolio_service.py index cf9c194a..665e4fea 100644 --- a/investing_algorithm_framework/services/portfolios/portfolio_service.py +++ b/investing_algorithm_framework/services/portfolios/portfolio_service.py @@ -1,8 +1,8 @@ import logging -from datetime import datetime +from datetime import datetime, timezone from investing_algorithm_framework.domain import OrderSide, OrderStatus, \ - OperationalException, MarketService, MarketCredentialService, Portfolio, \ + OperationalException, MarketCredentialService, Portfolio, \ Environment, ENVIRONMENT from investing_algorithm_framework.services.configuration_service import \ ConfigurationService @@ -22,22 +22,22 @@ class PortfolioService(RepositoryService): def __init__( self, configuration_service: ConfigurationService, - market_service: MarketService, market_credential_service: MarketCredentialService, order_service, - portfolio_repository, portfolio_configuration_service, portfolio_snapshot_service, - position_service + position_service, + portfolio_repository, + portfolio_provider_lookup ): + super(PortfolioService, self).__init__(portfolio_repository) self.configuration_service = configuration_service self.market_credential_service = market_credential_service - self.market_service = market_service self.portfolio_configuration_service = portfolio_configuration_service self.order_service = order_service self.portfolio_snapshot_service = portfolio_snapshot_service self.position_service = position_service - super(PortfolioService, self).__init__(portfolio_repository) + self.portfolio_provider_lookup = portfolio_provider_lookup def find(self, query_params): portfolio = self.repository.find(query_params) @@ -46,16 +46,6 @@ def find(self, query_params): portfolio.configuration = portfolio_configuration return portfolio - def get_all(self, query_params=None): - selection = super().get_all(query_params) - - for portfolio in selection: - portfolio_configuration = self.portfolio_configuration_service\ - .get(portfolio.identifier) - portfolio.configuration = portfolio_configuration - - return selection - def create(self, data): unallocated = data.get("unallocated", 0) market = data.get("market") @@ -103,7 +93,7 @@ def create(self, data): def create_snapshot(self, portfolio_id, created_at=None): if created_at is None: - created_at = datetime.utcnow() + created_at = datetime.now(tz=timezone.utc) portfolio = self.get(portfolio_id) pending_orders = self.order_service.get_all( @@ -174,3 +164,37 @@ def create_portfolio_from_configuration( self.create(data) return portfolio + + def update_portfolio_with_filled_order(self, order, filled_amount) -> None: + """ + Function to update the portfolio with filled order. + + Args: + order: Order object + filled_amount: float + + Returns: + None + """ + filled_size = filled_amount * order.get_price() + + if filled_size <= 0: + return + + logger.info( + f"Syncing portfolio with filled sell " + f"order {order.get_id()} with filled amount " + f"{filled_amount}" + ) + + position = self.position_service.get(order.position_id) + portfolio = self.get(position.portfolio_id) + + self.update( + portfolio.id, + { + "unallocated": portfolio.get_unallocated() + filled_size, + "total_trade_volume": + portfolio.get_total_trade_volume() + filled_size, + } + ) diff --git a/investing_algorithm_framework/services/portfolios/portfolio_sync_service.py b/investing_algorithm_framework/services/portfolios/portfolio_sync_service.py index 2979f1af..54aae38f 100644 --- a/investing_algorithm_framework/services/portfolios/portfolio_sync_service.py +++ b/investing_algorithm_framework/services/portfolios/portfolio_sync_service.py @@ -1,8 +1,7 @@ import logging from investing_algorithm_framework.domain import OperationalException, \ - AbstractPortfolioSyncService, RESERVED_BALANCES, SYMBOLS, \ - ENVIRONMENT, Environment + AbstractPortfolioSyncService, ENVIRONMENT, Environment from investing_algorithm_framework.services.trade_service import TradeService logger = logging.getLogger(__name__) @@ -11,6 +10,19 @@ class PortfolioSyncService(AbstractPortfolioSyncService): """ Service to sync the portfolio with the exchange. + + This service will sync the portfolio with the exchange + + Attributes: + trade_service: TradeService object + configuration_service: ConfigurationService object + order_service: OrderService object + position_repository: PositionRepository object + portfolio_repository: PortfolioRepository object + market_credential_service: MarketCredentialService object + market_service: MarketService object + portfolio_configuration_service: PortfolioConfigurationService object + portfolio_provider_lookup: PortfolioProviderLookup object """ def __init__( @@ -22,7 +34,8 @@ def __init__( portfolio_repository, portfolio_configuration_service, market_credential_service, - market_service + market_service, + portfolio_provider_lookup ): self.trade_service = trade_service self.configuration_service = configuration_service @@ -32,6 +45,7 @@ def __init__( self.market_credential_service = market_credential_service self.market_service = market_service self.portfolio_configuration_service = portfolio_configuration_service + self.portfolio_provider_lookup = portfolio_provider_lookup def sync_unallocated(self, portfolio): """ @@ -71,13 +85,17 @@ def sync_unallocated(self, portfolio): f"{portfolio.market}. Cannot sync unallocated amount." ) - # Get the unallocated balance of the portfolio from the exchange - balances = self.market_service.get_balance(market=portfolio.market) + portfolio_provider = self.portfolio_provider_lookup\ + .get_portfolio_provider(portfolio.market) + + position = portfolio_provider.get_position( + portfolio, portfolio.trading_symbol, market_credential + ) if not portfolio.initialized: # Check if the portfolio has an initial balance set if portfolio.initial_balance is not None: - available = float(balances[portfolio.trading_symbol.upper()]) + available = position.amount if portfolio.initial_balance > available: raise OperationalException( @@ -96,7 +114,7 @@ def sync_unallocated(self, portfolio): else: # If the portfolio does not have an initial balance # set, get the available balance on the exchange - if portfolio.trading_symbol.upper() not in balances: + if position is None: raise OperationalException( f"There is no available balance on the exchange for " f"{portfolio.trading_symbol.upper()} on market " @@ -106,9 +124,7 @@ def sync_unallocated(self, portfolio): f"{portfolio.market}." ) else: - unallocated = float( - balances[portfolio.trading_symbol.upper()] - ) + unallocated = position.amount update_data = { "unallocated": unallocated, @@ -134,12 +150,10 @@ def sync_unallocated(self, portfolio): # Check if the portfolio unallocated balance is # available on the exchange if portfolio.unallocated > 0: - if portfolio.trading_symbol.upper() not in balances \ - or portfolio.unallocated > \ - float(balances[portfolio.trading_symbol.upper()]): + if position is None or portfolio.unallocated > position.amount: raise OperationalException( f"Out of sync: the unallocated balance" - " of the portfolio is more than the available" + " of the exiting portfolio is more than the available" " balance on the exchange. Please make sure" " that you have at least " f"{portfolio.unallocated}" @@ -149,64 +163,6 @@ def sync_unallocated(self, portfolio): return portfolio - def sync_positions(self, portfolio): - """ - Method to sync the portfolio balances with the balances - on the exchange. - This method will retrieve the balances from the exchange and update - the portfolio balances accordingly. - - If the unallocated balance of the portfolio is less than the available - balance on the exchange, the unallocated balance of the portfolio will - be updated to match the available balance on the exchange. - - If the unallocated balance of the portfolio is more than the available - balance on the exchange, an OperationalException will be raised. - """ - portfolio_configuration = self.portfolio_configuration_service \ - .get(portfolio.identifier) - balances = self.market_service \ - .get_balance(market=portfolio_configuration.market) - reserved_balances = self.configuration_service.config \ - .get(RESERVED_BALANCES, {}) - symbols = self._get_symbols(portfolio) - - # If config symbols is set, add the symbols to the balances - if SYMBOLS in self.configuration_service.config \ - and self.configuration_service.config[SYMBOLS] is not None: - for symbol in symbols: - target_symbol = symbol.split("/")[0] - - if target_symbol not in balances: - balances[target_symbol] = 0 - - for key, value in balances.items(): - logger.info(f"Syncing balance for {key}") - amount = float(value) - - if key in reserved_balances: - logger.info( - f"{key} has reserved balance of {reserved_balances[key]}" - ) - reserved = float(reserved_balances[key]) - amount = amount - reserved - - if self.position_repository.exists({"symbol": key}): - position = self.position_repository.find({"symbol": key}) - data = {"amount": amount} - self.position_repository.update(position.id, data) - else: - portfolio = self.portfolio_repository.find( - {"identifier": portfolio.identifier} - ) - self.position_repository.create( - { - "symbol": key, - "amount": amount, - "portfolio_id": portfolio.id - } - ) - def sync_orders(self, portfolio): """ Function to sync all local orders with the orders on the exchange. @@ -227,50 +183,4 @@ def sync_orders(self, portfolio): and Environment.BACKTEST.equals(config[ENVIRONMENT]): return - self.order_service.check_pending_orders(portfolio=portfolio) - - def _get_symbols(self, portfolio): - config = self.configuration_service.config - available_symbols = [] - - # Check if there already are positions - positions = self.position_repository.get_all( - {"identifier": portfolio.get_identifier()} - ) - - if len(positions) > 0: - available_symbols = [ - f"{position.get_symbol()}/{portfolio.get_trading_symbol()}" - for position in positions - if position.get_symbol() != portfolio.trading_symbol - ] - - if SYMBOLS in config and config[SYMBOLS] is not None: - symbols = config[SYMBOLS] - - if not isinstance(symbols, list): - raise OperationalException( - "The symbols configuration should be a list of strings" - ) - - market_symbols = self.market_service.get_symbols(portfolio.market) - - for symbol in symbols: - - if symbol not in market_symbols: - raise OperationalException( - f"The symbol {symbol} in the configuration is not " - "available on the exchange. Please make sure that the " - "symbols in the configuration are available on the " - "exchange. The available symbols on the exchange are: " - f"{market_symbols}" - ) - else: - - if symbol not in available_symbols: - available_symbols.append(symbol) - else: - market_symbols = self.market_service.get_symbols(portfolio.market) - available_symbols = market_symbols - - return available_symbols + self.order_service.check_pending_orders(portfolio) diff --git a/investing_algorithm_framework/services/position_service.py b/investing_algorithm_framework/services/position_service.py deleted file mode 100644 index c249a59d..00000000 --- a/investing_algorithm_framework/services/position_service.py +++ /dev/null @@ -1,29 +0,0 @@ -from investing_algorithm_framework.domain import MarketService -from investing_algorithm_framework.services.repository_service import \ - RepositoryService - - -class PositionService(RepositoryService): - - def __init__( - self, - repository, - market_service: MarketService, - market_credential_service - ): - super().__init__(repository) - self._market_service: MarketService = market_service - self._market_credentials_service = market_credential_service - - def close_position(self, position_id, portfolio): - self._market_service.market_data_credentials = \ - self._market_credentials_service.get_all() - position = self.get(position_id) - - if position.amount > 0: - self._market_service.create_market_sell_order( - position.symbol, - portfolio.get_trading_symbol(), - position.amount, - market=portfolio.get_market(), - ) diff --git a/investing_algorithm_framework/services/positions/__init__.py b/investing_algorithm_framework/services/positions/__init__.py new file mode 100644 index 00000000..59e4967d --- /dev/null +++ b/investing_algorithm_framework/services/positions/__init__.py @@ -0,0 +1,7 @@ +from .position_service import PositionService +from .position_snapshot_service import PositionSnapshotService + +__all__ = [ + "PositionService", + "PositionSnapshotService" +] diff --git a/investing_algorithm_framework/services/positions/position_service.py b/investing_algorithm_framework/services/positions/position_service.py new file mode 100644 index 00000000..b5dbfac3 --- /dev/null +++ b/investing_algorithm_framework/services/positions/position_service.py @@ -0,0 +1,210 @@ +import logging + +from investing_algorithm_framework.services.repository_service import \ + RepositoryService + + +logger = logging.getLogger("investing_algorithm_framework") + + +class PositionService(RepositoryService): + + def __init__(self, repository, portfolio_repository): + """ + Initialize the PositionService. + + Args: + repository (Repository): The repository to use for storing + positions. + portfolio_repository (Repository): The repository to use for + storing portfolios. + """ + super().__init__(repository) + self.portfolio_repository = portfolio_repository + + def update(self, position_id, data): + """ + Function to update a position. + + Args: + position_id (str): The id of the position to update. + data (dict): The data to update the position with. + + Returns: + Position: The updated position. + """ + position = self.get(position_id) + logger.info( + f"Updating position {position_id} ({position.get_symbol()}) " + f"with data: {data}" + ) + return super().update(position_id, data) + + def update_positions_with_created_buy_order(self, order): + """ + Function to update positions with created order. + If the order is filled then also the amount of the position + is updated. + + Args: + order (Order): The order that has been created. + + Returns: + None + """ + position = self.get(order.position_id) + portfolio = self.portfolio_repository.get(position.portfolio_id) + size = order.get_size() + filled = order.get_filled() + + logger.info( + f"Syncing trading symbol {portfolio.get_trading_symbol()} " + "position with created buy " + f"order {order.get_id()} with size {size}" + ) + trading_symbol_position = self.find( + { + "portfolio": portfolio.id, + "symbol": portfolio.trading_symbol + } + ) + self.update( + trading_symbol_position.id, + { + "amount": trading_symbol_position.get_amount() - size + } + ) + + if filled > 0: + logger.info( + f"Syncing position {position.get_symbol()} with created buy " + f"order {order.get_id()} with filled size {order.get_filled()}" + ) + self.update( + position.id, + { + "amount": position.get_amount() + order.get_filled(), + "cost": position.get_cost() + size, + } + ) + + def update_positions_with_buy_order_filled(self, order, filled_amount): + """ + Function to update positions with filled order. + + Args: + order (Order): The order that has been filled. + filled_amount (float): The amount that has been filled. + + Returns: + None + """ + # Calculate the filled size + filled_size = filled_amount * order.get_price() + + if filled_amount <= 0: + return + + logger.info( + f"Syncing position with filled buy " + f"order {order.get_id()} with filled amount " + f"{filled_amount}" + ) + + # Update the position + position = self.get(order.position_id) + self.update( + position.id, + { + "amount": position.get_amount() + filled_amount, + "cost": + position.get_cost() + filled_size + } + ) + + def update_positions_with_created_sell_order(self, order): + """ + Function to update positions with created order. + If the order is filled then also the amount of the position + is updated. + + Args: + order (Order): The order that has been created. + + Returns: + None + """ + position = self.get(order.position_id) + portfolio = self.portfolio_repository.get(position.portfolio_id) + filled = order.get_filled() + filled_size = filled * order.get_price() + + logger.info( + f"Syncing position {position.get_symbol()} " + "with created sell " + f"order {order.get_id()} with amount {order.get_amount()}" + ) + self.update( + position.id, + { + "amount": position.get_amount() - order.get_amount(), + } + ) + + if filled > 0: + + logger.info( + f"Syncing trading symbol {portfolio.get_trading_symbol()} " + "position with created sell " + f"order {order.get_id()} with filled size {filled_size}" + ) + trading_symbol_position = self.find( + { + "portfolio": portfolio.id, + "symbol": portfolio.trading_symbol + } + ) + self.update( + trading_symbol_position.id, + { + "amount": + trading_symbol_position.get_amount() + filled_size + } + ) + + def update_positions_with_sell_filled_order(self, order, filled_amount): + """ + Function to update positions with filled order. + + Args: + order: + filled_amount: + + Returns: + + """ + position = self.get(order.position_id) + portfolio = self.portfolio_repository.get(position.portfolio_id) + trading_symbol_position = self.find( + { + "portfolio": portfolio.id, + "symbol": portfolio.trading_symbol + } + ) + filled_size = filled_amount * order.get_price() + + logger.info( + "Syncing trading symbol position " + f"{portfolio.get_trading_symbol()} " + f"with filled sell " + f"order {order.get_id()} with filled size " + f"{filled_size} {portfolio.get_trading_symbol()}" + ) + # Update the trading symbol position + self.update( + trading_symbol_position.id, + { + "amount": + trading_symbol_position.get_amount() + filled_size + } + ) diff --git a/investing_algorithm_framework/services/position_snapshot_service.py b/investing_algorithm_framework/services/positions/position_snapshot_service.py similarity index 100% rename from investing_algorithm_framework/services/position_snapshot_service.py rename to investing_algorithm_framework/services/positions/position_snapshot_service.py diff --git a/investing_algorithm_framework/services/strategy_orchestrator_service.py b/investing_algorithm_framework/services/strategy_orchestrator_service.py index d2d2fe26..594f778a 100644 --- a/investing_algorithm_framework/services/strategy_orchestrator_service.py +++ b/investing_algorithm_framework/services/strategy_orchestrator_service.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timezone import schedule @@ -91,7 +91,8 @@ def run_strategy(self, strategy, context, sync=False): thread.start() self.threads.append(thread) - self.history[strategy.worker_id] = {"last_run": datetime.utcnow()} + self.history[strategy.worker_id] = \ + {"last_run": datetime.now(tz=timezone.utc)} def run_backtest_strategy(self, strategy, context, config): data = \ @@ -128,7 +129,8 @@ def run_task(self, task, context, sync=False): thread.start() self.threads.append(thread) - self.history[task.worker_id] = {"last_run": datetime.utcnow()} + self.history[task.worker_id] = \ + {"last_run": datetime.now(tz=timezone.utc)} def start(self, context, number_of_iterations=None): """ diff --git a/investing_algorithm_framework/services/trade_service/trade_service.py b/investing_algorithm_framework/services/trade_service/trade_service.py index 6e2246cf..47fce547 100644 --- a/investing_algorithm_framework/services/trade_service/trade_service.py +++ b/investing_algorithm_framework/services/trade_service/trade_service.py @@ -1,11 +1,12 @@ import logging from datetime import datetime, timezone from queue import PriorityQueue +from typing import Union from investing_algorithm_framework.domain import OrderStatus, TradeStatus, \ - Trade, OperationalException, TradeRiskType, PeekableQueue, OrderType, \ - OrderSide, MarketDataType, Environment, ENVIRONMENT, \ - BACKTESTING_INDEX_DATETIME + Trade, OperationalException, TradeRiskType, OrderType, \ + OrderSide, MarketDataType, Environment, ENVIRONMENT, PeekableQueue, \ + BACKTESTING_INDEX_DATETIME, random_number, random_string from investing_algorithm_framework.services.repository_service import \ RepositoryService @@ -42,7 +43,7 @@ def __init__( self.trade_take_profit_repository = trade_take_profit_repository self.order_metadata_repository = order_metadata_repository - def create_trade_from_buy_order(self, buy_order) -> Trade: + def create_trade_from_buy_order(self, buy_order) -> Union[Trade, None]: """ Function to create a trade from a buy order. If the given buy order has its status set to CANCELED, EXPIRED, or REJECTED, @@ -54,7 +55,7 @@ def create_trade_from_buy_order(self, buy_order) -> Trade: buy_order: Order object representing the buy order Returns: - Trade object + Union[Trade, None] Representing the created trade object or None """ if buy_order.status in \ @@ -69,14 +70,15 @@ def create_trade_from_buy_order(self, buy_order) -> Trade: "buy_order": buy_order, "target_symbol": buy_order.target_symbol, "trading_symbol": buy_order.trading_symbol, - "amount": buy_order.amount, - "filled_amount": buy_order.filled, - "remaining": buy_order.filled, + "amount": buy_order.get_amount(), + "available_amount": buy_order.get_filled(), + "filled_amount": buy_order.get_filled(), + "remaining": buy_order.get_remaining(), "opened_at": buy_order.created_at, - "cost": buy_order.filled * buy_order.price + "cost": buy_order.get_filled() * buy_order.price } - if buy_order.filled > 0: + if buy_order.get_filled() > 0: data["status"] = TradeStatus.OPEN.value data["cost"] = buy_order.filled * buy_order.price @@ -104,18 +106,18 @@ def _create_trade_metadata_with_sell_order(self, sell_order): "portfolio_id": portfolio_id }) updated_at = sell_order.updated_at + total_available_to_close = 0 amount_to_close = sell_order.amount trade_queue = PriorityQueue() - total_remaining = 0 sell_order_id = sell_order.id sell_price = sell_order.price for trade in matching_trades: - if trade.remaining > 0: - total_remaining += trade.remaining + if trade.available_amount > 0: + total_available_to_close += trade.available_amount trade_queue.put(trade) - if total_remaining < amount_to_close: + if total_available_to_close < amount_to_close: raise OperationalException( "Not enough amount to close in trades." ) @@ -124,23 +126,21 @@ def _create_trade_metadata_with_sell_order(self, sell_order): while amount_to_close > 0 and not trade_queue.empty(): trade = trade_queue.get() trade_id = trade.id - available_to_close = trade.remaining - cost = 0 + available_to_close = trade.available_amount if amount_to_close >= available_to_close: amount_to_close = amount_to_close - available_to_close cost = trade.open_price * available_to_close net_gain = (sell_price * available_to_close) - cost - update_data = { - "remaining": 0, + "available_amount": 0, "orders": trade.orders.append(sell_order), "updated_at": updated_at, "closed_at": updated_at, "net_gain": trade.net_gain + net_gain } - if trade.filled_amount == trade.amount: + if trade.remaining == 0: update_data["status"] = TradeStatus.CLOSED.value self.update(trade_id, update_data) @@ -161,7 +161,8 @@ def _create_trade_metadata_with_sell_order(self, sell_order): self.update( trade_id, { - "remaining": trade.remaining - to_be_closed, + "available_amount": + trade.available_amount - to_be_closed, "orders": trade.orders.append(sell_order), "updated_at": updated_at, "net_gain": trade.net_gain + net_gain @@ -169,7 +170,7 @@ def _create_trade_metadata_with_sell_order(self, sell_order): ) self.repository.add_order_to_trade(trade, sell_order) - # Create a order metadata object + # Create an order metadata object self.order_metadata_repository.\ create({ "order_id": sell_order_id, @@ -281,6 +282,18 @@ def _create_trade_metadata_with_sell_order_and_trades( self, sell_order, trades ): """ + Function to create trade metadata with a sell order and trades. + + The metadata objects function as a link between the trades and + the sell order. The metadata objects are used to keep track + of the trades that are closed with the sell order. + + A single sell order can close or partially close multiple trades. + Therefore it is important to keep track of the trades that are + closed with the sell order. The metadata objects are used to + keep track of this relationship. + + """ sell_order_id = sell_order.id updated_at = sell_order.updated_at @@ -291,8 +304,8 @@ def _create_trade_metadata_with_sell_order_and_trades( trade = self.get(trade_data["trade_id"]) trade_id = trade.id open_price = trade.open_price - remaining = trade.remaining old_net_gain = trade.net_gain + available_amount = trade.available_amount filled_amount = trade.filled_amount amount = trade.amount @@ -309,14 +322,14 @@ def _create_trade_metadata_with_sell_order_and_trades( # Update the trade net_gain = (sell_price * sell_amount) - open_price * sell_amount - remaining = remaining - trade_data["amount"] + available_amount = available_amount - trade_data["amount"] trade_updated_data = { - "remaining": remaining, + "available_amount": available_amount, "updated_at": updated_at, "net_gain": old_net_gain + net_gain } - if remaining == 0 and filled_amount == amount: + if available_amount == 0 and filled_amount == amount: trade_updated_data["status"] = TradeStatus.CLOSED.value trade_updated_data["closed_at"] = updated_at else: @@ -429,11 +442,20 @@ def update_trade_with_removed_sell_order( self, sell_order ) -> Trade: """ - This function updates a trade with a removed sell order. It does - this by removing the sell transaction object from the trade object. + This function updates a trade with a removed sell order that belongs + to the trade. This function uses the order metadata objects to + update the trade object. The function will update the trade object + available amount, cost, and net gain. The function will also + update the stop loss and take profit objects that are associated + with the trade object. The function will update the position cost + and the portfolio net gain. - At time of removing, the remaining amount of the sell transaction - is added back to the trade object. + Args: + sell_order (Order): Order object representing the sell order + that has been removed + + Returns: + Trade: Trade object representing the updated trade object """ position_cost = 0 total_net_gain = 0 @@ -450,7 +472,7 @@ def update_trade_with_removed_sell_order( trade = self.get(metadata.trade_id) cost = metadata.amount_pending * trade.open_price net_gain = (sell_order.price * metadata.amount_pending) - cost - trade.remaining += metadata.amount_pending + trade.available_amount += metadata.amount_pending trade.status = TradeStatus.OPEN.value trade.updated_at = sell_order.updated_at trade.net_gain -= net_gain @@ -465,18 +487,27 @@ def update_trade_with_removed_sell_order( stop_loss = self.trade_stop_loss_repository\ .get(metadata.stop_loss_id) stop_loss.sold_amount -= metadata.amount_pending + stop_loss.remove_sell_price( + sell_order.price, sell_order.created_at + ) if stop_loss.sold_amount < stop_loss.sell_amount: stop_loss.active = True + stop_loss.high_water_mark = None + self.trade_stop_loss_repository.save(stop_loss) if metadata.take_profit_id is not None: take_profit = self.trade_take_profit_repository\ .get(metadata.take_profit_id) take_profit.sold_amount -= metadata.amount_pending + take_profit.remove_sell_price( + sell_order.price, sell_order.created_at + ) if take_profit.sold_amount < take_profit.sell_amount: take_profit.active = True + take_profit.high_water_mark = None self.trade_take_profit_repository.save(take_profit) @@ -491,6 +522,7 @@ def update_trade_with_removed_sell_order( portfolio = self.portfolio_repository.get(position.portfolio_id) portfolio.total_net_gain -= total_net_gain self.portfolio_repository.save(portfolio) + return trade def update_trade_with_buy_order( self, filled_difference, buy_order @@ -512,8 +544,17 @@ def update_trade_with_buy_order( Returns: Trade object """ - trade = self.find({"buy_order": buy_order.id}) + filled = buy_order.get_filled() + amount = buy_order.get_amount() + + if filled is None: + filled = trade.filled_amount + filled_difference + + remaining = buy_order.get_remaining() + + if remaining is None: + remaining = trade.remaining - filled_difference if trade is None: raise OperationalException( @@ -532,11 +573,16 @@ def update_trade_with_buy_order( trade = self.find({"order_id": buy_order.id}) updated_data = { - "filled_amount": trade.filled_amount + filled_difference, - "remaining": trade.remaining + filled_difference, + "available_amount": trade.available_amount + filled_difference, + "filled_amount": filled, + "remaining": remaining, "cost": trade.cost + filled_difference * buy_order.price } + if amount != trade.amount: + updated_data["amount"] = amount + updated_data["cost"] = amount * buy_order.price + if filled_difference > 0: updated_data["status"] = TradeStatus.OPEN.value @@ -547,6 +593,11 @@ def update_trade_with_filled_sell_order( self, filled_difference, sell_order ) -> Trade: """ + Function to update a trade with a filled sell order. This + function will update all the metadata objects that where + created by the sell order. + + """ # Update all metadata objects metadata_objects = self.order_metadata_repository.get_all({ @@ -556,12 +607,17 @@ def update_trade_with_filled_sell_order( trade_filled_difference = filled_difference stop_loss_filled_difference = filled_difference take_profit_filled_difference = filled_difference + total_amount_in_metadata = 0 + trade_metadata_objects = [] for metadata_object in metadata_objects: - + # Update the trade metadata object if metadata_object.trade_id is not None \ and trade_filled_difference > 0: + trade_metadata_objects.append(metadata_object) + total_amount_in_metadata += metadata_object.amount + if metadata_object.amount_pending >= trade_filled_difference: amount = trade_filled_difference trade_filled_difference = 0 @@ -604,7 +660,42 @@ def update_trade_with_filled_sell_order( metadata_object.amount_pending -= amount self.order_metadata_repository.save(metadata_object) + # Update trade available amount if the total amount in metadata + # is not equal to the sell order amount + if total_amount_in_metadata != sell_order.amount: + difference = sell_order.amount - total_amount_in_metadata + trades = [] + + for metadata_object in trade_metadata_objects: + trade = self.get(metadata_object.trade_id) + trades.append(trade) + + # Sort trades by created_at with the most recent first + trades = sorted( + trades, + key=lambda x: x.updated_at, + reverse=True + ) + queue = PeekableQueue(trades) + + while difference != 0 and not queue.is_empty(): + trade = queue.dequeue() + trade.available_amount -= difference + self.save(trade) + def update_trades_with_market_data(self, market_data): + """ + Function to update trades with market data. This function will + update the last reported price and last reported price date of the + trade. + + Args: + market_data: dict representing the market data + that will be used to update the trades + + Returns: + None + """ open_trades = self.get_all({"status": TradeStatus.OPEN.value}) meta_data = market_data["metadata"] @@ -789,7 +880,7 @@ def get_triggered_stop_loss_orders(self): for trade in stop_losses_by_target_symbol: stop_losses = stop_losses_by_target_symbol[trade] - available_amount = trade.remaining + available_amount = trade.available_amount stop_loss_que = PeekableQueue(stop_losses) order_amount = 0 stop_loss_metadata = [] @@ -824,14 +915,10 @@ def get_triggered_stop_loss_orders(self): "stop_loss_id": stop_loss.id, "amount": stop_loss_sell_amount }) - - if stop_loss.sell_prices is None: - stop_loss.sell_prices = trade.last_reported_price - else: - stop_loss.sell_prices = ( - f"{stop_loss.sell_prices}," - f"{trade.last_reported_price}" - ) + stop_loss.add_sell_price( + trade.last_reported_price, + trade.last_reported_price_datetime + ) position = self.position_repository.find({ "order_id": trade.orders[0].id @@ -864,7 +951,8 @@ def get_triggered_take_profit_orders(self): return a list of trade ids that have triggered stop losses. Returns: - List of trade ids + List of trade objects. A trade object is a dictionary + """ triggered_take_profits = {} sell_orders_data = [] @@ -877,7 +965,7 @@ def get_triggered_take_profit_orders(self): for open_trade in open_trades: triggered_take_profits = [] - available_amount = open_trade.remaining + available_amount = open_trade.available_amount # Skip if there is no available amount if available_amount == 0: @@ -899,7 +987,7 @@ def get_triggered_take_profit_orders(self): for trade in take_profits_by_target_symbol: take_profits = take_profits_by_target_symbol[trade] - available_amount = trade.remaining + available_amount = trade.available_amount take_profit_que = PeekableQueue(take_profits) order_amount = 0 take_profit_metadata = [] @@ -936,13 +1024,10 @@ def get_triggered_take_profit_orders(self): "amount": take_profit_sell_amount }) - if take_profit.sell_prices is None: - take_profit.sell_prices = trade.last_reported_price - else: - take_profit.sell_prices = ( - f"{take_profit.sell_prices}," - f"{trade.last_reported_price}" - ) + take_profit.add_sell_price( + trade.last_reported_price, + trade.last_reported_price_datetime + ) position = self.position_repository.find({ "order_id": trade.orders[0].id @@ -968,3 +1053,23 @@ def get_triggered_take_profit_orders(self): self.trade_take_profit_repository\ .save_objects(to_be_saved_take_profit_objects) return sell_orders_data + + def _create_order_id(self) -> str: + """ + Function to create a unique order id. This function will + create a unique order id based on the current time and + the order id counter. + + Returns: + str: Unique order id + """ + unique = False + order_id = None + + while not unique: + order_id = f"{random_number(8)}-{random_string(8)}" + + if not self.exists({"order_id": order_id}): + unique = True + + return order_id diff --git a/investing_algorithm_framework/test.py b/investing_algorithm_framework/test.py deleted file mode 100644 index d37eb535..00000000 --- a/investing_algorithm_framework/test.py +++ /dev/null @@ -1,16 +0,0 @@ -from investing_algorithm_framework import download - - -if __name__ == "__main__": - data = download( - symbol="BTC/USDT", - market="binance", - data_type="ohlcv", - start_date="2023-01-01", - end_date="2023-10-01", - window_size=200, - pandas=True, - save=True, - storage_path="./data" - ) - print(data) diff --git a/poetry.lock b/poetry.lock index b6736fb0..421a574d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "aiodns" -version = "3.2.0" +version = "3.4.0" description = "Simple DNS resolver for asyncio" optional = false -python-versions = "*" +python-versions = ">=3.9" files = [ - {file = "aiodns-3.2.0-py3-none-any.whl", hash = "sha256:e443c0c27b07da3174a109fd9e736d69058d808f144d3c9d56dbd1776964c5f5"}, - {file = "aiodns-3.2.0.tar.gz", hash = "sha256:62869b23409349c21b072883ec8998316b234c9a9e36675756e8e317e8768f72"}, + {file = "aiodns-3.4.0-py3-none-any.whl", hash = "sha256:4da2b25f7475343f3afbb363a2bfe46afa544f2b318acb9a945065e622f4ed24"}, + {file = "aiodns-3.4.0.tar.gz", hash = "sha256:24b0ae58410530367f21234d0c848e4de52c1f16fbddc111726a4ab536ec1b2f"}, ] [package.dependencies] @@ -351,13 +351,13 @@ files = [ [[package]] name = "azure-core" -version = "1.33.0" +version = "1.34.0" description = "Microsoft Azure Core Library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "azure_core-1.33.0-py3-none-any.whl", hash = "sha256:9b5b6d0223a1d38c37500e6971118c1e0f13f54951e6893968b38910bc9cda8f"}, - {file = "azure_core-1.33.0.tar.gz", hash = "sha256:f367aa07b5e3005fec2c1e184b882b0b039910733907d001c20fb08ebb8c0eb9"}, + {file = "azure_core-1.34.0-py3-none-any.whl", hash = "sha256:0615d3b756beccdb6624d1c0ae97284f38b78fb59a2a9839bf927c66fbbdddd6"}, + {file = "azure_core-1.34.0.tar.gz", hash = "sha256:bdb544989f246a0ad1c85d72eeb45f2f835afdcbc5b45e43f0dbde7461c81ece"}, ] [package.dependencies] @@ -371,13 +371,13 @@ tracing = ["opentelemetry-api (>=1.26,<2.0)"] [[package]] name = "azure-identity" -version = "1.21.0" +version = "1.23.0" description = "Microsoft Azure Identity Library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "azure_identity-1.21.0-py3-none-any.whl", hash = "sha256:258ea6325537352440f71b35c3dffe9d240eae4a5126c1b7ce5efd5766bd9fd9"}, - {file = "azure_identity-1.21.0.tar.gz", hash = "sha256:ea22ce6e6b0f429bc1b8d9212d5b9f9877bd4c82f1724bfa910760612c07a9a6"}, + {file = "azure_identity-1.23.0-py3-none-any.whl", hash = "sha256:dbbeb64b8e5eaa81c44c565f264b519ff2de7ff0e02271c49f3cb492762a50b0"}, + {file = "azure_identity-1.23.0.tar.gz", hash = "sha256:d9cdcad39adb49d4bb2953a217f62aec1f65bbb3c63c9076da2be2a47e53dde4"}, ] [package.dependencies] @@ -594,13 +594,13 @@ files = [ [[package]] name = "ccxt" -version = "4.4.77" +version = "4.4.82" description = "A JavaScript / TypeScript / Python / C# / PHP cryptocurrency trading library with support for 100+ exchanges" optional = false python-versions = "*" files = [ - {file = "ccxt-4.4.77-py2.py3-none-any.whl", hash = "sha256:024c47093d9de7591bd88413a37eb213e628bb4a43a5e15a04e3fd8359bfb498"}, - {file = "ccxt-4.4.77.tar.gz", hash = "sha256:9dee605054be8cd4b12d8640018c32dbe48e351bb3869080a357a4bd6026e436"}, + {file = "ccxt-4.4.82-py2.py3-none-any.whl", hash = "sha256:fa67d2504145ae62846914cb6715067474f75d3d4c89f86ba3b3ea15a3768b12"}, + {file = "ccxt-4.4.82.tar.gz", hash = "sha256:d2b3423009f0d6287fc4b4f9dac7ca6c0102aaa2deb0c6e703cf30a836e624a6"}, ] [package.dependencies] @@ -709,114 +709,114 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, ] [[package]] name = "click" -version = "8.1.8" +version = "8.2.0" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c"}, + {file = "click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d"}, ] [package.dependencies] @@ -916,46 +916,48 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "44.0.2" +version = "44.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" files = [ - {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, - {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, - {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, - {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, - {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, - {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, - {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, + {file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"}, + {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"}, + {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"}, + {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"}, + {file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"}, + {file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"}, + {file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"}, + {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"}, + {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"}, + {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"}, + {file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"}, + {file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"}, + {file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"}, + {file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"}, + {file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"}, ] [package.dependencies] @@ -968,7 +970,7 @@ nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1128,15 +1130,18 @@ yaml = ["pyyaml"] [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + [package.extras] test = ["pytest (>=6)"] @@ -1186,21 +1191,22 @@ pyflakes = ">=3.2.0,<3.3.0" [[package]] name = "flask" -version = "3.1.0" +version = "3.1.1" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.9" files = [ - {file = "flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136"}, - {file = "flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac"}, + {file = "flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c"}, + {file = "flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e"}, ] [package.dependencies] -blinker = ">=1.9" +blinker = ">=1.9.0" click = ">=8.1.3" -itsdangerous = ">=2.2" -Jinja2 = ">=3.1.2" -Werkzeug = ">=3.1" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" [package.extras] async = ["asgiref (>=3.2)"] @@ -1394,66 +1400,66 @@ files = [ [[package]] name = "greenlet" -version = "3.2.1" +version = "3.2.2" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.9" files = [ - {file = "greenlet-3.2.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:777c1281aa7c786738683e302db0f55eb4b0077c20f1dc53db8852ffaea0a6b0"}, - {file = "greenlet-3.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3059c6f286b53ea4711745146ffe5a5c5ff801f62f6c56949446e0f6461f8157"}, - {file = "greenlet-3.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e1a40a17e2c7348f5eee5d8e1b4fa6a937f0587eba89411885a36a8e1fc29bd2"}, - {file = "greenlet-3.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5193135b3a8d0017cb438de0d49e92bf2f6c1c770331d24aa7500866f4db4017"}, - {file = "greenlet-3.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:639a94d001fe874675b553f28a9d44faed90f9864dc57ba0afef3f8d76a18b04"}, - {file = "greenlet-3.2.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fe303381e7e909e42fb23e191fc69659910909fdcd056b92f6473f80ef18543"}, - {file = "greenlet-3.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:72c9b668454e816b5ece25daac1a42c94d1c116d5401399a11b77ce8d883110c"}, - {file = "greenlet-3.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6079ae990bbf944cf66bea64a09dcb56085815630955109ffa98984810d71565"}, - {file = "greenlet-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:e63cd2035f49376a23611fbb1643f78f8246e9d4dfd607534ec81b175ce582c2"}, - {file = "greenlet-3.2.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aa30066fd6862e1153eaae9b51b449a6356dcdb505169647f69e6ce315b9468b"}, - {file = "greenlet-3.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b0f3a0a67786facf3b907a25db80efe74310f9d63cc30869e49c79ee3fcef7e"}, - {file = "greenlet-3.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64a4d0052de53ab3ad83ba86de5ada6aeea8f099b4e6c9ccce70fb29bc02c6a2"}, - {file = "greenlet-3.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852ef432919830022f71a040ff7ba3f25ceb9fe8f3ab784befd747856ee58530"}, - {file = "greenlet-3.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4818116e75a0dd52cdcf40ca4b419e8ce5cb6669630cb4f13a6c384307c9543f"}, - {file = "greenlet-3.2.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9afa05fe6557bce1642d8131f87ae9462e2a8e8c46f7ed7929360616088a3975"}, - {file = "greenlet-3.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5c12f0d17a88664757e81a6e3fc7c2452568cf460a2f8fb44f90536b2614000b"}, - {file = "greenlet-3.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dbb4e1aa2000852937dd8f4357fb73e3911da426df8ca9b8df5db231922da474"}, - {file = "greenlet-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:cb5ee928ce5fedf9a4b0ccdc547f7887136c4af6109d8f2fe8e00f90c0db47f5"}, - {file = "greenlet-3.2.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:0ba2811509a30e5f943be048895a983a8daf0b9aa0ac0ead526dfb5d987d80ea"}, - {file = "greenlet-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4245246e72352b150a1588d43ddc8ab5e306bef924c26571aafafa5d1aaae4e8"}, - {file = "greenlet-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7abc0545d8e880779f0c7ce665a1afc3f72f0ca0d5815e2b006cafc4c1cc5840"}, - {file = "greenlet-3.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dcc6d604a6575c6225ac0da39df9335cc0c6ac50725063fa90f104f3dbdb2c9"}, - {file = "greenlet-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2273586879affca2d1f414709bb1f61f0770adcabf9eda8ef48fd90b36f15d12"}, - {file = "greenlet-3.2.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff38c869ed30fff07f1452d9a204ece1ec6d3c0870e0ba6e478ce7c1515acf22"}, - {file = "greenlet-3.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e934591a7a4084fa10ee5ef50eb9d2ac8c4075d5c9cf91128116b5dca49d43b1"}, - {file = "greenlet-3.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:063bcf7f8ee28eb91e7f7a8148c65a43b73fbdc0064ab693e024b5a940070145"}, - {file = "greenlet-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7132e024ebeeeabbe661cf8878aac5d2e643975c4feae833142592ec2f03263d"}, - {file = "greenlet-3.2.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac"}, - {file = "greenlet-3.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437"}, - {file = "greenlet-3.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a"}, - {file = "greenlet-3.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c"}, - {file = "greenlet-3.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982"}, - {file = "greenlet-3.2.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07"}, - {file = "greenlet-3.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95"}, - {file = "greenlet-3.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123"}, - {file = "greenlet-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:24a496479bc8bd01c39aa6516a43c717b4cee7196573c47b1f8e1011f7c12495"}, - {file = "greenlet-3.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526"}, - {file = "greenlet-3.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5"}, - {file = "greenlet-3.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32"}, - {file = "greenlet-3.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc"}, - {file = "greenlet-3.2.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb"}, - {file = "greenlet-3.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8"}, - {file = "greenlet-3.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d"}, - {file = "greenlet-3.2.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189"}, - {file = "greenlet-3.2.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:17964c246d4f6e1327edd95e2008988a8995ae3a7732be2f9fc1efed1f1cdf8c"}, - {file = "greenlet-3.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04b4ec7f65f0e4a1500ac475c9343f6cc022b2363ebfb6e94f416085e40dea15"}, - {file = "greenlet-3.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b38d53cf268da963869aa25a6e4cc84c1c69afc1ae3391738b2603d110749d01"}, - {file = "greenlet-3.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05a7490f74e8aabc5f29256765a99577ffde979920a2db1f3676d265a3adba41"}, - {file = "greenlet-3.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4339b202ac20a89ccd5bde0663b4d00dc62dd25cb3fb14f7f3034dec1b0d9ece"}, - {file = "greenlet-3.2.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a750f1046994b9e038b45ae237d68153c29a3a783075211fb1414a180c8324b"}, - {file = "greenlet-3.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:374ffebaa5fbd10919cd599e5cf8ee18bae70c11f9d61e73db79826c8c93d6f9"}, - {file = "greenlet-3.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8b89e5d44f55372efc6072f59ced5ed1efb7b44213dab5ad7e0caba0232c6545"}, - {file = "greenlet-3.2.1-cp39-cp39-win32.whl", hash = "sha256:b7503d6b8bbdac6bbacf5a8c094f18eab7553481a1830975799042f26c9e101b"}, - {file = "greenlet-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e98328b8b8f160925d6b1c5b1879d8e64f6bd8cf11472b7127d579da575b77d9"}, - {file = "greenlet-3.2.1.tar.gz", hash = "sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7"}, + {file = "greenlet-3.2.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6"}, + {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7"}, + {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c"}, + {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907"}, + {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f"}, + {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13"}, + {file = "greenlet-3.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5"}, + {file = "greenlet-3.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:00cd814b8959b95a546e47e8d589610534cfb71f19802ea8a2ad99d95d702057"}, + {file = "greenlet-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d0cb7d47199001de7658c213419358aa8937df767936506db0db7ce1a71f4a2f"}, + {file = "greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068"}, + {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce"}, + {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b"}, + {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3"}, + {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74"}, + {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe"}, + {file = "greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e"}, + {file = "greenlet-3.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c23ea227847c9dbe0b3910f5c0dd95658b607137614eb821e6cbaecd60d81cc6"}, + {file = "greenlet-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0a16fb934fcabfdfacf21d79e6fed81809d8cd97bc1be9d9c89f0e4567143d7b"}, + {file = "greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330"}, + {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b"}, + {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e"}, + {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275"}, + {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65"}, + {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3"}, + {file = "greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e"}, + {file = "greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5"}, + {file = "greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec"}, + {file = "greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59"}, + {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf"}, + {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325"}, + {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5"}, + {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825"}, + {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d"}, + {file = "greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf"}, + {file = "greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708"}, + {file = "greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421"}, + {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418"}, + {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4"}, + {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763"}, + {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b"}, + {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207"}, + {file = "greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8"}, + {file = "greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51"}, + {file = "greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240"}, + {file = "greenlet-3.2.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1e4747712c4365ef6765708f948acc9c10350719ca0545e362c24ab973017370"}, + {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782743700ab75716650b5238a4759f840bb2dcf7bff56917e9ffdf9f1f23ec59"}, + {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:354f67445f5bed6604e493a06a9a49ad65675d3d03477d38a4db4a427e9aad0e"}, + {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3aeca9848d08ce5eb653cf16e15bb25beeab36e53eb71cc32569f5f3afb2a3aa"}, + {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb8553ee954536500d88a1a2f58fcb867e45125e600e80f586ade399b3f8819"}, + {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1592a615b598643dbfd566bac8467f06c8c8ab6e56f069e573832ed1d5d528cc"}, + {file = "greenlet-3.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f72667cc341c95184f1c68f957cb2d4fc31eef81646e8e59358a10ce6689457"}, + {file = "greenlet-3.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a8fa80665b1a29faf76800173ff5325095f3e66a78e62999929809907aca5659"}, + {file = "greenlet-3.2.2-cp39-cp39-win32.whl", hash = "sha256:6629311595e3fe7304039c67f00d145cd1d38cf723bb5b99cc987b23c1433d61"}, + {file = "greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834"}, + {file = "greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485"}, ] [package.extras] @@ -1603,19 +1609,19 @@ test-extra = ["curio", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "n [[package]] name = "ipywidgets" -version = "8.1.6" +version = "8.1.7" description = "Jupyter interactive widgets" optional = false python-versions = ">=3.7" files = [ - {file = "ipywidgets-8.1.6-py3-none-any.whl", hash = "sha256:446e7630a1d025bdc7635e1169fcc06f2ce33b5bd41c2003edeb4a47c8d4bbb1"}, - {file = "ipywidgets-8.1.6.tar.gz", hash = "sha256:d8ace49c66f14419fc66071371b99d01bed230bbc15d8a60233b18bfbd782851"}, + {file = "ipywidgets-8.1.7-py3-none-any.whl", hash = "sha256:764f2602d25471c213919b8a1997df04bef869251db4ca8efba1b76b1bd9f7bb"}, + {file = "ipywidgets-8.1.7.tar.gz", hash = "sha256:15f1ac050b9ccbefd45dccfbb2ef6bed0029d8278682d569d71b8dd96bee0376"}, ] [package.dependencies] comm = ">=0.1.3" ipython = ">=6.1.0" -jupyterlab_widgets = ">=3.0.14,<3.1.0" +jupyterlab_widgets = ">=3.0.15,<3.1.0" traitlets = ">=4.3.1" widgetsnbextension = ">=4.0.14,<4.1.0" @@ -1889,13 +1895,13 @@ jupyter-server = ">=1.1.2" [[package]] name = "jupyter-server" -version = "2.15.0" +version = "2.16.0" description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." optional = false python-versions = ">=3.9" files = [ - {file = "jupyter_server-2.15.0-py3-none-any.whl", hash = "sha256:872d989becf83517012ee669f09604aa4a28097c0bd90b2f424310156c2cdae3"}, - {file = "jupyter_server-2.15.0.tar.gz", hash = "sha256:9d446b8697b4f7337a1b7cdcac40778babdd93ba614b6d68ab1c0c918f1c4084"}, + {file = "jupyter_server-2.16.0-py3-none-any.whl", hash = "sha256:3d8db5be3bc64403b1c65b400a1d7f4647a5ce743f3b20dbdefe8ddb7b55af9e"}, + {file = "jupyter_server-2.16.0.tar.gz", hash = "sha256:65d4b44fdf2dcbbdfe0aa1ace4a842d4aaf746a2b7b168134d5aaed35621b7f6"}, ] [package.dependencies] @@ -1944,13 +1950,13 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (> [[package]] name = "jupyterlab" -version = "4.4.1" +version = "4.4.2" description = "JupyterLab computational environment" optional = false python-versions = ">=3.9" files = [ - {file = "jupyterlab-4.4.1-py3-none-any.whl", hash = "sha256:989bca3f9cf2d04b2022e7e657e2df6d4aca808b364810d31c4865edd968a5f7"}, - {file = "jupyterlab-4.4.1.tar.gz", hash = "sha256:c75c4f33056fbd84f0b31eb44622a00c7a5f981b85adfeb198a83721f0465808"}, + {file = "jupyterlab-4.4.2-py3-none-any.whl", hash = "sha256:857111a50bed68542bf55dca784522fe728f9f88b4fe69e8c585db5c50900419"}, + {file = "jupyterlab-4.4.2.tar.gz", hash = "sha256:afa9caf28c0cb966488be18e5e8daba9f018a1c4273a406b7d5006344cbc6d16"}, ] [package.dependencies] @@ -2014,13 +2020,13 @@ test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-v [[package]] name = "jupyterlab-widgets" -version = "3.0.14" +version = "3.0.15" description = "Jupyter interactive widgets for JupyterLab" optional = false python-versions = ">=3.7" files = [ - {file = "jupyterlab_widgets-3.0.14-py3-none-any.whl", hash = "sha256:54c33e3306b7fca139d165d6190dc6c0627aafa5d14adfc974a4e9a3d26cb703"}, - {file = "jupyterlab_widgets-3.0.14.tar.gz", hash = "sha256:bad03e59546869f026e537e0d170e454259e6dc7048e14041707ca31e523c8a1"}, + {file = "jupyterlab_widgets-3.0.15-py3-none-any.whl", hash = "sha256:d59023d7d7ef71400d51e6fee9a88867f6e65e10a4201605d2d7f3e8f012a31c"}, + {file = "jupyterlab_widgets-3.0.15.tar.gz", hash = "sha256:2920888a0c2922351a9202817957a68c07d99673504d6cd37345299e971bb08b"}, ] [[package]] @@ -2415,18 +2421,18 @@ files = [ [[package]] name = "notebook" -version = "7.4.1" +version = "7.4.2" description = "Jupyter Notebook - A web-based notebook environment for interactive computing" optional = false python-versions = ">=3.8" files = [ - {file = "notebook-7.4.1-py3-none-any.whl", hash = "sha256:498f12cf567d95b20e780d62d52564ee4310248b3175e996b667b5808028e5d3"}, - {file = "notebook-7.4.1.tar.gz", hash = "sha256:96894962b230013ea0c0a466e4e642c5aace25ba8c86686175b69990ef628ff9"}, + {file = "notebook-7.4.2-py3-none-any.whl", hash = "sha256:9ccef602721aaa5530852e3064710b8ae5415c4e2ce26f8896d0433222755259"}, + {file = "notebook-7.4.2.tar.gz", hash = "sha256:e739defd28c3f615a6bfb0a2564bd75018a9cc6613aa00bbd9c15e68eed2de1b"}, ] [package.dependencies] jupyter-server = ">=2.4.0,<3" -jupyterlab = ">=4.4.0rc0,<4.5" +jupyterlab = ">=4.4.0,<4.5" jupyterlab-server = ">=2.27.1,<3" notebook-shim = ">=0.2,<0.3" tornado = ">=6.2.0" @@ -2667,13 +2673,13 @@ ptyprocess = ">=0.5" [[package]] name = "platformdirs" -version = "4.3.7" +version = "4.3.8" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" files = [ - {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, - {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, ] [package.extras] @@ -2683,18 +2689,18 @@ type = ["mypy (>=1.14.1)"] [[package]] name = "polars" -version = "1.28.1" +version = "1.29.0" description = "Blazingly fast DataFrame library" optional = false python-versions = ">=3.9" files = [ - {file = "polars-1.28.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8f88d2ba73a4e831eb39433fcf9c84619f8c21a3d83ce9db465b822b4321bb95"}, - {file = "polars-1.28.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:36a199344e2b6b9d5fa30d2835830965f8c7677b38a9e96b1f9be482c31c18c6"}, - {file = "polars-1.28.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f37a14458a7336351ae32f199a566e784bd0b25895d0f83c6c02bd0f0c25c57d"}, - {file = "polars-1.28.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:c0d209295627fbca45ebb2fa3279df291b1360685ce3f12e24f8327a79b3d384"}, - {file = "polars-1.28.1-cp39-abi3-win_amd64.whl", hash = "sha256:b1a17108e0ed3844a2e55da3a674414523aaab29299a2956cb691cdfc0db6246"}, - {file = "polars-1.28.1-cp39-abi3-win_arm64.whl", hash = "sha256:35ca8ab1937e5c72496789968a53e138796e1f5156c832d52f846ef7332149d7"}, - {file = "polars-1.28.1.tar.gz", hash = "sha256:cdc0a62a1452e2daf1777e36fcf9351424cba2a588827cc32550fe0fa79dec82"}, + {file = "polars-1.29.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d053ee3217df31468caf2f5ddb9fd0f3a94fd42afdf7d9abe23d9d424adca02b"}, + {file = "polars-1.29.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:14131078e365eae5ccda3e67383cd43c0c0598d7f760bdf1cb4082566c5494ce"}, + {file = "polars-1.29.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54f6902da333f99208b8d27765d580ba0299b412787c0564275912122c228e40"}, + {file = "polars-1.29.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:7a0ac6a11088279af4d715f4b58068835f551fa5368504a53401743006115e78"}, + {file = "polars-1.29.0-cp39-abi3-win_amd64.whl", hash = "sha256:f5aac4656e58b1e12f9481950981ef68b5b0e53dd4903bd72472efd2d09a74c8"}, + {file = "polars-1.29.0-cp39-abi3-win_arm64.whl", hash = "sha256:0c105b07b980b77fe88c3200b015bf4695e53185385f0f244c13e2d1027c7bbf"}, + {file = "polars-1.29.0.tar.gz", hash = "sha256:d2acb71fce1ff0ea76db5f648abd91a7a6c460fafabce9a2e8175184efa00d02"}, ] [package.dependencies] @@ -2981,82 +2987,82 @@ test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"] [[package]] name = "pycares" -version = "4.6.1" +version = "4.8.0" description = "Python interface for c-ares" optional = false python-versions = ">=3.9" files = [ - {file = "pycares-4.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad45460b195a11db792bf9f43242ed35bce9b7b7f7bfd943d1ae7a320daf32ed"}, - {file = "pycares-4.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f4b45537f186c8c09fc4f5452e009f87113dc772ae08c9aa72ac29afa5e8e1a"}, - {file = "pycares-4.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dab444430c7d8b98fa7515aff280c5723bd94f0791e268437952fa22fd8d714"}, - {file = "pycares-4.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f65b584d1fd70050735cfde61220935d734f39934224ac8daef0d2a9c4bd597"}, - {file = "pycares-4.6.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cf0f5f3fdd2f170c394842a1001538cf36289166f0e48321810f729517877a4"}, - {file = "pycares-4.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9041641d154ea9f6253fd9cfaa8d66be887631999377da0ee9d8ba51bfff8b6"}, - {file = "pycares-4.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48f127ee18a0bb6655291d5896643ac44be2566374a1536a67d41e2b7ae63c47"}, - {file = "pycares-4.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a1b630f1bdd20daf771c481394561a0dc1d1e7e5cc935c2d241fb74cb41ab03"}, - {file = "pycares-4.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a883fae7e74081bca902217bb5de9879cd764c49abfb9a4ba4ee2a4ec31cb4de"}, - {file = "pycares-4.6.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:cb7295968ca4eaba2af3de1d664e9541b8c0e6808110285271bf90f3df720498"}, - {file = "pycares-4.6.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9bae8ff8c7eff442b43dea301e5478813848b547436b02a2aab93a3d61610101"}, - {file = "pycares-4.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:767fe9aab135284cd8e8abe91a220b4fe552d6a8b2bbff247248d1ad8a21a7f5"}, - {file = "pycares-4.6.1-cp310-cp310-win32.whl", hash = "sha256:39275e526274aea27f50c7695f7c105c49cdff282a36ff4c0566bf396b52d4d6"}, - {file = "pycares-4.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:786c18cf61fbb2826ff6329537d78e7c7f04b323c8ff0c9035b2800bde502391"}, - {file = "pycares-4.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6502aebee73bc0b2ab2a9d0110acafdd4b44a134779607b09d03aacbf08c593b"}, - {file = "pycares-4.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bff55383fcec45d765ebf2ac0c6662026ec836f378b0b1e5958f4cae882dfac3"}, - {file = "pycares-4.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92f58007727536c49087639a86f02a2bc15193d5a2e871dacc17985150e17d4c"}, - {file = "pycares-4.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb926209a50854f556aa80a48d16d31bb96e2979af52fc7e4ba8aa92a031cd56"}, - {file = "pycares-4.6.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4562eb5c22a7b6a1ec1a741c9b760d7922792c865dea47952f998c99a4d4770"}, - {file = "pycares-4.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf56c63643e397184aefd0eddc30f99bcc0baea123cb0fd436c602bac854ec05"}, - {file = "pycares-4.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:682a8580a5ff05b7bd0eeb4e27a5d8b3d441fde072119507eb42a514b5ddb91c"}, - {file = "pycares-4.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7249ef4707757ee68c86b663f9af1525a5f7d56f1de2a4f962d69b523e94f2a9"}, - {file = "pycares-4.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:95a65cd23ab2bce88f1ef92a3e0011578a1873c455b66c2b4dab3d652a2199e3"}, - {file = "pycares-4.6.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5acb47ecdc4b228c35d01264b2ca04c89c0202cf60823247e7f4dce1821dc7f0"}, - {file = "pycares-4.6.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f9dd256776d780ebd84a7499d6e882cb26a5196b9963d6fb836067184420ca9d"}, - {file = "pycares-4.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9c2c3f714376b90b154d6b443e57930b47e4a5ddb7b5da8a740d70bfbcf8768"}, - {file = "pycares-4.6.1-cp311-cp311-win32.whl", hash = "sha256:3cd5904843fd4f6e4fcdbcd95f6d38537f553dc8697119ebd909e1d7f8c82d8a"}, - {file = "pycares-4.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4056934bb9fd47385a37591138b8ccdce9fc1abb0b41a348d9d0707a6de48d8"}, - {file = "pycares-4.6.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:669296f42ca7689b2e611a7b35951e2dd592be52482c3c935052c16cda45e7f2"}, - {file = "pycares-4.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ea54a668d87f471655a6603df82ef90a4e18ee14898e10e4763e843bae5156b0"}, - {file = "pycares-4.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2074a7151462d2f189f04eed422a981a9a4bbcfeed00acd4a8f147c71f8e231b"}, - {file = "pycares-4.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5052abb22decc53a5bca4a289833b64f457a8f02aafe69c2c7a2cab3229618fb"}, - {file = "pycares-4.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cba2a0efae21ae1d0110ba287388db0f458fbc8dd7938e81700ac3c17ddd82e"}, - {file = "pycares-4.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21bf51a851895f3ca2df7a48b17869f1f51bebb5cae9d7cd07feb2ab4ccdf32b"}, - {file = "pycares-4.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb0b0c2de88d2f00e21afb8b6184a44439f36a596c522d6224f1bcceb8f74a6f"}, - {file = "pycares-4.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e08dd3cd9f1b8cc211c92789f1bc820a73959656aaed107bcf2dac6a7c117bed"}, - {file = "pycares-4.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:19fb1154b352b684b831660ba05da7b438afb97539d74240d63daeb9b545ac00"}, - {file = "pycares-4.6.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:92ae8169d84cbaa076429fa6c96f058df6cf77511484b8befeb3bb0f51c67c37"}, - {file = "pycares-4.6.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:974146f723914ececb5cb3bb2fe5426cda4ec6674eef01e259081cf52cb9da1c"}, - {file = "pycares-4.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b6bf4c1e676e98a1d756be24876e8bda1a127df873328e65438d3ec0b568c717"}, - {file = "pycares-4.6.1-cp312-cp312-win32.whl", hash = "sha256:f523a7474d07dd95484bccab539ca72d8cbdbe3d874e62005115b725a9dab681"}, - {file = "pycares-4.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:79f9350a69be408ba77dcdde9d7a6c5467ccd1d497eeed19131c69420e8f2a28"}, - {file = "pycares-4.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:61142ed827914a0e5fa2a0376b37eb1bd8496d9dd071f0b5a2780f6240b68b36"}, - {file = "pycares-4.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ab052ea977da904703bc5804846d48937c885b8272389c10a9d5b3cb4c4489c"}, - {file = "pycares-4.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e46a3c1087c90dd803b717d5e5e5ff9bede5f2df9c6c66e1dc9a0f622d99b1"}, - {file = "pycares-4.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6449319fd267e3ddd0bc47a5f28b219857d9e591ec04684dbb2b84a0a42cd0c4"}, - {file = "pycares-4.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87af256798012466f4c586720f47ae4bde19a527e55967db67187ea1d6f5c5b3"}, - {file = "pycares-4.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39a79ff5ffcb92c2400b01f121308c17688563712b0c1aafa946653a76cf70c7"}, - {file = "pycares-4.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85a27a8dfad64730ecf8fc0b76c4188b3dac1d75406b9cfff995f98bddac752e"}, - {file = "pycares-4.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1e6ee97556c8e99050eb84970863ff8b464b255261b3dfd8b7b0fa85815ea82a"}, - {file = "pycares-4.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d5746aa978d957e4ec6e8026d8d6ca87c6b9fa1872d3f645ea399cb1548ee9b"}, - {file = "pycares-4.6.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8709f47cd0930298b0916755cb0b47edd70e3f88788e68bb2c560e8fdad515f4"}, - {file = "pycares-4.6.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1df9d1df0497801cce68a632b463ef7dff3e67c119f95f56725e229a869f8550"}, - {file = "pycares-4.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3926c585d21bb1ea70020a933d7de4e1528d23c41f3054a04acf970289cb8d22"}, - {file = "pycares-4.6.1-cp313-cp313-win32.whl", hash = "sha256:e375e4624866cfee0289eef4f5ffbed671be28c54b3d00120e69e477e6a515c8"}, - {file = "pycares-4.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d7fec6f2bad3961ad0a376b24bea9e0ba8b61606067f112af7fda48e0670281"}, - {file = "pycares-4.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9aeaaa644d6cc2a1b4a776ea40ef968e9603ef036ee70b6540f19fc148d34827"}, - {file = "pycares-4.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:16aa1ae5ce6adcf466e379978fc3a8cb904793d45f97b486029d14d16fc00063"}, - {file = "pycares-4.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5cb4f12385cbb281c8b59593984cb99c7c8859ba4e66a29b2ceff20bafe7a3b"}, - {file = "pycares-4.6.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:848e7a3c27380543d468ef3cc6ad62b484c3ba4743e8d90e45e6d1103b364790"}, - {file = "pycares-4.6.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c43fca53697a8f1c59b90de2fae94c9086c2482ebb9575148fe58b986464512"}, - {file = "pycares-4.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfe2d03a927d8362eda162f1e39e1ff86d60d38e4ffc6d2bd187af25f8b93613"}, - {file = "pycares-4.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:63ca7d92857ef668d55d8fba05e36d7631e3102de7740240a7a9659525bd725a"}, - {file = "pycares-4.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:eefe899fbc2bc96972caea985d01f6f3caf382c06efa64d64ee8f3d66b8aaea0"}, - {file = "pycares-4.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7de29719e0e22672a5fc1f0f0920dfaca2f9e6401fbd53cdfb03e2d6d7f168f6"}, - {file = "pycares-4.6.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:90d127a6efd59f0c0d5cd43dfd8e44dea091579043c1d3d168071c8016aad82b"}, - {file = "pycares-4.6.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1f7ff8e5395aaac8ec5e53c3ea44557d8b5ebf165f9320b667e7e1a371a3df2d"}, - {file = "pycares-4.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9052cf9bbb9ef1b277171084f0bedf7b15c5875013c167c481b5adfffd9430b"}, - {file = "pycares-4.6.1-cp39-cp39-win32.whl", hash = "sha256:d7c5b982e00e361e8efaed89e1ffbc7991c4eab47c8bac8ea282ed8a0a2b8e3a"}, - {file = "pycares-4.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a82cc9304cc675f3aad938d5c519f3594254561edbfaf6821085cfb48a319a"}, - {file = "pycares-4.6.1.tar.gz", hash = "sha256:8a1d981206a16240eedc79b51af3293575715d4b0b971f4eb47e24839d5ab440"}, + {file = "pycares-4.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f40d9f4a8de398b110fdf226cdfadd86e8c7eb71d5298120ec41cf8d94b0012f"}, + {file = "pycares-4.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:339de06fc849a51015968038d2bbed68fc24047522404af9533f32395ca80d25"}, + {file = "pycares-4.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372a236c1502b9056b0bea195c64c329603b4efa70b593a33b7ae37fbb7fad00"}, + {file = "pycares-4.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03f66a5e143d102ccc204bd4e29edd70bed28420f707efd2116748241e30cb73"}, + {file = "pycares-4.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef50504296cd5fc58cfd6318f82e20af24fbe2c83004f6ff16259adb13afdf14"}, + {file = "pycares-4.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1bc541b627c7951dd36136b18bd185c5244a0fb2af5b1492ffb8acaceec1c5b"}, + {file = "pycares-4.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:938d188ed6bed696099be67ebdcdf121827b9432b17a9ea9e40dc35fd9d85363"}, + {file = "pycares-4.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:327837ffdc0c7adda09c98e1263c64b2aff814eea51a423f66733c75ccd9a642"}, + {file = "pycares-4.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a6b9b8d08c4508c45bd39e0c74e9e7052736f18ca1d25a289365bb9ac36e5849"}, + {file = "pycares-4.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:feac07d5e6d2d8f031c71237c21c21b8c995b41a1eba64560e8cf1e42ac11bc6"}, + {file = "pycares-4.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5bcdbf37012fd2323ca9f2a1074421a9ccf277d772632f8f0ce8c46ec7564250"}, + {file = "pycares-4.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebb692cb43fcf34fe0d26f2cf9a0ea53fdfb136463845b81fad651277922db"}, + {file = "pycares-4.8.0-cp310-cp310-win32.whl", hash = "sha256:d98447ec0efff3fa868ccc54dcc56e71faff498f8848ecec2004c3108efb4da2"}, + {file = "pycares-4.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:1abb8f40917960ead3c2771277f0bdee1967393b0fdf68743c225b606787da68"}, + {file = "pycares-4.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e25db89005ddd8d9c5720293afe6d6dd92e682fc6bc7a632535b84511e2060d"}, + {file = "pycares-4.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6f9665ef116e6ee216c396f5f927756c2164f9f3316aec7ff1a9a1e1e7ec9b2a"}, + {file = "pycares-4.8.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54a96893133471f6889b577147adcc21a480dbe316f56730871028379c8313f3"}, + {file = "pycares-4.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51024b3a69762bd3100d94986a29922be15e13f56f991aaefb41f5bcd3d7f0bb"}, + {file = "pycares-4.8.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47ff9db50c599e4d965ae3bec99cc30941c1d2b0f078ec816680b70d052dd54a"}, + {file = "pycares-4.8.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27ef8ff4e0f60ea6769a60d1c3d1d2aefed1d832e7bb83fc3934884e2dba5cdd"}, + {file = "pycares-4.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63511af7a3f9663f562fbb6bfa3591a259505d976e2aba1fa2da13dde43c6ca7"}, + {file = "pycares-4.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:73c3219b47616e6a5ad1810de96ed59721c7751f19b70ae7bf24997a8365408f"}, + {file = "pycares-4.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da42a45207c18f37be5e491c14b6d1063cfe1e46620eb661735d0cedc2b59099"}, + {file = "pycares-4.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8a068e898bb5dd09cd654e19cd2abf20f93d0cc59d5d955135ed48ea0f806aa1"}, + {file = "pycares-4.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:962aed95675bb66c0b785a2fbbd1bb58ce7f009e283e4ef5aaa4a1f2dc00d217"}, + {file = "pycares-4.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce8b1a16c1e4517a82a0ebd7664783a327166a3764d844cf96b1fb7b9dd1e493"}, + {file = "pycares-4.8.0-cp311-cp311-win32.whl", hash = "sha256:b3749ddbcbd216376c3b53d42d8b640b457133f1a12b0e003f3838f953037ae7"}, + {file = "pycares-4.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:5ce8a4e1b485b2360ab666c4ea1db97f57ede345a3b566d80bfa52b17e616610"}, + {file = "pycares-4.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3273e01a75308ed06d2492d83c7ba476e579a60a24d9f20fe178ce5e9d8d028b"}, + {file = "pycares-4.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fcedaadea1f452911fd29935749f98d144dae758d6003b7e9b6c5d5bd47d1dff"}, + {file = "pycares-4.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aae6cb33e287e06a4aabcbc57626df682c9a4fa8026207f5b498697f1c2fb562"}, + {file = "pycares-4.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25038b930e5be82839503fb171385b2aefd6d541bc5b7da0938bdb67780467d2"}, + {file = "pycares-4.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cc8499b6e7dfbe4af65f6938db710ce9acd1debf34af2cbb93b898b1e5da6a5a"}, + {file = "pycares-4.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4e1c6a68ef56a7622f6176d9946d4e51f3c853327a0123ef35a5380230c84cd"}, + {file = "pycares-4.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7cc8c3c9114b9c84e4062d25ca9b4bddc80a65d0b074c7cb059275273382f89"}, + {file = "pycares-4.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4404014069d3e362abf404c9932d4335bb9c07ba834cfe7d683c725b92e0f9da"}, + {file = "pycares-4.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ee0a58c32ec2a352cef0e1d20335a7caf9871cd79b73be2ca2896fe70f09c9d7"}, + {file = "pycares-4.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:35f32f52b486b8fede3cbebf088f30b01242d0321b5216887c28e80490595302"}, + {file = "pycares-4.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecbb506e27a3b3a2abc001c77beeccf265475c84b98629a6b3e61bd9f2987eaa"}, + {file = "pycares-4.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9392b2a34adbf60cb9e38f4a0d363413ecea8d835b5a475122f50f76676d59dd"}, + {file = "pycares-4.8.0-cp312-cp312-win32.whl", hash = "sha256:f0fbefe68403ffcff19c869b8d621c88a6d2cef18d53cf0dab0fa9458a6ca712"}, + {file = "pycares-4.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fa8aab6085a2ddfb1b43a06ddf1b498347117bb47cd620d9b12c43383c9c2737"}, + {file = "pycares-4.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:358a9a2c6fed59f62788e63d88669224955443048a1602016d4358e92aedb365"}, + {file = "pycares-4.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e3e1278967fa8d4a0056be3fcc8fc551b8bad1fc7d0e5172196dccb8ddb036a"}, + {file = "pycares-4.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79befb773e370a8f97de9f16f5ea2c7e7fa0e3c6c74fbea6d332bf58164d7d06"}, + {file = "pycares-4.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b00d3695db64ce98a34e632e1d53f5a1cdb25451489f227bec2a6c03ff87ee8"}, + {file = "pycares-4.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37bdc4f2ff0612d60fc4f7547e12ff02cdcaa9a9e42e827bb64d4748994719f1"}, + {file = "pycares-4.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd92c44498ec7a6139888b464b28c49f7ba975933689bd67ea8d572b94188404"}, + {file = "pycares-4.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2665a0d810e2bbc41e97f3c3e5ea7950f666b3aa19c5f6c99d6b018ccd2e0052"}, + {file = "pycares-4.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45a629a6470a33478514c566bce50c63f1b17d1c5f2f964c9a6790330dc105fb"}, + {file = "pycares-4.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:47bb378f1773f41cca8e31dcdf009ce4a9b8aff8a30c7267aaff9a099c407ba5"}, + {file = "pycares-4.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fb3feae38458005cc101956e38f16eb3145fff8cd793e35cd4bdef6bf1aa2623"}, + {file = "pycares-4.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:14bc28aeaa66b0f4331ac94455e8043c8a06b3faafd78cc49d4b677bae0d0b08"}, + {file = "pycares-4.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62c82b871470f2864a1febf7b96bb1d108ce9063e6d3d43727e8a46f0028a456"}, + {file = "pycares-4.8.0-cp313-cp313-win32.whl", hash = "sha256:01afa8964c698c8f548b46d726f766aa7817b2d4386735af1f7996903d724920"}, + {file = "pycares-4.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:22f86f81b12ab17b0a7bd0da1e27938caaed11715225c1168763af97f8bb51a7"}, + {file = "pycares-4.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:61325d13a95255e858f42a7a1a9e482ff47ef2233f95ad9a4f308a3bd8ecf903"}, + {file = "pycares-4.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfec3a7d42336fa46a1e7e07f67000fd4b97860598c59a894c08f81378629e4e"}, + {file = "pycares-4.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b65067e4b4f5345688817fff6be06b9b1f4ec3619b0b9ecc639bc681b73f646b"}, + {file = "pycares-4.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0322ad94bbaa7016139b5bbdcd0de6f6feb9d146d69e03a82aaca342e06830a6"}, + {file = "pycares-4.8.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:456c60f170c997f9a43c7afa1085fced8efb7e13ae49dd5656f998ae13c4bdb4"}, + {file = "pycares-4.8.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a2c4c9ce423a85b0e0227409dbaf0d478f5e0c31d9e626768e77e1e887d32f"}, + {file = "pycares-4.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:478d9c479108b7527266864c0affe3d6e863492c9bc269217e36100c8fd89b91"}, + {file = "pycares-4.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aed56bca096990ca0aa9bbf95761fc87e02880e04b0845922b5c12ea9abe523f"}, + {file = "pycares-4.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ef265a390928ee2f77f8901c2273c53293157860451ad453ce7f45dd268b72f9"}, + {file = "pycares-4.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a5f17d7a76d8335f1c90a8530c8f1e8bb22e9a1d70a96f686efaed946de1c908"}, + {file = "pycares-4.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:891f981feb2ef34367378f813fc17b3d706ce95b6548eeea0c9fe7705d7e54b1"}, + {file = "pycares-4.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4102f6d9117466cc0a1f527907a1454d109cc9e8551b8074888071ef16050fe3"}, + {file = "pycares-4.8.0-cp39-cp39-win32.whl", hash = "sha256:d6775308659652adc88c82c53eda59b5e86a154aaba5ad1e287bbb3e0be77076"}, + {file = "pycares-4.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:8bc05462aa44788d48544cca3d2532466fed2cdc5a2f24a43a92b620a61c9d19"}, + {file = "pycares-4.8.0.tar.gz", hash = "sha256:2fc2ebfab960f654b3e3cf08a732486950da99393a657f8b44618ad3ed2d39c1"}, ] [package.dependencies] @@ -3610,13 +3616,13 @@ win32 = ["pywin32"] [[package]] name = "setuptools" -version = "80.0.0" +version = "80.7.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" files = [ - {file = "setuptools-80.0.0-py3-none-any.whl", hash = "sha256:a38f898dcd6e5380f4da4381a87ec90bd0a7eec23d204a5552e80ee3cab6bd27"}, - {file = "setuptools-80.0.0.tar.gz", hash = "sha256:c40a5b3729d58dd749c0f08f1a07d134fb8a0a3d7f87dc33e7c5e1f762138650"}, + {file = "setuptools-80.7.1-py3-none-any.whl", hash = "sha256:ca5cc1069b85dc23070a6628e6bcecb3292acac802399c7f8edc0100619f9009"}, + {file = "setuptools-80.7.1.tar.gz", hash = "sha256:f6ffc5f0142b1bd8d0ca94ee91b30c0ca862ffd50826da1ea85258a06fd94552"}, ] [package.extras] @@ -3663,68 +3669,68 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.40" +version = "2.0.41" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" files = [ - {file = "SQLAlchemy-2.0.40-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ae9597cab738e7cc823f04a704fb754a9249f0b6695a6aeb63b74055cd417a96"}, - {file = "SQLAlchemy-2.0.40-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37a5c21ab099a83d669ebb251fddf8f5cee4d75ea40a5a1653d9c43d60e20867"}, - {file = "SQLAlchemy-2.0.40-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bece9527f5a98466d67fb5d34dc560c4da964240d8b09024bb21c1246545e04e"}, - {file = "SQLAlchemy-2.0.40-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:8bb131ffd2165fae48162c7bbd0d97c84ab961deea9b8bab16366543deeab625"}, - {file = "SQLAlchemy-2.0.40-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9408fd453d5f8990405cc9def9af46bfbe3183e6110401b407c2d073c3388f47"}, - {file = "SQLAlchemy-2.0.40-cp37-cp37m-win32.whl", hash = "sha256:00a494ea6f42a44c326477b5bee4e0fc75f6a80c01570a32b57e89cf0fbef85a"}, - {file = "SQLAlchemy-2.0.40-cp37-cp37m-win_amd64.whl", hash = "sha256:c7b927155112ac858357ccf9d255dd8c044fd9ad2dc6ce4c4149527c901fa4c3"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-win32.whl", hash = "sha256:cd2f75598ae70bcfca9117d9e51a3b06fe29edd972fdd7fd57cc97b4dbf3b08a"}, - {file = "sqlalchemy-2.0.40-cp310-cp310-win_amd64.whl", hash = "sha256:2cbafc8d39ff1abdfdda96435f38fab141892dc759a2165947d1a8fffa7ef596"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-win32.whl", hash = "sha256:c0cae71e20e3c02c52f6b9e9722bca70e4a90a466d59477822739dc31ac18b4b"}, - {file = "sqlalchemy-2.0.40-cp311-cp311-win_amd64.whl", hash = "sha256:574aea2c54d8f1dd1699449f332c7d9b71c339e04ae50163a3eb5ce4c4325ee4"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-win32.whl", hash = "sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1"}, - {file = "sqlalchemy-2.0.40-cp312-cp312-win_amd64.whl", hash = "sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500"}, - {file = "sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:50f5885bbed261fc97e2e66c5156244f9704083a674b8d17f24c72217d29baf5"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cf0e99cdb600eabcd1d65cdba0d3c91418fee21c4aa1d28db47d095b1064a7d8"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe147fcd85aaed53ce90645c91ed5fca0cc88a797314c70dfd9d35925bd5d106"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baf7cee56bd552385c1ee39af360772fbfc2f43be005c78d1140204ad6148438"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4aeb939bcac234b88e2d25d5381655e8353fe06b4e50b1c55ecffe56951d18c2"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c268b5100cfeaa222c40f55e169d484efa1384b44bf9ca415eae6d556f02cb08"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-win32.whl", hash = "sha256:46628ebcec4f23a1584fb52f2abe12ddb00f3bb3b7b337618b80fc1b51177aff"}, - {file = "sqlalchemy-2.0.40-cp38-cp38-win_amd64.whl", hash = "sha256:7e0505719939e52a7b0c65d20e84a6044eb3712bb6f239c6b1db77ba8e173a37"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c884de19528e0fcd9dc34ee94c810581dd6e74aef75437ff17e696c2bfefae3e"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1abb387710283fc5983d8a1209d9696a4eae9db8d7ac94b402981fe2fe2e39ad"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cfa124eda500ba4b0d3afc3e91ea27ed4754e727c7f025f293a22f512bcd4c9"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b6b28d303b9d57c17a5164eb1fd2d5119bb6ff4413d5894e74873280483eeb5"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b5a5bbe29c10c5bfd63893747a1bf6f8049df607638c786252cb9243b86b6706"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f0fda83e113bb0fb27dc003685f32a5dcb99c9c4f41f4fa0838ac35265c23b5c"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-win32.whl", hash = "sha256:957f8d85d5e834397ef78a6109550aeb0d27a53b5032f7a57f2451e1adc37e98"}, - {file = "sqlalchemy-2.0.40-cp39-cp39-win_amd64.whl", hash = "sha256:1ffdf9c91428e59744f8e6f98190516f8e1d05eec90e936eb08b257332c5e870"}, - {file = "sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a"}, - {file = "sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6854175807af57bdb6425e47adbce7d20a4d79bbfd6f6d6519cd10bb7109a7f8"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05132c906066142103b83d9c250b60508af556982a385d96c4eaa9fb9720ac2b"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b4af17bda11e907c51d10686eda89049f9ce5669b08fbe71a29747f1e876036"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:c0b0e5e1b5d9f3586601048dd68f392dc0cc99a59bb5faf18aab057ce00d00b2"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0b3dbf1e7e9bc95f4bac5e2fb6d3fb2f083254c3fdd20a1789af965caf2d2348"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-win32.whl", hash = "sha256:1e3f196a0c59b0cae9a0cd332eb1a4bda4696e863f4f1cf84ab0347992c548c2"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-win_amd64.whl", hash = "sha256:6ab60a5089a8f02009f127806f777fca82581c49e127f08413a66056bd9166dd"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90144d3b0c8b139408da50196c5cad2a6909b51b23df1f0538411cd23ffa45d3"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:023b3ee6169969beea3bb72312e44d8b7c27c75b347942d943cf49397b7edeb5"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725875a63abf7c399d4548e686debb65cdc2549e1825437096a0af1f7e374814"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81965cc20848ab06583506ef54e37cf15c83c7e619df2ad16807c03100745dea"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dd5ec3aa6ae6e4d5b5de9357d2133c07be1aff6405b136dad753a16afb6717dd"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ff8e80c4c4932c10493ff97028decfdb622de69cae87e0f127a7ebe32b4069c6"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-win32.whl", hash = "sha256:4d44522480e0bf34c3d63167b8cfa7289c1c54264c2950cc5fc26e7850967e45"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-win_amd64.whl", hash = "sha256:81eedafa609917040d39aa9332e25881a8e7a0862495fcdf2023a9667209deda"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71"}, + {file = "sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576"}, + {file = "sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9"}, ] [package.dependencies] @@ -4275,4 +4281,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.0" python-versions = ">=3.10" -content-hash = "9063f84b683f71d20643225d6bee951a679c4ac916c44509792e8c3010ee5bdc" +content-hash = "e84ef93b6529a0ccfbd28ca2b2bc2a48c85b468a4e0636c559c8e3baefb4a694" diff --git a/pyproject.toml b/pyproject.toml index df8d0593..186ec9e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ azure-mgmt-storage = "^21.2.1" azure-mgmt-web = "^7.3.1" azure-mgmt-resource = "^23.2.0" python-dotenv = "^1.0.1" +pyarrow = ">=19.0.1" [tool.poetry.group.test.dependencies] coverage= "7.4.2" diff --git a/tests/app/algorithm/test_check_order_status.py b/tests/app/algorithm/test_check_order_status.py index c44c29fe..fc80ec7a 100644 --- a/tests/app/algorithm/test_check_order_status.py +++ b/tests/app/algorithm/test_check_order_status.py @@ -41,4 +41,4 @@ def test_check_order_status(self): order = order_repository.find({"target_symbol": "BTC"}) self.assertEqual(OrderStatus.CLOSED.value, order.status) position = position_repository.find({"symbol": "BTC"}) - self.assertEqual(Decimal(1), position.get_amount()) + self.assertEqual(1, position.get_amount()) diff --git a/tests/app/algorithm/test_close_position.py b/tests/app/algorithm/test_close_position.py index e363d78f..776a8340 100644 --- a/tests/app/algorithm/test_close_position.py +++ b/tests/app/algorithm/test_close_position.py @@ -62,7 +62,7 @@ def test_close_position(self): self.assertIsNotNone(btc_position.get_amount()) self.assertEqual(Decimal(1), btc_position.get_amount()) self.assertNotEqual(Decimal(990), trading_symbol_position.get_amount()) - self.app.context.close_position("BTC") + self.app.context.close_position(btc_position) self.app.run(number_of_iterations=1) btc_position = self.app.context.get_position("BTC") self.assertEqual(Decimal(0), btc_position.get_amount()) diff --git a/tests/app/algorithm/test_close_trade.py b/tests/app/algorithm/test_close_trade.py index 0a9d6458..d216217c 100644 --- a/tests/app/algorithm/test_close_trade.py +++ b/tests/app/algorithm/test_close_trade.py @@ -63,6 +63,9 @@ def test_close_trade(self): trades = self.app.context.get_trades() trade = trades[0] self.assertIsNotNone(trade.amount) + self.assertEqual(trade.remaining, 0) + self.assertEqual(trade.filled_amount, 1) + self.assertEqual(trade.available_amount, 1) self.assertEqual(Decimal(1), trade.amount) self.app.context.close_trade(trade) self.assertEqual(1, len(self.app.context.get_trades())) diff --git a/tests/app/algorithm/test_get_open_trades.py b/tests/app/algorithm/test_get_open_trades.py index 8afbff04..189b8299 100644 --- a/tests/app/algorithm/test_get_open_trades.py +++ b/tests/app/algorithm/test_get_open_trades.py @@ -169,13 +169,13 @@ def test_get_open_trades_with_close_trades_of_partial_buy_orders(self): ) trade_one = self.app.context.get_trade(order_id=order_one_id) trade_two = self.app.context.get_trade(order_id=order_two_id) - self.assertEqual(2.5, trade_one.remaining) - self.assertEqual(5, trade_two.remaining) + self.assertEqual(2.5, trade_one.available_amount) + self.assertEqual(5, trade_two.available_amount) self.app.context.order_service.check_pending_orders() trade_one = self.app.context.get_trade(order_id=order_one_id) trade_two = self.app.context.get_trade(order_id=order_two_id) - self.assertEqual(2.5, trade_one.remaining) - self.assertEqual(5, trade_two.remaining) + self.assertEqual(2.5, trade_one.available_amount) + self.assertEqual(5, trade_two.available_amount) self.assertEqual(2, len(self.app.context.get_open_trades("BTC"))) self.app.context.create_limit_order( target_symbol="BTC", @@ -183,16 +183,18 @@ def test_get_open_trades_with_close_trades_of_partial_buy_orders(self): order_side="SELL", amount=5 ) + trades = self.app.context.get_open_trades() self.assertEqual(1, len(trades)) trade_one = self.app.context.get_trade(order_id=order_one_id) trade_two = self.app.context.get_trade(order_id=order_two_id) - self.assertEqual(0, trade_one.remaining) - self.assertEqual(2.5, trade_two.remaining) + self.assertEqual(0, trade_one.available_amount) + self.assertEqual(2.5, trade_two.available_amount) + self.app.context.order_service.check_pending_orders() trades = self.app.context.get_open_trades() self.assertEqual(1, len(trades)) trade_one = self.app.context.get_trade(order_id=order_one_id) trade_two = self.app.context.get_trade(order_id=order_two_id) - self.assertEqual(0, trade_one.remaining) - self.assertEqual(2.5, trade_two.remaining) + self.assertEqual(0, trade_one.available_amount) + self.assertEqual(2.5, trade_two.available_amount) diff --git a/tests/app/algorithm/test_get_pending_orders.py b/tests/app/algorithm/test_get_pending_orders.py index c670a2c2..df9308c8 100644 --- a/tests/app/algorithm/test_get_pending_orders.py +++ b/tests/app/algorithm/test_get_pending_orders.py @@ -65,9 +65,6 @@ class TestPortfolioService(TestBase): }, ), ] - external_balances = { - "EUR": 700, - } market_data_source_service = MarketDataSourceServiceStub() def test_get_pending_orders(self): @@ -77,6 +74,7 @@ def test_get_pending_orders(self): The test should make sure that the portfolio service can sync existing orders from the market service to the order service. """ + portfolio_service: PortfolioService \ = self.app.container.portfolio_service() portfolio = portfolio_service.find({"market": "binance"}) @@ -133,9 +131,9 @@ def test_get_pending_orders(self): # Check if eur position exists eur_position = position_service.find( - {"portfolio_id": portfolio.id, "symbol": "EUR"} + {"portfolio_id": portfolio.id, "symbol": portfolio.trading_symbol} ) - self.assertEqual(400, eur_position.amount) + self.assertEqual(700, eur_position.amount) pending_orders = self.app.context.get_pending_orders() self.assertEqual(2, len(pending_orders)) diff --git a/tests/app/algorithm/test_run_strategy.py b/tests/app/algorithm/test_run_strategy.py index ea1695ef..05567678 100644 --- a/tests/app/algorithm/test_run_strategy.py +++ b/tests/app/algorithm/test_run_strategy.py @@ -5,7 +5,7 @@ TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, \ Algorithm, MarketCredential from tests.resources import random_string, MarketServiceStub, \ - MarketDataSourceServiceStub + MarketDataSourceServiceStub, OrderExecutorTest, PortfolioProviderTest class StrategyOne(TradingStrategy): @@ -78,6 +78,8 @@ def tearDown(self) -> None: def test_with_strategy_object(self): app = create_app(config={RESOURCE_DIRECTORY: self.resource_dir}) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.container.market_service.override(MarketServiceStub(None)) app.container.portfolio_configuration_service().clear() app.add_portfolio_configuration( @@ -106,6 +108,8 @@ def test_with_strategy_object(self): def test_with_decorator(self): app = create_app(config={RESOURCE_DIRECTORY: self.resource_dir}) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.container.market_service.override(MarketServiceStub(None)) app.container.portfolio_configuration_service().clear() app.add_portfolio_configuration( @@ -135,6 +139,8 @@ def run_strategy(context, market_data): def test_stateless(self): app = create_app(config={RESOURCE_DIRECTORY: self.resource_dir}) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.container.market_service.override(MarketServiceStub(None)) app.container.portfolio_configuration_service().clear() app.add_portfolio_configuration( diff --git a/tests/app/algorithm/test_trade_price_update.py b/tests/app/algorithm/test_trade_price_update.py index 44caf49c..f7f7a818 100644 --- a/tests/app/algorithm/test_trade_price_update.py +++ b/tests/app/algorithm/test_trade_price_update.py @@ -6,7 +6,8 @@ TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, \ Algorithm, MarketCredential, CSVOHLCVMarketDataSource, \ CSVTickerMarketDataSource -from tests.resources import random_string, MarketServiceStub +from tests.resources import random_string, MarketServiceStub, \ + PortfolioProviderTest, OrderExecutorTest class StrategyOne(TradingStrategy): time_unit = TimeUnit.SECOND @@ -16,7 +17,6 @@ class StrategyOne(TradingStrategy): def apply_strategy(self, context, market_data): pass - class Test(TestCase): def setUp(self) -> None: @@ -52,6 +52,8 @@ def tearDown(self) -> None: def test_trade_recent_price_update(self): app = create_app(config={RESOURCE_DIRECTORY: self.resource_dir}) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.container.market_service.override(MarketServiceStub(None)) app.container.portfolio_configuration_service().clear() app.add_market_data_source( @@ -102,10 +104,12 @@ def test_trade_recent_price_update(self): app.initialize() app.context.create_limit_order( target_symbol="btc", - amount=20, + amount=1, price=20, order_side="BUY" ) + order_service = app.container.order_service() + order_service.check_pending_orders() app.run(number_of_iterations=1) strategy_orchestration_service = app.algorithm\ .strategy_orchestrator_service diff --git a/tests/app/test_app_initialize.py b/tests/app/test_app_initialize.py index 9316ded2..99ca1205 100644 --- a/tests/app/test_app_initialize.py +++ b/tests/app/test_app_initialize.py @@ -3,8 +3,8 @@ from investing_algorithm_framework import create_app, PortfolioConfiguration, \ MarketCredential, Algorithm, AppMode, APP_MODE, RESOURCE_DIRECTORY -from investing_algorithm_framework.domain import SQLALCHEMY_DATABASE_URI -from tests.resources import MarketServiceStub +from tests.resources import MarketServiceStub, PortfolioProviderTest, \ + OrderExecutorTest class TestAppInitialize(TestCase): @@ -43,6 +43,8 @@ def test_app_initialize_default(self): app = create_app( config={RESOURCE_DIRECTORY: self.resource_dir} ) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.container.market_service.override( MarketServiceStub(app.container.market_credential_service()) ) @@ -74,6 +76,8 @@ def test_app_initialize_web(self): config={RESOURCE_DIRECTORY: self.resource_dir}, web=True ) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.container.market_service.override(MarketServiceStub(None)) app.add_portfolio_configuration( PortfolioConfiguration( diff --git a/tests/app/test_config.py b/tests/app/test_config.py index 2ce69da6..4aacfa7c 100644 --- a/tests/app/test_config.py +++ b/tests/app/test_config.py @@ -1,7 +1,7 @@ import os -from investing_algorithm_framework import create_app, Algorithm, \ - PortfolioConfiguration, MarketCredential +from investing_algorithm_framework import PortfolioConfiguration, \ + MarketCredential from investing_algorithm_framework.domain import RESOURCE_DIRECTORY, \ DATABASE_DIRECTORY_NAME, DATABASE_DIRECTORY_PATH, DATABASE_NAME, \ ENVIRONMENT, Environment, BacktestDateRange diff --git a/tests/app/test_start.py b/tests/app/test_start.py index 23b34bc9..448d7ec3 100644 --- a/tests/app/test_start.py +++ b/tests/app/test_start.py @@ -4,7 +4,8 @@ from investing_algorithm_framework import create_app, TradingStrategy, \ TimeUnit, RESOURCE_DIRECTORY, PortfolioConfiguration, Algorithm, \ MarketCredential -from tests.resources import MarketServiceStub +from tests.resources import MarketServiceStub, OrderExecutorTest, \ + PortfolioProviderTest class StrategyOne(TradingStrategy): @@ -114,6 +115,8 @@ def test_default(self): app = create_app({ RESOURCE_DIRECTORY: self.resource_dir }) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.add_portfolio_configuration( PortfolioConfiguration( market="BITVAVO", @@ -153,6 +156,8 @@ def test_web(self): web=True, config={ RESOURCE_DIRECTORY: self.resource_dir } ) + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.add_portfolio_configuration( PortfolioConfiguration( market="BITVAVO", @@ -185,40 +190,42 @@ def test_web(self): self.assertTrue(strategy_orchestrator_service.has_run("StrategyOne")) self.assertTrue(strategy_orchestrator_service.has_run("StrategyTwo")) - def test_with_payload(self): - app = create_app( - config={ RESOURCE_DIRECTORY: self.resource_dir } - ) - app.add_portfolio_configuration( - PortfolioConfiguration( - market="BITVAVO", - trading_symbol="EUR" - ) - ) - app.container.market_service.override(MarketServiceStub(None)) - algorithm = Algorithm() - algorithm.add_strategy(StrategyOne) - algorithm.add_strategy(StrategyTwo) - app.add_algorithm(algorithm) - app.add_market_credential( - MarketCredential( - market="BITVAVO", - api_key="api_key", - secret_key="secret_key" - ) - ) - market_service_stub = MarketServiceStub(None) - market_service_stub.balances = { - "EUR": 1000 - } - app.container.market_service.override(market_service_stub) - app.run( - number_of_iterations=2, - payload={"ACTION": "RUN_STRATEGY"}, - ) - # self.assertEqual(2, StrategyOne.number_of_runs) - # self.assertEqual(2, StrategyTwo.number_of_runs) - strategy_orchestrator_service = app\ - .algorithm.strategy_orchestrator_service - self.assertTrue(strategy_orchestrator_service.has_run("StrategyOne")) - self.assertTrue(strategy_orchestrator_service.has_run("StrategyTwo")) + # def test_with_payload(self): + # app = create_app( + # config={ RESOURCE_DIRECTORY: self.resource_dir } + # ) + # app.add_portfolio_provider(PortfolioProviderTest) + # app.add_order_executor(OrderExecutorTest) + # app.add_portfolio_configuration( + # PortfolioConfiguration( + # market="BITVAVO", + # trading_symbol="EUR" + # ) + # ) + # app.container.market_service.override(MarketServiceStub(None)) + # algorithm = Algorithm() + # algorithm.add_strategy(StrategyOne) + # algorithm.add_strategy(StrategyTwo) + # app.add_algorithm(algorithm) + # app.add_market_credential( + # MarketCredential( + # market="BITVAVO", + # api_key="api_key", + # secret_key="secret_key" + # ) + # ) + # market_service_stub = MarketServiceStub(None) + # market_service_stub.balances = { + # "EUR": 1000 + # } + # app.container.market_service.override(market_service_stub) + # app.run( + # number_of_iterations=2, + # payload={"ACTION": "RUN_STRATEGY"}, + # ) + # # self.assertEqual(2, StrategyOne.number_of_runs) + # # self.assertEqual(2, StrategyTwo.number_of_runs) + # strategy_orchestrator_service = app\ + # .algorithm.strategy_orchestrator_service + # self.assertTrue(strategy_orchestrator_service.has_run("StrategyOne")) + # self.assertTrue(strategy_orchestrator_service.has_run("StrategyTwo")) diff --git a/tests/app/test_start_with_new_external_orders.py b/tests/app/test_start_with_new_external_orders.py deleted file mode 100644 index b5f3c6e8..00000000 --- a/tests/app/test_start_with_new_external_orders.py +++ /dev/null @@ -1,132 +0,0 @@ -from investing_algorithm_framework import Order, PortfolioConfiguration, \ - MarketCredential -from tests.resources import TestBase - - -class Test(TestBase): - initial_orders = [ - Order.from_dict( - { - "id": "1323", - "side": "buy", - "symbol": "BTC/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - Order.from_dict( - { - "id": "14354", - "side": "buy", - "symbol": "DOT/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-09-22T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - Order.from_dict( - { - "id": "1323", - "side": "buy", - "symbol": "ETH/EUR", - "amount": 10, - "price": 10.0, - "status": "OPEN", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 0, - "remaining": 0, - }, - ), - ] - external_orders = [ - Order.from_dict( - { - "id": "1323", - "side": "buy", - "symbol": "ETH/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - # Order that is not tracked by the trading bot - Order.from_dict( - { - "id": "133423", - "side": "buy", - "symbol": "KSM/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - ] - external_balances = {"EUR": 1000} - portfolio_configurations = [ - PortfolioConfiguration( - market="BITVAVO", - trading_symbol="EUR" - ) - ] - market_credentials = [ - MarketCredential( - market="bitvavo", - api_key="api_key", - secret_key="secret_key" - ) - ] - - def test_start_with_new_external_positions(self): - """ - Test how the framework handles new external positions on an broker or - exchange. - - If the positions where not in the database, the algorithm should - not include them, because they could be positions from another - user or from another algorithm. - """ - self.assertTrue(self.app.context.has_position("BTC")) - btc_position = self.app.context.get_position("BTC") - self.assertEqual(10, btc_position.get_amount()) - self.assertTrue(self.app.context.has_position("DOT")) - dot_position = self.app.context.get_position("DOT") - self.assertEqual(10, dot_position.get_amount()) - # Eth position still has open order - self.assertFalse(self.app.context.has_position("ETH")) - eth_position = self.app.context.get_position("ETH") - self.assertEqual(0, eth_position.get_amount()) - self.assertFalse(self.app.context.has_position("KSM")) - self.app.run(number_of_iterations=1) - self.assertTrue(self.app.context.has_position("BTC")) - btc_position = self.app.context.get_position("BTC") - self.assertEqual(10, btc_position.get_amount()) - self.assertTrue(self.app.context.has_position("DOT")) - dot_position = self.app.context.get_position("DOT") - self.assertEqual(10, dot_position.get_amount()) - self.assertTrue(self.app.context.has_position("ETH")) - eth_position = self.app.context.get_position("ETH") - self.assertEqual(10, eth_position.get_amount()) - self.assertFalse(self.app.context.has_position("KSM")) diff --git a/tests/app/test_start_with_new_external_positions.py b/tests/app/test_start_with_new_external_positions.py deleted file mode 100644 index b5f3c6e8..00000000 --- a/tests/app/test_start_with_new_external_positions.py +++ /dev/null @@ -1,132 +0,0 @@ -from investing_algorithm_framework import Order, PortfolioConfiguration, \ - MarketCredential -from tests.resources import TestBase - - -class Test(TestBase): - initial_orders = [ - Order.from_dict( - { - "id": "1323", - "side": "buy", - "symbol": "BTC/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - Order.from_dict( - { - "id": "14354", - "side": "buy", - "symbol": "DOT/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-09-22T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - Order.from_dict( - { - "id": "1323", - "side": "buy", - "symbol": "ETH/EUR", - "amount": 10, - "price": 10.0, - "status": "OPEN", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 0, - "remaining": 0, - }, - ), - ] - external_orders = [ - Order.from_dict( - { - "id": "1323", - "side": "buy", - "symbol": "ETH/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - # Order that is not tracked by the trading bot - Order.from_dict( - { - "id": "133423", - "side": "buy", - "symbol": "KSM/EUR", - "amount": 10, - "price": 10.0, - "status": "CLOSED", - "order_type": "limit", - "order_side": "buy", - "created_at": "2023-08-08T14:40:56.626362Z", - "filled": 10, - "remaining": 0, - }, - ), - ] - external_balances = {"EUR": 1000} - portfolio_configurations = [ - PortfolioConfiguration( - market="BITVAVO", - trading_symbol="EUR" - ) - ] - market_credentials = [ - MarketCredential( - market="bitvavo", - api_key="api_key", - secret_key="secret_key" - ) - ] - - def test_start_with_new_external_positions(self): - """ - Test how the framework handles new external positions on an broker or - exchange. - - If the positions where not in the database, the algorithm should - not include them, because they could be positions from another - user or from another algorithm. - """ - self.assertTrue(self.app.context.has_position("BTC")) - btc_position = self.app.context.get_position("BTC") - self.assertEqual(10, btc_position.get_amount()) - self.assertTrue(self.app.context.has_position("DOT")) - dot_position = self.app.context.get_position("DOT") - self.assertEqual(10, dot_position.get_amount()) - # Eth position still has open order - self.assertFalse(self.app.context.has_position("ETH")) - eth_position = self.app.context.get_position("ETH") - self.assertEqual(0, eth_position.get_amount()) - self.assertFalse(self.app.context.has_position("KSM")) - self.app.run(number_of_iterations=1) - self.assertTrue(self.app.context.has_position("BTC")) - btc_position = self.app.context.get_position("BTC") - self.assertEqual(10, btc_position.get_amount()) - self.assertTrue(self.app.context.has_position("DOT")) - dot_position = self.app.context.get_position("DOT") - self.assertEqual(10, dot_position.get_amount()) - self.assertTrue(self.app.context.has_position("ETH")) - eth_position = self.app.context.get_position("ETH") - self.assertEqual(10, eth_position.get_amount()) - self.assertFalse(self.app.context.has_position("KSM")) diff --git a/tests/domain/backtesting/test_pretty_print_backtest.py b/tests/domain/backtesting/test_pretty_print_backtest.py index 9c713e15..112911c0 100644 --- a/tests/domain/backtesting/test_pretty_print_backtest.py +++ b/tests/domain/backtesting/test_pretty_print_backtest.py @@ -27,7 +27,7 @@ def setUp(self): def test_pretty_print(self): path = os.path.join( self.resource_dir, - "backtest_reports_for_testing/report_GoldenCrossStrategy_backtest-start-date_2023-08-24-00-00_backtest-end-date_2023-12-02-00-00_created-at_2025-01-27-08-21.json" + "backtest_reports_for_testing/test_algorithm_backtest_created-at_2025-04-21-21-21" ) report = load_backtest_report(path) pretty_print_backtest(report) diff --git a/tests/domain/metrics/__init__.py b/tests/domain/metrics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/metrics/test_get_price_efficiency_ratio.py b/tests/domain/metrics/test_get_price_efficiency_ratio.py index 348128c6..4cc8704c 100644 --- a/tests/domain/metrics/test_get_price_efficiency_ratio.py +++ b/tests/domain/metrics/test_get_price_efficiency_ratio.py @@ -6,6 +6,7 @@ get_price_efficiency_ratio class TestGetPriceEfficiencyRatio(TestCase): + def test_get_price_efficiency_ratio(self): # Given data = { diff --git a/tests/domain/models/test_trade.py b/tests/domain/models/test_trade.py index 802c7bec..73225ffc 100644 --- a/tests/domain/models/test_trade.py +++ b/tests/domain/models/test_trade.py @@ -32,6 +32,8 @@ def test_trade(self): target_symbol="BTC", trading_symbol="EUR", amount=1, + available_amount=0, + filled_amount=0, remaining=1, open_price=10000, opened_at=trade_opened_at, diff --git a/tests/domain/utils/__init__.py b/tests/domain/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/utils/test_polars.py b/tests/domain/utils/test_polars.py index 7a24dffe..c8821e0e 100644 --- a/tests/domain/utils/test_polars.py +++ b/tests/domain/utils/test_polars.py @@ -1,7 +1,7 @@ from unittest import TestCase from polars import DataFrame from pandas import Timestamp -from investing_algorithm_framework import convert_pandas_to_polars +from investing_algorithm_framework import convert_polars_to_pandas class TestConvertPandasToPolars(TestCase): @@ -11,9 +11,12 @@ def test_convert_pandas_to_polars(self): "Close": [1, 2, 3] }) - polars_df_converted = convert_pandas_to_polars(polars_df) - self.assertEqual(polars_df_converted.shape, (3, 1)) - self.assertEqual(polars_df_converted.columns, ["Close"]) + polars_df_converted = convert_polars_to_pandas(polars_df) + self.assertEqual(polars_df_converted.shape, (3, 2)) + + # Check if the columns are as expected + column_names = polars_df_converted.columns.tolist() + self.assertEqual(column_names, ['Close', 'Datetime']) # Check if the index is a datetime object self.assertEqual(polars_df_converted.index.dtype, "datetime64[ns]") diff --git a/tests/infrastructure/models/test_portfolio.py b/tests/infrastructure/models/test_portfolio.py index 8471bf7d..12474d14 100644 --- a/tests/infrastructure/models/test_portfolio.py +++ b/tests/infrastructure/models/test_portfolio.py @@ -42,7 +42,7 @@ def test_default(self): self.assertEqual(10000, portfolio.get_net_size()) self.assertIsNone(portfolio.get_initial_balance()) self.assertIsNotNone(portfolio.get_created_at()) - self.assertIsNone(portfolio.get_updated_at()) + self.assertIsNotNone(portfolio.get_updated_at()) def test_created_by_app(self): portfolio = self.app.context.get_portfolio() @@ -61,374 +61,6 @@ def test_created_by_app(self): self.assertIsNotNone(portfolio.get_created_at()) self.assertIsNotNone(portfolio.get_updated_at()) - # def test_get_trading_symbol(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # self.assertIsNotNone(portfolio.get_trading_symbol()) - - # def test_get_unallocated(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # self.create_buy_order( - # amount=1, - # target_symbol=self.TARGET_SYMBOL_A, - # portfolio_manager=portfolio_manager, - # reference_id=10, - # price=10 - # ) - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - - # self.assertIsNotNone(portfolio.get_unallocated()) - # self.assertIsNotNone(portfolio.get_unallocated()) - # self.assertTrue( - # isinstance(portfolio.get_unallocated(), Position) - # ) - - # def test_get_allocated(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # self.create_buy_order( - # amount=1, - # target_symbol=self.TARGET_SYMBOL_A, - # portfolio_manager=portfolio_manager, - # reference_id=10, - # price=10 - # ) - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # positions = portfolio.get_positions() - - # for position in positions: - # position.price = self.get_price(position.get_symbol()).price - - # self.assertIsNotNone(portfolio.get_allocated()) - # self.assertNotEqual(0, portfolio.get_allocated()) - - # def test_get_id(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # self.assertIsNotNone(portfolio.get_identifier()) - - # def test_get_total_revenue(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # self.assertIsNotNone(portfolio.get_total_revenue()) - # self.assertEqual(0, portfolio.get_total_revenue()) - - # def test_add_order(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # order = portfolio_manager.create_order( - # amount=10, - # target_symbol=self.TARGET_SYMBOL_A, - # price=self.get_price(self.TARGET_SYMBOL_A).price, - # order_type=OrderType.LIMIT.value - # ) - - # order.status = OrderStatus.PENDING - # order.reference_id = 2 - # db.session.commit() - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - - # self.assertEqual(0, len(portfolio.get_orders())) - # self.assertEqual(1, len(portfolio.get_positions())) - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # portfolio.add_order(order) - - # self.assertEqual(1, len(portfolio.get_orders())) - # self.assertEqual(2, len(portfolio.get_positions())) - - # def test_add_orders(self): - # orders = [] - - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # order = portfolio_manager.create_order( - # target_symbol=self.TARGET_SYMBOL_A, - # amount=1, - # price=self.get_price(self.TARGET_SYMBOL_A).price, - # ) - # order.reference_id = 1 - # db.session.commit() - # orders.append(order) - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # portfolio.add_orders(orders) - - # def test_add_position(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # portfolio.add_position( - # Position(target_symbol=self.TARGET_SYMBOL_C, amount=20) - # ) - - # self.assertEqual(2, len(portfolio.get_positions())) - - # def test_add_positions(self): - # positions = [ - # Position(target_symbol=self.TARGET_SYMBOL_C, amount=20), - # Position(target_symbol=self.TARGET_SYMBOL_D, amount=20) - # ] - - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # portfolio.add_positions(positions) - - # self.assertEqual(3, len(portfolio.get_positions())) - - # def test_get_position(self): - # positions = [ - # Position(symbol=self.TARGET_SYMBOL_B, amount=20), - # Position(symbol=self.TARGET_SYMBOL_C, amount=20) - # ] - - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # portfolio.add_positions(positions) - - # self.assertEqual(3, len(portfolio.get_positions())) - - # position_b = portfolio.get_position(self.TARGET_SYMBOL_B) - # position_c = portfolio.get_position(self.TARGET_SYMBOL_C) - - # self.assertIsNotNone(position_b) - # self.assertIsNotNone(position_c) - - # self.assertEqual( - # self.TARGET_SYMBOL_B, position_b.get_target_symbol() - # ) - # self.assertEqual( - # self.TARGET_SYMBOL_C, position_c.get_target_symbol() - # ) - - # def test_get_positions(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - - # self.assertIsNotNone(portfolio.get_positions()) - # self.assertEqual(1, len(portfolio.get_positions())) - - # def test_get_orders(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # orders = [ - # Order.from_dict( - # { - # "reference_id": 2, - # "target_symbol": self.TARGET_SYMBOL_A, - # "trading_symbol": "usdt", - # "amount": 4, - # "price": self.get_price(self.TARGET_SYMBOL_A).price, - # "status": OrderStatus.PENDING.value, - # "order_side": OrderSide.BUY.value, - # "order_type": OrderType.LIMIT.value - # } - # ), - # Order.from_dict( - # { - # "reference_id": 3, - # "target_symbol": self.TARGET_SYMBOL_A, - # "trading_symbol": "usdt", - # "amount": 4, - # "price": self.get_price(self.TARGET_SYMBOL_A).price, - # "status": OrderStatus.CLOSED.value, - # "initial_price": self.get_price( - # self.TARGET_SYMBOL_A).price, - # "order_side": OrderSide.BUY.value, - # "order_type": OrderType.LIMIT.value - # } - # ) - # ] - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # portfolio.add_orders(orders) - - # self.assertEqual(2, len(portfolio.get_orders())) - # self.assertEqual( - # 1, len(portfolio.get_orders(status=OrderStatus.CLOSED)) - # ) - # self.assertEqual( - # 1, len(portfolio.get_orders(status=OrderStatus.PENDING)) - # ) - - # self.assertEqual( - # 0, len(portfolio.get_orders(status=OrderStatus.TO_BE_SENT)) - # ) - # self.assertEqual( - # 0, len(portfolio.get_orders(order_side=OrderSide.SELL)) - # ) - # self.assertEqual( - # 0, len(portfolio.get_orders(order_type=OrderType.MARKET)) - # ) - # self.assertEqual( - # 1, len(portfolio.get_orders( - # status=OrderStatus.PENDING, - # order_type=OrderType.LIMIT, - # order_side=OrderSide.BUY - # )) - # ) - # self.assertEqual( - # 1, len(portfolio.get_orders( - # status=OrderStatus.CLOSED, - # order_type=OrderType.LIMIT, - # order_side=OrderSide.BUY - # )) - # ) - - # def test_from_dict(self): - # portfolio = Portfolio.from_dict( - # { - # "identifier": "BINANCE", - # "trading_symbol": "USDT", - # "market": "BINANCE", - # "positions": [ - # {"symbol": "USDT", "amount": 10000}, - # {"symbol": "DOT", "amount": 40}, - # {"symbol": "BTC", "amount": 0.04}, - # ] - # } - # ) - # self.assertIsNotNone(portfolio.get_identifier()) - # self.assertIsNotNone(portfolio.get_trading_symbol()) - # self.assertIsNotNone(portfolio.get_unallocated()) - # self.assertIsNotNone(portfolio.get_positions()) - # self.assertEqual(3, len(portfolio.get_positions())) - # self.assertEqual(0, len(portfolio.get_orders())) - - # def test_from_dict_with_orders(self): - # portfolio = Portfolio( - # orders=[ - # Order( - # reference_id=2, - # trading_symbol="USDT", - # target_symbol=self.TARGET_SYMBOL_A, - # status=OrderStatus.PENDING, - # price=10, - # amount=10, - # order_side=OrderSide.BUY, - # order_type=OrderType.LIMIT - # ) - # ], - # identifier="BINANCE", - # trading_symbol="USDT", - # positions=[ - # Position(amount=10, symbol=self.TARGET_SYMBOL_A, price=10), - # Position(amount=10, symbol="USDT") - # ], - # ) - # self.assertIsNotNone(portfolio.get_identifier()) - # self.assertIsNotNone(portfolio.get_trading_symbol()) - # self.assertIsNotNone(portfolio.get_unallocated()) - # self.assertIsNotNone(portfolio.get_positions()) - # self.assertEqual(2, len(portfolio.get_positions())) - # self.assertEqual(1, len(portfolio.get_orders())) - - # def test_to_dict(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - # data = portfolio.to_dict() - # self.assertIsNotNone(data) - - # def test_update_positions(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - - # positions = [ - # Position(symbol=self.TARGET_SYMBOL_A, amount=4), - # Position(symbol=self.TARGET_SYMBOL_B, amount=20), - # Position(symbol=self.TARGET_SYMBOL_C, amount=20) - # ] - - # self.assertEqual(1, len(portfolio.get_positions())) - # portfolio.add_positions(positions) - # self.assertEqual(4, len(portfolio.get_positions())) - - # for position in portfolio.get_positions(): - # self.assertTrue(isinstance(position, Position)) - - # position_a = portfolio.get_position(self.TARGET_SYMBOL_A) - # position_b = portfolio.get_position(self.TARGET_SYMBOL_B) - # position_c = portfolio.get_position(self.TARGET_SYMBOL_C) - - # self.assertEqual(4, position_a.get_amount()) - # self.assertEqual(20, position_b.get_amount()) - # self.assertEqual(20, position_c.get_amount()) - - # def test_update_orders(self): - # portfolio_manager = self.algo_app.algorithm \ - # .get_portfolio_manager("default") - - # portfolio = portfolio_manager.get_portfolio(algorithm_context=None) - - # orders = [ - # Order.from_dict( - # { - # "reference_id": 1, - # "target_symbol": self.TARGET_SYMBOL_A, - # "trading_symbol": "usdt", - # "amount": 4, - # "price": self.get_price(self.TARGET_SYMBOL_A).price, - # "initial_price": self - # .get_price(self.TARGET_SYMBOL_A).price, - # "status": OrderStatus.CLOSED.value, - # "order_side": OrderSide.BUY.value, - # "order_type": OrderType.LIMIT.value - # } - # ), - # Order.from_dict( - # { - # "reference_id": 2, - # "target_symbol": self.TARGET_SYMBOL_A, - # "trading_symbol": "usdt", - # "amount": 4, - # "price": self.get_price(self.TARGET_SYMBOL_A).price, - # "status": OrderStatus.PENDING.value, - # "order_side": OrderSide.BUY.value, - # "order_type": OrderType.LIMIT.value - # } - # ), - # Order.from_dict( - # { - # "reference_id": 3, - # "target_symbol": self.TARGET_SYMBOL_A, - # "trading_symbol": "usdt", - # "amount": 4, - # "price": self.get_price(self.TARGET_SYMBOL_A).price, - # "status": OrderStatus.CLOSED.value, - # "initial_price": self - # .get_price(self.TARGET_SYMBOL_A).price, - # "order_side": OrderSide.BUY.value, - # "order_type": OrderType.LIMIT.value - # } - # ) - # ] - - # portfolio.add_orders(orders) - # order_one = portfolio.get_order(2) - # self.assertTrue(OrderStatus.PENDING.equals(order_one.get_status())) - # self.assertEqual(3, len(portfolio.get_orders())) + def test_get_trading_symbol(self): + portfolio = self.app.context.get_portfolio() + self.assertIsNotNone(portfolio.get_trading_symbol()) diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py index 9d89c108..6e00a82b 100644 --- a/tests/resources/__init__.py +++ b/tests/resources/__init__.py @@ -1,6 +1,7 @@ from .stubs import MarketServiceStub, RandomPriceMarketDataSourceServiceStub,\ MarketDataSourceServiceStub -from .test_base import TestBase, FlaskTestBase +from .test_base import TestBase, FlaskTestBase, OrderExecutorTest, \ + PortfolioProviderTest from .utils import random_string __all__ = [ @@ -9,5 +10,7 @@ "MarketServiceStub", "FlaskTestBase", "RandomPriceMarketDataSourceServiceStub", - "MarketDataSourceServiceStub" + "MarketDataSourceServiceStub", + "OrderExecutorTest", + "PortfolioProviderTest", ] diff --git a/tests/resources/stubs/__init__.py b/tests/resources/stubs/__init__.py index f57b78d4..d23d1b46 100644 --- a/tests/resources/stubs/__init__.py +++ b/tests/resources/stubs/__init__.py @@ -1,9 +1,15 @@ from .market_data_source_service_stub import \ RandomPriceMarketDataSourceServiceStub, MarketDataSourceServiceStub from .market_service_stub import MarketServiceStub +from .portfolio_sync_service import PortfolioSyncServiceStub +from .order_executor import OrderExecutorTest +from .portfolio_provider import PortfolioProviderTest __all__ = [ "MarketServiceStub", "RandomPriceMarketDataSourceServiceStub", - "MarketDataSourceServiceStub" + "MarketDataSourceServiceStub", + "PortfolioSyncServiceStub", + "OrderExecutorTest", + "PortfolioProviderTest", ] diff --git a/tests/resources/stubs/order_executor.py b/tests/resources/stubs/order_executor.py new file mode 100644 index 00000000..d6344d80 --- /dev/null +++ b/tests/resources/stubs/order_executor.py @@ -0,0 +1,39 @@ +from investing_algorithm_framework import Order +from investing_algorithm_framework.domain import OrderExecutor, OrderStatus +from tests.resources.utils import random_string + + +class OrderExecutorTest(OrderExecutor): + """ + Test order executor for testing purposes. + """ + + def __init__(self): + super().__init__() + self.order_amount = None + self.order_amount_filled = None + self.order_status = None + + def execute_order(self, portfolio, order, market_credential) -> Order: + order.external_id = random_string(10) + order.status = OrderStatus.OPEN + + if self.order_amount is not None: + order.amount = self.order_amount + order.remaining = self.order_amount + + if self.order_amount_filled is not None: + order.filled = self.order_amount_filled + order.remaining = order.amount - self.order_amount_filled + + if self.order_status is not None: + order.status = self.order_status + + return order + + def supports_market(self, market): + return True + + def cancel_order(self, portfolio, order, market_credential) -> Order: + order.status = OrderStatus.CANCELED + return order diff --git a/tests/resources/stubs/portfolio_provider.py b/tests/resources/stubs/portfolio_provider.py new file mode 100644 index 00000000..1388e7a2 --- /dev/null +++ b/tests/resources/stubs/portfolio_provider.py @@ -0,0 +1,52 @@ +from typing import Union + +from investing_algorithm_framework import Position, Order, OrderStatus +from investing_algorithm_framework.domain import PortfolioProvider + + +class PortfolioProviderTest(PortfolioProvider): + + def __init__(self): + super().__init__() + self.status = OrderStatus.CLOSED.value + self.external_balances = { + "EUR": 1000, + } + self.order_amount = None + self.order_amount_filled = None + + def get_order( + self, portfolio, order, market_credential + ) -> Union[Order, None]: + + if self.order_amount is not None: + order.amount = self.order_amount + + if self.order_amount_filled is not None: + order.filled = self.order_amount_filled + else: + order.filled = order.amount + + order.status = self.status + order.remaining = 0 + return order + + def get_position( + self, portfolio, symbol, market_credential + ) -> Union[Position, None]: + if symbol not in self.external_balances: + position = Position( + symbol=symbol, + amount=1000, + portfolio_id=portfolio.id + ) + else: + position = Position( + symbol=symbol, + amount=self.external_balances[symbol], + portfolio_id=portfolio.id + ) + return position + + def supports_market(self, market) -> bool: + return True \ No newline at end of file diff --git a/tests/resources/stubs/portfolio_sync_service.py b/tests/resources/stubs/portfolio_sync_service.py new file mode 100644 index 00000000..13643985 --- /dev/null +++ b/tests/resources/stubs/portfolio_sync_service.py @@ -0,0 +1,43 @@ +from investing_algorithm_framework.services import PortfolioSyncService + + +class PortfolioSyncServiceStub: + """ + Stub class for PortfolioSyncService. This class is used to test the + PortfolioSyncService class without actually executing any orders. + """ + + def __init__(self, portfolio_repository, position_repository): + self.portfolio_repository = portfolio_repository + self.position_repository = position_repository + + def sync_unallocated(self, portfolio): + if portfolio.initial_balance is None: + unallocated = 1000 + else: + unallocated = portfolio.initial_balance + + update_data = { + "unallocated": unallocated, + "net_size": unallocated, + "initialized": True + } + portfolio = self.portfolio_repository.update( + portfolio.id, update_data + ) + + # Update also a trading symbol position + trading_symbol_position = self.position_repository.find( + { + "symbol": portfolio.trading_symbol, + "portfolio_id": portfolio.id + } + ) + self.position_repository.update( + trading_symbol_position.id, {"amount": unallocated} + ) + + return portfolio + + def sync_orders(self, portfolio): + return diff --git a/tests/resources/test_base.py b/tests/resources/test_base.py index 0776984c..281c063a 100644 --- a/tests/resources/test_base.py +++ b/tests/resources/test_base.py @@ -9,8 +9,8 @@ TradingStrategy, TimeUnit, OrderStatus from investing_algorithm_framework.domain import RESOURCE_DIRECTORY, \ ENVIRONMENT, Environment -from investing_algorithm_framework.infrastructure.database import Session -from tests.resources.stubs import MarketServiceStub +from tests.resources.stubs import MarketServiceStub, \ + PortfolioSyncServiceStub, OrderExecutorTest, PortfolioProviderTest logger = logging.getLogger(__name__) @@ -32,7 +32,7 @@ def run_strategy(self, algorithm, market_data): class TestBase(TestCase): portfolio_configurations = [] config = {} - external_balances = {} + external_balances = None external_orders = [] initial_orders = [] market_credentials = [] @@ -51,6 +51,18 @@ def setUp(self) -> None: self.market_service.balances = self.external_balances self.market_service.orders = self.external_orders self.app.container.market_service.override(self.market_service) + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.reset() + portfolio_provider_lookup = self.app.container\ + .portfolio_provider_lookup() + portfolio_provider_lookup.reset() + self.app.add_order_executor(OrderExecutorTest()) + portfolio_provider = PortfolioProviderTest() + + if self.external_balances is not None: + portfolio_provider.external_balances = self.external_balances + + self.app.add_portfolio_provider(portfolio_provider) if self.market_data_source_service is not None: self.app.container.market_data_source_service\ @@ -157,6 +169,33 @@ def create_app(self): self.market_service.balances = self.external_balances self.market_service.orders = self.external_orders self.iaf_app.container.market_service.override(self.market_service) + order_executor_lookup = self.iaf_app.container.order_executor_lookup() + order_executor_lookup.reset() + portfolio_provider_lookup = self.iaf_app.container\ + .portfolio_provider_lookup() + portfolio_provider_lookup.reset() + self.iaf_app.add_order_executor(OrderExecutorTest()) + portfolio_provider = PortfolioProviderTest() + + if self.external_balances is not None: + portfolio_provider.external_balances = self.external_balances + + self.iaf_app.add_portfolio_provider(portfolio_provider) + + # if self.market_data_source_service is not None: + # self.app.container.market_data_source_service\ + # .override(self.market_data_source_service) + + if len(self.portfolio_configurations) > 0: + for portfolio_configuration in self.portfolio_configurations: + self.iaf_app.add_portfolio_configuration( + portfolio_configuration + ) + + # Add all market credentials + if len(self.market_credentials) > 0: + for market_credential in self.market_credentials: + self.iaf_app.add_market_credential(market_credential) if len(self.portfolio_configurations) > 0: for portfolio_configuration in self.portfolio_configurations: diff --git a/tests/services/test_order_backtest_service.py b/tests/services/test_order_backtest_service.py index f812addd..45baf497 100644 --- a/tests/services/test_order_backtest_service.py +++ b/tests/services/test_order_backtest_service.py @@ -65,7 +65,7 @@ def setUp(self) -> None: OrderBacktestService( trade_service=self.app.container.trade_service(), order_repository=self.app.container.order_repository(), - position_repository=self.app.container.position_repository(), + position_service=self.app.container.position_service(), portfolio_repository=self.app.container.portfolio_repository(), portfolio_configuration_service=self.app.container. portfolio_configuration_service(), diff --git a/tests/services/test_order_service.py b/tests/services/test_order_service.py index d62c0d95..7436ffcb 100644 --- a/tests/services/test_order_service.py +++ b/tests/services/test_order_service.py @@ -1,7 +1,7 @@ from decimal import Decimal from investing_algorithm_framework import PortfolioConfiguration, \ - MarketCredential + MarketCredential, OrderStatus, TradeStatus from tests.resources import TestBase @@ -276,3 +276,528 @@ def test_update_buy_order_with_cancelled_order(self): def test_update_sell_order_with_cancelled_order(self): pass + + def test_create_buy_order_with_order_amount_changed_by_market(self): + """ + This test is to check if the order amount is changed by the market + that the trade and position are also updated. + + For the trade object, the amount should be updated, and the remaining + amount should be also updated. + + For the position object, the amount should be updated to the + amount of the order and the cost should be updated. + """ + trade_service = self.app.container.trade_service() + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.register_order_executor_for_market( + "binance", + ) + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount = 999 + order_executor.order_amount_filled = 999 + + order_service = self.app.container.order_service() + order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "BUY", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(0, order.get_remaining()) + self.assertEqual(999, order.get_filled()) + self.assertEqual(999, order.get_amount()) + + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(999, position.amount) + self.assertEqual(999, position.cost) + + trade = trade_service.find( + { + "order_id": order.id, + } + ) + + self.assertEqual(999, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(999, trade.filled_amount) + self.assertEqual(999, trade.available_amount) + self.assertEqual(1, trade.open_price) + + def test_update_buy_order_with_order_amount_changed_by_market(self): + """ + This test is to check if the order amount is changed during updating + by the market that the trade and position are also updated. + + For the trade object, the amount should be updated, and the remaining + amount should be also updated. + + For the position object, the amount should be updated to the + amount of the order and the cost should be updated. + """ + trade_service = self.app.container.trade_service() + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.register_order_executor_for_market( + "binance", + ) + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + + order_service = self.app.container.order_service() + order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "BUY", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(1000, order.get_remaining()) + self.assertEqual(0, order.get_filled()) + self.assertEqual(1000, order.get_amount()) + + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(0, position.amount) + self.assertEqual(0, position.cost) + + trade = trade_service.find( + { + "order_id": order.id, + } + ) + + self.assertEqual(1000, trade.amount) + self.assertEqual(1000, trade.remaining) + self.assertEqual(0, trade.filled_amount) + self.assertEqual(0, trade.available_amount) + self.assertEqual(1, trade.open_price) + + portfolio_provider_lookup = self.app.container.portfolio_provider_lookup() + portfolio_provider_lookup.register_portfolio_provider_for_market( + "binance", + ) + portfolio_provider = portfolio_provider_lookup.get_portfolio_provider( + "binance" + ) + portfolio_provider.order_amount = 999 + portfolio_provider.order_amount_filled = 999 + + order_service.check_pending_orders() + + order = order_service.get(order.id) + self.assertEqual(0, order.get_remaining()) + self.assertEqual(999, order.get_filled()) + self.assertEqual(999, order.get_amount()) + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(999, position.amount) + self.assertEqual(999, position.cost) + + trade = trade_service.find( + { + "order_id": order.id, + } + ) + self.assertEqual(999, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(999, trade.filled_amount) + self.assertEqual(999, trade.available_amount) + self.assertEqual(1, trade.open_price) + + def test_create_sell_order_with_order_amount_changed_by_market(self): + """ + This test is to check if the order amount is changed by the market + that the trade and position are also updated. + + For the trade object, the amount should be updated, and the remaining + amount should be also updated. + + For the position object, the amount should be updated to the + amount of the order and the cost should be updated. + """ + trade_service = self.app.container.trade_service() + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.register_order_executor_for_market( + "binance", + ) + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 1000 + + order_service = self.app.container.order_service() + order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "BUY", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(0, order.get_remaining()) + self.assertEqual(1000, order.get_filled()) + self.assertEqual(1000, order.get_amount()) + + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(1000, position.amount) + self.assertEqual(1000, position.cost) + + trade = trade_service.find( + { + "order_id": order.id, + } + ) + + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1, trade.open_price) + + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 999 + order_executor.order_amount = 999 + + sell_order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "SELL", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(0, sell_order.get_remaining()) + self.assertEqual(999, sell_order.get_filled()) + self.assertEqual(999, sell_order.get_amount()) + position = self.app.container.position_service().get( + sell_order.position_id + ) + self.assertEqual(1, position.amount) + self.assertEqual(1, position.cost) + + trade = trade_service.find( + { + "order_id": sell_order.id, + } + ) + + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1, trade.available_amount) + + def test_update_sell_order_with_order_amount_changed_by_market(self): + """ + This test is to check if the order amount is changed by the market + that the trade and position are also updated. + + For the trade object, the amount should be updated, and the remaining + amount should be also updated. + + For the position object, the amount should be updated to the + amount of the order and the cost should be updated. + """ + trade_service = self.app.container.trade_service() + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.register_order_executor_for_market( + "binance", + ) + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 1000 + + order_service = self.app.container.order_service() + order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "BUY", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(0, order.get_remaining()) + self.assertEqual(1000, order.get_filled()) + self.assertEqual(1000, order.get_amount()) + + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(1000, position.amount) + self.assertEqual(1000, position.cost) + + trade = trade_service.find( + { + "order_id": order.id, + } + ) + + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1, trade.open_price) + + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 0 + order_executor.status = OrderStatus.OPEN.value + sell_order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "SELL", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(1000, sell_order.get_remaining()) + self.assertEqual(0, sell_order.get_filled()) + self.assertEqual(1000, sell_order.get_amount()) + position = self.app.container.position_service().get( + sell_order.position_id + ) + self.assertEqual(0, position.amount) + self.assertEqual(0, position.cost) + + trade = trade_service.find( + { + "order_id": sell_order.id, + } + ) + + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(0, trade.available_amount) + + portfolio_provider_lookup = self.app.container.portfolio_provider_lookup() + portfolio_provider_lookup.register_portfolio_provider_for_market( + "binance", + ) + portfolio_provider = portfolio_provider_lookup.get_portfolio_provider( + "binance" + ) + portfolio_provider.order_amount = 999 + portfolio_provider.order_amount_filled = 999 + order_service.check_pending_orders() + + order = order_service.get(sell_order.id) + self.assertEqual(0, order.get_remaining()) + self.assertEqual(999, order.get_filled()) + self.assertEqual(999, order.get_amount()) + + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(1, position.amount) + self.assertEqual(1, position.cost) + + trade = trade_service.find( + { + "order_id": order.id, + } + ) + + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1, trade.available_amount) + + def test_create_buy_order_that_has_been_filled_immediately(self): + trade_service = self.app.container.trade_service() + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.register_order_executor_for_market( + "binance", + ) + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 1000 + order_executor.order_status = OrderStatus.CLOSED.value + + order_service = self.app.container.order_service() + order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "BUY", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(0, order.get_remaining()) + self.assertEqual(1000, order.get_filled()) + self.assertEqual(1000, order.get_amount()) + self.assertEqual(OrderStatus.CLOSED.value, order.get_status()) + + # Check position + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(1000, position.amount) + self.assertEqual(1000, position.cost) + + # Check portfolio + portfolio = self.app.container.portfolio_service().get( + position.portfolio_id + ) + self.assertEqual(0, portfolio.get_unallocated()) + + # Check trade + trade = trade_service.find( + { + "order_id": order.id, + } + ) + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1, trade.open_price) + self.assertTrue(TradeStatus.OPEN.equals(trade.status)) + + def test_create_sell_order_that_has_been_filled_immediately(self): + trade_service = self.app.container.trade_service() + order_executor_lookup = self.app.container.order_executor_lookup() + order_executor_lookup.register_order_executor_for_market( + "binance", + ) + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 1000 + order_executor.order_status = OrderStatus.CLOSED.value + + order_service = self.app.container.order_service() + order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "BUY", + "price": 1, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + self.assertEqual(0, order.get_remaining()) + self.assertEqual(1000, order.get_filled()) + self.assertEqual(1000, order.get_amount()) + self.assertEqual(OrderStatus.CLOSED.value, order.get_status()) + + # Check position + position = self.app.container.position_service().get( + order.position_id + ) + self.assertEqual(1000, position.amount) + self.assertEqual(1000, position.cost) + + # Check portfolio + portfolio = self.app.container.portfolio_service().get( + position.portfolio_id + ) + self.assertEqual(0, portfolio.get_unallocated()) + + # Check trade + trade = trade_service.find( + { + "order_id": order.id, + } + ) + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1, trade.open_price) + self.assertTrue(TradeStatus.OPEN.equals(trade.status)) + + sell_order = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 1000, + "order_side": "SELL", + "price": 2, + "order_type": "LIMIT", + "portfolio_id": 1, + } + ) + + order_executor = order_executor_lookup.get_order_executor( + "binance" + ) + order_executor.order_amount_filled = 1000 + order_executor.order_status = OrderStatus.CLOSED.value + + self.assertEqual(0, sell_order.get_remaining()) + self.assertEqual(1000, sell_order.get_filled()) + self.assertEqual(1000, sell_order.get_amount()) + self.assertEqual(OrderStatus.CLOSED.value, sell_order.get_status()) + + # Check position + position = self.app.container.position_service().get( + sell_order.position_id + ) + self.assertEqual(0, position.amount) + + # Check portfolio + portfolio = self.app.container.portfolio_service().get( + position.portfolio_id + ) + self.assertEqual(2000, portfolio.get_unallocated()) + + # Check trade + trade = trade_service.find( + { + "order_id": order.id, + } + ) + self.assertEqual(1000, trade.amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(0, trade.available_amount) + self.assertEqual(1, trade.open_price) + self.assertTrue(TradeStatus.CLOSED.equals(trade.status)) diff --git a/tests/services/test_portfolio_sync_service.py b/tests/services/test_portfolio_sync_service.py index 8b0ed292..740aa5db 100644 --- a/tests/services/test_portfolio_sync_service.py +++ b/tests/services/test_portfolio_sync_service.py @@ -69,15 +69,24 @@ def test_sync_unallocated_with_no_balance(self): secret_key="test" ) ) - self.market_service.balances = {"EUR": 0} self.app.add_algorithm(Algorithm()) + portfolio_provider_lookup = \ + self.app.container.portfolio_provider_lookup() + portfolio_provider_lookup\ + .register_portfolio_provider_for_market( + "binance" + ) + portfolio_provider = \ + portfolio_provider_lookup.get_portfolio_provider("binance") + portfolio_provider.external_balances = {"EUR": 400} + with self.assertRaises(OperationalException) as context: self.app.initialize_config() self.app.initialize() self.assertEqual( - "The initial balance of the portfolio configuration (1000.0 EUR) is more than the available balance on the exchange. Please make sure that the initial balance of the portfolio configuration is less than the available balance on the exchange 0.0 EUR.", + "The initial balance of the portfolio configuration (1000.0 EUR) is more than the available balance on the exchange. Please make sure that the initial balance of the portfolio configuration is less than the available balance on the exchange 400 EUR.", str(context.exception) ) diff --git a/tests/services/test_trade_service.py b/tests/services/test_trade_service.py index 147e0001..5867f450 100644 --- a/tests/services/test_trade_service.py +++ b/tests/services/test_trade_service.py @@ -1,6 +1,6 @@ from datetime import datetime from investing_algorithm_framework import PortfolioConfiguration, \ - MarketCredential, OrderStatus, TradeStatus, TradeRiskType + MarketCredential, OrderStatus, TradeStatus, OrderSide from tests.resources import TestBase @@ -41,10 +41,12 @@ def test_create_trade_from_buy_order_with_created_status(self): self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(0, trade.available_amount) + self.assertEqual(0, trade.filled_amount) + self.assertEqual(2004, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNotNone(trade.opened_at) self.assertIsNone(trade.closed_at) - self.assertEqual(0, trade.remaining) self.assertEqual(TradeStatus.CREATED.value, trade.status) def test_create_trade_from_buy_order_with_open_status(self): @@ -66,38 +68,41 @@ def test_create_trade_from_buy_order_with_open_status(self): self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1004, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNotNone(trade.opened_at) self.assertIsNone(trade.closed_at) - self.assertEqual(1000, trade.remaining) self.assertEqual(TradeStatus.OPEN.value, trade.status) - def test_create_trade_from_buy_order_with_closed_status(self): - order_repository = self.app.container.order_repository() buy_order = order_repository.create( { - "target_symbol": "ADA", + "target_symbol": "DOT", "trading_symbol": "EUR", - "amount": 2004, - "filled": 2004, + "amount": 10, + "filled": 5, + "remaining": 5, "order_side": "BUY", - "price": 0.24262, + "price": 6, "order_type": "LIMIT", - "status": "OPEN", + "status": OrderStatus.OPEN.value, } ) trade_service = self.app.container.trade_service() trade = trade_service.create_trade_from_buy_order(buy_order) - self.assertEqual("ADA", trade.target_symbol) + self.assertEqual("DOT", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) - self.assertEqual(2004, trade.amount) - self.assertEqual(0.24262, trade.open_price) + self.assertEqual(10, trade.amount) + self.assertEqual(5, trade.available_amount) + self.assertEqual(5, trade.filled_amount) + self.assertEqual(5, trade.remaining) + self.assertEqual(6, trade.open_price) self.assertIsNotNone(trade.opened_at) self.assertIsNone(trade.closed_at) - self.assertEqual(2004, trade.remaining) self.assertEqual(TradeStatus.OPEN.value, trade.status) - def test_create_trade_from_buy_order_with_rejected_status(self): + def test_create_trade_from_buy_order_with_closed_status(self): order_repository = self.app.container.order_repository() buy_order = order_repository.create( { @@ -108,25 +113,35 @@ def test_create_trade_from_buy_order_with_rejected_status(self): "order_side": "BUY", "price": 0.24262, "order_type": "LIMIT", - "status": OrderStatus.REJECTED.value, + "status": "OPEN", } ) trade_service = self.app.container.trade_service() trade = trade_service.create_trade_from_buy_order(buy_order) - self.assertIsNone(trade) + self.assertEqual("ADA", trade.target_symbol) + self.assertEqual("EUR", trade.trading_symbol) + self.assertEqual(2004, trade.amount) + self.assertEqual(2004, trade.filled_amount) + self.assertEqual(2004, trade.available_amount) + self.assertEqual(0, trade.remaining) + self.assertEqual(0.24262, trade.open_price) + self.assertIsNotNone(trade.opened_at) + self.assertIsNone(trade.closed_at) + self.assertEqual(0, trade.remaining) + self.assertEqual(TradeStatus.OPEN.value, trade.status) - def test_create_trade_from_buy_order_with_rejected_buy_order(self): + def test_create_trade_from_buy_order_with_rejected_status(self): order_repository = self.app.container.order_repository() buy_order = order_repository.create( { "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 2004, - "filled": 2004, + "filled": 0, "order_side": "BUY", "price": 0.24262, "order_type": "LIMIT", - "status": "REJECTED", + "status": OrderStatus.REJECTED.value, } ) trade_service = self.app.container.trade_service() @@ -140,7 +155,7 @@ def test_create_trade_from_buy_order_with_canceled_buy_order(self): "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 2004, - "filled": 2004, + "filled": 0, "order_side": "BUY", "price": 0.24262, "order_type": "LIMIT", @@ -189,11 +204,13 @@ def test_update_trade_with_filled_buy_order(self): self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(2004, trade.remaining) + self.assertEqual(0, trade.filled_amount) + self.assertEqual(0, trade.available_amount) self.assertEqual(0.24262, trade.open_price) self.assertEqual(TradeStatus.CREATED.value, trade.status) self.assertIsNotNone(trade.opened_at) self.assertIsNone(trade.closed_at) - self.assertEqual(0, trade.remaining) buy_order = order_repository.get(order_id) buy_order = order_repository.update( buy_order.id, @@ -206,9 +223,11 @@ def test_update_trade_with_filled_buy_order(self): self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(2004, trade.filled_amount) + self.assertEqual(2004, trade.available_amount) + self.assertEqual(0, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNone(trade.closed_at) - self.assertEqual(2004, trade.remaining) self.assertEqual(TradeStatus.OPEN.value, trade.status) def test_update_trade_with_existing_buy_order(self): @@ -231,10 +250,12 @@ def test_update_trade_with_existing_buy_order(self): self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1004, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNotNone(trade.opened_at) self.assertIsNone(trade.closed_at) - self.assertEqual(1000, trade.remaining) buy_order = order_repository.get(order_id) buy_order = order_repository.update( buy_order.id, @@ -247,50 +268,72 @@ def test_update_trade_with_existing_buy_order(self): self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(2004, trade.filled_amount) + self.assertEqual(2004, trade.available_amount) + self.assertEqual(0, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNone(trade.closed_at) - self.assertEqual(2004, trade.remaining) + self.assertEqual(0, trade.remaining) - def test_update_trade_with_existing_buy_order_and_partily_closed(self): - order_repository = self.app.container.order_repository() - buy_order = order_repository.create( + def test_update_trade_with_existing_buy_order_and_partialy_closed(self): + order_service = self.app.container.order_service() + buy_order = order_service.create( { + "portfolio_id": 1, "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 2004, - "filled": 1000, "order_side": "BUY", "price": 0.24262, "order_type": "LIMIT", "status": "CREATED", } ) + + order_service.update( + buy_order.id, + { + "status": OrderStatus.OPEN.value, + "filled": 1000, + "remaining": 1004, + } + ) + order_id = buy_order.id trade_service = self.app.container.trade_service() - trade = trade_service.create_trade_from_buy_order(buy_order) + trade = trade_service.find( + {"order_id": order_id} + ) self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) + self.assertEqual(1004, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNotNone(trade.opened_at) self.assertIsNone(trade.closed_at) - self.assertEqual(1000, trade.remaining) self.assertEqual(TradeStatus.OPEN.value, trade.status) - buy_order = order_repository.get(order_id) - buy_order = order_repository.update( + buy_order = order_service.get(order_id) + buy_order = order_service.update( buy_order.id, { "status": OrderStatus.OPEN.value, - "filled": 500, + "filled": 1500, + "remaining": 504, } ) - trade = trade_service.update_trade_with_buy_order(500, buy_order) + trade = trade_service.find( + {"order_id": order_id} + ) self.assertEqual("ADA", trade.target_symbol) self.assertEqual("EUR", trade.trading_symbol) self.assertEqual(2004, trade.amount) + self.assertEqual(1500, trade.filled_amount) + self.assertEqual(1500, trade.available_amount) + self.assertEqual(504, trade.remaining) self.assertEqual(0.24262, trade.open_price) self.assertIsNone(trade.closed_at) - self.assertEqual(1500, trade.remaining) def test_close_trades(self): portfolio = self.app.context.get_portfolio() @@ -317,6 +360,7 @@ def test_close_trades(self): { "status": OrderStatus.CLOSED.value, "filled": 2004, + "remaining": 0 } ) @@ -326,7 +370,9 @@ def test_close_trades(self): ) trade_id = trade.id self.assertEqual(2004, trade.amount) - self.assertEqual(2004, trade.remaining) + self.assertEqual(2004, trade.filled_amount) + self.assertEqual(2004, trade.available_amount) + self.assertEqual(0, trade.remaining) self.assertEqual(TradeStatus.OPEN.value, trade.status) self.assertEqual(0.2, trade.open_price) self.assertEqual(1, len(trade.orders)) @@ -352,6 +398,9 @@ def test_close_trades(self): trade = trade_service.get(trade_id) self.assertEqual(2004, trade.amount) self.assertEqual(0, trade.remaining) + self.assertEqual(0, trade.available_amount) + self.assertEqual(2004, trade.filled_amount) + self.assertAlmostEqual(2004 * 0.3 - 2004 * 0.2, trade.net_gain) self.assertEqual(TradeStatus.CLOSED.value, trade.status) self.assertEqual(0.2, trade.open_price) self.assertEqual(2, len(trade.orders)) @@ -362,6 +411,7 @@ def test_close_trades(self): { "status": OrderStatus.CLOSED.value, "filled": 2004, + "remaining": 0 } ) @@ -369,6 +419,8 @@ def test_close_trades(self): trade = trade_service.get(trade_id) self.assertEqual(2004, trade.amount) self.assertEqual(0, trade.remaining) + self.assertEqual(0, trade.available_amount) + self.assertEqual(2004, trade.filled_amount) self.assertEqual(TradeStatus.CLOSED.value, trade.status) self.assertEqual(0.2, trade.open_price) self.assertAlmostEqual(2004 * 0.3 - 2004 * 0.2, trade.net_gain) @@ -399,6 +451,7 @@ def test_active_trade_after_canceling_close_trades(self): { "status": OrderStatus.CLOSED.value, "filled": 2004, + "remaining": 0 } ) @@ -408,7 +461,9 @@ def test_active_trade_after_canceling_close_trades(self): ) trade_id = trade.id self.assertEqual(2004, trade.amount) - self.assertEqual(2004, trade.remaining) + self.assertEqual(0, trade.remaining) + self.assertEqual(2004, trade.available_amount) + self.assertEqual(2004, trade.filled_amount) self.assertEqual(TradeStatus.OPEN.value, trade.status) self.assertEqual(0.2, trade.open_price) self.assertEqual(1, len(trade.orders)) @@ -434,7 +489,10 @@ def test_active_trade_after_canceling_close_trades(self): trade = trade_service.get(trade_id) self.assertEqual(2004, trade.amount) self.assertEqual(0, trade.remaining) + self.assertEqual(0, trade.available_amount) + self.assertEqual(2004, trade.filled_amount) self.assertEqual(TradeStatus.CLOSED.value, trade.status) + self.assertAlmostEqual(2004 * 0.3 - 2004 * 0.2, trade.net_gain) self.assertEqual(0.2, trade.open_price) self.assertEqual(2, len(trade.orders)) @@ -449,7 +507,9 @@ def test_active_trade_after_canceling_close_trades(self): # Check that the trade was updated trade = trade_service.get(trade_id) self.assertEqual(2004, trade.amount) - self.assertEqual(2004, trade.remaining) + self.assertEqual(0, trade.remaining) + self.assertEqual(2004, trade.available_amount) + self.assertEqual(2004, trade.filled_amount) self.assertEqual(TradeStatus.OPEN.value, trade.status) self.assertEqual(0.2, trade.open_price) self.assertAlmostEqual(0, trade.net_gain) @@ -539,6 +599,7 @@ def test_close_trades_with_multiple_trades(self): { "status": OrderStatus.CLOSED.value, "filled": order.amount, + "remaining": 0 } ) @@ -551,7 +612,8 @@ def test_close_trades_with_multiple_trades(self): for t in trades: self.assertNotEqual(0, t.amount) - self.assertEqual(t.amount, t.remaining) + self.assertEqual(t.amount, t.filled_amount) + self.assertEqual(t.available_amount, t.amount) self.assertEqual(TradeStatus.OPEN.value, t.status) self.assertEqual(1, len(t.orders)) @@ -578,14 +640,18 @@ def test_close_trades_with_multiple_trades(self): for t in trades: self.assertNotEqual(0, t.amount) self.assertEqual(0, t.remaining) + self.assertEqual(t.amount, t.filled_amount) + self.assertEqual(0, t.available_amount) self.assertEqual(TradeStatus.CLOSED.value, t.status) self.assertEqual(2, len(t.orders)) + self.assertNotEqual(0, t.net_gain) order_service.update( sell_order_id, { "status": OrderStatus.CLOSED.value, "filled": sell_order.amount, + "remaining": 0 } ) @@ -599,10 +665,13 @@ def test_close_trades_with_multiple_trades(self): for t in trades: self.assertNotEqual(0, t.amount) - self.assertNotEqual(t.amount, t.remaining) + self.assertNotEqual(t.amount, t.available_amount) + self.assertEqual(0, t.remaining) + self.assertEqual(t.amount, t.filled_amount) self.assertEqual(TradeStatus.CLOSED.value, t.status) self.assertEqual(2, len(t.orders)) self.assertEqual(0, t.remaining) + self.assertNotEqual(0, t.net_gain) trade = trade_service.find({"order_id": order_one_id}) self.assertEqual(200, trade.net_gain) @@ -632,18 +701,20 @@ def test_close_trades_with_partailly_filled_buy_order(self): { "status": OrderStatus.OPEN.value, "filled": order.amount / 2, + "remaining": order.amount / 2, } ) trade_service = self.app.container.trade_service() - trade = trade_service.find( - {"order_id": order_id} - ) + trade = trade_service.find({"order_id": order_id}) self.assertEqual(2000, trade.amount) self.assertEqual(1000, trade.remaining) + self.assertEqual(1000, trade.filled_amount) + self.assertEqual(1000, trade.available_amount) self.assertEqual(TradeStatus.OPEN.value, trade.status) self.assertEqual(0.2, trade.open_price) self.assertEqual(1, len(trade.orders)) + order = order_service.create( { "target_symbol": "ADA", @@ -663,11 +734,13 @@ def test_close_trades_with_partailly_filled_buy_order(self): { "status": OrderStatus.CLOSED.value, "filled": 1000, + "remaining": 0, } ) trade = trade_service.find({"order_id": order_id}) self.assertEqual(2000, trade.amount) - self.assertEqual(0, trade.remaining) + self.assertEqual(1000, trade.remaining) + self.assertEqual(0, trade.available_amount) self.assertEqual(1000, trade.filled_amount) self.assertEqual(TradeStatus.OPEN.value, trade.status) self.assertEqual(0.2, trade.open_price) @@ -705,7 +778,9 @@ def test_trade_closing_winning_trade(self): ) self.assertEqual(trade.status, "OPEN") self.assertEqual(trade.amount, 1000) - self.assertEqual(trade.remaining, 1000) + self.assertEqual(trade.available_amount, 1000) + self.assertEqual(trade.remaining, 0) + self.assertEqual(trade.filled_amount, 1000) self.assertEqual(trade.open_price, 0.2) # Create a sell order with a higher price @@ -730,7 +805,6 @@ def test_trade_closing_winning_trade(self): "remaining": 0, } ) - updated_sell_order = order_service.get(sell_order.id) self.assertEqual(0.3, updated_sell_order.get_price()) self.assertEqual(updated_sell_order.amount, 1000) self.assertEqual(updated_sell_order.filled, 1000) @@ -742,6 +816,10 @@ def test_trade_closing_winning_trade(self): self.assertEqual(100.0, trade.net_gain) self.assertEqual(trade.status, "CLOSED") + self.assertEqual(trade.amount, 1000) + self.assertEqual(trade.available_amount, 0) + self.assertEqual(trade.remaining, 0) + self.assertEqual(trade.filled_amount, 1000) self.assertIsNotNone(trade.closed_at) def test_add_stop_loss_to_trade(self): @@ -1061,6 +1139,7 @@ def test_get_triggered_stop_loss_orders(self): "last_reported_price_datetime": datetime.now(), } ) + order_service.check_pending_orders() sell_order_data = trade_service.get_triggered_stop_loss_orders() self.assertEqual(2, len(sell_order_data)) @@ -1107,8 +1186,6 @@ def test_get_triggered_stop_loss_orders_with_unfilled_order(self): "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 20, "order_type": "LIMIT", @@ -1116,7 +1193,14 @@ def test_get_triggered_stop_loss_orders_with_unfilled_order(self): "status": "CLOSED", } ) - + order_service.update( + buy_order_one.id, + { + "status": "CLOSED", + "filled": 20, + "remaining": 0, + } + ) trade_service = self.app.container.trade_service() trade_one = self.app.container.trade_service().find( {"order_id": buy_order_one.id} @@ -1139,13 +1223,12 @@ def test_get_triggered_stop_loss_orders_with_unfilled_order(self): trade_one = trade_service.get(trade_one_id) self.assertEqual(2, len(trade_one.stop_losses)) + # This order should not be filled! buy_order_two = order_service.create( { "target_symbol": "DOT", "trading_symbol": "EUR", "amount": 20, - "filled": 0, - "remaining": 20, "order_side": "BUY", "price": 10, "order_type": "LIMIT", @@ -1153,11 +1236,12 @@ def test_get_triggered_stop_loss_orders_with_unfilled_order(self): "status": "CREATED", } ) + trade_two = self.app.container.trade_service().find( {"order_id": buy_order_two.id} ) trade_two_id = trade_two.id - stop_loss_two = trade_service.add_stop_loss( + trade_service.add_stop_loss( trade_two, 10, "trailing", @@ -1182,13 +1266,24 @@ def test_get_triggered_stop_loss_orders_with_unfilled_order(self): sell_order_data = trade_service.get_triggered_stop_loss_orders() self.assertEqual(1, len(sell_order_data)) - for order_data in sell_order_data: - self.assertEqual("SELL", order_data["order_side"]) - self.assertEqual("EUR", order_data["trading_symbol"]) - self.assertEqual(1, order_data["portfolio_id"]) - self.assertEqual("LIMIT", order_data["order_type"]) - self.assertEqual(17, order_data["price"]) - self.assertEqual(15, order_data["amount"]) + # Filter out the item with the target symbol "ADA" + ada_sell_order_data = [ + order_data for order_data in sell_order_data + if order_data["target_symbol"] == "ADA" + ] + ada_sell_order_data = ada_sell_order_data[0] + self.assertEqual("SELL", ada_sell_order_data["order_side"]) + self.assertEqual("EUR", ada_sell_order_data["trading_symbol"]) + self.assertEqual(1, ada_sell_order_data["portfolio_id"]) + self.assertEqual("LIMIT", ada_sell_order_data["order_type"]) + self.assertEqual(17, ada_sell_order_data["price"]) + self.assertEqual(15, ada_sell_order_data["amount"]) + + dot_sell_order_data = [ + order_data for order_data in sell_order_data + if order_data["target_symbol"] == "DOT" + ] + self.assertEqual(len(dot_sell_order_data), 0) def test_get_triggered_stop_loss_orders_with_cancelled_order(self): """ @@ -1230,6 +1325,7 @@ def test_get_triggered_stop_loss_orders_with_cancelled_order(self): "status": "CLOSED", } ) + order_service.check_pending_orders() # Check that the position costs are correctly updated ada_position = self.app.container.position_service().find( @@ -1274,6 +1370,7 @@ def test_get_triggered_stop_loss_orders_with_cancelled_order(self): "status": "CLOSED", } ) + order_service.check_pending_orders() dot_position = self.app.container.position_service().find( {"symbol": "DOT", "portfolio_id": 1} @@ -1347,11 +1444,11 @@ def test_get_triggered_stop_loss_orders_with_cancelled_order(self): ) self.assertEqual(2, len(ada_trade.orders)) - self.assertEqual(5, ada_trade.remaining) + self.assertEqual(5, ada_trade.available_amount) self.assertEqual(20, ada_trade.amount) self.assertEqual(2, len(dot_trade.orders)) - self.assertEqual(15, dot_trade.remaining) + self.assertEqual(15, dot_trade.available_amount) self.assertEqual(20, dot_trade.amount) # Update the ada order to be partially filled @@ -1396,7 +1493,7 @@ def test_get_triggered_stop_loss_orders_with_cancelled_order(self): self.assertEqual(2, len(ada_trade.orders)) self.assertEqual(20, dot_trade.amount) self.assertEqual(0, dot_trade.net_gain) - self.assertEqual(20, dot_trade.remaining) + self.assertEqual(20, dot_trade.available_amount) ada_trade = trade_service.find( {"order_id": buy_order_one.id} @@ -1404,7 +1501,7 @@ def test_get_triggered_stop_loss_orders_with_cancelled_order(self): # Check that the ada trade is open, with amount of 15, and net gain # of 5 self.assertEqual(2, len(ada_trade.orders)) - self.assertEqual(15, ada_trade.remaining) + self.assertEqual(15, ada_trade.available_amount) self.assertEqual(20, ada_trade.amount) # Check that all stop losses are active again and filled back to @@ -1461,8 +1558,6 @@ def test_get_triggered_stop_loss_orders_with_partially_filled_orders(self): "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 20, - "filled": 10, - "remaining": 0, "order_side": "BUY", "price": 20, "order_type": "LIMIT", @@ -1470,6 +1565,13 @@ def test_get_triggered_stop_loss_orders_with_partially_filled_orders(self): "status": "OPEN", } ) + order_service.update( + buy_order_one.id, + { + "filled": 10, + "remaining": 0, + } + ) # Check that the position costs are correctly updated ada_position = self.app.container.position_service().find( @@ -1505,8 +1607,6 @@ def test_get_triggered_stop_loss_orders_with_partially_filled_orders(self): "target_symbol": "DOT", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 10, "order_type": "LIMIT", @@ -1514,7 +1614,13 @@ def test_get_triggered_stop_loss_orders_with_partially_filled_orders(self): "status": "CLOSED", } ) - + order_service.update( + buy_order_two.id, + { + "filled": 20, + "remaining": 0, + } + ) dot_position = self.app.container.position_service().find( {"symbol": "DOT", "portfolio_id": 1} ) @@ -1586,12 +1692,12 @@ def test_get_triggered_stop_loss_orders_with_partially_filled_orders(self): ) self.assertEqual(2, len(ada_trade.orders)) - self.assertEqual(0, ada_trade.remaining) + self.assertEqual(0, ada_trade.available_amount) self.assertEqual(20, ada_trade.amount) self.assertEqual("OPEN", ada_trade.status) self.assertEqual(2, len(dot_trade.orders)) - self.assertEqual(15, dot_trade.remaining) + self.assertEqual(15, dot_trade.available_amount) self.assertEqual(20, dot_trade.amount) def test_get_triggered_take_profits_orders(self): @@ -1626,8 +1732,6 @@ def test_get_triggered_take_profits_orders(self): "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 20, "order_type": "LIMIT", @@ -1635,6 +1739,14 @@ def test_get_triggered_take_profits_orders(self): "status": "CLOSED", } ) + order_service.update( + buy_order_one.id, + { + "status": "CLOSED", + "filled": 20, + "remaining": 0, + } + ) trade_service = self.app.container.trade_service() trade_one = self.app.container.trade_service().find( @@ -1664,8 +1776,6 @@ def test_get_triggered_take_profits_orders(self): "target_symbol": "DOT", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 10, "order_type": "LIMIT", @@ -1673,6 +1783,14 @@ def test_get_triggered_take_profits_orders(self): "status": "CREATED", } ) + order_service.update( + buy_order_two.id, + { + "status": "CLOSED", + "filled": 20, + "remaining": 0, + } + ) trade_two = self.app.container.trade_service().find( {"order_id": buy_order_two.id} ) @@ -1700,7 +1818,8 @@ def test_get_triggered_take_profits_orders(self): trade_service.update( trade_two_id, { - "last_reported_price": 11, "last_reported_price_datetime": datetime.now(), + "last_reported_price": 11, + "last_reported_price_datetime": datetime.now(), } ) sell_order_data = trade_service.get_triggered_take_profit_orders() @@ -1827,6 +1946,13 @@ def test_get_triggered_take_profits_with_unfilled_order(self): "status": "OPEN", } ) + order_service.update( + buy_order_one.id, + { + "filled": 10, + "remaining": 10, + } + ) trade_service = self.app.container.trade_service() trade_one = self.app.container.trade_service().find( @@ -1860,8 +1986,6 @@ def test_get_triggered_take_profits_with_unfilled_order(self): "target_symbol": "DOT", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 10, "order_type": "LIMIT", @@ -1869,6 +1993,14 @@ def test_get_triggered_take_profits_with_unfilled_order(self): "status": "CREATED", } ) + order_service.update( + buy_order_two.id, + { + "status": "CLOSED", + "filled": 20, + "remaining": 0, + } + ) trade_two = self.app.container.trade_service().find( {"order_id": buy_order_two.id} ) @@ -1929,7 +2061,7 @@ def test_get_triggered_take_profits_with_unfilled_order(self): trade_one = self.app.container.trade_service().find( {"order_id": buy_order_one.id} ) - self.assertEqual(0, trade_one.remaining) + self.assertEqual(0, trade_one.available_amount) self.assertEqual(20, trade_one.amount) self.assertEqual("OPEN", trade_one.status) @@ -2041,8 +2173,6 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 20, "order_type": "LIMIT", @@ -2050,6 +2180,14 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): "status": "CLOSED", } ) + order_service.update( + buy_order_one.id, + { + "filled": 20, + "remaining": 0, + "status": OrderStatus.CLOSED.value, + } + ) trade_service = self.app.container.trade_service() trade_one = self.app.container.trade_service().find( @@ -2079,8 +2217,6 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): "target_symbol": "DOT", "trading_symbol": "EUR", "amount": 20, - "filled": 20, - "remaining": 0, "order_side": "BUY", "price": 10, "order_type": "LIMIT", @@ -2088,6 +2224,14 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): "status": "CREATED", } ) + order_service.update( + buy_order_two.id, + { + "filled": 20, + "remaining": 0, + "status": OrderStatus.CLOSED.value, + } + ) trade_two = self.app.container.trade_service().find( {"order_id": buy_order_two.id} ) @@ -2147,11 +2291,11 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): trade_one = trade_service.get(trade_one_id) trade_two = trade_service.get(trade_two_id) - self.assertEqual(10, trade_one.remaining) + self.assertEqual(10, trade_one.available_amount) self.assertEqual(20, trade_one.amount) self.assertEqual("OPEN", trade_one.status) - self.assertEqual(20, trade_two.remaining) + self.assertEqual(20, trade_two.available_amount) self.assertEqual(20, trade_two.amount) self.assertEqual("OPEN", trade_two.status) @@ -2246,11 +2390,11 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): trade_one = trade_service.get(trade_one_id) trade_two = trade_service.get(trade_two_id) - self.assertEqual(5, trade_one.remaining) + self.assertEqual(5, trade_one.available_amount) self.assertEqual(20, trade_one.amount) self.assertEqual("OPEN", trade_one.status) - self.assertEqual(15, trade_two.remaining) + self.assertEqual(15, trade_two.available_amount) self.assertEqual(20, trade_two.amount) self.assertEqual("OPEN", trade_two.status) @@ -2300,13 +2444,13 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): trade_one = trade_service.get(trade_one_id) trade_two = trade_service.get(trade_two_id) - self.assertEqual(7.5, trade_one.remaining) + self.assertEqual(7.5, trade_one.available_amount) self.assertEqual("ADA", trade_one.target_symbol) self.assertEqual(20, trade_one.amount) self.assertEqual("OPEN", trade_one.status) self.assertEqual("DOT", trade_two.target_symbol) - self.assertEqual(20, trade_two.remaining) + self.assertEqual(20, trade_two.available_amount) self.assertEqual(20, trade_two.amount) self.assertEqual("OPEN", trade_two.status) @@ -2314,14 +2458,14 @@ def test_get_triggered_take_profits_orders_with_cancelled_order(self): take_profit_two.id ) self.assertEqual(22.5, take_profit_two.take_profit_price) - self.assertEqual(25, take_profit_two.high_water_mark) + self.assertIsNone(take_profit_two.high_water_mark) self.assertEqual(2.5, take_profit_two.sold_amount) take_profit_three = trade_take_profit_repository.get( take_profit_three.id ) self.assertEqual(12.6, take_profit_three.take_profit_price) - self.assertEqual(14, take_profit_three.high_water_mark) + self.assertIsNone(take_profit_three.high_water_mark) self.assertEqual(0, take_profit_three.sold_amount) ada_trade = trade_service.find( @@ -2390,8 +2534,6 @@ def test_get_triggered_tp_orders_with_partially_filled_orders(self): "target_symbol": "ADA", "trading_symbol": "EUR", "amount": 20, - "filled": 10, - "remaining": 0, "order_side": "BUY", "price": 20, "order_type": "LIMIT", @@ -2400,6 +2542,14 @@ def test_get_triggered_tp_orders_with_partially_filled_orders(self): } ) + order_service.update( + buy_order_one.id, + { + "filled": 10, + "remaining": 0, + } + ) + trade_service = self.app.container.trade_service() trade_one = self.app.container.trade_service().find( {"order_id": buy_order_one.id} @@ -2437,6 +2587,14 @@ def test_get_triggered_tp_orders_with_partially_filled_orders(self): "status": "CREATED", } ) + order_service.update( + buy_order_two.id, + { + "filled": 20, + "remaining": 0, + "status": OrderStatus.CLOSED.value, + } + ) trade_two = self.app.container.trade_service().find( {"order_id": buy_order_two.id} ) @@ -2496,11 +2654,11 @@ def test_get_triggered_tp_orders_with_partially_filled_orders(self): trade_one = trade_service.get(trade_one_id) trade_two = trade_service.get(trade_two_id) - self.assertEqual(0, trade_one.remaining) + self.assertEqual(0, trade_one.available_amount) self.assertEqual(20, trade_one.amount) self.assertEqual("OPEN", trade_one.status) - self.assertEqual(20, trade_two.remaining) + self.assertEqual(20, trade_two.available_amount) self.assertEqual(20, trade_two.amount) self.assertEqual("OPEN", trade_two.status) @@ -2624,6 +2782,7 @@ def test_deactivation_of_take_profits_when_stop_losses_are_triggered(self): "last_reported_price_datetime": datetime.now(), } ) + order_service.check_pending_orders() sell_order_data = trade_service.get_triggered_stop_loss_orders() self.assertEqual(1, len(sell_order_data)) @@ -2639,7 +2798,7 @@ def test_deactivation_of_take_profits_when_stop_losses_are_triggered(self): # Trade should be closed trade_one = trade_service.get(trade_one_id) - self.assertEqual(0, trade_one.remaining) + self.assertEqual(0, trade_one.available_amount) self.assertEqual(20, trade_one.amount) self.assertEqual("CLOSED", trade_one.status) @@ -2722,6 +2881,7 @@ def test_deactivation_of_stop_losses_when_take_profits_are_triggered(self): "last_reported_price_datetime": datetime.now(), } ) + order_service.check_pending_orders() sell_order_data = trade_service.get_triggered_take_profit_orders() self.assertEqual(1, len(sell_order_data)) @@ -3013,7 +3173,6 @@ def test_add_stop_loss_100_percent_and_take_profit_sell(self): "status": "CLOSED", } ) - trade_service = self.app.container.trade_service() trade_one = self.app.container.trade_service().find( {"order_id": buy_order_one.id} @@ -3045,6 +3204,8 @@ def test_add_stop_loss_100_percent_and_take_profit_sell(self): ) self.assertEqual(18, stop_loss_one.stop_loss_price) + order_service.check_pending_orders() + # Update the last reported price of ada to 21 EUR, triggering 0 # stop loss orders. Both stop losses should have their high water mark # set to 21 EUR @@ -3077,6 +3238,7 @@ def test_add_stop_loss_100_percent_and_take_profit_sell(self): ) sell_order_data = trade_service.get_triggered_take_profit_orders() + print(sell_order_data) self.assertEqual(2, len(sell_order_data[0]['take_profits'])) for order_data in sell_order_data: @@ -3197,6 +3359,7 @@ def test_add_stop_loss_and_take_profit_sell_100_percent(self): "last_reported_price_datetime": datetime.now(), } ) + order_service.check_pending_orders() sell_order_data = trade_service.get_triggered_stop_loss_orders() self.assertEqual(2, len(sell_order_data[0]['stop_losses'])) @@ -3220,6 +3383,7 @@ def test_add_stop_loss_and_take_profit_sell_100_percent(self): } ) + order_service.check_pending_orders() sell_order_data = trade_service.get_triggered_take_profit_orders() self.assertEqual(1, len(sell_order_data)) @@ -3238,4 +3402,218 @@ def test_add_stop_loss_and_take_profit_sell_100_percent(self): take_profit_one = take_profit_repository.get( take_profit_one.id ) - self.assertFalse(take_profit_one.active) \ No newline at end of file + self.assertFalse(take_profit_one.active) + + def test_trade_net_gain_when_stop_loss_order_canceled(self): + order_service = self.app.container.order_service() + buy_order_one = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 20, + "order_side": "BUY", + "price": 20, + "order_type": "LIMIT", + "portfolio_id": 1, + "status": "CLOSED", + } + ) + order_service.update( + buy_order_one.id, + { + "filled": 20, + "remaining": 0, + "status": OrderStatus.CLOSED.value, + } + ) + + trade_service = self.app.container.trade_service() + trade_one = self.app.container.trade_service().find( + {"order_id": buy_order_one.id} + ) + trade_one_id = trade_one.id + stop_loss_one = trade_service.add_stop_loss( + trade_one, + 5, + "fixed", + sell_percentage=50, + ) + self.assertEqual(19, stop_loss_one.stop_loss_price) + + stop_loss_two = trade_service.add_stop_loss( + trade_one, + 10, + "fixed", + sell_percentage=50, + ) + self.assertEqual(18, stop_loss_two.stop_loss_price) + + trade_service.update( + trade_one_id, + { + "last_reported_price": 18, + "last_reported_price_datetime": datetime.now(), + } + ) + order_service.check_pending_orders() + + sell_order_data = trade_service.get_triggered_stop_loss_orders() + self.assertEqual(2, len(sell_order_data[0]['stop_losses'])) + + for order_data in sell_order_data: + self.assertEqual("SELL", order_data["order_side"]) + self.assertEqual("EUR", order_data["trading_symbol"]) + self.assertEqual(1, order_data["portfolio_id"]) + self.assertEqual("LIMIT", order_data["order_type"]) + self.assertEqual(18, order_data["price"]) + self.assertEqual(20, order_data["amount"]) + self.assertEqual("ADA", order_data["target_symbol"]) + sell_order = order_service.create(order_data) + + # Check that the trade is closed + trade_one = trade_service.get(trade_one_id) + self.assertEqual(0, trade_one.available_amount) + self.assertEqual(20, trade_one.amount) + self.assertEqual("CLOSED", trade_one.status) + + # Check that the take profits are triggered and not active anymore. + # Cancel all orders + open_orders = order_service.get_all( + { + "order_side": OrderSide.SELL.value, + "status": OrderStatus.OPEN.value, + "portfolio_id": 1 + } + ) + + portfolio = self.app.context.get_portfolio() + portfolio_provider_lookup = self.app.container.portfolio_provider_lookup() + portfolio_provider = portfolio_provider_lookup.get_portfolio_provider( + portfolio.market + ) + portfolio_provider.set_orders_to_closed = False + portfolio_provider.order_amount_filled = 0 + portfolio_provider.status = OrderStatus.OPEN.value + + for order in open_orders: + order_service.cancel_order(order) + + # Check that available amount is back to 20 and the status is open + trade_one = trade_service.get(trade_one_id) + self.assertEqual(20, trade_one.available_amount) + self.assertEqual(20, trade_one.amount) + self.assertEqual("OPEN", trade_one.status) + + # Check that the stop losses are active again + for stop_loss in trade_one.stop_losses: + self.assertTrue(stop_loss.active) + self.assertIsNone(stop_loss.sell_prices) + + def test_trade_net_gain_when_take_profit_order_canceled(self): + order_service = self.app.container.order_service() + buy_order_one = order_service.create( + { + "target_symbol": "ADA", + "trading_symbol": "EUR", + "amount": 20, + "order_side": "BUY", + "price": 20, + "order_type": "LIMIT", + "portfolio_id": 1, + "status": "CLOSED", + } + ) + order_service.update( + buy_order_one.id, + { + "filled": 20, + "remaining": 0, + "status": OrderStatus.CLOSED.value, + } + ) + + trade_service = self.app.container.trade_service() + trade_one = self.app.container.trade_service().find( + {"order_id": buy_order_one.id} + ) + trade_one_id = trade_one.id + take_profit_one = trade_service.add_take_profit( + trade_one, + 5, + "fixed", + sell_percentage=50, + ) + self.assertEqual(21, take_profit_one.take_profit_price) + + take_profit_two = trade_service.add_take_profit( + trade_one, + 10, + "fixed", + sell_percentage=50, + ) + self.assertEqual(22, take_profit_two.take_profit_price) + + trade_service.update( + trade_one_id, + { + "last_reported_price": 22, + "last_reported_price_datetime": datetime.now(), + } + ) + order_service.check_pending_orders() + + sell_order_data = trade_service.get_triggered_take_profit_orders() + self.assertEqual(2, len(sell_order_data[0]['take_profits'])) + + for order_data in sell_order_data: + self.assertEqual("SELL", order_data["order_side"]) + self.assertEqual("EUR", order_data["trading_symbol"]) + self.assertEqual(1, order_data["portfolio_id"]) + self.assertEqual("LIMIT", order_data["order_type"]) + self.assertEqual(22, order_data["price"]) + self.assertEqual(20, order_data["amount"]) + self.assertEqual("ADA", order_data["target_symbol"]) + sell_order = order_service.create(order_data) + + # Check that the trade is closed + trade_one = trade_service.get(trade_one_id) + self.assertEqual(0, trade_one.available_amount) + self.assertEqual(20, trade_one.amount) + self.assertEqual("CLOSED", trade_one.status) + + # Check that the take profits are triggered and not active anymore. + # Cancel all orders + open_orders = order_service.get_all( + { + "order_side": OrderSide.SELL.value, + "status": OrderStatus.OPEN.value, + "portfolio_id": 1 + } + ) + + portfolio = self.app.context.get_portfolio() + portfolio_provider_lookup = self.app.container.portfolio_provider_lookup() + portfolio_provider = portfolio_provider_lookup.get_portfolio_provider( + portfolio.market + ) + portfolio_provider.set_orders_to_closed = False + portfolio_provider = portfolio_provider_lookup.get_portfolio_provider( + portfolio.market + ) + portfolio_provider.set_orders_to_closed = False + portfolio_provider.order_amount_filled = 0 + portfolio_provider.status = OrderStatus.OPEN.value + + for order in open_orders: + order_service.cancel_order(order) + + # Check that available amount is back to 20 and the status is open + trade_one = trade_service.get(trade_one_id) + self.assertEqual(20, trade_one.available_amount) + self.assertEqual(20, trade_one.amount) + self.assertEqual("OPEN", trade_one.status) + + # Check that the stop losses are active again + for take_profit in trade_one.take_profits: + self.assertTrue(take_profit.active) + self.assertIsNone(take_profit.sell_prices) diff --git a/tests/test_create_app.py b/tests/test_create_app.py index 3e661e63..d1062eb2 100644 --- a/tests/test_create_app.py +++ b/tests/test_create_app.py @@ -4,7 +4,8 @@ from investing_algorithm_framework import create_app, \ PortfolioConfiguration, Algorithm, MarketCredential from investing_algorithm_framework.domain import RESOURCE_DIRECTORY -from tests.resources import MarketServiceStub +from tests.resources import MarketServiceStub, OrderExecutorTest, \ + PortfolioProviderTest class TestCreateApp(TestCase): @@ -60,13 +61,9 @@ def test_create_app_web(self): secret_key="secret_key" ) ) - market_service = MarketServiceStub(app.container.market_credential_service()) - market_service.balances = { - "USDT": 1000 - } - app.container.market_service.override( - market_service - ) + # + app.add_portfolio_provider(PortfolioProviderTest) + app.add_order_executor(OrderExecutorTest) app.initialize_config() app.initialize() self.assertIsNotNone(app)