Skip to content

Alpaca multi leg options and tweaks #702

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

Open
wants to merge 11 commits into
base: dev
Choose a base branch
from
Open
209 changes: 192 additions & 17 deletions lumibot/brokers/alpaca.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pandas_market_calendars as mcal
from alpaca.trading.client import TradingClient
from alpaca.trading.stream import TradingStream
from alpaca.trading.requests import GetOrdersRequest
from dateutil import tz
from termcolor import colored

Expand Down Expand Up @@ -266,10 +267,25 @@ def _parse_broker_position(self, broker_position, strategy, orders=None):
symbol=position.symbol.replace("USD", ""),
asset_type="crypto",
)
elif position.asset_class == "option":
elif position.asset_class == "us_option":
underlying = ''.join([char for char in position.symbol[:-9] if not char.isdigit()])

year = int('20' + position.symbol[len(position.symbol)-15:len(position.symbol)-13])
month = int(position.symbol[len(position.symbol)-13:len(position.symbol)-11])
day = int(position.symbol[len(position.symbol)-11:len(position.symbol)-9])
exp = datetime.datetime(year, month, day).date()

right = 'CALL' if position.symbol[-9] == 'C' else 'PUT'

strike_price = position.symbol[-8:-3]
strike_price = float(strike_price)

asset = Asset(
symbol=position.symbol,
symbol=underlying,
asset_type="option",
expiration=exp,
strike=strike_price,
right=right
)
else:
asset = Asset(
Expand Down Expand Up @@ -346,20 +362,57 @@ def _parse_broker_order(self, response, strategy_name, strategy_object=None):
else:
symbol = response.symbol

order = Order(
strategy_name,
Asset(
symbol=symbol,
asset_type=self.map_asset_type(response.asset_class),
),
Decimal(response.qty),
response.side,
limit_price=response.limit_price,
stop_price=response.stop_price,
time_in_force=response.time_in_force,
# TODO: remove hardcoding in case Alpaca allows crypto to crypto trading
quote=Asset(symbol="USD", asset_type="forex"),
)
asset_type = self.map_asset_type(response.asset_class)

qty = Decimal(response.qty) if response.qty != None else Decimal(response.filled_qty)
fill_price = Decimal(response.filled_avg_price) if response.filled_avg_price != None else 0

if asset_type == Asset.AssetType.OPTION:
underlying = ''.join([char for char in symbol[:-9] if not char.isdigit()])

year = int('20' + symbol[len(symbol)-15:len(symbol)-13])
month = int(symbol[len(symbol)-13:len(symbol)-11])
day = int(symbol[len(symbol)-11:len(symbol)-9])
exp = datetime.datetime(year, month, day).date()

right = 'CALL' if symbol[-9] == 'C' else 'PUT'

strike_price = symbol[-8:-3]
strike_price = float(strike_price)

asset = Asset(
symbol=underlying,
asset_type=Asset.AssetType.OPTION,
expiration=exp,
strike=strike_price,
right=right
)
order = Order(
strategy_name,
asset,
qty,
response.side,
limit_price=response.limit_price,
stop_price=response.stop_price,
time_in_force=response.time_in_force,
avg_fill_price=fill_price
)
else:
order = Order(
strategy_name,
Asset(
symbol=symbol,
asset_type=asset_type,
),
qty,
response.side,
limit_price=response.limit_price,
stop_price=response.stop_price,
time_in_force=response.time_in_force,
avg_fill_price=fill_price,
# TODO: remove hardcoding in case Alpaca allows crypto to crypto trading
quote=Asset(symbol="USD", asset_type="forex"),
)
order.set_identifier(response.id)
order.status = response.status
order.update_raw(response)
Expand All @@ -372,7 +425,8 @@ def _pull_broker_order(self, identifier):

def _pull_broker_all_orders(self):
"""Get the broker orders"""
return self.api.get_orders()
orders = self.api.get_orders(GetOrdersRequest(status='all'))
return orders

def _flatten_order(self, order):
"""Some submitted orders may trigger other orders.
Expand All @@ -387,6 +441,126 @@ def _flatten_order(self, order):

return orders

def _submit_multileg_order(self, orders, order_type="market", duration="day", price=None, tag=None) -> Order:
"""
Submit a multi-leg order to Tradier. This function will submit the multi-leg order to Tradier.

Parameters
----------
orders: list[Order]
List of orders to submit
order_type: str
The type of multi-leg order to submit. Valid values are ('market', 'debit', 'credit', 'even'). Default is 'market'.
duration: str
The duration of the order. Valid values are ('day', 'gtc', 'pre', 'post'). Default is 'day'.
price: float
The limit price for the order. Required for 'debit' and 'credit' order types.
tag: str
The tag to associate with the order.

Returns
-------
parent order of the multi-leg orders
"""

# Check if the order type is valid
if order_type not in ["market", "debit", "credit", "even"]:
raise ValueError(f"Invalid order type '{order_type}' for multi-leg order.")

# Check if the duration is valid
if duration not in ["day", "gtc", "pre", "post"]:
raise ValueError(f"Invalid duration {duration} for multi-leg order.")

# Check if the price is required
if order_type in ["debit", "credit"] and price is None:
raise ValueError(f"Price is required for '{order_type}' order type.")

# Check that all the order objects have the same symbol
if len(set([order.asset.symbol for order in orders])) > 1:
raise ValueError("All orders in a multi-leg order must have the same symbol.")

# Get the symbol from the first order
symbol = orders[0].asset.symbol

# Create the legs for the multi-leg order
legs = []
for order in orders:
leg = {
"symbol": order.asset.symbol,
"ratio_qty": order.quantity,
"side": order.side,
"position_intent": order.position_intent
}
legs.append(leg)

parent_asset = Asset(symbol=symbol)
parent_order = Order(
asset=parent_asset,
strategy=orders[0].strategy,
order_class=Order.OrderClass.MULTILEG,
side=orders[0].side,
quantity=orders[0].quantity,
type=orders[0].type,
time_in_force=duration,
limit_price=price if price != None else orders[0].limit_price,
tag=tag,
status=Order.OrderStatus.SUBMITTED,
legs=legs
)
order_response = self.submit_order(parent_order)
for o in orders:
o.parent_identifier = order_response.identifier

parent_order.child_orders = orders
parent_order.update_raw(order_response) # This marks order as 'transmitted'
self._unprocessed_orders.append(parent_order)
self.stream.dispatch(self.NEW_ORDER, order=parent_order)
return parent_order

def _submit_orders(self, orders, is_multileg=False, order_type=None, 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.

Parameters
----------
orders: list[Order]
List of orders to submit
is_multileg: bool
Whether the order is a multi-leg order. Default is False.
order_type: str
The type of multi-leg order to submit, if applicable. Valid values are ('market', 'debit', 'credit', 'even'). Default is 'market'.
duration: str
The duration of the order. Valid values are ('day', 'gtc', 'pre', 'post'). Default is 'day'.
price: float
The limit price for the order. Required for 'debit' and 'credit' order types.

Returns
-------
Order
The list of processed order objects.
"""

# Check if order_type is set, if not, set it to 'market'
if order_type is None:
order_type = "market"

# Check if the orders are empty
if not orders or len(orders) == 0:
return

# Check if it is a multi-leg order
if is_multileg:
parent_order = self._submit_multileg_order(orders, order_type, duration, price)
return [parent_order]

else:
# Submit each order
for order in orders:
self._submit_order(order)
Copy link
Collaborator

Choose a reason for hiding this comment

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

_submit_order() returns an order object with the Identifier filled in. This is "usually" the same as the order object passed in, but it is an important thing to track so that the proper items get passed to lifecycle methods like "on_new_order()" or "on_filled_order()" later. Please collect all of the orders passed back by _submit_order() and return them as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I got this as well, I take the newly submitted orders and return those, rather than the passed in orders


return orders

def _submit_order(self, order):
"""Submit an order for an asset"""

Expand Down Expand Up @@ -425,6 +599,7 @@ def _submit_order(self, order):
"stop_price": str(order.stop_price) if order.stop_price else None,
"trail_price": str(order.trail_price) if order.trail_price else None,
"trail_percent": order.trail_percent,
"legs": order.legs
Copy link
Collaborator

Choose a reason for hiding this comment

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

The only place that I can see where "legs" was ever set was in the other submit function "_submit_multileg_order()". It seems like this will never be valid at this point in time and that child_orders should be referenced instead to build out the required legs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I commented on the other, but the same thing applies here. As I understand alpacapy wants the legs in the order submission, not as an array of orders. I could be wrong though

Copy link
Collaborator

Choose a reason for hiding this comment

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

All of that is fine, you do have to Send it to Alpaca in the legs format it expects. However, you don't need to Store it in the order as "order.legs". This is because the structure of "legs" is different for each broker and it can be confusing months from now if there are updates that need to be made. I just think that "legs" should just live inside the alpaca.py file and not be a generic item in Order. Whenever a legs item is needed, it can just be created on the fly from order.child_orders (and each other broker can do the same thing for their own legs format)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I am with you almost got it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok should all be fixed. Added tests for spread and condor

image

Moved to child ordres. Let me know what you think. I am opening it for real I think its good.

}
# Remove items with None values
kwargs = {k: v for k, v in kwargs.items() if v}
Expand Down
9 changes: 3 additions & 6 deletions lumibot/data_sources/alpaca_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,6 @@ def get_last_price(self, asset, quote=None, exchange=None, **kwargs) -> Union[fl
symbol = f"{asset[0].symbol}/{quote.symbol}"
else:
symbol = f"{asset.symbol}/{quote.symbol}"
elif type(asset) == Asset and asset.asset_type == Asset.AssetType.OPTION:
strike_formatted = f"{asset.strike:08.3f}".replace('.', '').rjust(8, '0')
date = asset.expiration.strftime("%y%m%d")
symbol = f"{asset.symbol}{date}{asset.right[0]}{strike_formatted}"
elif isinstance(asset, tuple):
symbol = f"{asset[0].symbol}/{asset[1].symbol}"
elif isinstance(asset, str):
Expand All @@ -177,11 +173,12 @@ def get_last_price(self, asset, quote=None, exchange=None, **kwargs) -> Union[fl
# The price is the average of the bid and ask
price = (quote.bid_price + quote.ask_price) / 2
elif (isinstance(asset, tuple) and asset[0].asset_type == Asset.AssetType.OPTION) or (isinstance(asset, Asset) and asset.asset_type == Asset.AssetType.OPTION):
logging.info(f"Getting {asset} option price")
strike_formatted = f"{asset.strike:08.3f}".replace('.', '').rjust(8, '0')
date = asset.expiration.strftime("%y%m%d")
symbol = f"{asset.symbol}{date}{asset.right[0]}{strike_formatted}"
client = OptionHistoricalDataClient(self.api_key, self.api_secret)
params = OptionLatestTradeRequest(symbol_or_symbols=symbol)
trade = client.get_option_latest_trade(params)
print(f'This {trade} {symbol}')
price = trade[symbol].price
else:
# Stocks
Expand Down
4 changes: 3 additions & 1 deletion lumibot/entities/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from decimal import Decimal
from threading import Event
import datetime
from typing import Union
from typing import Union, List

import lumibot.entities as entities
from lumibot.tools.types import check_positive, check_price
Expand Down Expand Up @@ -119,6 +119,7 @@ def __init__(
child_orders: list = None,
tag: str = "",
status: OrderStatus = "unprocessed",
legs: List[dict] = None
):
"""Order class for managing individual orders.

Expand Down Expand Up @@ -332,6 +333,7 @@ def __init__(
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
self.status = status
self.legs = legs

# Options:
self.exchange = exchange
Expand Down
32 changes: 31 additions & 1 deletion tests/test_alpaca.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,37 @@ def test_limit_order_conforms_when_limit_price_lte_one_dollar(self):
broker._conform_order(order)
assert order.limit_price == 0.1235

def test_mulitleg_options_order(self):
broker = Alpaca(ALPACA_CONFIG)
porders = broker._pull_all_orders('test', None)

spy_price = broker.get_last_price(asset='SPY')
casset = Asset('SPY', Asset.AssetType.OPTION, expiration=datetime.now(), strike=math.floor(spy_price), right='CALL')
passet = Asset('SPY', Asset.AssetType.OPTION, expiration=datetime.now(), strike=math.floor(spy_price), right='PUT')
orders = [Order(strategy='test', asset=passet, quantity=1, limit_price=0.01, side="buy"), Order(strategy='test', asset=casset, quantity=1, side="buy")]
broker.submit_orders(orders)

orders = broker._pull_all_orders('test', None)
oids = {o.identifier for o in porders}
orders = [o for o in orders if o.identifier not in oids]
assert len(orders) == 2
assert len([o.asset.right == 'CALL' and o.asset.symbol == 'SPY' for o in orders]) == 1
assert len([o.asset.right == 'PUT' and o.asset.symbol == 'SPY' for o in orders]) == 1

def test_option_order(self):
broker = Alpaca(ALPACA_CONFIG)
porders = broker._pull_all_orders('test', None)

spy_price = broker.get_last_price(asset='SPY')
casset = Asset('SPY', Asset.AssetType.OPTION, expiration=datetime.now(), strike=math.floor(spy_price), right='CALL')
broker.submit_order(Order('test', asset=casset))

orders = broker._pull_all_orders('test', None)
oids = {o.identifier for o in porders}
orders = [o for o in orders if o.identifier not in oids]
assert len(orders) == 1
assert len([o.asset.right == 'CALL' and o.asset.symbol == 'SPY' for o in orders]) == 1

def test_option_get_last_price(self):
broker = Alpaca(ALPACA_CONFIG)
dte = datetime.now()
Expand All @@ -82,6 +113,5 @@ def test_option_get_historical_prices(self):
dte = datetime.now() - timedelta(days=2)
spy_price = broker.get_last_price(asset='SPY')
asset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='CALL')
print(asset)
bars = broker.data_source.get_historical_prices(asset, 10, "day")
assert len(bars.df) > 0
Loading