Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

multileg notifications bug fix #442

Merged
merged 5 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 0 additions & 3 deletions docsrc/strategy_methods.data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,5 @@ Data methods are made to get data for you to use in your strategies. You can use
get_historical_prices
get_historical_prices_for_assets
get_quote
start_realtime_bars
get_realtime_bars
cancel_realtime_bars
get_yesterday_dividend
get_next_trading_day
9 changes: 8 additions & 1 deletion lumibot/brokers/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,14 @@ def _parse_broker_orders(self, broker_orders, strategy_name, strategy_object=Non
result = []
if broker_orders is not None:
for broker_order in broker_orders:
result.append(self._parse_broker_order(broker_order, strategy_name, strategy_object=strategy_object))
# Check if it is a multileg order
if "leg" in broker_order and isinstance(broker_order["leg"], list):
for leg in broker_order["leg"]:
order = self._parse_broker_order(leg, strategy_name, strategy_object=strategy_object)
result.append(order)
else:
order = self._parse_broker_order(broker_order, strategy_name, strategy_object=strategy_object)
result.append(order)
else:
self.logger.warning("No orders found in broker._parse_broker_orders: the broker_orders object is None")

Expand Down
191 changes: 129 additions & 62 deletions lumibot/brokers/tradier.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,19 @@ def cancel_order(self, order: Order):
# Cancel the order
self.tradier.orders.cancel(order.identifier)

def submit_orders(self, orders, is_multileg=False, order_type="market", duration="day", price=None, tag=None):
def submit_orders(self, orders, is_multileg=False, order_type="market", duration="day", price=None):
"""
Submit multiple orders to the broker. This function will submit the orders in the order they are provided.
If any order fails to submit, the function will stop submitting orders and return the last successful order.
"""
# Check if the orders are empty
if not orders:
if not orders or len(orders) == 0:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Readability and Maintainability

It seems that the check for empty orders list in submit_orders method is redundant. The condition if not orders or len(orders) == 0: can be simplified to if not orders: since the not orders check will already return True for an empty list.

return

# Check if it is a multi-leg order
if is_multileg:
tag = orders[0].tag if orders[0].tag else orders[0].strategy
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Readability and Maintainability

The submit_orders method no longer accepts tag as a parameter, but the variable is still being used within the method. This could lead to a NameError at runtime. Please ensure that tag is defined within the method or added back to the method signature if necessary.


# Submit the multi-leg order
return self._submit_multileg_order(orders, order_type, duration, price, tag)

Expand Down Expand Up @@ -326,6 +328,47 @@ def _pull_position(self, strategy, asset):
return position

return None

def _parse_broker_order_dict(self, response: dict, strategy_name: str, strategy_object=None):
"""
Parse a broker order representation to a Lumi order object or objects. Once the Lumi order has been created,
it will be dispatched to our "stream" queue for processing until a time when Live Streaming can be implemented.

Parameters
----------
response: dict
The output from TradierAPI call returned by pull_broker_order()
strategy_name: str
The name of the strategy that placed the order
strategy_object: Strategy
The strategy object that placed the order

Returns
-------
list[Order]
The Lumibot order objects created from the response
"""

# Check if the order is a multileg order
if "leg" in response and isinstance(response["leg"], list):
# Create the orders list
orders = []

# Loop through each leg in the response
for leg in response["leg"]:
# Create the order object
order = self._parse_broker_order(leg, strategy_name, strategy_object)

# TODO: Get the average fill price and quantity
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Documentation

The method _parse_broker_order_dict contains a TODO comment to get the average fill price and quantity, but it seems this functionality has not been implemented yet. This could lead to incomplete order objects being created without the necessary information.


# Add the order to the list
orders.append(order)

return orders

# Create the order object
order = self._parse_broker_order(response, strategy_name, strategy_object)
return [order]

def _parse_broker_order(self, response: dict, strategy_name: str, strategy_object=None):
"""
Expand Down Expand Up @@ -366,6 +409,7 @@ def _parse_broker_order(self, response: dict, strategy_name: str, strategy_objec
stop_price=response["stop_price"] if "stop_price" in response and response["stop_price"] else None,
tag=response["tag"] if "tag" in response and response["tag"] else None,
date_created=response["create_date"],
avg_fill_price=response["avg_fill_price"] if "avg_fill_price" in response else None,
)
order.status = response["status"]
order.avg_fill_price = response.get("avg_fill_price", order.avg_fill_price)
Expand Down Expand Up @@ -468,72 +512,83 @@ def do_polling(self):
raw_orders = self._pull_broker_all_orders()
stored_orders = {x.identifier: x for x in self.get_all_orders()}
for order_row in raw_orders:
order = self._parse_broker_order(order_row, strategy_name=order_row.get("tag"))

# First time seeing this order, something weird has happened, dispatch it as a new order
if order.identifier not in stored_orders:
# If it is the brokers first iteration then fully process the order because it is likely
# that the order was filled/canceled/etc before the strategy started.
if self._first_iteration:
if order.status == Order.OrderStatus.FILLED:
self._process_new_order(order)
self._process_filled_order(order, order.avg_fill_price, order.quantity)
elif order.status == Order.OrderStatus.CANCELED:
self._process_new_order(order)
self._process_canceled_order(order)
elif order.status == Order.OrderStatus.PARTIALLY_FILLED:
self._process_new_order(order)
self._process_partially_filled_order(order, order.avg_fill_price, order.quantity)
elif order.status == Order.OrderStatus.NEW:
orders = self._parse_broker_order_dict(order_row, strategy_name=order_row.get("tag"))

for order in orders:
# First time seeing this order, something weird has happened
if order.identifier not in stored_orders:
# If it is the brokers first iteration then fully process the order because it is likely
# that the order was filled/canceled/etc before the strategy started.
if self._first_iteration:
if order.status == Order.OrderStatus.FILLED:
self._process_new_order(order)
self._process_filled_order(order, order.avg_fill_price, order.quantity)
elif order.status == Order.OrderStatus.CANCELED:
self._process_new_order(order)
self._process_canceled_order(order)
elif order.status == Order.OrderStatus.PARTIALLY_FILLED:
self._process_new_order(order)
self._process_partially_filled_order(order, order.avg_fill_price, order.quantity)
elif order.status == Order.OrderStatus.NEW:
self._process_new_order(order)
else:
# Add to order in lumibot.
self._process_new_order(order)
else:
# Add to order in lumibot.
self._process_new_order(order)
else:
stored_order = stored_orders[order.identifier]
stored_order.quantity = order.quantity # Update the quantity in case it has changed

# Status has changed since last time we saw it, dispatch the new status.
# - Polling methods are unable to track partial fills
# - Partial fills often happen quickly and it is highly likely that polling will miss some of them
# - Additionally, Lumi Order objects don't have a way to track quantity status changes and
# adjusting the average sell price can be tricky
# - Only dispatch filled orders if they are completely filled.
if not order.equivalent_status(stored_order):
match order.status.lower():
case "submitted" | "open":
self.stream.dispatch(self.NEW_ORDER, order=stored_order)
case "partial_filled":
# Not handled for polling, only dispatch completely filled orders
pass
case "fill":
fill_price = order_row["avg_fill_price"]
fill_qty = order_row["exec_quantity"] if "exec_quantity" in order_row else order.quantity
self.stream.dispatch(
self.FILLED_ORDER, order=stored_order, price=fill_price, filled_quantity=fill_qty
)
case "canceled":
self.stream.dispatch(self.CANCELED_ORDER, order=stored_order)
case "error":
default_msg = f"{self.name} encountered an error with order {order.identifier} | {order}"
msg = order_row["reason_description"] if "reason_description" in order_row else default_msg
self.stream.dispatch(self.ERROR_ORDER, order=stored_order, error_msg=msg)
case "cash_settled":
# Don't know how to detect this case in Tradier.
# Reference: https://documentation.tradier.com/brokerage-api/reference/response/orders
# Theory:
# - Tradier will auto settle and create a new fill order for cash settled orders. Needs
# testing to confirm.
pass
else:
# Status hasn't changed, but make sure we use the broker's status.
# I.e. 'submitted' becomes 'open'
stored_order.status = order.status
stored_order = stored_orders[order.identifier]
stored_order.quantity = order.quantity # Update the quantity in case it has changed

# Status has changed since last time we saw it, dispatch the new status.
# - Polling methods are unable to track partial fills
# - Partial fills often happen quickly and it is highly likely that polling will miss some of them
# - Additionally, Lumi Order objects don't have a way to track quantity status changes and
# adjusting the average sell price can be tricky
# - Only dispatch filled orders if they are completely filled.
if not order.equivalent_status(stored_order):
match order.status.lower():
case "submitted" | "open":
self.stream.dispatch(self.NEW_ORDER, order=stored_order)
case "partial_filled":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Naming

The do_polling method contains a match case statement with 'partial_filled' which should be 'partially_filled' according to the Order.OrderStatus enumeration. This typo will prevent the correct handling of partially filled orders.

# Not handled for polling, only dispatch completely filled orders
pass
case "fill":
# Check if the order has an avg_fill_price, if not use the order_row price
if order.avg_fill_price is None:
fill_price = order_row["avg_fill_price"]
else:
fill_price = order.avg_fill_price

# Check if the order has a quantity, if not
if order.quantity is None:
fill_qty = order_row["exec_quantity"]
else:
fill_qty = order.quantity

self.stream.dispatch(
self.FILLED_ORDER, order=stored_order, price=fill_price, filled_quantity=fill_qty
)
case "canceled":
self.stream.dispatch(self.CANCELED_ORDER, order=stored_order)
case "error":
default_msg = f"{self.name} encountered an error with order {order.identifier} | {order}"
msg = order_row["reason_description"] if "reason_description" in order_row else default_msg
self.stream.dispatch(self.ERROR_ORDER, order=stored_order, error_msg=msg)
case "cash_settled":
# Don't know how to detect this case in Tradier.
# Reference: https://documentation.tradier.com/brokerage-api/reference/response/orders
# Theory:
# - Tradier will auto settle and create a new fill order for cash settled orders. Needs
# testing to confirm.
pass
else:
# Status hasn't changed, but make sure we use the broker's status.
# I.e. 'submitted' becomes 'open'
stored_order.status = order.status

# See if there are any tracked (aka active) orders that are no longer in the broker's list,
# dispatch them as cancelled
tracked_orders = {x.identifier: x for x in self.get_tracked_orders()}
broker_ids = [o["id"] for o in raw_orders]
broker_ids = self._get_broker_id_from_raw_orders(raw_orders)
for order_id, order in tracked_orders.items():
if order_id not in broker_ids:
logging.info(
Expand All @@ -542,6 +597,18 @@ def do_polling(self):
)
self.stream.dispatch(self.CANCELED_ORDER, order=order)

def _get_broker_id_from_raw_orders(self, raw_orders):
ids = []
for o in raw_orders:
if "id" in o:
ids.append(o["id"])
if "leg" in o:
for leg in o["leg"]:
if "id" in leg:
ids.append(leg["id"])

return ids

def _get_stream_object(self):
"""get the broker stream connection"""
stream = PollingStream(self.polling_interval)
Expand Down
5 changes: 4 additions & 1 deletion lumibot/entities/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def __init__(
trade_cost: float = None,
custom_params={},
identifier=None,
avg_fill_price=None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Readability and Maintainability

It looks like the 'avg_fill_price' parameter is added without validation and a default value in the constructor. To maintain consistency with other parameters, please add validation for 'avg_fill_price' and set a default value if none is provided.

tag="",
):
"""Order class for managing individual orders.
Expand Down Expand Up @@ -185,6 +186,8 @@ def __init__(
custom_params : dict
A dictionary of custom parameters that can be used to pass additional information to the broker. This is useful for passing custom parameters to the broker that are not supported by Lumibot.
Eg. `custom_params={"leverage": 3}` for Kraken margin trading.
avg_fill_price: float
The average price that the order was fileld at.
tag: str
A tag that can be used to identify the order. This is useful for tracking orders in the broker. Not all
brokers support this feature and lumibot will simply ignore it for those that don't.
Expand Down Expand Up @@ -282,7 +285,7 @@ def __init__(
self.custom_params = custom_params
self._trail_stop_price = None
self.tag = tag
self.avg_fill_price = 0.0 # The weighted average filled price for this order. Calculated if not given by broker
self.avg_fill_price = avg_fill_price # The weighted average filled price for this order. Calculated if not given by broker
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category Error Handling

It looks like the new 'avg_fill_price' parameter is not being validated. We should ensure that this value is a float and non-negative to prevent potential issues with order processing.

self.broker_create_date = None # The datetime the order was created by the broker
self.broker_update_date = None # The datetime the order was last updated by the broker

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setuptools.setup(
name="lumibot",
version="3.4.2",
version="3.4.3",
author="Robert Grzesik",
author_email="[email protected]",
description="Backtesting and Trading Library, Made by Lumiwealth",
Expand Down