diff --git a/docusaurus/docs/Getting Started/orders.md b/docusaurus/docs/Getting Started/orders.md index 5612dadb..f85d14e0 100644 --- a/docusaurus/docs/Getting Started/orders.md +++ b/docusaurus/docs/Getting Started/orders.md @@ -14,24 +14,64 @@ Orders are instructions to buy or sell assets in the market. The framework provi ### Market Orders -Execute immediately at the current market price: +Execute at the best available price. The framework looks up the current price as an **estimated price** for sizing and cash reservation. The actual fill price is determined at execution time and the portfolio is automatically reconciled. + +**Using the general method:** ```python -# Buy order - spend 100 USDT to buy BTC -algorithm.create_buy_order( +from investing_algorithm_framework import OrderSide + +# Buy order - spend 100 EUR worth of BTC at market price +self.create_market_order( target_symbol="BTC", - amount=100, # Amount in trading symbol (USDT) - order_type="MARKET" + order_side=OrderSide.BUY, + amount_trading_symbol=100, # Amount in trading symbol (EUR) ) -# Sell order - sell 50% of BTC holdings -algorithm.create_sell_order( +# Sell order - sell 50% of BTC position at market price +self.create_market_order( target_symbol="BTC", - percentage=0.5, # Sell 50% of holdings - order_type="MARKET" + order_side=OrderSide.SELL, + percentage_of_position=50, # Sell 50% of position ) ``` +**Using convenience methods:** + +```python +# Buy: spend 10% of portfolio on BTC +self.create_market_buy_order( + target_symbol="BTC", + percentage_of_portfolio=10, +) + +# Sell: sell 0.5 BTC +self.create_market_sell_order( + target_symbol="BTC", + amount=0.5, +) +``` + +:::info How market orders work internally +1. The framework fetches the **latest price** as an estimated price. +2. The order is created with `price=estimated_price` for cash reservation and position sizing. +3. At fill time (next candle open in backtesting, exchange fill in live trading), the **actual fill price** replaces the estimate. +4. The portfolio is **reconciled**: any difference between the estimated and actual price is adjusted in your unallocated balance and position. +::: + +#### Backtesting Behavior + +In backtesting, market orders fill at the **Open price of the next candle** after the order is placed. If you have configured `TradingCost` with a `slippage_percentage`, slippage is applied on top of the open price: + +```python +from investing_algorithm_framework import TradingCost + +class MyStrategy(TradingStrategy): + trading_costs = [ + TradingCost(symbol="BTC", slippage_percentage=0.001), # 0.1% slippage + ] +``` + ### Limit Orders Execute only at a specified price or better: @@ -47,7 +87,7 @@ algorithm.create_buy_order( # Sell limit order algorithm.create_sell_order( - target_symbol="BTC", + target_symbol="BTC", percentage=1.0, order_type="LIMIT", price=55000 # Only sell if BTC price is 55,000 USDT or higher @@ -71,7 +111,7 @@ algorithm.create_sell_order( algorithm.create_buy_order( target_symbol="BTC", amount=100, - order_type="STOP", + order_type="STOP", stop_price=52000 ) ``` @@ -93,30 +133,41 @@ algorithm.create_sell_order( ## Order Parameters +### Market Order Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `target_symbol` | `str` | The asset to trade (e.g., `"BTC"`, `"ETH"`) | +| `order_side` | `OrderSide` | `OrderSide.BUY` or `OrderSide.SELL` | +| `amount` | `float` | Amount of the target asset | +| `amount_trading_symbol` | `float` | Amount in trading symbol (e.g., EUR) to spend | +| `percentage_of_portfolio` | `float` | % of portfolio to buy (BUY only) | +| `percentage_of_position` | `float` | % of position to sell (SELL only) | +| `percentage` | `float` | % of portfolio net size to allocate | +| `precision` | `int` | Decimal precision for rounding the amount | +| `metadata` | `dict` | Additional metadata for the order | + +### Limit Order Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `target_symbol` | `str` | The asset to trade (e.g., `"BTC"`, `"ETH"`) | +| `order_side` | `OrderSide` | `OrderSide.BUY` or `OrderSide.SELL` | +| `price` | `float` | Limit price for the order | +| `amount` | `float` | Amount of the target asset | +| `amount_trading_symbol` | `float` | Amount in trading symbol to spend | +| `percentage_of_portfolio` | `float` | % of portfolio to buy (BUY only) | +| `percentage_of_position` | `float` | % of position to sell (SELL only) | +| `percentage` | `float` | % of portfolio net size to allocate | +| `precision` | `int` | Decimal precision for rounding the amount | + ### Common Parameters -- **target_symbol**: The asset to buy/sell (e.g., "BTC", "ETH") -- **amount**: Amount to spend (for buy orders) in trading symbol -- **percentage**: Percentage of holdings to sell (for sell orders) -- **order_type**: Type of order ("MARKET", "LIMIT", "STOP", "STOP_LIMIT") -- **price**: Limit price (for limit and stop-limit orders) -- **stop_price**: Stop price (for stop and stop-limit orders) +All order creation methods support these additional parameters: -### Advanced Parameters - -```python -# Order with advanced parameters -algorithm.create_buy_order( - target_symbol="BTC", - amount=100, - order_type="LIMIT", - price=50000, - # Advanced parameters - time_in_force="GTC", # Good Till Cancelled - reduce_only=False, # Allow position increase - post_only=True # Only maker orders (some exchanges) -) -``` +- **execute** (default `True`): Whether to execute the order immediately +- **validate** (default `True`): Whether to validate the order (balance/position checks) +- **sync** (default `True`): Whether to sync the order with the portfolio ## Order Management @@ -126,11 +177,11 @@ algorithm.create_buy_order( def apply_strategy(self, algorithm, market_data): # Get all orders orders = algorithm.get_orders() - + # Filter by status pending_orders = [order for order in orders if order.status == "OPEN"] filled_orders = [order for order in orders if order.status == "FILLED"] - + # Check specific order for order in pending_orders: print(f"Order {order.id}: {order.order_type} {order.target_symbol} - {order.status}") @@ -145,7 +196,7 @@ def apply_strategy(self, algorithm, market_data): for order in orders: if order.status == "OPEN" and order.created_at < some_time_threshold: algorithm.cancel_order(order.id) - + # Cancel all open orders for a symbol algorithm.cancel_all_orders(symbol="BTC/USDT") ``` @@ -155,7 +206,7 @@ def apply_strategy(self, algorithm, market_data): ```python def apply_strategy(self, algorithm, market_data): orders = algorithm.get_orders() - + for order in orders: if order.status == "OPEN" and order.order_type == "LIMIT": # Update order price @@ -172,17 +223,16 @@ def apply_strategy(self, algorithm, market_data): ```python class DCAStrategy(TradingStrategy): - - def __init__(self, buy_amount=100): - super().__init__() - self.buy_amount = buy_amount - - def apply_strategy(self, algorithm, market_data): + time_unit = TimeUnit.DAY + interval = 1 + symbols = ["BTC"] + trading_symbol = "EUR" + + def apply_strategy(self, context, data): # Buy fixed amount regardless of price - algorithm.create_buy_order( + self.create_market_buy_order( target_symbol="BTC", - amount=self.buy_amount, - order_type="MARKET" + amount_trading_symbol=100, # Buy 100 EUR worth of BTC ) ``` @@ -190,43 +240,42 @@ class DCAStrategy(TradingStrategy): ```python class GridStrategy(TradingStrategy): - - def __init__(self, grid_levels=5, grid_spacing=0.02): - super().__init__() + time_unit = TimeUnit.HOUR + interval = 1 + symbols = ["BTC"] + trading_symbol = "EUR" + + def __init__(self, grid_levels=5, grid_spacing=0.02, **kwargs): + super().__init__(**kwargs) self.grid_levels = grid_levels self.grid_spacing = grid_spacing - - def apply_strategy(self, algorithm, market_data): - symbol = "BTC/USDT" - current_price = market_data.get_last_price(symbol) - - # Cancel existing orders - algorithm.cancel_all_orders(symbol) - - # Place buy orders below current price + + def apply_strategy(self, context, data): + current_price = context.get_latest_price("BTC/EUR") + + # Place buy limit orders below current price for i in range(1, self.grid_levels + 1): buy_price = current_price * (1 - self.grid_spacing * i) - algorithm.create_buy_order( + self.create_limit_order( target_symbol="BTC", - amount=100, - order_type="LIMIT", - price=buy_price + order_side=OrderSide.BUY, + amount_trading_symbol=100, + price=buy_price, ) - - # Place sell orders above current price - positions = algorithm.get_positions() - btc_position = next((p for p in positions if p.symbol == "BTC/USDT"), None) - - if btc_position and btc_position.amount > 0: - sell_amount_per_level = btc_position.amount / self.grid_levels - + + # Place sell limit orders above current price + position = self.get_position(symbol="BTC") + + if position and position.get_amount() > 0: + sell_amount = position.get_amount() / self.grid_levels + for i in range(1, self.grid_levels + 1): sell_price = current_price * (1 + self.grid_spacing * i) - algorithm.create_sell_order( + self.create_limit_order( target_symbol="BTC", - amount=sell_amount_per_level, - order_type="LIMIT", - price=sell_price + order_side=OrderSide.SELL, + amount=sell_amount, + price=sell_price, ) ``` @@ -234,68 +283,65 @@ class GridStrategy(TradingStrategy): ```python class TrailingStopStrategy(TradingStrategy): - - def __init__(self, trailing_percent=0.05): - super().__init__() + time_unit = TimeUnit.HOUR + interval = 1 + symbols = ["BTC"] + trading_symbol = "EUR" + + def __init__(self, trailing_percent=0.05, **kwargs): + super().__init__(**kwargs) self.trailing_percent = trailing_percent self.highest_price = None - - def apply_strategy(self, algorithm, market_data): - symbol = "BTC/USDT" - current_price = market_data.get_last_price(symbol) - + + def apply_strategy(self, context, data): + current_price = context.get_latest_price("BTC/EUR") + # Update highest price if self.highest_price is None or current_price > self.highest_price: self.highest_price = current_price - + # Check if we have a position - positions = algorithm.get_positions() - btc_position = next((p for p in positions if p.symbol == symbol), None) - - if btc_position and btc_position.amount > 0: + if self.has_position(symbol="BTC", amount_gt=0): # Calculate trailing stop price stop_price = self.highest_price * (1 - self.trailing_percent) - + if current_price <= stop_price: - # Trigger trailing stop - algorithm.create_sell_order( + # Trigger trailing stop - sell entire position at market + self.create_market_sell_order( target_symbol="BTC", - percentage=1.0, - order_type="MARKET" + percentage_of_position=100, ) self.highest_price = None # Reset for next position ``` ## Order Validation -The framework includes built-in order validation: +The framework includes built-in order validation for both limit and market orders: ### Balance Checks +For **buy orders**, the framework validates that you have sufficient unallocated balance. For market orders, this check uses the estimated price: + ```python # Framework automatically checks if you have sufficient balance -try: - algorithm.create_buy_order( - target_symbol="BTC", - amount=10000, # This might exceed available balance - order_type="MARKET" - ) -except InsufficientBalanceError as e: - print(f"Order failed: {e}") +# This will raise an OperationalException if balance is insufficient +self.create_market_buy_order( + target_symbol="BTC", + amount_trading_symbol=10000, # This might exceed available balance +) ``` ### Position Checks +For **sell orders**, the framework checks that you have enough holdings: + ```python # Framework checks if you have enough holdings to sell -try: - algorithm.create_sell_order( - target_symbol="BTC", - percentage=1.5, # Cannot sell more than 100% - order_type="MARKET" - ) -except InsufficientHoldingsError as e: - print(f"Order failed: {e}") +# This will raise an OperationalException if position is insufficient +self.create_market_sell_order( + target_symbol="BTC", + percentage_of_position=150, # Cannot sell more than 100% +) ``` ## Best Practices @@ -313,7 +359,7 @@ Always check if your orders are being filled as expected: ```python def check_order_health(self, algorithm): orders = algorithm.get_orders() - + # Check for old unfilled orders current_time = datetime.now() for order in orders: @@ -328,12 +374,12 @@ def check_order_health(self, algorithm): ```python def handle_partial_fills(self, algorithm): orders = algorithm.get_orders() - + for order in orders: if order.status == "PARTIALLY_FILLED": fill_ratio = order.filled_amount / order.amount print(f"Order {order.id} is {fill_ratio:.1%} filled") - + # Decide whether to cancel or wait if fill_ratio < 0.1: # Less than 10% filled algorithm.cancel_order(order.id) @@ -347,7 +393,7 @@ Always include risk controls in your order logic: def apply_strategy(self, algorithm, market_data): # Check portfolio exposure before placing orders portfolio = algorithm.get_portfolio() - + if portfolio.get_total_exposure() < 0.9: # Less than 90% invested # Safe to place buy orders algorithm.create_buy_order( diff --git a/docusaurus/docs/Getting Started/strategies.md b/docusaurus/docs/Getting Started/strategies.md index 3efc99a4..472d0c5e 100644 --- a/docusaurus/docs/Getting Started/strategies.md +++ b/docusaurus/docs/Getting Started/strategies.md @@ -357,6 +357,28 @@ order = self.create_limit_order( sync=True ) +# Create a market order (fills at best available price) +order = self.create_market_order( + target_symbol="BTC", + order_side=OrderSide.BUY, + amount=0.01, # Amount in target symbol + # OR + amount_trading_symbol=500, # Amount in trading symbol (EUR) + # OR + percentage_of_portfolio=10, # 10% of portfolio +) + +# Convenience methods for market orders +self.create_market_buy_order( + target_symbol="BTC", + percentage_of_portfolio=10, # Buy 10% of portfolio +) + +self.create_market_sell_order( + target_symbol="BTC", + percentage_of_position=50, # Sell 50% of position +) + # Close a position entirely self.close_position(symbol="BTC") ``` diff --git a/investing_algorithm_framework/app/context.py b/investing_algorithm_framework/app/context.py index ef4ebe9b..9d8a7171 100644 --- a/investing_algorithm_framework/app/context.py +++ b/investing_algorithm_framework/app/context.py @@ -270,6 +270,225 @@ def create_limit_order( order_data, execute=execute, validate=validate, sync=sync ) + def create_market_order( + self, + target_symbol, + order_side, + amount=None, + amount_trading_symbol=None, + percentage=None, + percentage_of_portfolio=None, + percentage_of_position=None, + precision=None, + market=None, + execute=True, + validate=True, + sync=True, + metadata=None + ) -> Order: + """ + Function to create a market order. Market orders execute at + the best available price. In backtesting, this means the + open price of the next candle (+ slippage). + + An estimated price (current latest price) is used for amount + calculation and cash reservation. The actual fill price is + determined at fill time and the portfolio is reconciled. + + Args: + target_symbol: The symbol of the asset to trade + order_side: The side of the order (BUY or SELL) + amount (optional): The amount of the asset to trade + amount_trading_symbol (optional): The amount of the + trading symbol to trade + percentage (optional): The percentage of the portfolio + to allocate to the order + percentage_of_portfolio (optional): The percentage + of the portfolio to allocate to the order + percentage_of_position (optional): The percentage + of the position to allocate to the + order. (Only supported for SELL orders) + precision (optional): The precision of the amount + market (optional): The market to trade the asset + execute (optional): Default True. If set to True, + the order will be executed + validate (optional): Default True. If set to + True, the order will be validated + sync (optional): Default True. If set to True, + the created order will be synced with the + portfolio of the algorithm + metadata (optional): Additional metadata for the order + + Returns: + Order: Instance of the order created + """ + portfolio = self.portfolio_service.find({"market": market}) + full_symbol = (f"{target_symbol}/{portfolio.trading_symbol}") + estimated_price = self.get_latest_price(full_symbol, market=market) + + if estimated_price is None: + raise OperationalException( + f"Cannot create market order for {target_symbol}: " + f"no price data available to estimate order size." + ) + + if percentage_of_portfolio is not None: + if not OrderSide.BUY.equals(order_side): + raise OperationalException( + "Percentage of portfolio is only supported for BUY orders." + ) + + net_size = portfolio.get_net_size() + size = net_size * (percentage_of_portfolio / 100) + amount = size / estimated_price + + elif percentage_of_position is not None: + + if not OrderSide.SELL.equals(order_side): + raise OperationalException( + "Percentage of position is only supported for SELL orders." + ) + + position = self.position_service.find( + { + "symbol": target_symbol, + "portfolio": portfolio.id + } + ) + amount = position.get_amount() * (percentage_of_position / 100) + + elif percentage is not None: + net_size = portfolio.get_net_size() + size = net_size * (percentage / 100) + amount = size / estimated_price + + if precision is not None: + amount = RoundingService.round_down(amount, precision) + + if amount_trading_symbol is not None: + amount = amount_trading_symbol / estimated_price + + if amount is None: + raise OperationalException( + "The amount parameter is required to create a market order. " + "Either the amount, amount_trading_symbol, percentage, " + "percentage_of_portfolio or percentage_of_position " + "parameter must be specified." + ) + + logger.info( + f"Creating market order: {target_symbol} " + f"{order_side} {amount} @ estimated {estimated_price}" + ) + + order_metadata = metadata if metadata is not None else {} + order_metadata["estimated_price"] = estimated_price + + order_data = { + "target_symbol": target_symbol, + "price": estimated_price, + "amount": amount, + "order_type": OrderType.MARKET.value, + "order_side": OrderSide.from_value(order_side).value, + "portfolio_id": portfolio.id, + "status": OrderStatus.CREATED.value, + "trading_symbol": portfolio.trading_symbol, + "metadata": order_metadata, + } + + 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=execute, validate=validate, sync=sync + ) + + def create_market_buy_order( + self, + target_symbol, + amount=None, + percentage_of_portfolio=None, + market=None, + portfolio_id=None, + metadata=None + ) -> Order: + """ + Function to create a market buy order. + + Args: + target_symbol (str): The symbol of the asset to buy + amount (float, optional): The amount of the asset to buy + percentage_of_portfolio (float, optional): The percentage of the + portfolio to buy. + market (str, optional): the portfolio corresponding to the market + to buy the asset + portfolio_id (str, optional): The ID of the portfolio to buy + the asset from. + metadata (dict, optional): Additional metadata for the order + + Returns: + Order: The order created + """ + + if amount is None and percentage_of_portfolio is None: + raise OperationalException( + "Either amount or percentage_of_portfolio must be specified " + "to create a market buy order." + ) + + return self.create_market_order( + target_symbol=target_symbol, + order_side=OrderSide.BUY, + amount=amount, + percentage_of_portfolio=percentage_of_portfolio, + market=market, + metadata=metadata + ) + + def create_market_sell_order( + self, + target_symbol, + amount=None, + percentage_of_position=None, + market=None, + portfolio_id=None, + metadata=None + ) -> Order: + """ + Function to create a market sell order. + + Args: + target_symbol (str): The symbol of the asset to sell + amount (float, optional): The amount of the asset to sell + percentage_of_position (float, optional): The percentage of the + position to sell. + market (str, optional): the portfolio corresponding to the market + to sell the asset + portfolio_id (str, optional): The ID of the portfolio to sell + the asset from. + metadata (dict, optional): Additional metadata for the order + + Returns: + Order: The order created + """ + + if amount is None and percentage_of_position is None: + raise OperationalException( + "Either amount or percentage_of_position must be specified " + "to create a market sell order." + ) + + return self.create_market_order( + target_symbol=target_symbol, + order_side=OrderSide.SELL, + amount=amount, + percentage_of_position=percentage_of_position, + market=market, + metadata=metadata + ) + def create_limit_sell_order( self, target_symbol, diff --git a/investing_algorithm_framework/app/strategy.py b/investing_algorithm_framework/app/strategy.py index 70cc325f..3979c46f 100644 --- a/investing_algorithm_framework/app/strategy.py +++ b/investing_algorithm_framework/app/strategy.py @@ -989,6 +989,69 @@ def create_limit_order( metadata=metadata ) + def create_market_order( + self, + target_symbol, + order_side, + amount=None, + amount_trading_symbol=None, + percentage=None, + percentage_of_portfolio=None, + percentage_of_position=None, + precision=None, + market=None, + execute=True, + validate=True, + sync=True, + metadata=None + ) -> Order: + """ + Function to create a market order. Market orders execute at + the best available price. In backtesting, this means the + open price of the next candle (+ slippage). + + Args: + target_symbol: The symbol of the asset to trade + order_side: The side of the order (BUY or SELL) + amount (optional): The amount of the asset to trade + amount_trading_symbol (optional): The amount of the trading + symbol to trade + percentage (optional): The percentage of the portfolio to + allocate to the order + percentage_of_portfolio (optional): The percentage of + the portfolio to allocate to the order + percentage_of_position (optional): The percentage of + the position to allocate to the order. + (Only supported for SELL orders) + precision (optional): The precision of the amount + market (optional): The market to trade the asset + execute (optional): Default True. If set to True, the order + will be executed + validate (optional): Default True. If set to True, the order + will be validated + sync (optional): Default True. If set to True, the created + order will be synced with the portfolio of the context + metadata (optional): Additional metadata for the order + + Returns: + Order: Instance of the order created + """ + return self.context.create_market_order( + target_symbol=target_symbol, + order_side=order_side, + amount=amount, + amount_trading_symbol=amount_trading_symbol, + percentage=percentage, + percentage_of_portfolio=percentage_of_portfolio, + percentage_of_position=percentage_of_position, + precision=precision, + market=market, + execute=execute, + validate=validate, + sync=sync, + metadata=metadata + ) + def close_position( self, symbol, market=None, identifier=None, precision=None ) -> Order: diff --git a/investing_algorithm_framework/domain/models/order/order.py b/investing_algorithm_framework/domain/models/order/order.py index b08fdb72..39d7b3a2 100644 --- a/investing_algorithm_framework/domain/models/order/order.py +++ b/investing_algorithm_framework/domain/models/order/order.py @@ -377,12 +377,28 @@ def __repr__(self): updated_at=self.get_updated_at(), ) + @property + def estimated_price(self): + """Get the estimated price stored in metadata (used for market + orders to track the price estimate at creation time).""" + return self.metadata.get("estimated_price") + + @estimated_price.setter + def estimated_price(self, value): + self.metadata["estimated_price"] = value + def get_size(self): """ - Get the size of the order + Get the size of the order. For market orders with an estimated + price, uses the estimated price for size calculation. Returns: float: The size of the order """ - return self.get_amount() * self.get_price() \ - if self.get_price() is not None else 0 + price = self.get_price() + + if price is None or price == 0: + # Fall back to estimated_price for market orders + price = self.estimated_price + + return self.get_amount() * price if price is not None else 0 diff --git a/investing_algorithm_framework/domain/models/order/order_type.py b/investing_algorithm_framework/domain/models/order/order_type.py index bfe5ab79..c0a38df9 100644 --- a/investing_algorithm_framework/domain/models/order/order_type.py +++ b/investing_algorithm_framework/domain/models/order/order_type.py @@ -3,6 +3,7 @@ class OrderType(Enum): LIMIT = 'LIMIT' + MARKET = 'MARKET' @staticmethod def from_string(value: str): diff --git a/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py b/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py index 42c8b35a..fd70155e 100644 --- a/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py +++ b/investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py @@ -65,6 +65,29 @@ def execute_order(self, portfolio, order, market_credential) -> Order: external_order = exchange.createLimitSellOrder( symbol, amount, price, ) + elif OrderType.MARKET.equals(order_type): + if OrderSide.BUY.equals(order_side): + + if not hasattr(exchange, "createMarketBuyOrder"): + raise OperationalException( + f"Exchange {market} does not support " + f"functionality createMarketBuyOrder" + ) + + external_order = exchange.createMarketBuyOrder( + symbol, amount, + ) + else: + + if not hasattr(exchange, "createMarketSellOrder"): + raise OperationalException( + f"Exchange {market} does not support " + f"functionality createMarketSellOrder" + ) + + external_order = exchange.createMarketSellOrder( + symbol, amount, + ) else: raise OperationalException( f"Order type {order_type} not supported " 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 0dd73090..a219de87 100644 --- a/investing_algorithm_framework/services/order_service/order_backtest_service.py +++ b/investing_algorithm_framework/services/order_service/order_backtest_service.py @@ -3,7 +3,7 @@ import polars as pl from investing_algorithm_framework.domain import INDEX_DATETIME, \ - OrderStatus, OrderSide, Order, DataType + OrderStatus, OrderSide, Order, DataType, OrderType from .order_service import OrderService logger = logging.getLogger("investing_algorithm_framework") @@ -155,6 +155,10 @@ def has_executed(self, order, ohlcv_data_frame: pl.DataFrame): pl.col('Datetime') >= created_at ) + # Market orders always execute on the next available candle + if OrderType.MARKET.equals(order.get_order_type()): + return not ohlcv_data_after_order.is_empty() + # Check if the order execution conditions are met if OrderSide.BUY.equals(order_side): # Check if the low price drops below or equals the order price diff --git a/investing_algorithm_framework/services/order_service/order_service.py b/investing_algorithm_framework/services/order_service/order_service.py index d0e37762..7d852429 100644 --- a/investing_algorithm_framework/services/order_service/order_service.py +++ b/investing_algorithm_framework/services/order_service/order_service.py @@ -304,6 +304,8 @@ def validate_order(self, order_data, portfolio): if OrderType.LIMIT.equals(order_data["order_type"]): self.validate_limit_order(order_data, portfolio) + elif OrderType.MARKET.equals(order_data["order_type"]): + self.validate_market_order(order_data, portfolio) else: raise OperationalException( f"Order type {order_data['order_type']} is not supported" @@ -402,6 +404,64 @@ def validate_limit_order(self, order_data, portfolio): f"{portfolio.trading_symbol} of the portfolio" ) + def validate_market_order(self, order_data, portfolio): + """ + Validate a market order. For sell orders, validates position + size. For buy orders, validates using the estimated price + (stored in the order price field at creation time) against + the portfolio's unallocated balance. + """ + + if OrderSide.SELL.equals(order_data["order_side"]): + amount = order_data["amount"] + position = self.position_service\ + .find( + { + "portfolio": portfolio.id, + "symbol": order_data["target_symbol"] + } + ) + + if amount <= 0: + raise OperationalException( + f"Order amount: {amount} {position.symbol}, is " + f"less or equal to 0" + ) + + if amount > position.get_amount(): + raise OperationalException( + f"Order amount: {amount} {position.symbol}, is " + f"larger then position size: {position.get_amount()} " + f"{position.symbol} of the portfolio" + ) + else: + # Use the estimated price for validation + estimated_price = order_data.get("price", 0) + total_price = order_data["amount"] * estimated_price + unallocated_position = self.position_service\ + .find( + { + "portfolio": portfolio.id, + "symbol": portfolio.trading_symbol + } + ) + unallocated_amount = unallocated_position.get_amount() + + if unallocated_amount is None: + raise OperationalException( + "Unallocated amount of the portfolio is None. " + "Can't validate market order. Please check if " + "the portfolio configuration is correct." + ) + + if unallocated_amount < total_price: + raise OperationalException( + f"Order total (estimated): {total_price} " + f"{portfolio.trading_symbol}, is " + f"larger then unallocated size: {portfolio.unallocated} " + f"{portfolio.trading_symbol} of the portfolio" + ) + def check_pending_orders(self, portfolio=None): """ Function to check if 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 a9f066ca..67e80373 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 @@ -3,7 +3,7 @@ import polars as pl from investing_algorithm_framework.domain import OrderSide, OrderStatus, \ - Trade, Order, TradeStatus, TradingCost + Trade, Order, TradeStatus, TradingCost, OrderType from .trade_order_evaluator import TradeOrderEvaluator @@ -117,6 +117,75 @@ def _check_has_executed(self, order, ohlcv_df): 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 + + 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 + + fee = tc.get_fee(fill_price * order.amount) + 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 + + # 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 + + if cost_adjustment != 0: + position = self.order_service.position_service.get( + order.position_id + ) + portfolio = \ + self.order_service.portfolio_repository.get( + position.portfolio_id + ) + + 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( + { + "symbol": portfolio.trading_symbol, + "portfolio": portfolio.id + } + ) + self.order_service.position_service.update( + trading_position.id, + { + "amount": + trading_position.get_amount() + - cost_adjustment + } + ) + + 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(): diff --git a/tests/services/test_market_order.py b/tests/services/test_market_order.py new file mode 100644 index 00000000..d7d0cc5d --- /dev/null +++ b/tests/services/test_market_order.py @@ -0,0 +1,578 @@ +""" +Tests for market order support (#430). + +Covers: +- OrderType.MARKET enum +- Market order creation via context API +- Market order validation +- Market order fill logic in BacktestTradeOrderEvaluator + (fills at next candle open + slippage, reconciles portfolio) +- Order.estimated_price property +""" +import os +from datetime import datetime, timezone + +import polars as pl + +from investing_algorithm_framework import ( + PortfolioConfiguration, + MarketCredential, + OrderStatus, + OrderType, + TradeStatus, + BacktestDateRange, + OrderSide, +) +from investing_algorithm_framework.domain import ( + INDEX_DATETIME, + Order, + TradingCost, +) +from investing_algorithm_framework.services import ( + BacktestTradeOrderEvaluator, + OrderBacktestService, +) +from tests.resources import TestBase + +# Path to OHLCV CSV used by these tests (BTC-EUR, 15m, Binance) +OHLCV_CSV = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "resources", "test_data", "ohlcv", + "OHLCV_BTC-EUR_BINANCE_15m_2023-12-14-21-45_2023-12-25-00-00.csv", +) + + +class TestOrderTypeEnum(TestBase): + """Test that OrderType.MARKET is recognized.""" + + market_credentials = [ + MarketCredential( + market="binance", + api_key="api_key", + secret_key="secret_key", + ) + ] + portfolio_configurations = [ + PortfolioConfiguration( + market="binance", + trading_symbol="EUR", + ) + ] + external_balances = {"EUR": 1000000} + + def test_market_order_type_exists(self): + self.assertEqual(OrderType.MARKET.value, "MARKET") + + def test_market_order_type_from_string(self): + self.assertEqual( + OrderType.from_string("MARKET"), OrderType.MARKET + ) + + def test_market_order_type_equals(self): + self.assertTrue(OrderType.MARKET.equals("MARKET")) + self.assertFalse(OrderType.MARKET.equals("LIMIT")) + + +class TestOrderEstimatedPrice(TestBase): + """Test Order.estimated_price property via metadata.""" + + market_credentials = [ + MarketCredential( + market="binance", + api_key="api_key", + secret_key="secret_key", + ) + ] + portfolio_configurations = [ + PortfolioConfiguration( + market="binance", + trading_symbol="EUR", + ) + ] + external_balances = {"EUR": 1000000} + + def test_estimated_price_getter_and_setter(self): + order = Order( + order_type=OrderType.MARKET.value, + order_side=OrderSide.BUY.value, + amount=1.0, + target_symbol="BTC", + trading_symbol="EUR", + ) + self.assertIsNone(order.estimated_price) + order.estimated_price = 100.0 + self.assertEqual(order.estimated_price, 100.0) + self.assertEqual(order.metadata["estimated_price"], 100.0) + + def test_get_size_with_estimated_price(self): + """When price is None, get_size should fall back to + estimated_price.""" + order = Order( + order_type=OrderType.MARKET.value, + order_side=OrderSide.BUY.value, + amount=2.0, + price=None, + target_symbol="BTC", + trading_symbol="EUR", + metadata={"estimated_price": 50.0}, + ) + self.assertEqual(order.get_size(), 100.0) + + def test_get_size_prefers_real_price(self): + """When price is set, get_size should use it, not + estimated_price.""" + order = Order( + order_type=OrderType.MARKET.value, + order_side=OrderSide.BUY.value, + amount=2.0, + price=60.0, + target_symbol="BTC", + trading_symbol="EUR", + metadata={"estimated_price": 50.0}, + ) + self.assertEqual(order.get_size(), 120.0) + + +class TestMarketOrderCreation(TestBase): + """Test market order creation via order service.""" + + market_credentials = [ + MarketCredential( + market="binance", + api_key="api_key", + secret_key="secret_key", + ) + ] + portfolio_configurations = [ + PortfolioConfiguration( + market="binance", + trading_symbol="EUR", + ) + ] + external_balances = {"EUR": 1000000} + + def setUp(self): + super().setUp() + self.app.container.order_service.override( + OrderBacktestService( + trade_service=self.app.container.trade_service(), + order_repository=self.app.container.order_repository(), + position_service=self.app.container.position_service(), + portfolio_repository=( + self.app.container.portfolio_repository() + ), + portfolio_configuration_service=( + self.app.container.portfolio_configuration_service() + ), + portfolio_snapshot_service=( + self.app.container.portfolio_snapshot_service() + ), + configuration_service=( + self.app.container.configuration_service() + ), + ) + ) + backtest_date_range = BacktestDateRange( + start_date=datetime(2023, 12, 14), + end_date=datetime(2023, 12, 25), + ) + self.app.initialize_backtest_config(backtest_date_range) + configuration_service = self.app.container.configuration_service() + configuration_service.add_value( + INDEX_DATETIME, + datetime(2023, 12, 14, 21, 0, 0, tzinfo=timezone.utc), + ) + + def test_create_market_buy_order(self): + """Market buy order is created with estimated_price in + metadata and status OPEN.""" + order_service = self.app.container.order_service() + order = order_service.create({ + "target_symbol": "BTC", + "trading_symbol": "EUR", + "amount": 0.1, + "order_side": OrderSide.BUY.value, + "price": 39000, # estimated price + "order_type": OrderType.MARKET.value, + "portfolio_id": 1, + "status": "CREATED", + "metadata": {"estimated_price": 39000}, + }) + self.assertEqual(OrderType.MARKET.value, order.order_type) + self.assertEqual(OrderStatus.OPEN.value, order.status) + self.assertEqual(39000, order.estimated_price) + + def test_create_market_sell_order(self): + """Market sell order is created after buying a position.""" + order_service = self.app.container.order_service() + + # First create a buy to have a position + buy_order = order_service.create({ + "target_symbol": "BTC", + "trading_symbol": "EUR", + "amount": 0.1, + "order_side": OrderSide.BUY.value, + "price": 39000, + "order_type": OrderType.LIMIT.value, + "portfolio_id": 1, + "status": "CREATED", + }) + # Fill the buy order + order_service.update(buy_order.id, { + "status": OrderStatus.CLOSED.value, + "filled": 0.1, + "remaining": 0, + }) + + # Now create market sell + sell_order = order_service.create({ + "target_symbol": "BTC", + "trading_symbol": "EUR", + "amount": 0.1, + "order_side": OrderSide.SELL.value, + "price": 39000, + "order_type": OrderType.MARKET.value, + "portfolio_id": 1, + "status": "CREATED", + "metadata": {"estimated_price": 39000}, + }) + self.assertEqual(OrderType.MARKET.value, sell_order.order_type) + self.assertEqual(OrderStatus.OPEN.value, sell_order.status) + + +class TestMarketOrderFill(TestBase): + """Test market order fill in BacktestTradeOrderEvaluator. + + Market orders should fill at the Open price of the first candle + after order.updated_at, with slippage applied. + """ + + market_credentials = [ + MarketCredential( + market="binance", + api_key="api_key", + secret_key="secret_key", + ) + ] + portfolio_configurations = [ + PortfolioConfiguration( + market="binance", + trading_symbol="EUR", + ) + ] + external_balances = {"EUR": 1000000} + + def setUp(self): + super().setUp() + self.app.container.order_service.override( + OrderBacktestService( + trade_service=self.app.container.trade_service(), + order_repository=self.app.container.order_repository(), + position_service=self.app.container.position_service(), + portfolio_repository=( + self.app.container.portfolio_repository() + ), + portfolio_configuration_service=( + self.app.container.portfolio_configuration_service() + ), + portfolio_snapshot_service=( + self.app.container.portfolio_snapshot_service() + ), + configuration_service=( + self.app.container.configuration_service() + ), + ) + ) + backtest_date_range = BacktestDateRange( + start_date=datetime(2023, 12, 14), + end_date=datetime(2023, 12, 25), + ) + self.app.initialize_backtest_config(backtest_date_range) + configuration_service = self.app.container.configuration_service() + configuration_service.add_value( + INDEX_DATETIME, + datetime(2023, 12, 14, 21, 0, 0, tzinfo=timezone.utc), + ) + + self.ohlcv_df = pl.read_csv(OHLCV_CSV) + self.ohlcv_df = self.ohlcv_df.with_columns( + pl.col("Datetime") + .str.to_datetime() + .dt.replace_time_zone("UTC") + ) + + def _create_evaluator(self, trading_costs=None): + return BacktestTradeOrderEvaluator( + trade_service=self.app.container.trade_service(), + order_service=self.app.container.order_service(), + trade_stop_loss_service=( + self.app.container.trade_stop_loss_service() + ), + trade_take_profit_service=( + self.app.container.trade_take_profit_service() + ), + configuration_service=( + self.app.container.configuration_service() + ), + trading_costs=trading_costs, + ) + + def _create_pending_market_buy_order( + self, target_symbol, estimated_price, amount + ): + order_service = self.app.container.order_service() + return order_service.create({ + "target_symbol": target_symbol, + "trading_symbol": "EUR", + "amount": amount, + "order_side": OrderSide.BUY.value, + "price": estimated_price, + "order_type": OrderType.MARKET.value, + "portfolio_id": 1, + "status": "CREATED", + "metadata": {"estimated_price": estimated_price}, + }) + + def test_market_buy_order_fills_at_open_price(self): + """Market buy order fills at the Open of the first available + candle.""" + order = self._create_pending_market_buy_order("BTC", 39000, 0.1) + order_service = self.app.container.order_service() + trade_service = self.app.container.trade_service() + + open_orders = order_service.get_all( + {"status": OrderStatus.OPEN.value} + ) + self.assertEqual(1, len(open_orders)) + + evaluator = self._create_evaluator() + evaluator.evaluate( + open_trades=trade_service.get_all( + {"status": TradeStatus.OPEN.value} + ), + open_orders=open_orders, + ohlcv_data={"BTC/EUR": self.ohlcv_df}, + ) + + # Order should be filled + filled_order = order_service.get(order.id) + self.assertEqual(OrderStatus.CLOSED.value, filled_order.status) + self.assertEqual(0.1, filled_order.filled) + self.assertEqual(0, filled_order.remaining) + + # Fill price should be the Open of the first candle + first_candle_open = self.ohlcv_df.head(1)["Open"][0] + self.assertEqual(filled_order.price, first_candle_open) + + def test_market_buy_order_fills_with_slippage(self): + """Market buy order fill price includes slippage from + TradingCost.""" + slippage_pct = 0.1 # 0.1% + tc = TradingCost( + symbol="BTC", + slippage_percentage=slippage_pct, + ) + + order = self._create_pending_market_buy_order("BTC", 39000, 0.1) + order_service = self.app.container.order_service() + trade_service = self.app.container.trade_service() + + evaluator = self._create_evaluator(trading_costs=[tc]) + evaluator.evaluate( + open_trades=trade_service.get_all( + {"status": TradeStatus.OPEN.value} + ), + open_orders=order_service.get_all( + {"status": OrderStatus.OPEN.value} + ), + ohlcv_data={"BTC/EUR": self.ohlcv_df}, + ) + + filled_order = order_service.get(order.id) + first_candle_open = self.ohlcv_df.head(1)["Open"][0] + expected_fill = first_candle_open * (1 + slippage_pct / 100) + self.assertAlmostEqual( + filled_order.price, expected_fill, places=2 + ) + # Slippage should be fill_price - base_price + self.assertAlmostEqual( + filled_order.slippage, + expected_fill - first_candle_open, + places=2, + ) + + def test_market_order_reconciles_portfolio(self): + """When the fill price differs from the estimated price, + the portfolio unallocated balance is adjusted.""" + order_service = self.app.container.order_service() + trade_service = self.app.container.trade_service() + portfolio_repo = self.app.container.portfolio_repository() + + # Get initial unallocated balance + portfolio_before = portfolio_repo.get(1) + + # Create order with estimated_price different from what + # the open will be + estimated_price = 35000 # intentionally different from actual + order = self._create_pending_market_buy_order( + "BTC", estimated_price, 0.1 + ) + + # Record unallocated after order creation (cash reserved + # at estimated_price) + portfolio_after_create = portfolio_repo.get(1) + reserved = estimated_price * 0.1 + + evaluator = self._create_evaluator() + evaluator.evaluate( + open_trades=trade_service.get_all( + {"status": TradeStatus.OPEN.value} + ), + open_orders=order_service.get_all( + {"status": OrderStatus.OPEN.value} + ), + ohlcv_data={"BTC/EUR": self.ohlcv_df}, + ) + + # After fill, the portfolio should be reconciled + portfolio_after_fill = portfolio_repo.get(1) + filled_order = order_service.get(order.id) + actual_fill_price = filled_order.price + + # The reconciliation adjusts by (fill_price - estimated) * amount + # So final unallocated = after_create - delta + delta = (actual_fill_price - estimated_price) * 0.1 + expected_unallocated = \ + portfolio_after_create.get_unallocated() - delta + + self.assertAlmostEqual( + portfolio_after_fill.get_unallocated(), + expected_unallocated, + places=2, + ) + + def test_market_order_creates_trade(self): + """A trade is created for the market order.""" + order = self._create_pending_market_buy_order("BTC", 39000, 0.1) + trade_service = self.app.container.trade_service() + order_service = self.app.container.order_service() + + evaluator = self._create_evaluator() + evaluator.evaluate( + open_trades=trade_service.get_all( + {"status": TradeStatus.OPEN.value} + ), + open_orders=order_service.get_all( + {"status": OrderStatus.OPEN.value} + ), + ohlcv_data={"BTC/EUR": self.ohlcv_df}, + ) + + # Trade should exist and be OPEN + trade = trade_service.find({"order_id": order.id}) + self.assertIsNotNone(trade) + self.assertEqual(TradeStatus.OPEN.value, trade.status) + + +class TestMarketOrderValidation(TestBase): + """Test market order validation.""" + + market_credentials = [ + MarketCredential( + market="binance", + api_key="api_key", + secret_key="secret_key", + ) + ] + portfolio_configurations = [ + PortfolioConfiguration( + market="binance", + trading_symbol="EUR", + ) + ] + external_balances = {"EUR": 1000} + + def setUp(self): + super().setUp() + self.app.container.order_service.override( + OrderBacktestService( + trade_service=self.app.container.trade_service(), + order_repository=self.app.container.order_repository(), + position_service=self.app.container.position_service(), + portfolio_repository=( + self.app.container.portfolio_repository() + ), + portfolio_configuration_service=( + self.app.container.portfolio_configuration_service() + ), + portfolio_snapshot_service=( + self.app.container.portfolio_snapshot_service() + ), + configuration_service=( + self.app.container.configuration_service() + ), + ) + ) + backtest_date_range = BacktestDateRange( + start_date=datetime(2023, 12, 14), + end_date=datetime(2023, 12, 25), + ) + self.app.initialize_backtest_config(backtest_date_range) + configuration_service = self.app.container.configuration_service() + configuration_service.add_value( + INDEX_DATETIME, + datetime(2023, 12, 14, 21, 0, 0, tzinfo=timezone.utc), + ) + + def test_market_buy_order_exceeding_balance_raises(self): + """Market buy order that exceeds portfolio balance should + raise an error.""" + order_service = self.app.container.order_service() + + with self.assertRaises(Exception): + order_service.create({ + "target_symbol": "BTC", + "trading_symbol": "EUR", + "amount": 100, # 100 BTC * 39000 >> 1000 EUR balance + "order_side": OrderSide.BUY.value, + "price": 39000, + "order_type": OrderType.MARKET.value, + "portfolio_id": 1, + "status": "CREATED", + "metadata": {"estimated_price": 39000}, + }) + + def test_market_sell_order_exceeding_position_raises(self): + """Market sell order that exceeds position should raise.""" + order_service = self.app.container.order_service() + + # Buy a small amount first + buy = order_service.create({ + "target_symbol": "BTC", + "trading_symbol": "EUR", + "amount": 0.01, + "order_side": OrderSide.BUY.value, + "price": 100, + "order_type": OrderType.LIMIT.value, + "portfolio_id": 1, + "status": "CREATED", + }) + order_service.update(buy.id, { + "status": OrderStatus.CLOSED.value, + "filled": 0.01, + "remaining": 0, + }) + + # Try to sell more than we have + with self.assertRaises(Exception): + order_service.create({ + "target_symbol": "BTC", + "trading_symbol": "EUR", + "amount": 1.0, # way more than 0.01 + "order_side": OrderSide.SELL.value, + "price": 100, + "order_type": OrderType.MARKET.value, + "portfolio_id": 1, + "status": "CREATED", + "metadata": {"estimated_price": 100}, + })