diff --git a/.gitignore b/.gitignore index 5ffa52f3..3ffab512 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,4 @@ venv examples/tutorial/data examples/tutorial/backtest_results examples/tutorial/resources +.data_cache/ diff --git a/docusaurus/docs/Advanced Concepts/blotter.md b/docusaurus/docs/Advanced Concepts/blotter.md new file mode 100644 index 00000000..78679dc5 --- /dev/null +++ b/docusaurus/docs/Advanced Concepts/blotter.md @@ -0,0 +1,295 @@ +--- +sidebar_position: 3 +--- + +# Blotter + +Learn how the Blotter system works and how to use slippage models, commission models, and custom order routing. + +## Overview + +The **Blotter** sits between your strategy and the order execution layer. Every order you create — whether via `create_limit_order()`, `create_market_order()`, or `batch_order()` — flows through the blotter before reaching the `OrderService`. + +``` +Strategy (Context) + │ + ▼ + ┌────────┐ + │ Blotter │ ← slippage, commission, routing + └────┬───┘ + │ + ▼ + OrderService → OrderExecutor → Exchange / Simulation +``` + +The framework automatically selects a blotter if you don't set one: + +| Mode | Default Blotter | Behavior | +|-------------|----------------------|---------------------------------------------| +| Live trading | `DefaultBlotter` | Pass-through — no slippage or commission | +| Backtesting | `SimulationBlotter` | Configurable slippage and commission models | + +You can override the default by calling `app.set_blotter(...)` with any `Blotter` subclass. + +## Slippage Models + +Slippage models determine how the execution price deviates from the intended order price. They are used by the `SimulationBlotter` during backtesting. + +### NoSlippage (default) + +Orders fill at the exact intended price. + +```python +from investing_algorithm_framework import SimulationBlotter, NoSlippage + +app.set_blotter(SimulationBlotter( + slippage_model=NoSlippage() +)) +``` + +### PercentageSlippage + +Buy orders fill at a slightly higher price, sell orders at a slightly lower price. + +```python +from investing_algorithm_framework import SimulationBlotter, PercentageSlippage + +# 0.1% slippage +app.set_blotter(SimulationBlotter( + slippage_model=PercentageSlippage(percentage=0.001) +)) +``` + +For a buy order at price `100.0` with `0.1%` slippage, the fill price becomes `100.10`. For a sell order, it becomes `99.90`. + +### FixedSlippage + +Adds or subtracts a fixed amount from the order price. + +```python +from investing_algorithm_framework import SimulationBlotter, FixedSlippage + +# $0.05 slippage per order +app.set_blotter(SimulationBlotter( + slippage_model=FixedSlippage(amount=0.05) +)) +``` + +### Custom Slippage Model + +Create your own by extending `SlippageModel`: + +```python +from investing_algorithm_framework import SlippageModel + +class VolumeWeightedSlippage(SlippageModel): + def __init__(self, base_pct=0.001, volume_factor=0.0001): + self.base_pct = base_pct + self.volume_factor = volume_factor + + def calculate_slippage(self, price, order_side, amount=None): + pct = self.base_pct + if amount is not None: + pct += self.volume_factor * amount + + if order_side == "BUY": + return price * (1 + pct) + return price * (1 - pct) +``` + +## Commission Models + +Commission models determine the fee charged for each trade. They are used by the `SimulationBlotter` during backtesting. + +### NoCommission (default) + +Zero fees on all trades. + +```python +from investing_algorithm_framework import SimulationBlotter, NoCommission + +app.set_blotter(SimulationBlotter( + commission_model=NoCommission() +)) +``` + +### PercentageCommission + +Fee is a percentage of the total trade value (`price × amount`). + +```python +from investing_algorithm_framework import SimulationBlotter, PercentageCommission + +# 0.1% commission +app.set_blotter(SimulationBlotter( + commission_model=PercentageCommission(percentage=0.001) +)) +``` + +### FixedCommission + +A fixed fee per trade, regardless of trade size. + +```python +from investing_algorithm_framework import SimulationBlotter, FixedCommission + +# $1.00 per trade +app.set_blotter(SimulationBlotter( + commission_model=FixedCommission(amount=1.0) +)) +``` + +### Custom Commission Model + +Create your own by extending `CommissionModel`: + +```python +from investing_algorithm_framework import CommissionModel + +class TieredCommission(CommissionModel): + def calculate_commission(self, price, amount, order_side): + trade_value = price * amount + if trade_value > 10000: + return trade_value * 0.0005 # 0.05% for large trades + return trade_value * 0.001 # 0.1% for small trades +``` + +## SimulationBlotter + +The `SimulationBlotter` applies slippage and commission models to every order and records each fill as a `Transaction`. + +```python +from investing_algorithm_framework import ( + SimulationBlotter, + PercentageSlippage, + PercentageCommission, +) + +app.set_blotter(SimulationBlotter( + slippage_model=PercentageSlippage(0.001), # 0.1% slippage + commission_model=PercentageCommission(0.001), # 0.1% commission +)) +``` + +:::info Automatic Setup +If you don't set a blotter and run a backtest, the framework automatically uses a `SimulationBlotter` with `NoSlippage` and `NoCommission`. +::: + +## Transactions + +Every order placed through the blotter is recorded as a `Transaction`. Transactions provide an audit trail of all fills, including the actual execution price, slippage, and commission. + +```python +class MyStrategy(TradingStrategy): + def run_strategy(self, algorithm, market_data): + # Place some orders... + self.create_limit_order( + target_symbol="BTC", price=50000, amount=0.1 + ) + + # Get all transactions recorded by the blotter + transactions = self.get_transactions() + + for tx in transactions: + print(f"{tx.symbol} {tx.order_side}: " + f"price={tx.price}, amount={tx.amount}, " + f"commission={tx.commission}, slippage={tx.slippage}") +``` + +Each `Transaction` contains: + +| Field | Description | +|-------|-------------| +| `order_id` | ID of the order | +| `symbol` | Target symbol (e.g. `"BTC"`) | +| `order_side` | `"BUY"` or `"SELL"` | +| `price` | Actual fill price (after slippage) | +| `amount` | Fill amount | +| `cost` | Total cost (`price × amount`) | +| `commission` | Commission charged | +| `slippage` | Slippage amount (`abs(fill_price - intended_price)`) | +| `timestamp` | UTC timestamp of the fill | + +You can serialize a transaction with `tx.to_dict()`. + +## Batch Orders + +The `batch_order()` method lets you place multiple orders at once through the blotter: + +```python +class MyStrategy(TradingStrategy): + def run_strategy(self, algorithm, market_data): + orders = [ + { + "target_symbol": "BTC", + "order_side": "BUY", + "price": 50000, + "amount": 0.1, + }, + { + "target_symbol": "ETH", + "order_side": "BUY", + "price": 3000, + "amount": 1.0, + }, + ] + created_orders = self.batch_order(orders) +``` + +The default implementation places orders sequentially. Override `batch_order()` in a custom blotter for atomic batch behavior or smart order routing. + +## Custom Blotter + +Create your own blotter by extending the `Blotter` class and implementing `place_order()` and `cancel_order()`: + +```python +from investing_algorithm_framework import Blotter + +class SmartOrderRouter(Blotter): + def place_order(self, order_data, context): + """ + Custom order routing logic. + """ + symbol = order_data.get("target_symbol") + + # Example: route large orders differently + amount = order_data.get("amount", 0) + if amount > 100: + # Split into smaller orders + half = amount / 2 + order_data["amount"] = half + order1 = context.order_service.create(order_data) + order2 = context.order_service.create(order_data) + return order1 # Return the first order + + return context.order_service.create(order_data) + + def cancel_order(self, order_id, context): + """ + Cancel a specific order. + """ + order = context.order_service.get(order_id) + if order is None: + raise Exception(f"Order {order_id} not found") + + context.order_service.update( + order_id, {"status": "CANCELED"} + ) + return context.order_service.get(order_id) + +# Register the custom blotter +app.set_blotter(SmartOrderRouter()) +``` + +### Blotter API + +| Method | Required | Description | +|--------|----------|-------------| +| `place_order(order_data, context)` | Yes | Place a single order. Must be implemented. | +| `cancel_order(order_id, context)` | Yes | Cancel an order. Must be implemented. | +| `batch_order(orders_data, context)` | No | Place multiple orders. Default calls `place_order()` sequentially. | +| `get_open_orders(context, target_symbol)` | No | Get open orders. Default delegates to context. | +| `get_transactions()` | No | Get recorded transactions. | +| `record_transaction(transaction)` | No | Record a fill. | +| `clear_transactions()` | No | Clear all recorded transactions. | +| `prune_orders(context)` | No | Clean up stale orders. Default is a no-op. | diff --git a/docusaurus/sidebars.js b/docusaurus/sidebars.js index 07747526..3839b5da 100644 --- a/docusaurus/sidebars.js +++ b/docusaurus/sidebars.js @@ -77,6 +77,10 @@ const sidebars = { type: 'category', label: 'Advanced Concepts', items: [ + { + type: 'doc', + id: 'Advanced Concepts/blotter', + }, { type: 'doc', id: 'Advanced Concepts/custom-data-providers', diff --git a/investing_algorithm_framework/__init__.py b/investing_algorithm_framework/__init__.py index 944be978..03ee4f3e 100644 --- a/investing_algorithm_framework/__init__.py +++ b/investing_algorithm_framework/__init__.py @@ -24,7 +24,12 @@ APPLICATION_DIRECTORY, DataSource, OrderExecutor, PortfolioProvider, \ SnapshotInterval, AWS_S3_STATE_BUCKET_NAME, BacktestEvaluationFocus, \ save_backtests_to_directory, BacktestMetrics, DATA_DIRECTORY, \ - retag_backtests + retag_backtests, \ + Blotter, DefaultBlotter, SimulationBlotter, Transaction, \ + SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, \ + VolumeImpactSlippage, \ + CommissionModel, NoCommission, PercentageCommission, FixedCommission, \ + FillModel, FullFill, VolumeBasedFill from .infrastructure import AzureBlobStorageStateHandler, \ CSVOHLCVDataProvider, CSVTickerDataProvider, CSVURLDataProvider, \ JSONURLDataProvider, ParquetURLDataProvider, \ @@ -229,5 +234,21 @@ "download_v2", "DownloadResult", "create_data_storage_path", - "DATA_DIRECTORY" + "DATA_DIRECTORY", + "Blotter", + "DefaultBlotter", + "SimulationBlotter", + "Transaction", + "SlippageModel", + "NoSlippage", + "PercentageSlippage", + "FixedSlippage", + "VolumeImpactSlippage", + "CommissionModel", + "NoCommission", + "PercentageCommission", + "FixedCommission", + "FillModel", + "FullFill", + "VolumeBasedFill", ] diff --git a/investing_algorithm_framework/app/app.py b/investing_algorithm_framework/app/app.py index 38f69b07..15b22b06 100644 --- a/investing_algorithm_framework/app/app.py +++ b/investing_algorithm_framework/app/app.py @@ -24,7 +24,7 @@ LAST_SNAPSHOT_DATETIME, BACKTESTING_FLAG, DATA_DIRECTORY from investing_algorithm_framework.infrastructure import setup_sqlalchemy, \ create_all_tables, CCXTOrderExecutor, CCXTPortfolioProvider, \ - BacktestOrderExecutor, CCXTOHLCVDataProvider, clear_db, \ + CCXTOHLCVDataProvider, clear_db, \ PandasOHLCVDataProvider from investing_algorithm_framework.services import OrderBacktestService, \ BacktestPortfolioService, DefaultTradeOrderEvaluator @@ -71,10 +71,18 @@ def __init__(self, state_handler=None, name=None): self._state_handler = state_handler self._run_history = None self._name = name + self._blotter = None @property def context(self): - return self.container.context() + from investing_algorithm_framework.domain.blotter import \ + DefaultBlotter + + ctx = self.container.context() + ctx._blotter = self._blotter \ + if self._blotter is not None else DefaultBlotter() + + return ctx @property def resource_directory_path(self): @@ -651,7 +659,9 @@ def run(self, number_of_iterations: int = None): .trade_stop_loss_service(), trade_take_profit_service=self.container .trade_take_profit_service(), - configuration_service=self.container.configuration_service() + configuration_service=self.container.configuration_service(), + blotter=self._blotter, + context=self.context ) event_loop_service = EventLoopService( configuration_service=self.container.configuration_service(), @@ -1499,6 +1509,7 @@ def run_backtests( checkpoint_batch_size=checkpoint_batch_size, fill_missing_data=fill_missing_data, iterative_summary_update=iterative_summary_update, + blotter=self._blotter, ) # Cleanup resources @@ -1737,6 +1748,7 @@ def run_backtest( trading_symbol=trading_symbol, fill_missing_data=fill_missing_data, skip_data_sources_initialization=True, + blotter=self._blotter, ) # Store run history @@ -2207,6 +2219,39 @@ def add_market( ) self.add_market_credential(market_credential) + def set_blotter(self, blotter): + """ + Set a blotter for order book management. The blotter sits + between the strategy and the order execution layer, enabling + batch ordering, transaction tracking, and custom order routing. + + Args: + blotter: Instance of Blotter + + Returns: + None + """ + from investing_algorithm_framework.domain.blotter import Blotter + + if inspect.isclass(blotter): + blotter = blotter() + + if not isinstance(blotter, Blotter): + raise OperationalException( + "Blotter should be an instance of Blotter" + ) + + self._blotter = blotter + + def get_blotter(self): + """ + Get the configured blotter. + + Returns: + Blotter or None: The configured blotter instance. + """ + return self._blotter + def add_order_executor(self, order_executor): """ Function to add an order executor to the app. The order executor @@ -2284,24 +2329,30 @@ def initialize_order_executors(self): """ Function to initialize the order executors. This function will first check if the app is running in backtest mode or not. If it is - running in backtest mode, all order executors will be removed and - a single BacktestOrderExecutor will be added to the order executors. + running in backtest mode, all order executors will be removed + (OrderBacktestService handles execution directly) and the + SimulationBlotter will be set as the default blotter if no custom + blotter has been configured. If it is not running in backtest mode, it will add the default CCXTOrderExecutor with a priority 3. """ + from investing_algorithm_framework.domain.blotter import \ + SimulationBlotter + logger.info("Adding order executors") order_executor_lookup = self.container.order_executor_lookup() environment = self.config[ENVIRONMENT] if Environment.BACKTEST.equals(environment): - # If the app is running in backtest mode, - # remove all order executors - # and add a single BacktestOrderExecutor + # In backtest mode, OrderBacktestService handles execution + # directly — no order executor needed order_executor_lookup.reset() - order_executor_lookup.add_order_executor( - BacktestOrderExecutor(priority=1) - ) + + # Auto-set SimulationBlotter for backtesting if no + # custom blotter has been configured + if self._blotter is None: + self._blotter = SimulationBlotter() else: order_executor_lookup.add_order_executor( CCXTOrderExecutor(priority=3) @@ -2498,9 +2549,7 @@ def initialize_portfolio_providers(self): environment = self.config[ENVIRONMENT] if Environment.BACKTEST.equals(environment): - # If the app is running in backtest mode, - # remove all order executors - # and add a single BacktestOrderExecutor + # In backtest mode, remove all portfolio providers portfolio_provider_lookup.reset() else: portfolio_provider_lookup.add_portfolio_provider( diff --git a/investing_algorithm_framework/app/context.py b/investing_algorithm_framework/app/context.py index f8058db2..f5ea3167 100644 --- a/investing_algorithm_framework/app/context.py +++ b/investing_algorithm_framework/app/context.py @@ -50,6 +50,7 @@ def __init__( trade_stop_loss_service self.trade_take_profit_service: TradeTakeProfitService = \ trade_take_profit_service + self._blotter = None def _validate_target_symbol(self, target_symbol, market=None): """ @@ -174,9 +175,11 @@ def create_order( order_data["created_at"] = \ self.configuration_service.config[INDEX_DATETIME] - return self.order_service.create( - order_data, execute=execute, validate=validate, sync=sync - ) + order_data["_execute"] = execute + order_data["_validate"] = validate + order_data["_sync"] = sync + + return self._blotter.place_order(order_data, self) def has_balance(self, symbol, amount, market=None): """ @@ -329,9 +332,11 @@ def create_limit_order( order_data["created_at"] = \ self.configuration_service.config[INDEX_DATETIME] - return self.order_service.create( - order_data, execute=execute, validate=validate, sync=sync - ) + order_data["_execute"] = execute + order_data["_validate"] = validate + order_data["_sync"] = sync + + return self._blotter.place_order(order_data, self) def create_market_order( self, @@ -464,9 +469,11 @@ def create_market_order( order_data["created_at"] = \ self.configuration_service.config[INDEX_DATETIME] - return self.order_service.create( - order_data, execute=execute, validate=validate, sync=sync - ) + order_data["_execute"] = execute + order_data["_validate"] = validate + order_data["_sync"] = sync + + return self._blotter.place_order(order_data, self) def create_market_buy_order( self, @@ -590,43 +597,14 @@ def create_limit_sell_order( "to create a limit sell order." ) - if portfolio_id is not None: - portfolio = self.portfolio_service.get(portfolio_id) - elif market is not None: - portfolio = self.portfolio_service.find({"market": market}) - else: - portfolio = self.portfolio_service.get_all()[0] - - if percentage_of_position is not None: - position = self.position_service.find( - { - "symbol": target_symbol, - "portfolio": portfolio.id - } - ) - amount = position.get_amount() * (percentage_of_position / 100) - - logger.info( - f"Creating limit order: {target_symbol} " - f"SELL {amount} @ {price}" + return self.create_limit_order( + target_symbol=target_symbol, + price=price, + order_side=OrderSide.SELL, + amount=amount, + percentage_of_position=percentage_of_position, + market=market, ) - order_data = { - "target_symbol": target_symbol, - "price": price, - "amount": amount, - "order_type": OrderType.LIMIT.value, - "order_side": OrderSide.SELL.value, - "portfolio_id": portfolio.id, - "status": OrderStatus.CREATED.value, - "trading_symbol": portfolio.trading_symbol, - } - - if BACKTESTING_FLAG in self.configuration_service.config \ - and self.configuration_service.config[BACKTESTING_FLAG]: - order_data["created_at"] = \ - self.configuration_service.config[INDEX_DATETIME] - - return self.order_service.create(order_data) def create_limit_buy_order( self, @@ -667,37 +645,13 @@ def create_limit_buy_order( "to create a limit buy order." ) - if portfolio_id is not None: - portfolio = self.portfolio_service.get(portfolio_id) - elif market is not None: - portfolio = self.portfolio_service.find({"market": market}) - else: - portfolio = self.portfolio_service.get_all()[0] - - if percentage_of_portfolio is not None: - net_size = portfolio.get_net_size() - size = net_size * (percentage_of_portfolio / 100) - amount = size / price - logger.info( - f"Creating limit order: {target_symbol} " - f"BUY {amount} @ {price}" - ) - order_data = { - "target_symbol": target_symbol, - "price": price, - "amount": amount, - "order_type": OrderType.LIMIT.value, - "order_side": OrderSide.BUY.value, - "portfolio_id": portfolio.id, - "status": OrderStatus.CREATED.value, - "trading_symbol": portfolio.trading_symbol, - } - if BACKTESTING_FLAG in self.configuration_service.config \ - and self.configuration_service.config[BACKTESTING_FLAG]: - order_data["created_at"] = \ - self.configuration_service.config[INDEX_DATETIME] - return self.order_service.create( - order_data, execute=True, validate=True, sync=True + return self.create_limit_order( + target_symbol=target_symbol, + price=price, + order_side=OrderSide.BUY, + amount=amount, + percentage_of_portfolio=percentage_of_portfolio, + market=market, ) def get_portfolio(self, market=None) -> Portfolio: @@ -1189,7 +1143,7 @@ def close_position( "status": OrderStatus.OPEN.value } ): - self.order_service.cancel_order(order) + self._blotter.cancel_order(order.id, self) target_symbol = position.get_symbol() symbol = f"{target_symbol.upper()}/{portfolio.trading_symbol.upper()}" @@ -1810,16 +1764,11 @@ def close_trade(self, trade, precision=None) -> None: date=self.config[INDEX_DATETIME] ) logger.info(f"Closing trade {trade.id} {trade.symbol}") - self.order_service.create( - { - "portfolio_id": portfolio.id, - "trading_symbol": trade.trading_symbol, - "target_symbol": trade.target_symbol, - "amount": amount, - "order_side": OrderSide.SELL.value, - "order_type": OrderType.LIMIT.value, - "price": ticker["bid"], - } + self.create_limit_order( + target_symbol=trade.target_symbol, + amount=amount, + order_side=OrderSide.SELL, + price=ticker["bid"], ) def get_number_of_positions(self): @@ -2196,3 +2145,59 @@ def run_strategy(self, context, data): self._parquet_url_providers[url] = provider return self._parquet_url_providers[url].get_data() + + def batch_order(self, orders, market=None): + """ + Place multiple orders at once through the blotter. + + Each order dict supports the same parameters as + ``create_limit_order`` and ``create_market_order``. + + Args: + orders (list[dict]): List of order dicts. Each dict should + contain at minimum ``target_symbol``, ``order_side``, + and either ``amount`` or ``percentage_of_portfolio``. + Include ``price`` for limit orders. + Include ``order_type`` to specify MARKET orders + (default is LIMIT). + market (str, optional): Default market for all orders. + Can be overridden per order. + + Returns: + list[Order]: The created orders. + + Example:: + + context.batch_order([ + { + "target_symbol": "BTC", + "order_side": OrderSide.BUY, + "percentage_of_portfolio": 5.0, + "price": 45000, + }, + { + "target_symbol": "ETH", + "order_side": OrderSide.BUY, + "percentage_of_portfolio": 3.0, + "price": 3000, + }, + ]) + """ + # Add default market to each order if not set + for order_data in orders: + if "market" not in order_data and market is not None: + order_data["market"] = market + + return self._blotter.batch_order(orders, self) + + def get_transactions(self): + """ + Get all recorded transactions from the blotter. + + Returns a list of Transaction objects representing all + fills/executions that have been processed through the blotter. + + Returns: + list[Transaction]: Recorded transactions. + """ + return self._blotter.get_transactions() diff --git a/investing_algorithm_framework/domain/__init__.py b/investing_algorithm_framework/domain/__init__.py index 443b0059..9ea1e863 100644 --- a/investing_algorithm_framework/domain/__init__.py +++ b/investing_algorithm_framework/domain/__init__.py @@ -25,6 +25,11 @@ TakeProfitRule, StopLossRule, PositionSize, ScalingRule, TradingCost from .order_executor import OrderExecutor from .portfolio_provider import PortfolioProvider +from .blotter import Blotter, DefaultBlotter, SimulationBlotter, Transaction, \ + SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, \ + VolumeImpactSlippage, \ + CommissionModel, NoCommission, PercentageCommission, FixedCommission, \ + FillModel, FullFill, VolumeBasedFill from .services import MarketCredentialService, AbstractPortfolioSyncService, \ RoundingService, StateHandler from .stateless_actions import StatelessActions @@ -152,4 +157,20 @@ "save_backtests_to_directory", "retag_backtests", "generate_algorithm_id", + "Blotter", + "DefaultBlotter", + "SimulationBlotter", + "Transaction", + "SlippageModel", + "NoSlippage", + "PercentageSlippage", + "FixedSlippage", + "VolumeImpactSlippage", + "CommissionModel", + "NoCommission", + "PercentageCommission", + "FixedCommission", + "FillModel", + "FullFill", + "VolumeBasedFill", ] diff --git a/investing_algorithm_framework/domain/blotter.py b/investing_algorithm_framework/domain/blotter.py new file mode 100644 index 00000000..f4fd4d68 --- /dev/null +++ b/investing_algorithm_framework/domain/blotter.py @@ -0,0 +1,752 @@ +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from typing import List + +logger = logging.getLogger("investing_algorithm_framework") + + +class SlippageModel(ABC): + """ + Abstract base class for slippage models. + + Slippage models determine how the execution price deviates from + the intended order price. This is used by the SimulationBlotter + to model realistic order fills during backtesting. + """ + + @abstractmethod + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + """ + Calculate the slipped execution price. + + Args: + price: The intended fill price + order_side: The side of the order ('BUY' or 'SELL') + amount: The order amount (for volume-based models) + volume: Available market volume (for impact models) + + Returns: + float: The adjusted price after slippage + """ + raise NotImplementedError + + +class NoSlippage(SlippageModel): + """No slippage — fills at the exact order price.""" + + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + return price + + +class PercentageSlippage(SlippageModel): + """ + Percentage-based slippage model. + + Buy orders fill at a slightly higher price, + sell orders fill at a slightly lower price. + """ + + def __init__(self, percentage=0.001): + """ + Args: + percentage: Slippage as a decimal (0.001 = 0.1%). + """ + self.percentage = percentage + + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + from investing_algorithm_framework.domain.models.order.order_side \ + import OrderSide + + if OrderSide.BUY.equals(order_side): + return price * (1 + self.percentage) + else: + return price * (1 - self.percentage) + + +class FixedSlippage(SlippageModel): + """ + Fixed amount slippage model. + + Buy orders fill at price + fixed amount, + sell orders fill at price - fixed amount. + """ + + def __init__(self, amount=0.01): + """ + Args: + amount: Fixed slippage amount in price units. + """ + self.amount = amount + + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + from investing_algorithm_framework.domain.models.order.order_side \ + import OrderSide + + if OrderSide.BUY.equals(order_side): + return price + self.amount + else: + return price - self.amount + + +class VolumeImpactSlippage(SlippageModel): + """ + Volume-based market impact slippage model. + + Slippage increases with order size relative to available volume + using a power-law model: + + impact = base_percentage * (amount / volume) ^ impact_power + + Buy orders fill higher, sell orders fill lower. When volume + data is unavailable, falls back to base_percentage as flat + slippage. + + Usage:: + + VolumeImpactSlippage( + base_percentage=0.001, # 0.1% base + impact_power=0.5 # square-root impact + ) + """ + + def __init__(self, base_percentage=0.001, impact_power=0.5): + """ + Args: + base_percentage: Base slippage as a decimal (0.001 = 0.1%). + impact_power: Exponent for the participation rate. + 0.5 gives square-root impact (common in literature). + """ + self.base_percentage = base_percentage + self.impact_power = impact_power + + def calculate_slippage( + self, price, order_side, amount=None, volume=None + ): + from investing_algorithm_framework.domain.models.order.order_side \ + import OrderSide + + if ( + volume is not None + and volume > 0 + and amount is not None + and amount > 0 + ): + participation_rate = amount / volume + impact = self.base_percentage * ( + participation_rate ** self.impact_power + ) + else: + impact = self.base_percentage + + if OrderSide.BUY.equals(order_side): + return price * (1 + impact) + else: + return price * (1 - impact) + + +class CommissionModel(ABC): + """ + Abstract base class for commission models. + + Commission models determine the fee charged for each trade. + Used by the SimulationBlotter during backtesting. + """ + + @abstractmethod + def calculate_commission(self, price, amount, order_side): + """ + Calculate the commission for a trade. + + Args: + price: The fill price + amount: The fill amount + order_side: The side of the order ('BUY' or 'SELL') + + Returns: + float: The commission amount + """ + raise NotImplementedError + + +class NoCommission(CommissionModel): + """No commission — zero fees.""" + + def calculate_commission(self, price, amount, order_side): + return 0.0 + + +class PercentageCommission(CommissionModel): + """ + Percentage-based commission model. + + Commission is calculated as a percentage of the total trade value + (price * amount). + """ + + def __init__(self, percentage=0.001): + """ + Args: + percentage: Commission as a decimal (0.001 = 0.1%). + """ + self.percentage = percentage + + def calculate_commission(self, price, amount, order_side): + return price * amount * self.percentage + + +class FixedCommission(CommissionModel): + """ + Fixed commission per trade, regardless of trade size. + """ + + def __init__(self, amount=1.0): + """ + Args: + amount: Fixed commission amount per trade. + """ + self.amount = amount + + def calculate_commission(self, price, amount, order_side): + return self.amount + + +class FillModel(ABC): + """ + Abstract base class for fill models. + + Fill models determine how much of an order can be filled + in a single evaluation step. This enables partial fill + simulation during backtesting. + """ + + @abstractmethod + def get_fill_amount(self, order_amount, available_volume=None): + """ + Calculate the fillable amount for this evaluation step. + + Args: + order_amount: The remaining order amount to fill. + available_volume: Available market volume for the + current candle (None if unknown). + + Returns: + float: The amount that can be filled (up to order_amount). + """ + raise NotImplementedError + + +class FullFill(FillModel): + """Fill the entire order immediately (default behavior).""" + + def get_fill_amount(self, order_amount, available_volume=None): + return order_amount + + +class VolumeBasedFill(FillModel): + """ + Volume-based partial fill model. + + Limits each fill to a fraction of the candle's traded volume, + simulating realistic liquidity constraints. Orders larger than + the available volume fraction remain partially open and are + re-evaluated on subsequent candles. + + Usage:: + + VolumeBasedFill(max_volume_fraction=0.1) # max 10% of volume + """ + + def __init__(self, max_volume_fraction=0.1): + """ + Args: + max_volume_fraction: Maximum fraction of candle volume + that can be filled per step (0.1 = 10%). + """ + self.max_volume_fraction = max_volume_fraction + + def get_fill_amount(self, order_amount, available_volume=None): + if available_volume is None or available_volume <= 0: + return order_amount + + max_fill = available_volume * self.max_volume_fraction + return min(order_amount, max_fill) + + +class Transaction: + """ + Represents a completed fill/transaction recorded by the blotter. + + Transactions provide an audit trail of all order fills, including + the actual execution price, slippage, and commission. + """ + + def __init__( + self, + order_id, + symbol, + order_side, + price, + amount, + cost, + commission=0.0, + slippage=0.0, + timestamp=None, + ): + self.order_id = order_id + self.symbol = symbol + self.order_side = order_side + self.price = price + self.amount = amount + self.cost = cost + self.commission = commission + self.slippage = slippage + self.timestamp = timestamp or datetime.now(tz=timezone.utc) + + def to_dict(self): + timestamp = self.timestamp + + if hasattr(timestamp, "isoformat"): + timestamp = timestamp.isoformat() + + return { + "order_id": self.order_id, + "symbol": self.symbol, + "order_side": self.order_side, + "price": self.price, + "amount": self.amount, + "cost": self.cost, + "commission": self.commission, + "slippage": self.slippage, + "timestamp": timestamp, + } + + def __repr__(self): + return ( + f"Transaction(order_id={self.order_id}, " + f"symbol={self.symbol}, " + f"side={self.order_side}, " + f"price={self.price}, " + f"amount={self.amount}, " + f"commission={self.commission})" + ) + + +class Blotter(ABC): + """ + Abstract base class for order book management. + + The Blotter centralizes order management logic and sits between + the strategy (Context) and the order execution layer. It enables: + + - Batch ordering (place multiple orders at once) + - Transaction tracking (audit trail of all fills) + - Custom order routing and execution logic + - Pluggable slippage and commission models + + Usage:: + + class SmartOrderRouter(Blotter): + def place_order(self, order_data, context): + # Custom routing logic + return context.create_limit_order(...) + + def cancel_order(self, order_id, context): + # Custom cancellation logic + pass + + app.set_blotter(SmartOrderRouter()) + """ + + def __init__(self): + self._config = None + self._transactions: List[Transaction] = [] + + @property + def config(self): + return self._config + + @config.setter + def config(self, config): + self._config = config + + @abstractmethod + def place_order(self, order_data, context): + """ + Place a single order through this blotter. + + Args: + order_data: Dict with order parameters. Supported keys: + - target_symbol (str): Required. The symbol to trade. + - order_side (str/OrderSide): Required. BUY or SELL. + - price (float): Required for limit orders. + - order_type (str/OrderType): Default LIMIT. + - amount (float): Amount to trade. + - percentage_of_portfolio (float): % of portfolio. + - percentage_of_position (float): % of position. + - market (str): The market/exchange. + - precision (int): Decimal precision for amount. + - metadata (dict): Additional metadata. + context: The Context instance. + + Returns: + Order: The created order. + """ + raise NotImplementedError + + def batch_order(self, orders_data, context): + """ + Place multiple orders at once. + + Default implementation places orders sequentially. + Override for atomic batch behavior or custom routing. + + Args: + orders_data: List of order data dicts (same format + as place_order). + context: The Context instance. + + Returns: + List[Order]: The created orders. + """ + results = [] + + for data in orders_data: + order = self.place_order(data, context) + results.append(order) + + return results + + @abstractmethod + def cancel_order(self, order_id, context): + """ + Cancel a specific order. + + Args: + order_id: The ID of the order to cancel. + context: The Context instance. + + Returns: + Order: The cancelled order. + """ + raise NotImplementedError + + def get_open_orders(self, context, target_symbol=None): + """ + Get open orders, optionally filtered by symbol. + + Args: + context: The Context instance. + target_symbol: Optional symbol filter. + + Returns: + List[Order]: Open orders. + """ + return context.get_open_orders(target_symbol=target_symbol) + + def get_transactions(self): + """ + Get all recorded transactions. + + Returns: + List[Transaction]: All recorded transactions. + """ + return list(self._transactions) + + def record_transaction(self, transaction): + """Record a transaction (fill).""" + self._transactions.append(transaction) + + def clear_transactions(self): + """Clear all recorded transactions.""" + self._transactions.clear() + + def prune_orders(self, context): + """ + Clean up completed orders from tracking. + Override for custom pruning logic. + """ + pass + + # ------------------------------------------------------------------ + # Fill-time methods: used by the BacktestTradeOrderEvaluator + # to calculate fill prices, fees, and amounts at the moment an + # order is detected as filled. + # ------------------------------------------------------------------ + + def get_fill_price( + self, base_price, order_side, amount=None, volume=None + ): + """ + Calculate the fill price after slippage. + + Called by the evaluator when an order fill is detected. + Override for custom slippage behavior. + + Args: + base_price: The base price (candle open for market + orders, limit price for limit orders). + order_side: The side of the order ('BUY' or 'SELL'). + amount: The fill amount. + volume: Available candle volume (for impact models). + + Returns: + float: The adjusted fill price. + """ + return base_price + + def get_fill_commission(self, price, amount, order_side): + """ + Calculate commission for a fill. + + Called by the evaluator when an order fill is detected. + Override for custom fee models. + + Args: + price: The fill price. + amount: The fill amount. + order_side: The side of the order. + + Returns: + float: The commission amount. + """ + return 0.0 + + def get_fill_amount(self, order_amount, available_volume=None): + """ + Calculate how much of an order can be filled. + + Override for partial fill behavior based on volume. + + Args: + order_amount: The remaining order amount. + available_volume: Available candle volume. + + Returns: + float: The fillable amount (up to order_amount). + """ + return order_amount + + def get_commission_rate(self): + """ + Return the commission rate if applicable. + + Returns: + float or None: The commission rate as a decimal, + or None if not rate-based. + """ + return None + + def on_fill( + self, order_id, symbol, order_side, + fill_price, base_price, fill_amount, + ): + """ + Called when an order fill is detected. Records a transaction + and returns the commission. + + Args: + order_id: The order ID. + symbol: The symbol traded. + order_side: The side of the order. + fill_price: The actual fill price. + base_price: The original base price before slippage. + fill_amount: The amount filled. + + Returns: + float: The commission amount. + """ + commission = self.get_fill_commission( + fill_price, fill_amount, order_side + ) + slippage = abs(fill_price - base_price) + self.record_transaction(Transaction( + order_id=order_id, + symbol=symbol, + order_side=order_side, + price=fill_price, + amount=fill_amount, + cost=fill_price * fill_amount, + commission=commission, + slippage=slippage, + )) + return commission + + +class DefaultBlotter(Blotter): + """ + Pass-through blotter for live trading. + + Does not apply slippage or commission — orders are forwarded + directly to the OrderService for execution through the + configured OrderExecutor. + + This is the default blotter used when no custom blotter is + configured. + + Usage:: + + app.set_blotter(DefaultBlotter()) + """ + + def place_order(self, order_data, context): + """ + Place an order directly through the order service. + + Args: + order_data: Dict with order parameters (as built by Context). + context: The Context instance. + + Returns: + Order: The created order. + """ + execute = order_data.pop("_execute", True) + validate = order_data.pop("_validate", True) + sync = order_data.pop("_sync", True) + return context.order_service.create( + order_data, execute=execute, validate=validate, sync=sync + ) + + def cancel_order(self, order_id, context): + """ + Cancel a specific order by delegating to the OrderService. + + In live trading, this communicates with the exchange to + cancel the order. In backtesting, it updates the order + status directly. + + Args: + order_id: The ID of the order to cancel. + context: The Context instance. + + Returns: + Order: The cancelled order. + """ + from investing_algorithm_framework.domain.exceptions \ + import OperationalException + + order = context.order_service.get(order_id) + + if order is None: + raise OperationalException( + f"Order with id {order_id} not found" + ) + + context.order_service.cancel_order(order) + return context.order_service.get(order_id) + + +class SimulationBlotter(Blotter): + """ + Default blotter for backtesting with configurable slippage, + commission, and fill models. + + Slippage and commission are applied at fill time (when the + BacktestTradeOrderEvaluator detects a fill in OHLCV data), + not at order creation time. This avoids double-counting and + provides accurate fill tracking. + + Usage:: + + from investing_algorithm_framework import ( + SimulationBlotter, PercentageSlippage, + PercentageCommission, VolumeBasedFill + ) + + app.set_blotter(SimulationBlotter( + slippage_model=PercentageSlippage(0.001), # 0.1% + commission_model=PercentageCommission(0.001), # 0.1% + fill_model=VolumeBasedFill(0.1), # max 10% of volume + )) + """ + + def __init__( + self, + slippage_model=None, + commission_model=None, + fill_model=None, + ): + super().__init__() + self.slippage_model = slippage_model or NoSlippage() + self.commission_model = commission_model or NoCommission() + self.fill_model = fill_model or FullFill() + + def place_order(self, order_data, context): + """ + Place an order through the order service. + + Slippage and commission are NOT applied here — they are + applied at fill time by the BacktestTradeOrderEvaluator + using get_fill_price() and get_fill_commission(). + + Args: + order_data: Dict with order parameters. + context: The Context instance. + + Returns: + Order: The created order. + """ + execute = order_data.pop("_execute", True) + validate = order_data.pop("_validate", True) + sync = order_data.pop("_sync", True) + return context.order_service.create( + order_data, execute=execute, validate=validate, sync=sync + ) + + def get_fill_price( + self, base_price, order_side, amount=None, volume=None + ): + """Calculate fill price using the configured slippage model.""" + return self.slippage_model.calculate_slippage( + base_price, order_side, amount=amount, volume=volume + ) + + def get_fill_commission(self, price, amount, order_side): + """Calculate commission using the configured commission model.""" + return self.commission_model.calculate_commission( + price, amount, order_side + ) + + def get_fill_amount(self, order_amount, available_volume=None): + """Calculate fillable amount using the configured fill model.""" + return self.fill_model.get_fill_amount( + order_amount, available_volume + ) + + def get_commission_rate(self): + """Return the commission rate if using PercentageCommission.""" + if hasattr(self.commission_model, 'percentage'): + return self.commission_model.percentage + return None + + def cancel_order(self, order_id, context): + """ + Cancel a specific order by delegating to the OrderService. + + Args: + order_id: The ID of the order to cancel. + context: The Context instance. + + Returns: + Order: The cancelled order. + """ + from investing_algorithm_framework.domain.exceptions \ + import OperationalException + + order = context.order_service.get(order_id) + + if order is None: + raise OperationalException( + f"Order with id {order_id} not found" + ) + + context.order_service.cancel_order(order) + return context.order_service.get(order_id) diff --git a/investing_algorithm_framework/infrastructure/__init__.py b/investing_algorithm_framework/infrastructure/__init__.py index a60208b3..ced53abb 100644 --- a/investing_algorithm_framework/infrastructure/__init__.py +++ b/investing_algorithm_framework/infrastructure/__init__.py @@ -18,7 +18,7 @@ OHLCVDataProviderBase, \ YahooOHLCVDataProvider, AlphaVantageOHLCVDataProvider, \ PolygonOHLCVDataProvider -from .order_executors import CCXTOrderExecutor, BacktestOrderExecutor +from .order_executors import CCXTOrderExecutor from .portfolio_providers import CCXTPortfolioProvider __all__ = [ @@ -53,7 +53,6 @@ "AWSS3StorageStateHandler", "CCXTOHLCVDataProvider", "CCXTTickerDataProvider", - "BacktestOrderExecutor", "PandasOHLCVDataProvider", "OHLCVDataProviderBase", "CSVURLDataProvider", diff --git a/investing_algorithm_framework/infrastructure/order_executors/__init__.py b/investing_algorithm_framework/infrastructure/order_executors/__init__.py index 05126a90..71f9a481 100644 --- a/investing_algorithm_framework/infrastructure/order_executors/__init__.py +++ b/investing_algorithm_framework/infrastructure/order_executors/__init__.py @@ -1,5 +1,4 @@ from .ccxt_order_executor import CCXTOrderExecutor -from .backtest_oder_executor import BacktestOrderExecutor def get_default_order_executors(): @@ -16,6 +15,5 @@ def get_default_order_executors(): __all__ = [ 'CCXTOrderExecutor', - 'BacktestOrderExecutor', 'get_default_order_executors', ] diff --git a/investing_algorithm_framework/infrastructure/order_executors/backtest_oder_executor.py b/investing_algorithm_framework/infrastructure/order_executors/backtest_oder_executor.py deleted file mode 100644 index bb0d37df..00000000 --- a/investing_algorithm_framework/infrastructure/order_executors/backtest_oder_executor.py +++ /dev/null @@ -1,28 +0,0 @@ -from investing_algorithm_framework.domain import OrderExecutor, OrderStatus, \ - INDEX_DATETIME, Order - - -class BacktestOrderExecutor(OrderExecutor): - """ - Backtest implementation of order executor. This executor is used to - simulate order execution in a backtesting environment. - - !Important: This executor does not actually execute orders on any market. - It should be used only for backtesting purposes. - """ - - def execute_order(self, portfolio, order, market_credential) -> Order: - order.status = OrderStatus.OPEN.value - order.remaining = order.get_amount() - order.filled = 0 - order.updated_at = self.config[INDEX_DATETIME] - return order - - def cancel_order(self, portfolio, order, market_credential) -> Order: - order.status = OrderStatus.CANCELED.value - order.remaining = 0 - order.updated_at = self.config[INDEX_DATETIME] - return order - - def supports_market(self, market): - return True diff --git a/investing_algorithm_framework/infrastructure/services/backtesting/backtest_service.py b/investing_algorithm_framework/infrastructure/services/backtesting/backtest_service.py index 3a7356c6..c326b119 100644 --- a/investing_algorithm_framework/infrastructure/services/backtesting/backtest_service.py +++ b/investing_algorithm_framework/infrastructure/services/backtesting/backtest_service.py @@ -1971,6 +1971,7 @@ def run_backtests( checkpoint_batch_size: int = 25, fill_missing_data: bool = True, iterative_summary_update: bool = False, + blotter=None, ) -> List[Backtest]: """ Run event-driven backtests for multiple algorithms with optional @@ -2253,6 +2254,8 @@ def run_backtests( ), trading_costs=all_trading_costs, portfolio_configuration=pc, + blotter=blotter, + context=context, ) ) @@ -2586,6 +2589,7 @@ def run_backtest( market: str = None, trading_symbol: str = None, fill_missing_data: bool = True, + blotter=None, ) -> tuple: """ Run an event-driven backtest for a single algorithm. @@ -2639,6 +2643,7 @@ def run_backtest( backtest_storage_directory=backtest_storage_directory, use_checkpoints=use_checkpoints, fill_missing_data=fill_missing_data, + blotter=blotter, ) # Extract the single backtest result 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 a219de87..7fa48b52 100644 --- a/investing_algorithm_framework/services/order_service/order_backtest_service.py +++ b/investing_algorithm_framework/services/order_service/order_backtest_service.py @@ -106,7 +106,6 @@ def check_pending_orders(self, market_data): ) def cancel_order(self, order): - self.check_pending_orders() order = self.order_repository.get(order.id) if order is not None: diff --git a/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py b/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py index 67e80373..0c9dd302 100644 --- a/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py +++ b/investing_algorithm_framework/services/trade_order_evaluator/backtest_trade_oder_evaluator.py @@ -81,17 +81,19 @@ def _check_has_executed(self, order, ohlcv_df): """ Check if the order has been executed based on OHLCV data. + When a blotter is available (via the parent TradeOrderEvaluator), + fill pricing, commission, and fill amounts are delegated to the + blotter's models. Otherwise, TradingCost is used as a fallback. + BUY ORDER filled Rules: - Only uses prices after the last update_at of the order. - If the lowest low price of the series is below or equal - to the order price, e.g. if you buy asset at price 100 - and the low price of the series is 99, then the order is filled. + to the order price, the order is filled. SELL ORDER filled Rules: - Only uses prices after the last update_at of the order. - If the highest high price of the series is above or equal - to the order price, e.g. if you sell asset at price 100 - and the high price of the series is 101, then the order is filled. + to the order price, the order is filled. Args: order (Order): Order. @@ -115,35 +117,126 @@ def _check_has_executed(self, order, ohlcv_df): if ohlcv_data_after_order.is_empty(): return - tc = self._resolve_trading_cost(order.symbol) - # Market orders: fill at the open of the first available candle if OrderType.MARKET.equals(order.order_type): first_candle = ohlcv_data_after_order.head(1) base_price = first_candle["Open"][0] - estimated_price = order.estimated_price + volume = ( + first_candle["Volume"][0] + if "Volume" in first_candle.columns + else None + ) + self._apply_fill( + order, base_price, order_side, volume, + is_market_order=True + ) + return + + # Limit orders: check if OHLCV data triggers a fill + if OrderSide.BUY.equals(order_side): + fill_candles = ohlcv_data_after_order.filter( + pl.col('Low') <= order_price + ) + elif OrderSide.SELL.equals(order_side): + fill_candles = ohlcv_data_after_order.filter( + pl.col('High') >= order_price + ) + else: + return + + if fill_candles.is_empty(): + return + first_fill = fill_candles.head(1) + volume = ( + first_fill["Volume"][0] + if "Volume" in first_fill.columns + else None + ) + self._apply_fill( + order, order_price, order_side, volume, + is_market_order=False + ) + + def _apply_fill( + self, order, base_price, order_side, volume, + is_market_order=False + ): + """ + Apply a fill to an order, using blotter methods when available + or falling back to TradingCost. + + Supports partial fills when the blotter's fill model limits + the fillable amount (e.g. VolumeBasedFill). + """ + remaining = ( + order.remaining + if order.remaining is not None + else order.amount + ) + + if self._blotter is not None: + fill_price = self._blotter.get_fill_price( + base_price, order_side, remaining, volume + ) + fill_amount = min( + self._blotter.get_fill_amount(remaining, volume), + remaining + ) + if OrderSide.BUY.equals(order_side): + slippage = fill_price - base_price + else: + slippage = base_price - fill_price + fee = self._blotter.on_fill( + order.id, order.symbol, order_side, + fill_price, base_price, fill_amount, + ) + fee_rate = self._blotter.get_commission_rate() + else: + tc = self._resolve_trading_cost(order.symbol) if OrderSide.BUY.equals(order_side): fill_price = tc.get_buy_fill_price(base_price) slippage = fill_price - base_price else: fill_price = tc.get_sell_fill_price(base_price) slippage = base_price - fill_price + fill_amount = remaining + fee = tc.get_fee(fill_price * fill_amount) + fee_rate = ( + tc.fee_percentage / 100 if tc.fee_percentage else None + ) + + if fill_amount <= 0: + return - fee = tc.get_fee(fill_price * order.amount) + new_filled = (order.filled or 0) + fill_amount + new_remaining = order.amount - new_filled + accumulated_fee = (order.order_fee or 0) + fee + + if new_remaining <= 0: + # Full fill update_data = { 'status': OrderStatus.CLOSED.value, 'remaining': 0, 'filled': order.amount, 'price': fill_price, - 'order_fee': fee, + 'order_fee': accumulated_fee, 'slippage': slippage, } - if tc.fee_percentage: - update_data['order_fee_rate'] = tc.fee_percentage / 100 + if fee_rate is not None: + update_data['order_fee_rate'] = fee_rate + else: + # Partial fill — order stays open for next evaluation + update_data = { + 'filled': new_filled, + 'remaining': new_remaining, + 'order_fee': accumulated_fee, + } + + # Market order portfolio reconciliation + if is_market_order and new_remaining <= 0: + estimated_price = order.estimated_price - # Reconcile portfolio balance: adjust for the difference - # between estimated price and actual fill price if estimated_price is not None: price_delta = fill_price - estimated_price cost_adjustment = price_delta * order.amount @@ -158,15 +251,12 @@ def _check_has_executed(self, order, ohlcv_df): ) if OrderSide.BUY.equals(order_side): - # If fill price > estimated: need more cash - # If fill price < estimated: release excess cash new_unallocated = \ portfolio.get_unallocated() - cost_adjustment self.order_service.portfolio_repository.update( portfolio.id, {"unallocated": new_unallocated} ) - # Also update the trading symbol position trading_position = \ self.order_service.position_service.find( { @@ -183,43 +273,4 @@ def _check_has_executed(self, order, ohlcv_df): } ) - self.order_service.update(order.id, update_data) - return - - if OrderSide.BUY.equals(order_side): - # Check if the low price drops below or equals the order price - if (ohlcv_data_after_order['Low'] <= order_price).any(): - fill_price = tc.get_buy_fill_price(order_price) - fee = tc.get_fee(fill_price * order.amount) - slippage = fill_price - order_price - update_data = { - 'status': OrderStatus.CLOSED.value, - 'remaining': 0, - 'filled': order.amount, - 'price': fill_price, - 'order_fee': fee, - 'slippage': slippage, - } - if tc.fee_percentage: - update_data['order_fee_rate'] = \ - tc.fee_percentage / 100 - self.order_service.update(order.id, update_data) - - elif OrderSide.SELL.equals(order_side): - # Check if the high price goes above or equals the order price - if (ohlcv_data_after_order['High'] >= order_price).any(): - fill_price = tc.get_sell_fill_price(order_price) - fee = tc.get_fee(fill_price * order.amount) - slippage = order_price - fill_price - update_data = { - 'status': OrderStatus.CLOSED.value, - 'remaining': 0, - 'filled': order.amount, - 'price': fill_price, - 'order_fee': fee, - 'slippage': slippage, - } - if tc.fee_percentage: - update_data['order_fee_rate'] = \ - tc.fee_percentage / 100 - self.order_service.update(order.id, update_data) + self.order_service.update(order.id, update_data) diff --git a/investing_algorithm_framework/services/trade_order_evaluator/trade_order_evaluator.py b/investing_algorithm_framework/services/trade_order_evaluator/trade_order_evaluator.py index 24ec28a0..582b3894 100644 --- a/investing_algorithm_framework/services/trade_order_evaluator/trade_order_evaluator.py +++ b/investing_algorithm_framework/services/trade_order_evaluator/trade_order_evaluator.py @@ -12,13 +12,17 @@ def __init__( trade_stop_loss_service, trade_take_profit_service, order_service, - configuration_service=None + configuration_service=None, + blotter=None, + context=None ): self.trade_service = trade_service self.trade_stop_loss_service = trade_stop_loss_service self.trade_take_profit_service = trade_take_profit_service self.order_service = order_service self.configuration_service = configuration_service + self._blotter = blotter + self._context = context @abstractmethod def evaluate( @@ -46,6 +50,15 @@ def evaluate( """ pass + def _create_order(self, order_data): + """ + Create an order through the blotter if available, + otherwise fall back to the order service directly. + """ + if self._blotter is not None and self._context is not None: + return self._blotter.place_order(order_data, self._context) + return self.order_service.create(order_data) + def _check_take_profits(self): current_date = self.configuration_service.config[INDEX_DATETIME] take_profits_orders_data = self.trade_service \ @@ -53,7 +66,7 @@ def _check_take_profits(self): for take_profit_order in take_profits_orders_data: take_profits = take_profit_order["take_profits"] - self.order_service.create(take_profit_order) + self._create_order(take_profit_order) self.trade_take_profit_service.mark_triggered( [ take_profit.get("take_profit_id") @@ -70,7 +83,7 @@ def _check_stop_losses(self): for stop_loss_order in stop_losses_orders_data: stop_losses = stop_loss_order["stop_losses"] - self.order_service.create(stop_loss_order) + self._create_order(stop_loss_order) self.trade_stop_loss_service.mark_triggered( [ stop_loss.get("stop_loss_id") for stop_loss in diff --git a/tests/app/test_eventloop.py b/tests/app/test_eventloop.py index 40df8bad..eaec81ee 100644 --- a/tests/app/test_eventloop.py +++ b/tests/app/test_eventloop.py @@ -4,17 +4,12 @@ import shutil from investing_algorithm_framework import TradingStrategy, DataSource, \ - DataType, MarketCredential, PortfolioConfiguration, Order, Trade, \ - CCXTOHLCVDataProvider, BacktestDateRange, DataProvider, \ - INDEX_DATETIME, OrderStatus + DataType, MarketCredential, PortfolioConfiguration, \ + DataProvider from investing_algorithm_framework.app.eventloop import EventLoopService -from investing_algorithm_framework.domain import ENVIRONMENT, Environment, \ - BACKTESTING_START_DATE, LAST_SNAPSHOT_DATETIME, \ - SNAPSHOT_INTERVAL, SnapshotInterval -from investing_algorithm_framework.infrastructure import BacktestOrderExecutor -from investing_algorithm_framework.services import DataProviderService, \ +from investing_algorithm_framework.services import \ BacktestTradeOrderEvaluator -from tests.resources import TestBase, OrderExecutorTest +from tests.resources import TestBase class CustomFeedDataProvider(DataProvider): @@ -237,526 +232,15 @@ def test_get_data_sources_for_iteration(self): def tearDown(self) -> None: super().tearDown() - databases_directory = os.path.join(self.resource_directory, "databases") - backtest_databases_directory = os.path.join(self.resource_directory, "backtest_databases") + databases_directory = os.path.join( + self.resource_directory, "databases" + ) + backtest_databases_directory = os.path.join( + self.resource_directory, "backtest_databases" + ) if os.path.exists(databases_directory): shutil.rmtree(databases_directory) if os.path.exists(backtest_databases_directory): shutil.rmtree(backtest_databases_directory) - - # ------------------------------------------------------------------ - # The following backtest tests remain commented out because they - # depend on CCXTOHLCVDataProvider.prepare_backtest_data() which - # downloads live OHLCV data from the Bitvavo exchange. They cannot - # run in CI or without network access to the exchange API. - # ------------------------------------------------------------------ - -# def test_backtest_loop(self): -# self.app.initialize_config() -# self.app.initialize_storage() -# self.app.initialize_services() -# self.app.initialize_portfolios() -# data_provider_service = DataProviderService( -# configuration_service=self.app.container.configuration_service(), -# market_credential_service=self.app.container\ -# .market_credential_service() -# ) -# data_provider_service.add_data_provider( -# CCXTOHLCVDataProvider() -# ) -# strategy = StrategyForTesting() -# data_sources = strategy.data_sources -# self.app.add_strategy(strategy) -# backtest_date_range = BacktestDateRange( -# start_date=datetime(2023, 8, 28, 8, 0, tzinfo=timezone.utc), -# end_date=datetime(2023, 12, 2, 0, 0, tzinfo=timezone.utc) -# ) -# configuration_service = self.app.container.configuration_service() -# configuration_service.add_value(INDEX_DATETIME, -# backtest_date_range.start_date) -# # Register data providers for data sources -# data_provider_service.index_backtest_data_providers( -# strategy.data_sources, backtest_date_range -# ) -# # Prepare the backtest data for each data provider -# for data_source, data_provider in data_provider_service.data_provider_index.get_all(): -# data_provider.prepare_backtest_data( -# backtest_date_range.start_date, backtest_date_range.end_date -# ) -# -# # There should be a single backtest data provider registered -# self.assertEqual( -# len(data_provider_service.data_provider_index), 2 -# ) -# -# # Should be all CCXTOHLCVDataProvider -# for datasource in data_sources: -# data_provider = data_provider_service.data_provider_index.get(datasource) -# self.assertIsNotNone(data_provider) -# self.assertTrue( -# isinstance(data_provider, CCXTOHLCVDataProvider) -# ) -# -# event_loop_service = EventLoopService( -# order_service=self.app.container.order_service(), -# portfolio_service=self.app.container.portfolio_service(), -# configuration_service=self.app.container.configuration_service(), -# data_provider_service=data_provider_service, -# context=self.app.container.context(), -# trade_service=self.app.container.trade_service(), -# portfolio_snapshot_service=self.app.container.portfolio_snapshot_service(), -# ) -# event_loop_service.initialize( -# trade_order_evaluator=BacktestTradeOrderEvaluator( -# trade_service=self.app.container.trade_service(), -# order_service=self.app.container.order_service(), -# ), -# algorithm=self.app.get_algorithm() -# ) -# event_loop_service.start(number_of_iterations=1) -# history = event_loop_service.history -# -# # StrategyOne should have run once -# self.assertEqual( -# len(history[strategy.strategy_id]["runs"]), 1 -# ) -# -# def test_backtest_loop_with_executed_orders(self): -# """ -# -# """ -# self.app.initialize_config() -# self.app.initialize_storage() -# self.app.initialize_services() -# self.app.initialize_portfolios() -# # Set the app in backtest mode -# backtest_date_range = BacktestDateRange( -# start_date=datetime(2023, 8, 28, 8, 0, tzinfo=timezone.utc), -# end_date=datetime(2023, 12, 2, 0, 0, tzinfo=timezone.utc) -# ) -# configuration_service = self.app.container.configuration_service() -# configuration_service.add_value(INDEX_DATETIME, -# backtest_date_range.start_date) -# configuration_service.add_value(ENVIRONMENT, -# Environment.BACKTEST.value) -# configuration_service.add_value(BACKTESTING_START_DATE, -# backtest_date_range.start_date) -# -# self.app.add_order_executor(BacktestOrderExecutor()) -# self.app.add_portfolio_configuration( -# PortfolioConfiguration( -# market="bitvavo", -# trading_symbol="EUR", -# initial_balance=1000 -# ) -# ) -# self.app.initialize_backtest_config( -# backtest_date_range=backtest_date_range, -# initial_amount=1000 -# ) -# self.app.add_order_executor(OrderExecutorTest) -# order_service = self.app.container.order_service() -# order_service.create( -# { -# "target_symbol": "BTC", -# "trading_symbol": "EUR", -# "amount": 0.000002, -# "order_side": "buy", -# "order_type": "limit", -# "price": 1000000, -# "portfolio_id": 1 -# } -# ) -# -# data_provider_service = self.app.container.data_provider_service() -# data_provider_service.add_data_provider( -# CCXTOHLCVDataProvider() -# ) -# strategy = StrategyForTesting() -# data_sources = strategy.data_sources -# # Register data providers for data sources -# data_provider_service.index_backtest_data_providers( -# strategy.data_sources, backtest_date_range -# ) -# -# # Prepare the backtest data for each data provider -# for data_source, data_provider in data_provider_service.data_provider_index.get_all(): -# data_provider.prepare_backtest_data( -# backtest_date_range.start_date, backtest_date_range.end_date -# ) -# -# # There should be a single backtest data provider registered -# self.assertEqual( -# len(data_provider_service.data_provider_index.ohlcv_data_providers_no_market), 2 -# ) -# self.assertEqual( -# len(data_provider_service.data_provider_index.ohlcv_data_providers), 2 -# ) -# self.assertEqual( -# len(data_provider_service.data_provider_index.data_providers_lookup), 2 -# ) -# -# # Should be all CCXTOHLCVDataProvider -# for datasource in data_sources: -# data_provider = data_provider_service.data_provider_index.get(datasource) -# self.assertIsNotNone(data_provider) -# self.assertTrue( -# isinstance(data_provider, CCXTOHLCVDataProvider) -# ) -# -# backtest_trade_order_evaluator = BacktestTradeOrderEvaluator( -# trade_service=self.app.container.trade_service(), -# order_service=self.app.container.order_service(), -# ) -# event_loop_service = EventLoopService( -# order_service=self.app.container.order_service(), -# portfolio_service=self.app.container.portfolio_service(), -# configuration_service=self.app.container.configuration_service(), -# data_provider_service=data_provider_service, -# context=self.app.container.context(), -# trade_service=self.app.container.trade_service(), -# portfolio_snapshot_service=self.app.container.portfolio_snapshot_service(), -# ) -# self.app.add_strategy(strategy) -# event_loop_service.initialize( -# algorithm=self.app.get_algorithm(), -# trade_order_evaluator=backtest_trade_order_evaluator -# ) -# event_loop_service.start(number_of_iterations=1) -# history = event_loop_service.history -# -# # StrategyOne should have run once -# self.assertEqual( -# len(history[strategy.strategy_id]["runs"]), 1 -# ) -# # Check if the order was executed -# orders = order_service.get_all() -# self.assertEqual(len(orders), 1) -# self.assertEqual(orders[0].status, OrderStatus.CLOSED.value) -# self.assertNotEqual(orders[0].filled, 0) -# self.assertEqual(orders[0].remaining, 0.0) -# -# def test_event_loop_with_schedule(self): -# # Set the app in backtest mode -# backtest_date_range = BacktestDateRange( -# start_date=datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc), -# end_date=datetime(2023, 12, 31, 0, 0, tzinfo=timezone.utc) -# ) -# number_of_days = (backtest_date_range.end_date - backtest_date_range.start_date).days -# configuration_service = self.app.container.configuration_service() -# configuration_service.add_value(INDEX_DATETIME, -# backtest_date_range.start_date) -# configuration_service.add_value(ENVIRONMENT, -# Environment.BACKTEST.value) -# configuration_service.add_value(BACKTESTING_START_DATE, -# backtest_date_range.start_date) -# configuration_service.add_value(LAST_SNAPSHOT_DATETIME, backtest_date_range.start_date) -# configuration_service.add_value(SNAPSHOT_INTERVAL, SnapshotInterval.DAILY.value) -# strategy = StrategyForTesting() -# backtest_service = self.app.container.backtest_service() -# schedule = backtest_service.generate_schedule( -# start_date=backtest_date_range.start_date, -# end_date=backtest_date_range.end_date, -# strategies=[strategy], -# tasks=[] -# ) -# self.app.add_order_executor(BacktestOrderExecutor()) -# self.app.add_portfolio_configuration( -# PortfolioConfiguration( -# market="bitvavo", -# trading_symbol="EUR", -# initial_balance=1000 -# ) -# ) -# self.app.add_strategy(strategy) -# self.app.initialize_backtest_config( -# backtest_date_range -# ) -# self.app.initialize_storage() -# self.app.initialize_portfolios() -# -# data_provider_service = DataProviderService( -# configuration_service=self.app.container.configuration_service(), -# market_credential_service=self.app.container\ -# .market_credential_service() -# ) -# data_provider_service.add_data_provider( -# CCXTOHLCVDataProvider() -# ) -# data_sources = strategy.data_sources -# # Register data providers for data sources -# data_provider_service.index_backtest_data_providers( -# strategy.data_sources, backtest_date_range -# ) -# -# # Prepare the backtest data for each data provider -# for data_source, data_provider in data_provider_service.data_provider_index.get_all(): -# data_provider.prepare_backtest_data( -# backtest_start_date=backtest_date_range.start_date, -# backtest_end_date=backtest_date_range.end_date -# ) -# -# # There should be a single backtest data provider registered -# self.assertEqual( -# len(data_provider_service.data_provider_index), 2 -# ) -# -# # Should be all CCXTOHLCVDataProvider -# for datasource in data_sources: -# data_provider = data_provider_service.data_provider_index.get(datasource) -# self.assertIsNotNone(data_provider) -# self.assertTrue( -# isinstance(data_provider, CCXTOHLCVDataProvider) -# ) -# -# backtest_trade_order_evaluator = BacktestTradeOrderEvaluator( -# trade_service=self.app.container.trade_service(), -# order_service=self.app.container.order_service(), -# ) -# event_loop_service = EventLoopService( -# order_service=self.app.container.order_service(), -# portfolio_service=self.app.container.portfolio_service(), -# configuration_service=self.app.container.configuration_service(), -# data_provider_service=data_provider_service, -# context=self.app.container.context(), -# trade_service=self.app.container.trade_service(), -# portfolio_snapshot_service=self.app.container.portfolio_snapshot_service(), -# ) -# event_loop_service.strategies = [StrategyForTesting()] -# event_loop_service.initialize( -# algorithm=self.app.get_algorithm(), -# trade_order_evaluator=backtest_trade_order_evaluator -# ) -# event_loop_service.start(schedule=schedule, show_progress=False) -# -# # Check that for everyday a snapshot was created -# portfolio_snapshot_service = self.app.container.portfolio_snapshot_service() -# self.assertAlmostEqual( -# len(portfolio_snapshot_service.get_all()), number_of_days, delta=2 -# ) -# history = event_loop_service.history -# # StrategyOne should have run 8749 times -# self.assertEqual( -# len(history[strategy.strategy_id]["runs"]), 8749 -# ) -# -# def test_backtest_loop_with_strategy_iteration_snapshotting(self): -# """ -# -# """ -# backtest_date_range = BacktestDateRange( -# start_date=datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc), -# end_date=datetime(2023, 12, 31, 0, 0, tzinfo=timezone.utc) -# ) -# configuration_service = self.app.container.configuration_service() -# configuration_service.add_value(INDEX_DATETIME, -# backtest_date_range.start_date) -# configuration_service.add_value(ENVIRONMENT, -# Environment.BACKTEST.value) -# configuration_service.add_value(BACKTESTING_START_DATE, -# backtest_date_range.start_date) -# configuration_service.add_value(LAST_SNAPSHOT_DATETIME, -# backtest_date_range.start_date) -# configuration_service.add_value(SNAPSHOT_INTERVAL, -# SnapshotInterval.STRATEGY_ITERATION.value) -# -# strategy = StrategyForTesting() -# backtest_service = self.app.container.backtest_service() -# schedule = backtest_service.generate_schedule( -# start_date=backtest_date_range.start_date, -# end_date=backtest_date_range.end_date, -# strategies=[strategy], -# tasks=[] -# ) -# self.app.add_order_executor(BacktestOrderExecutor()) -# self.app.add_portfolio_configuration( -# PortfolioConfiguration( -# market="bitvavo", -# trading_symbol="EUR", -# initial_balance=1000 -# ) -# ) -# self.app.add_strategy(strategy) -# self.app.initialize_backtest_config( -# backtest_date_range -# ) -# self.app.initialize_storage() -# self.app.initialize_portfolios() -# -# data_provider_service = DataProviderService( -# configuration_service=self.app.container.configuration_service(), -# market_credential_service=self.app.container \ -# .market_credential_service() -# ) -# data_provider_service.add_data_provider( -# CCXTOHLCVDataProvider() -# ) -# data_sources = strategy.data_sources -# # Register data providers for data sources -# data_provider_service.index_backtest_data_providers( -# strategy.data_sources, backtest_date_range -# ) -# -# # Prepare the backtest data for each data provider -# for data_source, data_provider in data_provider_service.data_provider_index.get_all(): -# data_provider.prepare_backtest_data( -# backtest_start_date=backtest_date_range.start_date, -# backtest_end_date=backtest_date_range.end_date -# ) -# -# # There should be a single backtest data provider registered -# self.assertEqual( -# len(data_provider_service.data_provider_index), 2 -# ) -# -# # Should be all CCXTOHLCVDataProvider -# for datasource in data_sources: -# data_provider = data_provider_service.data_provider_index.get( -# datasource) -# self.assertIsNotNone(data_provider) -# self.assertTrue( -# isinstance(data_provider, CCXTOHLCVDataProvider) -# ) -# -# backtest_trade_order_evaluator = BacktestTradeOrderEvaluator( -# trade_service=self.app.container.trade_service(), -# order_service=self.app.container.order_service(), -# ) -# event_loop_service = EventLoopService( -# order_service=self.app.container.order_service(), -# portfolio_service=self.app.container.portfolio_service(), -# configuration_service=self.app.container.configuration_service(), -# data_provider_service=data_provider_service, -# context=self.app.container.context(), -# trade_service=self.app.container.trade_service(), -# portfolio_snapshot_service=self.app.container.portfolio_snapshot_service(), -# ) -# event_loop_service.strategies = [StrategyForTesting()] -# event_loop_service.initialize( -# algorithm=self.app.get_algorithm(), -# trade_order_evaluator=backtest_trade_order_evaluator -# ) -# event_loop_service.start(schedule=schedule, show_progress=False) -# -# # Check that for everyday a snapshot was created -# history = event_loop_service.history -# portfolio_snapshot_service = self.app.container.portfolio_snapshot_service() -# self.assertEqual( -# len(portfolio_snapshot_service.get_all()), 8749 -# ) -# # StrategyOne should have run 8749 times -# self.assertEqual( -# len(history[strategy.strategy_id]["runs"]), 8749 -# ) -# -# def test_schedule_with_daily_iteration_snapshotting(self): -# """ -# -# """ -# backtest_date_range = BacktestDateRange( -# start_date=datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc), -# end_date=datetime(2023, 12, 31, 0, 0, tzinfo=timezone.utc) -# ) -# number_of_days = ( -# backtest_date_range.end_date - backtest_date_range.start_date).days -# configuration_service = self.app.container.configuration_service() -# configuration_service.add_value(INDEX_DATETIME, -# backtest_date_range.start_date) -# configuration_service.add_value(ENVIRONMENT, -# Environment.BACKTEST.value) -# configuration_service.add_value(BACKTESTING_START_DATE, -# backtest_date_range.start_date) -# configuration_service.add_value(LAST_SNAPSHOT_DATETIME, -# backtest_date_range.start_date) -# configuration_service.add_value(SNAPSHOT_INTERVAL, -# SnapshotInterval.DAILY.value) -# strategy = StrategyForTesting() -# backtest_service = self.app.container.backtest_service() -# schedule = backtest_service.generate_schedule( -# start_date=backtest_date_range.start_date, -# end_date=backtest_date_range.end_date, -# strategies=[strategy], -# tasks=[] -# ) -# self.app.add_order_executor(BacktestOrderExecutor()) -# self.app.add_portfolio_configuration( -# PortfolioConfiguration( -# market="bitvavo", -# trading_symbol="EUR", -# initial_balance=1000 -# ) -# ) -# self.app.add_strategy(strategy) -# self.app.initialize_backtest_config( -# backtest_date_range -# ) -# self.app.initialize_storage() -# self.app.initialize_portfolios() -# -# data_provider_service = DataProviderService( -# configuration_service=self.app.container.configuration_service(), -# market_credential_service=self.app.container \ -# .market_credential_service() -# ) -# data_provider_service.add_data_provider( -# CCXTOHLCVDataProvider() -# ) -# data_sources = strategy.data_sources -# # Register data providers for data sources -# data_provider_service.index_backtest_data_providers( -# strategy.data_sources, backtest_date_range -# ) -# -# # Prepare the backtest data for each data provider -# for data_source, data_provider in data_provider_service.data_provider_index.get_all(): -# data_provider.prepare_backtest_data( -# backtest_start_date=backtest_date_range.start_date, -# backtest_end_date=backtest_date_range.end_date -# ) -# -# # There should be a single backtest data provider registered -# self.assertEqual( -# len(data_provider_service.data_provider_index), 2 -# ) -# -# # Should be all CCXTOHLCVDataProvider -# for datasource in data_sources: -# data_provider = data_provider_service.data_provider_index.get( -# datasource) -# self.assertIsNotNone(data_provider) -# self.assertTrue( -# isinstance(data_provider, CCXTOHLCVDataProvider) -# ) -# -# backtest_trade_order_evaluator = BacktestTradeOrderEvaluator( -# trade_service=self.app.container.trade_service(), -# order_service=self.app.container.order_service(), -# ) -# event_loop_service = EventLoopService( -# order_service=self.app.container.order_service(), -# portfolio_service=self.app.container.portfolio_service(), -# configuration_service=self.app.container.configuration_service(), -# data_provider_service=data_provider_service, -# context=self.app.container.context(), -# trade_service=self.app.container.trade_service(), -# portfolio_snapshot_service=self.app.container.portfolio_snapshot_service(), -# ) -# event_loop_service.strategies = [StrategyForTesting()] -# event_loop_service.initialize( -# algorithm=self.app.get_algorithm(), -# trade_order_evaluator=backtest_trade_order_evaluator -# ) -# event_loop_service.start(schedule=schedule, show_progress=False) -# -# # Check that for every day a snapshot was created -# portfolio_snapshot_service = self.app.container.portfolio_snapshot_service() -# self.assertAlmostEqual( -# len(portfolio_snapshot_service.get_all()), number_of_days, delta=2 -# ) -# history = event_loop_service.history -# # StrategyOne should have run 8749 times -# self.assertEqual( -# len(history[strategy.strategy_id]["runs"]), 8749 -# ) diff --git a/tests/domain/test_blotter.py b/tests/domain/test_blotter.py new file mode 100644 index 00000000..dbf4dd4d --- /dev/null +++ b/tests/domain/test_blotter.py @@ -0,0 +1,768 @@ +import unittest +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from investing_algorithm_framework.domain.blotter import ( + Blotter, DefaultBlotter, SimulationBlotter, Transaction, + SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, + VolumeImpactSlippage, + CommissionModel, NoCommission, PercentageCommission, FixedCommission, + FillModel, FullFill, VolumeBasedFill, +) + + +class TestNoSlippage(unittest.TestCase): + + def test_buy_no_slippage(self): + model = NoSlippage() + self.assertEqual(model.calculate_slippage(100.0, "BUY"), 100.0) + + def test_sell_no_slippage(self): + model = NoSlippage() + self.assertEqual(model.calculate_slippage(100.0, "SELL"), 100.0) + + +class TestPercentageSlippage(unittest.TestCase): + + def test_buy_slippage_increases_price(self): + model = PercentageSlippage(percentage=0.01) + result = model.calculate_slippage(100.0, "BUY") + self.assertAlmostEqual(result, 101.0) + + def test_sell_slippage_decreases_price(self): + model = PercentageSlippage(percentage=0.01) + result = model.calculate_slippage(100.0, "SELL") + self.assertAlmostEqual(result, 99.0) + + def test_default_percentage(self): + model = PercentageSlippage() + self.assertEqual(model.percentage, 0.001) + + def test_small_slippage(self): + model = PercentageSlippage(percentage=0.001) + result = model.calculate_slippage(50000.0, "BUY") + self.assertAlmostEqual(result, 50050.0) + + +class TestFixedSlippage(unittest.TestCase): + + def test_buy_slippage_adds_fixed_amount(self): + model = FixedSlippage(amount=0.50) + result = model.calculate_slippage(100.0, "BUY") + self.assertAlmostEqual(result, 100.50) + + def test_sell_slippage_subtracts_fixed_amount(self): + model = FixedSlippage(amount=0.50) + result = model.calculate_slippage(100.0, "SELL") + self.assertAlmostEqual(result, 99.50) + + def test_default_amount(self): + model = FixedSlippage() + self.assertEqual(model.amount, 0.01) + + +class TestNoCommission(unittest.TestCase): + + def test_zero_commission(self): + model = NoCommission() + result = model.calculate_commission(100.0, 10.0, "BUY") + self.assertEqual(result, 0.0) + + +class TestPercentageCommission(unittest.TestCase): + + def test_commission_calculation(self): + model = PercentageCommission(percentage=0.001) + # 100 * 10 * 0.001 = 1.0 + result = model.calculate_commission(100.0, 10.0, "BUY") + self.assertAlmostEqual(result, 1.0) + + def test_default_percentage(self): + model = PercentageCommission() + self.assertEqual(model.percentage, 0.001) + + def test_sell_commission(self): + model = PercentageCommission(percentage=0.002) + # 50 * 5 * 0.002 = 0.5 + result = model.calculate_commission(50.0, 5.0, "SELL") + self.assertAlmostEqual(result, 0.5) + + +class TestFixedCommission(unittest.TestCase): + + def test_fixed_commission(self): + model = FixedCommission(amount=5.0) + result = model.calculate_commission(100.0, 10.0, "BUY") + self.assertEqual(result, 5.0) + + def test_default_amount(self): + model = FixedCommission() + self.assertEqual(model.amount, 1.0) + + +class TestTransaction(unittest.TestCase): + + def test_transaction_creation(self): + ts = datetime(2024, 1, 1, tzinfo=timezone.utc) + tx = Transaction( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + price=45000.0, + amount=0.1, + cost=4500.0, + commission=4.5, + slippage=45.0, + timestamp=ts, + ) + self.assertEqual(tx.order_id, 1) + self.assertEqual(tx.symbol, "BTC/EUR") + self.assertEqual(tx.order_side, "BUY") + self.assertEqual(tx.price, 45000.0) + self.assertEqual(tx.amount, 0.1) + self.assertEqual(tx.cost, 4500.0) + self.assertEqual(tx.commission, 4.5) + self.assertEqual(tx.slippage, 45.0) + self.assertEqual(tx.timestamp, ts) + + def test_transaction_defaults(self): + tx = Transaction( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + price=100.0, + amount=1.0, + cost=100.0, + ) + self.assertEqual(tx.commission, 0.0) + self.assertEqual(tx.slippage, 0.0) + self.assertIsNotNone(tx.timestamp) + + def test_to_dict(self): + ts = datetime(2024, 1, 1, tzinfo=timezone.utc) + tx = Transaction( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + price=100.0, + amount=1.0, + cost=100.0, + commission=0.1, + slippage=0.5, + timestamp=ts, + ) + d = tx.to_dict() + self.assertEqual(d["order_id"], 1) + self.assertEqual(d["symbol"], "BTC/EUR") + self.assertEqual(d["price"], 100.0) + self.assertEqual(d["commission"], 0.1) + self.assertEqual(d["slippage"], 0.5) + self.assertIn("2024-01-01", d["timestamp"]) + + def test_repr(self): + tx = Transaction( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + price=100.0, + amount=1.0, + cost=100.0, + ) + r = repr(tx) + self.assertIn("BTC/EUR", r) + self.assertIn("order_id=1", r) + + +class ConcreteBlotter(Blotter): + """Minimal concrete implementation for testing.""" + + def place_order(self, order_data, context): + execute = order_data.pop("_execute", True) + validate = order_data.pop("_validate", True) + sync = order_data.pop("_sync", True) + return context.order_service.create( + order_data, execute=execute, validate=validate, sync=sync + ) + + def cancel_order(self, order_id, context): + context.order_service.update( + order_id, {"status": "CANCELED"} + ) + return context.order_service.get(order_id) + + +class TestBlotter(unittest.TestCase): + + def test_is_abstract(self): + with self.assertRaises(TypeError): + Blotter() + + def test_concrete_blotter(self): + blotter = ConcreteBlotter() + self.assertIsNotNone(blotter) + self.assertEqual(blotter.get_transactions(), []) + + def test_config_property(self): + blotter = ConcreteBlotter() + self.assertIsNone(blotter.config) + blotter.config = {"key": "value"} + self.assertEqual(blotter.config["key"], "value") + + def test_record_transaction(self): + blotter = ConcreteBlotter() + tx = Transaction( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + price=100.0, + amount=1.0, + cost=100.0, + ) + blotter.record_transaction(tx) + self.assertEqual(len(blotter.get_transactions()), 1) + self.assertEqual(blotter.get_transactions()[0].order_id, 1) + + def test_clear_transactions(self): + blotter = ConcreteBlotter() + tx = Transaction( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + price=100.0, + amount=1.0, + cost=100.0, + ) + blotter.record_transaction(tx) + self.assertEqual(len(blotter.get_transactions()), 1) + blotter.clear_transactions() + self.assertEqual(len(blotter.get_transactions()), 0) + + def test_batch_order_calls_place_order(self): + blotter = ConcreteBlotter() + context = MagicMock() + + mock_order_1 = MagicMock() + mock_order_2 = MagicMock() + context.order_service.create.side_effect = [ + mock_order_1, mock_order_2 + ] + + orders = [ + { + "target_symbol": "BTC", + "price": 45000, + "order_side": "BUY", + "amount": 0.1, + }, + { + "target_symbol": "ETH", + "price": 3000, + "order_side": "BUY", + "amount": 1.0, + }, + ] + + results = blotter.batch_order(orders, context) + self.assertEqual(len(results), 2) + self.assertEqual(results[0], mock_order_1) + self.assertEqual(results[1], mock_order_2) + self.assertEqual(context.order_service.create.call_count, 2) + + def test_get_open_orders_delegates_to_context(self): + blotter = ConcreteBlotter() + context = MagicMock() + context.get_open_orders.return_value = ["order1", "order2"] + + result = blotter.get_open_orders(context, target_symbol="BTC") + context.get_open_orders.assert_called_once_with( + target_symbol="BTC" + ) + self.assertEqual(result, ["order1", "order2"]) + + def test_prune_orders_does_not_raise(self): + blotter = ConcreteBlotter() + context = MagicMock() + blotter.prune_orders(context) + + +class TestDefaultBlotter(unittest.TestCase): + + def test_place_order_delegates_to_order_service(self): + blotter = DefaultBlotter() + context = MagicMock() + mock_order = MagicMock() + context.order_service.create.return_value = mock_order + + order_data = { + "target_symbol": "BTC", + "price": 45000.0, + "amount": 0.1, + "order_type": "LIMIT", + "order_side": "BUY", + "portfolio_id": 1, + "status": "CREATED", + "trading_symbol": "EUR", + } + + result = blotter.place_order(order_data, context) + self.assertEqual(result, mock_order) + context.order_service.create.assert_called_once() + + def test_place_order_respects_flow_control_params(self): + blotter = DefaultBlotter() + context = MagicMock() + mock_order = MagicMock() + context.order_service.create.return_value = mock_order + + order_data = { + "target_symbol": "BTC", + "price": 45000.0, + "amount": 0.1, + "_execute": False, + "_validate": False, + "_sync": False, + } + + blotter.place_order(order_data, context) + call_kwargs = context.order_service.create.call_args + self.assertEqual(call_kwargs.kwargs["execute"], False) + self.assertEqual(call_kwargs.kwargs["validate"], False) + self.assertEqual(call_kwargs.kwargs["sync"], False) + + def test_place_order_defaults_flow_control(self): + blotter = DefaultBlotter() + context = MagicMock() + mock_order = MagicMock() + context.order_service.create.return_value = mock_order + + order_data = { + "target_symbol": "BTC", + "price": 45000.0, + "amount": 0.1, + } + + blotter.place_order(order_data, context) + call_kwargs = context.order_service.create.call_args + self.assertEqual(call_kwargs.kwargs["execute"], True) + self.assertEqual(call_kwargs.kwargs["validate"], True) + self.assertEqual(call_kwargs.kwargs["sync"], True) + + def test_cancel_order(self): + blotter = DefaultBlotter() + context = MagicMock() + mock_order = MagicMock() + context.order_service.get.return_value = mock_order + + blotter.cancel_order(42, context) + + context.order_service.cancel_order.assert_called_once_with( + mock_order + ) + + def test_cancel_nonexistent_order_raises(self): + blotter = DefaultBlotter() + context = MagicMock() + context.order_service.get.return_value = None + + with self.assertRaises(Exception): + blotter.cancel_order(999, context) + + def test_no_transactions_by_default(self): + blotter = DefaultBlotter() + self.assertEqual(blotter.get_transactions(), []) + + +class TestSimulationBlotter(unittest.TestCase): + + def test_default_models(self): + blotter = SimulationBlotter() + self.assertIsInstance(blotter.slippage_model, NoSlippage) + self.assertIsInstance(blotter.commission_model, NoCommission) + self.assertIsInstance(blotter.fill_model, FullFill) + + def test_custom_models(self): + blotter = SimulationBlotter( + slippage_model=PercentageSlippage(0.01), + commission_model=PercentageCommission(0.002), + fill_model=VolumeBasedFill(0.1), + ) + self.assertIsInstance(blotter.slippage_model, PercentageSlippage) + self.assertEqual(blotter.slippage_model.percentage, 0.01) + self.assertIsInstance( + blotter.commission_model, PercentageCommission + ) + self.assertEqual(blotter.commission_model.percentage, 0.002) + self.assertIsInstance(blotter.fill_model, VolumeBasedFill) + + def test_place_order_no_slippage_at_creation(self): + """place_order should NOT apply slippage or commission.""" + blotter = SimulationBlotter( + slippage_model=PercentageSlippage(0.01), + commission_model=PercentageCommission(0.001), + ) + context = MagicMock() + mock_order = MagicMock() + context.order_service.create.return_value = mock_order + + result = blotter.place_order({ + "target_symbol": "BTC", + "order_side": "BUY", + "price": 45000.0, + "amount": 0.1, + "order_type": "LIMIT", + "portfolio_id": 1, + "status": "CREATED", + "trading_symbol": "EUR", + }, context) + + self.assertEqual(result, mock_order) + context.order_service.create.assert_called_once() + # Price should NOT have been modified by slippage + call_args = context.order_service.create.call_args + order_data = call_args[0][0] + self.assertEqual(order_data["price"], 45000.0) + # No transactions recorded at order creation time + self.assertEqual(len(blotter.get_transactions()), 0) + + def test_get_fill_price_buy(self): + blotter = SimulationBlotter( + slippage_model=PercentageSlippage(0.01) + ) + fill_price = blotter.get_fill_price(100.0, "BUY") + self.assertAlmostEqual(fill_price, 101.0) + + def test_get_fill_price_sell(self): + blotter = SimulationBlotter( + slippage_model=PercentageSlippage(0.01) + ) + fill_price = blotter.get_fill_price(100.0, "SELL") + self.assertAlmostEqual(fill_price, 99.0) + + def test_get_fill_commission(self): + blotter = SimulationBlotter( + commission_model=PercentageCommission(0.001) + ) + # 45000 * 0.1 * 0.001 = 4.5 + commission = blotter.get_fill_commission(45000.0, 0.1, "BUY") + self.assertAlmostEqual(commission, 4.5) + + def test_get_fill_amount_full_fill(self): + blotter = SimulationBlotter() + amount = blotter.get_fill_amount(10.0, available_volume=1000.0) + self.assertEqual(amount, 10.0) + + def test_get_fill_amount_volume_based(self): + blotter = SimulationBlotter( + fill_model=VolumeBasedFill(max_volume_fraction=0.1) + ) + # 10% of 50 = 5, order is 10 → fill 5 + amount = blotter.get_fill_amount(10.0, available_volume=50.0) + self.assertAlmostEqual(amount, 5.0) + + def test_get_commission_rate(self): + blotter = SimulationBlotter( + commission_model=PercentageCommission(0.002) + ) + self.assertEqual(blotter.get_commission_rate(), 0.002) + + def test_get_commission_rate_none_for_fixed(self): + blotter = SimulationBlotter( + commission_model=FixedCommission(5.0) + ) + self.assertIsNone(blotter.get_commission_rate()) + + def test_on_fill_records_transaction(self): + blotter = SimulationBlotter( + commission_model=PercentageCommission(0.001) + ) + commission = blotter.on_fill( + order_id=1, + symbol="BTC/EUR", + order_side="BUY", + fill_price=45045.0, + base_price=45000.0, + fill_amount=0.1, + ) + # 45045 * 0.1 * 0.001 = 4.5045 + self.assertAlmostEqual(commission, 4.5045) + self.assertEqual(len(blotter.get_transactions()), 1) + tx = blotter.get_transactions()[0] + self.assertEqual(tx.order_id, 1) + self.assertEqual(tx.symbol, "BTC/EUR") + self.assertAlmostEqual(tx.price, 45045.0) + self.assertAlmostEqual(tx.slippage, 45.0) + + def test_cancel_order(self): + blotter = SimulationBlotter() + context = MagicMock() + mock_order = MagicMock() + context.order_service.get.return_value = mock_order + + blotter.cancel_order(42, context) + + context.order_service.cancel_order.assert_called_once_with( + mock_order + ) + self.assertEqual( + context.order_service.get.call_count, 2 + ) + + +class TestVolumeImpactSlippage(unittest.TestCase): + + def test_buy_with_volume(self): + model = VolumeImpactSlippage( + base_percentage=0.01, impact_power=0.5 + ) + # amount=100, volume=10000 → ratio=0.01, impact=0.01*0.1=0.001 + result = model.calculate_slippage( + 100.0, "BUY", amount=100, volume=10000 + ) + self.assertGreater(result, 100.0) + + def test_sell_with_volume(self): + model = VolumeImpactSlippage( + base_percentage=0.01, impact_power=0.5 + ) + result = model.calculate_slippage( + 100.0, "SELL", amount=100, volume=10000 + ) + self.assertLess(result, 100.0) + + def test_no_volume_falls_back_to_base(self): + model = VolumeImpactSlippage(base_percentage=0.01) + result = model.calculate_slippage(100.0, "BUY") + self.assertAlmostEqual(result, 101.0) + + def test_large_order_higher_impact(self): + model = VolumeImpactSlippage( + base_percentage=0.01, impact_power=0.5 + ) + small = model.calculate_slippage( + 100.0, "BUY", amount=10, volume=10000 + ) + large = model.calculate_slippage( + 100.0, "BUY", amount=1000, volume=10000 + ) + self.assertGreater(large - 100.0, small - 100.0) + + +class TestFullFill(unittest.TestCase): + + def test_fills_entire_amount(self): + model = FullFill() + self.assertEqual(model.get_fill_amount(100.0, 50.0), 100.0) + + def test_fills_without_volume(self): + model = FullFill() + self.assertEqual(model.get_fill_amount(100.0, None), 100.0) + + +class TestVolumeBasedFill(unittest.TestCase): + + def test_partial_fill(self): + model = VolumeBasedFill(max_volume_fraction=0.1) + # 10% of 50 = 5, less than 100 → fill 5 + self.assertAlmostEqual( + model.get_fill_amount(100.0, 50.0), 5.0 + ) + + def test_full_fill_when_enough_volume(self): + model = VolumeBasedFill(max_volume_fraction=0.1) + # 10% of 10000 = 1000, more than 10 → fill 10 + self.assertAlmostEqual( + model.get_fill_amount(10.0, 10000.0), 10.0 + ) + + def test_no_volume_fills_entire(self): + model = VolumeBasedFill(max_volume_fraction=0.1) + self.assertEqual(model.get_fill_amount(100.0, None), 100.0) + + def test_default_fraction(self): + model = VolumeBasedFill() + self.assertEqual(model.max_volume_fraction, 0.1) + + def test_cancel_nonexistent_order_raises(self): + blotter = SimulationBlotter() + context = MagicMock() + context.order_service.get.return_value = None + + with self.assertRaises(Exception): + blotter.cancel_order(999, context) + + def test_batch_order_with_slippage_and_commission(self): + """batch_order delegates to place_order; no transactions at creation.""" + blotter = SimulationBlotter( + slippage_model=FixedSlippage(amount=10.0), + commission_model=FixedCommission(amount=5.0), + ) + context = MagicMock() + + mock_order_1 = MagicMock() + mock_order_2 = MagicMock() + + context.order_service.create.side_effect = [ + mock_order_1, mock_order_2 + ] + + results = blotter.batch_order([ + { + "target_symbol": "BTC", + "order_side": "BUY", + "price": 45000.0, + "amount": 0.1, + "order_type": "LIMIT", + "portfolio_id": 1, + "status": "CREATED", + "trading_symbol": "EUR", + }, + { + "target_symbol": "ETH", + "order_side": "BUY", + "price": 3000.0, + "amount": 1.0, + "order_type": "LIMIT", + "portfolio_id": 1, + "status": "CREATED", + "trading_symbol": "EUR", + }, + ], context) + + self.assertEqual(len(results), 2) + # No transactions at creation time — recorded at fill time + self.assertEqual(len(blotter.get_transactions()), 0) + + # Verify fill-time commission model works + self.assertEqual( + blotter.get_fill_commission(45000.0, 0.1, "BUY"), 5.0 + ) + + def test_place_order_with_no_price(self): + """Test that None/zero price doesn't cause errors.""" + blotter = SimulationBlotter( + slippage_model=PercentageSlippage(0.01) + ) + context = MagicMock() + mock_order = MagicMock() + context.order_service.create.return_value = mock_order + + result = blotter.place_order({ + "target_symbol": "BTC", + "order_side": "BUY", + "order_type": "MARKET", + "amount": 0.1, + "price": 0, + "portfolio_id": 1, + "status": "CREATED", + "trading_symbol": "EUR", + }, context) + + self.assertIsNotNone(result) + # No transactions at creation time + self.assertEqual(len(blotter.get_transactions()), 0) + + def test_place_order_passes_metadata(self): + blotter = SimulationBlotter() + context = MagicMock() + mock_order = MagicMock() + mock_order.get_id.return_value = 1 + mock_order.get_symbol.return_value = "BTC/EUR" + mock_order.get_order_side.return_value = "BUY" + mock_order.get_price.return_value = 45000.0 + mock_order.get_amount.return_value = 0.1 + context.order_service.create.return_value = mock_order + + blotter.place_order({ + "target_symbol": "BTC", + "order_side": "BUY", + "price": 45000.0, + "amount": 0.1, + "order_type": "LIMIT", + "portfolio_id": 1, + "status": "CREATED", + "trading_symbol": "EUR", + "metadata": {"strategy": "momentum"}, + }, context) + + call_args = context.order_service.create.call_args + order_data = call_args[0][0] + self.assertEqual( + order_data["metadata"], {"strategy": "momentum"} + ) + + def test_flow_control_params_extracted(self): + """Test that _execute/_validate/_sync are popped from order_data.""" + blotter = SimulationBlotter() + context = MagicMock() + mock_order = MagicMock() + mock_order.get_id.return_value = 1 + mock_order.get_symbol.return_value = "BTC/EUR" + mock_order.get_order_side.return_value = "BUY" + mock_order.get_price.return_value = 45000.0 + mock_order.get_amount.return_value = 0.1 + context.order_service.create.return_value = mock_order + + blotter.place_order({ + "target_symbol": "BTC", + "order_side": "BUY", + "price": 45000.0, + "amount": 0.1, + "_execute": False, + "_validate": False, + "_sync": False, + }, context) + + call_kwargs = context.order_service.create.call_args + self.assertEqual(call_kwargs.kwargs["execute"], False) + self.assertEqual(call_kwargs.kwargs["validate"], False) + self.assertEqual(call_kwargs.kwargs["sync"], False) + # Verify _execute/_validate/_sync are NOT in the order_data + order_data = call_kwargs[0][0] + self.assertNotIn("_execute", order_data) + self.assertNotIn("_validate", order_data) + self.assertNotIn("_sync", order_data) + + +class TestSlippageModelAbstract(unittest.TestCase): + + def test_is_abstract(self): + with self.assertRaises(TypeError): + SlippageModel() + + +class TestCommissionModelAbstract(unittest.TestCase): + + def test_is_abstract(self): + with self.assertRaises(TypeError): + CommissionModel() + + +class TestImports(unittest.TestCase): + """Test that all blotter classes are importable from the package.""" + + def test_import_from_domain(self): + from investing_algorithm_framework.domain import ( # noqa: F401 + Blotter, DefaultBlotter, SimulationBlotter, Transaction, + SlippageModel, NoSlippage, PercentageSlippage, FixedSlippage, + CommissionModel, NoCommission, PercentageCommission, + FixedCommission, + ) + self.assertIsNotNone(Blotter) + self.assertIsNotNone(DefaultBlotter) + self.assertIsNotNone(SimulationBlotter) + self.assertIsNotNone(Transaction) + + def test_import_from_top_level(self): + from investing_algorithm_framework import ( # noqa: F401 + Blotter, DefaultBlotter, SimulationBlotter, Transaction, + NoSlippage, PercentageSlippage, FixedSlippage, + NoCommission, PercentageCommission, FixedCommission, + ) + self.assertIsNotNone(Blotter) + self.assertIsNotNone(DefaultBlotter) + self.assertIsNotNone(SimulationBlotter) + self.assertIsNotNone(Transaction) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/algorithm_id.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/algorithm_id.json index fdb062a0..9ab210ec 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/algorithm_id.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/algorithm_id.json @@ -1,3 +1,3 @@ { - "algorithm_id": "BacktestTestStrategy" + "algorithm_id": "TestStrategy" } \ No newline at end of file diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231201_20231202/run.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231201_20231202/run.json index bf3da866..841ff1f7 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231201_20231202/run.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231201_20231202/run.json @@ -1 +1 @@ -{"backtest_start_date": "2023-12-01 00:00:00", "backtest_date_range_name": null, "backtest_end_date": "2023-12-02 00:00:00", "trading_symbol": "EUR", "initial_unallocated": 1000.0, "number_of_runs": 1441, "portfolio_snapshots": [{"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-01T00:00:00+00:00", "total_value": 1000.0}, {"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-02T00:00:00+00:00", "total_value": 1000.0}], "trades": [], "orders": [], "positions": [{"symbol": "EUR", "amount": 1000.0, "cost": 1000.0, "portfolio_id": 1}], "created_at": "2026-04-22 15:58:47", "symbols": [], "number_of_days": 0, "number_of_trades": 0, "number_of_trades_closed": 0, "number_of_trades_open": 0, "number_of_orders": 0, "number_of_positions": 0, "metadata": {}, "signals": {}, "signal_events": []} \ No newline at end of file +{"backtest_start_date": "2023-12-01 00:00:00", "backtest_date_range_name": null, "backtest_end_date": "2023-12-02 00:00:00", "trading_symbol": "EUR", "initial_unallocated": 1000.0, "number_of_runs": 1441, "portfolio_snapshots": [{"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-01T00:00:00+00:00", "total_value": 1000.0}, {"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-02T00:00:00+00:00", "total_value": 1000.0}], "trades": [], "orders": [], "positions": [{"symbol": "EUR", "amount": 1000.0, "cost": 1000.0, "portfolio_id": 1}], "created_at": "2026-04-23 19:17:03", "symbols": [], "number_of_days": 0, "number_of_trades": 0, "number_of_trades_closed": 0, "number_of_trades_open": 0, "number_of_orders": 0, "number_of_positions": 0, "metadata": {}, "signals": {}, "signal_events": []} \ No newline at end of file diff --git a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231202_20231203/run.json b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231202_20231203/run.json index ad2437b0..b7b4f224 100644 --- a/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231202_20231203/run.json +++ b/tests/resources/backtest_reports_for_testing/test_algorithm_backtest/runs/backtest_EUR_20231202_20231203/run.json @@ -1 +1 @@ -{"backtest_start_date": "2023-12-02 00:00:00", "backtest_date_range_name": null, "backtest_end_date": "2023-12-03 00:00:00", "trading_symbol": "EUR", "initial_unallocated": 1000.0, "number_of_runs": 1441, "portfolio_snapshots": [{"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-02T00:00:00+00:00", "total_value": 1000.0}, {"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-03T00:00:00+00:00", "total_value": 1000.0}], "trades": [], "orders": [], "positions": [{"symbol": "EUR", "amount": 1000.0, "cost": 1000.0, "portfolio_id": 1}], "created_at": "2026-04-22 15:57:01", "symbols": [], "number_of_days": 0, "number_of_trades": 0, "number_of_trades_closed": 0, "number_of_trades_open": 0, "number_of_orders": 0, "number_of_positions": 0, "metadata": {}, "signals": {}, "signal_events": []} \ No newline at end of file +{"backtest_start_date": "2023-12-02 00:00:00", "backtest_date_range_name": null, "backtest_end_date": "2023-12-03 00:00:00", "trading_symbol": "EUR", "initial_unallocated": 1000.0, "number_of_runs": 1441, "portfolio_snapshots": [{"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-02T00:00:00+00:00", "total_value": 1000.0}, {"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-03T00:00:00+00:00", "total_value": 1000.0}], "trades": [], "orders": [], "positions": [{"symbol": "EUR", "amount": 1000.0, "cost": 1000.0, "portfolio_id": 1}], "created_at": "2026-04-23 19:19:05", "symbols": [], "number_of_days": 0, "number_of_trades": 0, "number_of_trades_closed": 0, "number_of_trades_open": 0, "number_of_orders": 0, "number_of_positions": 0, "metadata": {}, "signals": {}, "signal_events": []} \ No newline at end of file