Skip to content

Commit 7950c99

Browse files
authored
Merge pull request #432 from coding-kitties/feature/market-order-support
feat: add market order support (estimated price + reconciliation)
2 parents ea91b76 + e8a09a6 commit 7950c99

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
@@ -333,6 +333,225 @@ def create_limit_order(
333333
order_data, execute=execute, validate=validate, sync=sync
334334
)
335335

336+
def create_market_order(
337+
self,
338+
target_symbol,
339+
order_side,
340+
amount=None,
341+
amount_trading_symbol=None,
342+
percentage=None,
343+
percentage_of_portfolio=None,
344+
percentage_of_position=None,
345+
precision=None,
346+
market=None,
347+
execute=True,
348+
validate=True,
349+
sync=True,
350+
metadata=None
351+
) -> Order:
352+
"""
353+
Function to create a market order. Market orders execute at
354+
the best available price. In backtesting, this means the
355+
open price of the next candle (+ slippage).
356+
357+
An estimated price (current latest price) is used for amount
358+
calculation and cash reservation. The actual fill price is
359+
determined at fill time and the portfolio is reconciled.
360+
361+
Args:
362+
target_symbol: The symbol of the asset to trade
363+
order_side: The side of the order (BUY or SELL)
364+
amount (optional): The amount of the asset to trade
365+
amount_trading_symbol (optional): The amount of the
366+
trading symbol to trade
367+
percentage (optional): The percentage of the portfolio
368+
to allocate to the order
369+
percentage_of_portfolio (optional): The percentage
370+
of the portfolio to allocate to the order
371+
percentage_of_position (optional): The percentage
372+
of the position to allocate to the
373+
order. (Only supported for SELL orders)
374+
precision (optional): The precision of the amount
375+
market (optional): The market to trade the asset
376+
execute (optional): Default True. If set to True,
377+
the order will be executed
378+
validate (optional): Default True. If set to
379+
True, the order will be validated
380+
sync (optional): Default True. If set to True,
381+
the created order will be synced with the
382+
portfolio of the algorithm
383+
metadata (optional): Additional metadata for the order
384+
385+
Returns:
386+
Order: Instance of the order created
387+
"""
388+
portfolio = self.portfolio_service.find({"market": market})
389+
full_symbol = (f"{target_symbol}/{portfolio.trading_symbol}")
390+
estimated_price = self.get_latest_price(full_symbol, market=market)
391+
392+
if estimated_price is None:
393+
raise OperationalException(
394+
f"Cannot create market order for {target_symbol}: "
395+
f"no price data available to estimate order size."
396+
)
397+
398+
if percentage_of_portfolio is not None:
399+
if not OrderSide.BUY.equals(order_side):
400+
raise OperationalException(
401+
"Percentage of portfolio is only supported for BUY orders."
402+
)
403+
404+
net_size = portfolio.get_net_size()
405+
size = net_size * (percentage_of_portfolio / 100)
406+
amount = size / estimated_price
407+
408+
elif percentage_of_position is not None:
409+
410+
if not OrderSide.SELL.equals(order_side):
411+
raise OperationalException(
412+
"Percentage of position is only supported for SELL orders."
413+
)
414+
415+
position = self.position_service.find(
416+
{
417+
"symbol": target_symbol,
418+
"portfolio": portfolio.id
419+
}
420+
)
421+
amount = position.get_amount() * (percentage_of_position / 100)
422+
423+
elif percentage is not None:
424+
net_size = portfolio.get_net_size()
425+
size = net_size * (percentage / 100)
426+
amount = size / estimated_price
427+
428+
if precision is not None:
429+
amount = RoundingService.round_down(amount, precision)
430+
431+
if amount_trading_symbol is not None:
432+
amount = amount_trading_symbol / estimated_price
433+
434+
if amount is None:
435+
raise OperationalException(
436+
"The amount parameter is required to create a market order. "
437+
"Either the amount, amount_trading_symbol, percentage, "
438+
"percentage_of_portfolio or percentage_of_position "
439+
"parameter must be specified."
440+
)
441+
442+
logger.info(
443+
f"Creating market order: {target_symbol} "
444+
f"{order_side} {amount} @ estimated {estimated_price}"
445+
)
446+
447+
order_metadata = metadata if metadata is not None else {}
448+
order_metadata["estimated_price"] = estimated_price
449+
450+
order_data = {
451+
"target_symbol": target_symbol,
452+
"price": estimated_price,
453+
"amount": amount,
454+
"order_type": OrderType.MARKET.value,
455+
"order_side": OrderSide.from_value(order_side).value,
456+
"portfolio_id": portfolio.id,
457+
"status": OrderStatus.CREATED.value,
458+
"trading_symbol": portfolio.trading_symbol,
459+
"metadata": order_metadata,
460+
}
461+
462+
if BACKTESTING_FLAG in self.configuration_service.config \
463+
and self.configuration_service.config[BACKTESTING_FLAG]:
464+
order_data["created_at"] = \
465+
self.configuration_service.config[INDEX_DATETIME]
466+
467+
return self.order_service.create(
468+
order_data, execute=execute, validate=validate, sync=sync
469+
)
470+
471+
def create_market_buy_order(
472+
self,
473+
target_symbol,
474+
amount=None,
475+
percentage_of_portfolio=None,
476+
market=None,
477+
portfolio_id=None,
478+
metadata=None
479+
) -> Order:
480+
"""
481+
Function to create a market buy order.
482+
483+
Args:
484+
target_symbol (str): The symbol of the asset to buy
485+
amount (float, optional): The amount of the asset to buy
486+
percentage_of_portfolio (float, optional): The percentage of the
487+
portfolio to buy.
488+
market (str, optional): the portfolio corresponding to the market
489+
to buy the asset
490+
portfolio_id (str, optional): The ID of the portfolio to buy
491+
the asset from.
492+
metadata (dict, optional): Additional metadata for the order
493+
494+
Returns:
495+
Order: The order created
496+
"""
497+
498+
if amount is None and percentage_of_portfolio is None:
499+
raise OperationalException(
500+
"Either amount or percentage_of_portfolio must be specified "
501+
"to create a market buy order."
502+
)
503+
504+
return self.create_market_order(
505+
target_symbol=target_symbol,
506+
order_side=OrderSide.BUY,
507+
amount=amount,
508+
percentage_of_portfolio=percentage_of_portfolio,
509+
market=market,
510+
metadata=metadata
511+
)
512+
513+
def create_market_sell_order(
514+
self,
515+
target_symbol,
516+
amount=None,
517+
percentage_of_position=None,
518+
market=None,
519+
portfolio_id=None,
520+
metadata=None
521+
) -> Order:
522+
"""
523+
Function to create a market sell order.
524+
525+
Args:
526+
target_symbol (str): The symbol of the asset to sell
527+
amount (float, optional): The amount of the asset to sell
528+
percentage_of_position (float, optional): The percentage of the
529+
position to sell.
530+
market (str, optional): the portfolio corresponding to the market
531+
to sell the asset
532+
portfolio_id (str, optional): The ID of the portfolio to sell
533+
the asset from.
534+
metadata (dict, optional): Additional metadata for the order
535+
536+
Returns:
537+
Order: The order created
538+
"""
539+
540+
if amount is None and percentage_of_position is None:
541+
raise OperationalException(
542+
"Either amount or percentage_of_position must be specified "
543+
"to create a market sell order."
544+
)
545+
546+
return self.create_market_order(
547+
target_symbol=target_symbol,
548+
order_side=OrderSide.SELL,
549+
amount=amount,
550+
percentage_of_position=percentage_of_position,
551+
market=market,
552+
metadata=metadata
553+
)
554+
336555
def create_limit_sell_order(
337556
self,
338557
target_symbol,

investing_algorithm_framework/app/strategy.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,69 @@ def create_limit_order(
991991
validate_symbol=validate_symbol
992992
)
993993

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