Skip to content

Commit e8a09a6

Browse files
committed
feat: add market order support (estimated price + reconciliation)
- Add MARKET to OrderType enum - Add estimated_price property to Order model (stored in metadata) - Add create_market_order(), create_market_buy_order(), create_market_sell_order() to Context and TradingStrategy - Add validate_market_order() to OrderService - Market orders fill at Open price of next candle in backtesting - Apply slippage via TradingCost when configured - Reconcile portfolio delta between estimated and actual fill price - Add CCXT market order execution (createMarketBuyOrder/createMarketSellOrder) - Add 14 tests covering enum, model, creation, fill, slippage, reconciliation, validation - Update orders.md and strategies.md documentation Closes #430
1 parent 232eadb commit e8a09a6

11 files changed

Lines changed: 1218 additions & 117 deletions

File tree

docusaurus/docs/Getting Started/orders.md

Lines changed: 158 additions & 112 deletions
Large diffs are not rendered by default.

docusaurus/docs/Getting Started/strategies.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,28 @@ order = self.create_limit_order(
357357
sync=True
358358
)
359359

360+
# Create a market order (fills at best available price)
361+
order = self.create_market_order(
362+
target_symbol="BTC",
363+
order_side=OrderSide.BUY,
364+
amount=0.01, # Amount in target symbol
365+
# OR
366+
amount_trading_symbol=500, # Amount in trading symbol (EUR)
367+
# OR
368+
percentage_of_portfolio=10, # 10% of portfolio
369+
)
370+
371+
# Convenience methods for market orders
372+
self.create_market_buy_order(
373+
target_symbol="BTC",
374+
percentage_of_portfolio=10, # Buy 10% of portfolio
375+
)
376+
377+
self.create_market_sell_order(
378+
target_symbol="BTC",
379+
percentage_of_position=50, # Sell 50% of position
380+
)
381+
360382
# Close a position entirely
361383
self.close_position(symbol="BTC")
362384
```

investing_algorithm_framework/app/context.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,225 @@ def create_limit_order(
270270
order_data, execute=execute, validate=validate, sync=sync
271271
)
272272

273+
def create_market_order(
274+
self,
275+
target_symbol,
276+
order_side,
277+
amount=None,
278+
amount_trading_symbol=None,
279+
percentage=None,
280+
percentage_of_portfolio=None,
281+
percentage_of_position=None,
282+
precision=None,
283+
market=None,
284+
execute=True,
285+
validate=True,
286+
sync=True,
287+
metadata=None
288+
) -> Order:
289+
"""
290+
Function to create a market order. Market orders execute at
291+
the best available price. In backtesting, this means the
292+
open price of the next candle (+ slippage).
293+
294+
An estimated price (current latest price) is used for amount
295+
calculation and cash reservation. The actual fill price is
296+
determined at fill time and the portfolio is reconciled.
297+
298+
Args:
299+
target_symbol: The symbol of the asset to trade
300+
order_side: The side of the order (BUY or SELL)
301+
amount (optional): The amount of the asset to trade
302+
amount_trading_symbol (optional): The amount of the
303+
trading symbol to trade
304+
percentage (optional): The percentage of the portfolio
305+
to allocate to the order
306+
percentage_of_portfolio (optional): The percentage
307+
of the portfolio to allocate to the order
308+
percentage_of_position (optional): The percentage
309+
of the position to allocate to the
310+
order. (Only supported for SELL orders)
311+
precision (optional): The precision of the amount
312+
market (optional): The market to trade the asset
313+
execute (optional): Default True. If set to True,
314+
the order will be executed
315+
validate (optional): Default True. If set to
316+
True, the order will be validated
317+
sync (optional): Default True. If set to True,
318+
the created order will be synced with the
319+
portfolio of the algorithm
320+
metadata (optional): Additional metadata for the order
321+
322+
Returns:
323+
Order: Instance of the order created
324+
"""
325+
portfolio = self.portfolio_service.find({"market": market})
326+
full_symbol = (f"{target_symbol}/{portfolio.trading_symbol}")
327+
estimated_price = self.get_latest_price(full_symbol, market=market)
328+
329+
if estimated_price is None:
330+
raise OperationalException(
331+
f"Cannot create market order for {target_symbol}: "
332+
f"no price data available to estimate order size."
333+
)
334+
335+
if percentage_of_portfolio is not None:
336+
if not OrderSide.BUY.equals(order_side):
337+
raise OperationalException(
338+
"Percentage of portfolio is only supported for BUY orders."
339+
)
340+
341+
net_size = portfolio.get_net_size()
342+
size = net_size * (percentage_of_portfolio / 100)
343+
amount = size / estimated_price
344+
345+
elif percentage_of_position is not None:
346+
347+
if not OrderSide.SELL.equals(order_side):
348+
raise OperationalException(
349+
"Percentage of position is only supported for SELL orders."
350+
)
351+
352+
position = self.position_service.find(
353+
{
354+
"symbol": target_symbol,
355+
"portfolio": portfolio.id
356+
}
357+
)
358+
amount = position.get_amount() * (percentage_of_position / 100)
359+
360+
elif percentage is not None:
361+
net_size = portfolio.get_net_size()
362+
size = net_size * (percentage / 100)
363+
amount = size / estimated_price
364+
365+
if precision is not None:
366+
amount = RoundingService.round_down(amount, precision)
367+
368+
if amount_trading_symbol is not None:
369+
amount = amount_trading_symbol / estimated_price
370+
371+
if amount is None:
372+
raise OperationalException(
373+
"The amount parameter is required to create a market order. "
374+
"Either the amount, amount_trading_symbol, percentage, "
375+
"percentage_of_portfolio or percentage_of_position "
376+
"parameter must be specified."
377+
)
378+
379+
logger.info(
380+
f"Creating market order: {target_symbol} "
381+
f"{order_side} {amount} @ estimated {estimated_price}"
382+
)
383+
384+
order_metadata = metadata if metadata is not None else {}
385+
order_metadata["estimated_price"] = estimated_price
386+
387+
order_data = {
388+
"target_symbol": target_symbol,
389+
"price": estimated_price,
390+
"amount": amount,
391+
"order_type": OrderType.MARKET.value,
392+
"order_side": OrderSide.from_value(order_side).value,
393+
"portfolio_id": portfolio.id,
394+
"status": OrderStatus.CREATED.value,
395+
"trading_symbol": portfolio.trading_symbol,
396+
"metadata": order_metadata,
397+
}
398+
399+
if BACKTESTING_FLAG in self.configuration_service.config \
400+
and self.configuration_service.config[BACKTESTING_FLAG]:
401+
order_data["created_at"] = \
402+
self.configuration_service.config[INDEX_DATETIME]
403+
404+
return self.order_service.create(
405+
order_data, execute=execute, validate=validate, sync=sync
406+
)
407+
408+
def create_market_buy_order(
409+
self,
410+
target_symbol,
411+
amount=None,
412+
percentage_of_portfolio=None,
413+
market=None,
414+
portfolio_id=None,
415+
metadata=None
416+
) -> Order:
417+
"""
418+
Function to create a market buy order.
419+
420+
Args:
421+
target_symbol (str): The symbol of the asset to buy
422+
amount (float, optional): The amount of the asset to buy
423+
percentage_of_portfolio (float, optional): The percentage of the
424+
portfolio to buy.
425+
market (str, optional): the portfolio corresponding to the market
426+
to buy the asset
427+
portfolio_id (str, optional): The ID of the portfolio to buy
428+
the asset from.
429+
metadata (dict, optional): Additional metadata for the order
430+
431+
Returns:
432+
Order: The order created
433+
"""
434+
435+
if amount is None and percentage_of_portfolio is None:
436+
raise OperationalException(
437+
"Either amount or percentage_of_portfolio must be specified "
438+
"to create a market buy order."
439+
)
440+
441+
return self.create_market_order(
442+
target_symbol=target_symbol,
443+
order_side=OrderSide.BUY,
444+
amount=amount,
445+
percentage_of_portfolio=percentage_of_portfolio,
446+
market=market,
447+
metadata=metadata
448+
)
449+
450+
def create_market_sell_order(
451+
self,
452+
target_symbol,
453+
amount=None,
454+
percentage_of_position=None,
455+
market=None,
456+
portfolio_id=None,
457+
metadata=None
458+
) -> Order:
459+
"""
460+
Function to create a market sell order.
461+
462+
Args:
463+
target_symbol (str): The symbol of the asset to sell
464+
amount (float, optional): The amount of the asset to sell
465+
percentage_of_position (float, optional): The percentage of the
466+
position to sell.
467+
market (str, optional): the portfolio corresponding to the market
468+
to sell the asset
469+
portfolio_id (str, optional): The ID of the portfolio to sell
470+
the asset from.
471+
metadata (dict, optional): Additional metadata for the order
472+
473+
Returns:
474+
Order: The order created
475+
"""
476+
477+
if amount is None and percentage_of_position is None:
478+
raise OperationalException(
479+
"Either amount or percentage_of_position must be specified "
480+
"to create a market sell order."
481+
)
482+
483+
return self.create_market_order(
484+
target_symbol=target_symbol,
485+
order_side=OrderSide.SELL,
486+
amount=amount,
487+
percentage_of_position=percentage_of_position,
488+
market=market,
489+
metadata=metadata
490+
)
491+
273492
def create_limit_sell_order(
274493
self,
275494
target_symbol,

investing_algorithm_framework/app/strategy.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,69 @@ def create_limit_order(
989989
metadata=metadata
990990
)
991991

992+
def create_market_order(
993+
self,
994+
target_symbol,
995+
order_side,
996+
amount=None,
997+
amount_trading_symbol=None,
998+
percentage=None,
999+
percentage_of_portfolio=None,
1000+
percentage_of_position=None,
1001+
precision=None,
1002+
market=None,
1003+
execute=True,
1004+
validate=True,
1005+
sync=True,
1006+
metadata=None
1007+
) -> Order:
1008+
"""
1009+
Function to create a market order. Market orders execute at
1010+
the best available price. In backtesting, this means the
1011+
open price of the next candle (+ slippage).
1012+
1013+
Args:
1014+
target_symbol: The symbol of the asset to trade
1015+
order_side: The side of the order (BUY or SELL)
1016+
amount (optional): The amount of the asset to trade
1017+
amount_trading_symbol (optional): The amount of the trading
1018+
symbol to trade
1019+
percentage (optional): The percentage of the portfolio to
1020+
allocate to the order
1021+
percentage_of_portfolio (optional): The percentage of
1022+
the portfolio to allocate to the order
1023+
percentage_of_position (optional): The percentage of
1024+
the position to allocate to the order.
1025+
(Only supported for SELL orders)
1026+
precision (optional): The precision of the amount
1027+
market (optional): The market to trade the asset
1028+
execute (optional): Default True. If set to True, the order
1029+
will be executed
1030+
validate (optional): Default True. If set to True, the order
1031+
will be validated
1032+
sync (optional): Default True. If set to True, the created
1033+
order will be synced with the portfolio of the context
1034+
metadata (optional): Additional metadata for the order
1035+
1036+
Returns:
1037+
Order: Instance of the order created
1038+
"""
1039+
return self.context.create_market_order(
1040+
target_symbol=target_symbol,
1041+
order_side=order_side,
1042+
amount=amount,
1043+
amount_trading_symbol=amount_trading_symbol,
1044+
percentage=percentage,
1045+
percentage_of_portfolio=percentage_of_portfolio,
1046+
percentage_of_position=percentage_of_position,
1047+
precision=precision,
1048+
market=market,
1049+
execute=execute,
1050+
validate=validate,
1051+
sync=sync,
1052+
metadata=metadata
1053+
)
1054+
9921055
def close_position(
9931056
self, symbol, market=None, identifier=None, precision=None
9941057
) -> Order:

investing_algorithm_framework/domain/models/order/order.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,28 @@ def __repr__(self):
377377
updated_at=self.get_updated_at(),
378378
)
379379

380+
@property
381+
def estimated_price(self):
382+
"""Get the estimated price stored in metadata (used for market
383+
orders to track the price estimate at creation time)."""
384+
return self.metadata.get("estimated_price")
385+
386+
@estimated_price.setter
387+
def estimated_price(self, value):
388+
self.metadata["estimated_price"] = value
389+
380390
def get_size(self):
381391
"""
382-
Get the size of the order
392+
Get the size of the order. For market orders with an estimated
393+
price, uses the estimated price for size calculation.
383394
384395
Returns:
385396
float: The size of the order
386397
"""
387-
return self.get_amount() * self.get_price() \
388-
if self.get_price() is not None else 0
398+
price = self.get_price()
399+
400+
if price is None or price == 0:
401+
# Fall back to estimated_price for market orders
402+
price = self.estimated_price
403+
404+
return self.get_amount() * price if price is not None else 0

investing_algorithm_framework/domain/models/order/order_type.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
class OrderType(Enum):
55
LIMIT = 'LIMIT'
6+
MARKET = 'MARKET'
67

78
@staticmethod
89
def from_string(value: str):

investing_algorithm_framework/infrastructure/order_executors/ccxt_order_executor.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ def execute_order(self, portfolio, order, market_credential) -> Order:
6565
external_order = exchange.createLimitSellOrder(
6666
symbol, amount, price,
6767
)
68+
elif OrderType.MARKET.equals(order_type):
69+
if OrderSide.BUY.equals(order_side):
70+
71+
if not hasattr(exchange, "createMarketBuyOrder"):
72+
raise OperationalException(
73+
f"Exchange {market} does not support "
74+
f"functionality createMarketBuyOrder"
75+
)
76+
77+
external_order = exchange.createMarketBuyOrder(
78+
symbol, amount,
79+
)
80+
else:
81+
82+
if not hasattr(exchange, "createMarketSellOrder"):
83+
raise OperationalException(
84+
f"Exchange {market} does not support "
85+
f"functionality createMarketSellOrder"
86+
)
87+
88+
external_order = exchange.createMarketSellOrder(
89+
symbol, amount,
90+
)
6891
else:
6992
raise OperationalException(
7093
f"Order type {order_type} not supported "

0 commit comments

Comments
 (0)