-
Notifications
You must be signed in to change notification settings - Fork 139
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
Changes from all commits
3562aea
1218bf2
87ad7d6
319496f
2fa5ef5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
return | ||
|
||
# Check if it is a multi-leg order | ||
if is_multileg: | ||
tag = orders[0].tag if orders[0].tag else orders[0].strategy | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
# Submit the multi-leg order | ||
return self._submit_multileg_order(orders, order_type, duration, price, tag) | ||
|
||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
# 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): | ||
""" | ||
|
@@ -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) | ||
|
@@ -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": | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
# 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( | ||
|
@@ -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) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -92,6 +92,7 @@ def __init__( | |
trade_cost: float = None, | ||
custom_params={}, | ||
identifier=None, | ||
avg_fill_price=None, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
tag="", | ||
): | ||
"""Order class for managing individual orders. | ||
|
@@ -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. | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
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 | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems that the check for empty orders list in
submit_orders
method is redundant. The conditionif not orders or len(orders) == 0:
can be simplified toif not orders:
since thenot orders
check will already returnTrue
for an empty list.