diff --git a/lumibot/brokers/alpaca.py b/lumibot/brokers/alpaca.py index 68f078c71..48ee7101e 100644 --- a/lumibot/brokers/alpaca.py +++ b/lumibot/brokers/alpaca.py @@ -10,12 +10,13 @@ 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 from lumibot.data_sources import AlpacaData from lumibot.entities import Asset, Order, Position -from lumibot.tools.helpers import has_more_than_n_decimal_places +from lumibot.tools.helpers import has_more_than_n_decimal_places, parse_symbol, create_options_symbol from .broker import Broker @@ -267,10 +268,15 @@ def _parse_broker_position(self, broker_position, strategy, orders=None): symbol=position.symbol.replace("USD", ""), asset_type=Asset.AssetType.CRYPTO, ) - elif position.asset_class == "option": + elif position.asset_class == Asset.AssetType.OPTION: + parsed = parse_symbol(position.symbol) + asset = Asset( - symbol=position.symbol, + symbol=parsed["stock_symbol"], asset_type=Asset.AssetType.OPTION, + expiration=parsed["expiration_date"], + strike=parsed["strike_price"], + right=parsed["option_type"] ) else: asset = Asset( @@ -340,38 +346,81 @@ def _parse_broker_order(self, response, strategy_name, strategy_object=None): """parse a broker order representation to an order object""" - # If the symbol includes a slash, then it is a crypto order and only the first part of - # the symbol is the real symbol - if "/" in response.symbol: - symbol = response.symbol.split("/")[0] - else: - symbol = response.symbol - # Alpaca Order type/class mostly matches LumiBot's Order type/class with exceptons of 'mleg' and 'trailing_stop' limit_price = response.limit_price if response.order_type != Order.OrderType.STOP_LIMIT else None stop_limit_price = response.limit_price if response.order_type == Order.OrderType.STOP_LIMIT else None order_class = response.order_class if response.order_class != "mleg" else Order.OrderClass.MULTILEG order_type = response.order_type if response.order_type != "trailing_stop" else Order.OrderType.TRAIL - order = Order( - strategy_name, - Asset( - symbol=symbol, - asset_type=self.map_asset_type(response.asset_class), - ), - Decimal(response.qty), - response.side, - limit_price=limit_price, # order.py always converts to 'float'. Crypto issue? - stop_price=response.stop_price, - stop_limit_price=stop_limit_price, - trail_price=response.trail_price if response.trail_price else None, - trail_percent=response.trail_percent if response.trail_percent else None, - time_in_force=response.time_in_force, - order_class=order_class, - order_type=order_type, - - # TODO: remove hardcoding in case Alpaca allows crypto to crypto trading - quote=Asset(symbol="USD", asset_type="forex"), - ) + 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 + + # Check for multileg first its a bit different + if response.order_class == "mleg": + asset = Asset( + symbol=response.legs[0].symbol, + asset_type=Asset.AssetType.OPTION, + ) + 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, + order_type=order_type + ) + + for l in response.legs: + parsed = parse_symbol(l.symbol) + + leg_asset = Asset( + symbol=parsed["stock_symbol"], + asset_type=Asset.AssetType.OPTION, + expiration=parsed["expiration_date"], + strike=parsed["strike_price"], + right=parsed["option_type"] + ) + order.add_child_order(Order( + strategy_name, + leg_asset, + Decimal(l.qty) if l.qty != None else Decimal(l.filled_qty), + l.side, + limit_price=l.limit_price, + stop_price=l.stop_price, + time_in_force=l.time_in_force, + avg_fill_price=fill_price + )) + else: + # If the symbol includes a slash, then it is a crypto order and only the first part of + # the symbol is the real symbol + if "/" in response.symbol: + symbol = response.symbol.split("/")[0] + 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=limit_price, # order.py always converts to 'float'. Crypto issue? + stop_price=response.stop_price, + stop_limit_price=stop_limit_price, + trail_price=response.trail_price if response.trail_price else None, + trail_percent=response.trail_percent if response.trail_percent else None, + time_in_force=response.time_in_force, + order_class=order_class, + order_type=order_type, + + # 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) @@ -384,7 +433,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. @@ -399,6 +449,129 @@ 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 + parent_asset = Asset(symbol=symbol) + + # Create the legs for the multi-leg order + parent_order = Order( + asset=parent_asset, + strategy=orders[0].strategy, + order_class=Order.OrderClass.MULTILEG, + side=orders[0].side, + quantity=orders[0].quantity, + order_type=orders[0].order_type, + time_in_force=duration, + limit_price=price if price != None else orders[0].limit_price, + tag=tag, + status=Order.OrderStatus.SUBMITTED, + ) + for order in orders: + child_order = Order( + asset=order.asset, + strategy=order.strategy, + order_class=Order.OrderClass.MULTILEG, + side=order.side, + quantity=order.quantity, + order_type=order.order_type, + time_in_force=duration, + limit_price=price if price != None else orders[0].limit_price, + tag=tag, + status=Order.OrderStatus.SUBMITTED, + ) + parent_order.add_child_order(child_order) + + order_response = self.submit_order(parent_order) + for o in orders: + o.parent_identifier = order_response.identifier + + parent_order.child_orders = order.child_orders + 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 + submittted_orders = [] + for order in orders: + submittted_order = self._submit_order(order) + submittted_orders.append(submittted_order) + + return submittted_orders + def _submit_order(self, order): """Submit an order for an asset""" @@ -429,19 +602,31 @@ def _submit_order(self, order): elif order.order_class in [Order.OrderClass.BRACKET, Order.OrderClass.OTO]: alpaca_type = Order.OrderType.MARKET + legs = [] + if len(order.child_orders) > 0: + for order in order.child_orders: + leg = { + "symbol": create_options_symbol(order.asset.symbol, order.asset.expiration, order.asset.right, order.asset.strike), + "ratio_qty": order.quantity, + "side": Order.OrderSide.BUY if order.is_buy_order() else Order.OrderSide.SELL, + "position_intent": order.side if order.side != Order.OrderSide.BUY and order.side != Order.OrderSide.SELL else None, + } + legs.append(leg) + limit_price = order.limit_price if order.order_type != Order.OrderType.STOP_LIMIT else order.stop_limit_price kwargs = { - "symbol": trade_symbol, + "symbol": trade_symbol if len(legs) == 0 else None, "qty": qty, "side": order.side, + "order_class": order.order_class if len(legs) == 0 else 'mleg', "type": alpaca_type, - "order_class": order.order_class, "time_in_force": order.time_in_force, # Crypto can use 9 decimal places on Alpaca "limit_price": str(limit_price) if limit_price else None, "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": str(order.trail_percent) if order.trail_percent else None, + "legs": legs if len(legs) > 0 else None } # Remove items with None values kwargs = {k: v for k, v in kwargs.items() if v} @@ -480,6 +665,7 @@ def _submit_order(self, order): except Exception as e: order.set_error(e) message = str(e) + print(message) if "stop price must not be greater than base price / 1.001" in message: logging.info( colored( diff --git a/lumibot/data_sources/alpaca_data.py b/lumibot/data_sources/alpaca_data.py index a0bb8ebff..eec4114bb 100644 --- a/lumibot/data_sources/alpaca_data.py +++ b/lumibot/data_sources/alpaca_data.py @@ -17,6 +17,7 @@ from alpaca.data.timeframe import TimeFrame from lumibot.entities import Asset, Bars +from lumibot.tools.helpers import create_options_symbol from .data_source import DataSource @@ -155,10 +156,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): @@ -177,11 +174,10 @@ 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") + symbol = create_options_symbol(asset.symbol, asset.expiration, asset.right, asset.strike) 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 diff --git a/lumibot/entities/order.py b/lumibot/entities/order.py index f64d9099c..392cac1e4 100644 --- a/lumibot/entities/order.py +++ b/lumibot/entities/order.py @@ -5,7 +5,7 @@ from enum import StrEnum 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 @@ -126,7 +126,7 @@ def __init__( error_message: str = None, child_orders: Union[list, None] = None, tag: str = "", - status: OrderStatus = "unprocessed", + status: OrderStatus = "unprocessed" ): """Order class for managing individual orders. diff --git a/requirements.txt b/requirements.txt index 4d901643c..0469be7a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ polygon-api-client>=1.13.3 -alpaca-py>=0.28.1 +alpaca-py>=0.38.0 alpha_vantage ibapi==9.81.1.post1 yfinance>=0.2.54 diff --git a/tests/test_alpaca.py b/tests/test_alpaca.py index 0690a4f64..5de123208 100644 --- a/tests/test_alpaca.py +++ b/tests/test_alpaca.py @@ -10,73 +10,152 @@ from datetime import datetime, timedelta import math +import unittest from lumibot.tools import get_trading_days if not ALPACA_TEST_CONFIG['API_KEY'] or ALPACA_TEST_CONFIG['API_KEY'] == '': pytest.skip("These tests requires an Alpaca API key", allow_module_level=True) +@pytest.mark.skipif( + not ALPACA_TEST_CONFIG['API_KEY'] or not ALPACA_TEST_CONFIG['API_SECRET'], + reason="This test requires an alpaca API key" + ) +@pytest.mark.skipif( + ALPACA_TEST_CONFIG['API_KEY'] == '', + reason="This test requires an alpaca API key" +) +class TestAlpacaBroker(unittest.TestCase): -class TestAlpacaBroker: + @classmethod + def setUpClass(self): + self.broker = Alpaca(ALPACA_TEST_CONFIG) def test_initialize_broker_legacy(self): """ This test to make sure the legacy way of initializing the broker still works. """ - broker = Alpaca(ALPACA_TEST_CONFIG) strategy = BuyAndHold( - broker=broker, + broker=self.broker, ) # Assert that strategy.broker is the same as broker - assert strategy.broker == broker + assert strategy.broker == self.broker # Assert that strategy.data_source is AlpacaData object assert isinstance(strategy.broker.data_source, AlpacaData) def test_submit_order_calls_conform_order(self): - broker = Alpaca(ALPACA_TEST_CONFIG) - broker._conform_order = MagicMock() + self.broker._conform_order = MagicMock() order = Order(asset=Asset("SPY"), quantity=10, side=Order.OrderSide.BUY, strategy='abc') - broker.submit_order(order=order) - broker._conform_order.assert_called_once() + self.broker.submit_order(order=order) + self.broker._conform_order.assert_called_once() def test_limit_order_conforms_when_limit_price_gte_one_dollar(self): - broker = Alpaca(ALPACA_TEST_CONFIG) order = Order(asset=Asset("SPY"), quantity=10, side=Order.OrderSide.BUY, limit_price=1.123455, strategy='abc') - broker._conform_order(order) + self.broker._conform_order(order) assert order.limit_price == 1.12 def test_limit_order_conforms_when_limit_price_lte_one_dollar(self): - broker = Alpaca(ALPACA_TEST_CONFIG) order = Order(asset=Asset("SPY"), quantity=10, side=Order.OrderSide.BUY, limit_price=0.12345, strategy='abc') - broker._conform_order(order) + self.broker._conform_order(order) assert order.limit_price == 0.1235 + def test_mulitleg_options_order_spread(self): + porders = self.broker._pull_all_orders('test', None) + + spy_price = self.broker.get_last_price(asset='SPY') + trading_days = get_trading_days( + start_date=(datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + end_date=(datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d') + ) + dte = trading_days.index[-1] + basset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='CALL') + sasset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price) + 2, right='CALL') + orders = [ + Order(strategy='test', asset=basset, quantity=1, order_type=Order.OrderType.LIMIT, limit_price=0.01, side=Order.OrderSide.BUY), + Order(strategy='test', asset=sasset, quantity=1, side=Order.OrderSide.SELL, limit_price=0.01, order_type=Order.OrderType.LIMIT) + ] + self.broker.submit_orders(orders, is_multileg=True) + + orders = self.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 + o = orders[0] + assert sum([o.asset.right == 'CALL' and o.asset.symbol == 'SPY' for o in o.child_orders]) == 2 + + def test_mulitleg_options_order_condor(self): + porders = self.broker._pull_all_orders('test', None) + + spy_price = self.broker.get_last_price(asset='SPY') + trading_days = get_trading_days( + start_date=(datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + end_date=(datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d') + ) + dte = trading_days.index[-1] + basset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='CALL') + sasset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price) - 3, right='CALL') + b1asset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='PUT') + s1asset = Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price) + 3, right='PUT') + orders = [ + Order(strategy='test', asset=basset, quantity=1, order_type='limit', limit_price=0.01, side=Order.OrderSide.BUY), + Order(strategy='test', asset=sasset, quantity=1, side=Order.OrderSide.SELL, limit_price=0.01, order_type='limit'), + Order(strategy='test', asset=b1asset, quantity=1, side=Order.OrderSide.BUY, limit_price=0.01, order_type='limit'), + Order(strategy='test', asset=s1asset, quantity=1, side=Order.OrderSide.SELL, limit_price=0.01, order_type='limit') + ] + self.broker.submit_orders(orders, is_multileg=True) + + orders = self.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 + o = orders[0] + assert sum([o.asset.right == 'CALL' and o.asset.symbol == 'SPY' for o in o.child_orders]) == 2 + assert sum([o.asset.right == 'PUT' and o.asset.symbol == 'SPY' for o in o.child_orders]) == 2 + + def test_option_order(self): + porders = self.broker._pull_all_orders('test', None) + + spy_price = self.broker.get_last_price(asset='QQQ') + trading_days = get_trading_days( + start_date=(datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + end_date=(datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d') + ) + dte = trading_days.index[-1] + casset = Asset('QQQ', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='CALL') + self.broker.submit_order(Order('test', asset=casset, quantity=1, side=Order.OrderSide.BUY, limit_price=0.01, order_type='limit')) + + orders = self.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_TEST_CONFIG) - dte = datetime.now() - spy_price = broker.get_last_price(asset=Asset('SPY')) - price = broker.get_last_price(asset=Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='CALL')) + trading_days = get_trading_days( + start_date=(datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + end_date=(datetime.now() + timedelta(days=2)).strftime('%Y-%m-%d') + ) + dte = trading_days.index[-1] + + spy_price = self.broker.get_last_price(asset='SPY') + price = self.broker.get_last_price(asset=Asset('SPY', Asset.AssetType.OPTION, expiration=dte, strike=math.floor(spy_price), right='CALL')) assert price != 0 def test_stock_get_last_price(self): - broker = Alpaca(ALPACA_TEST_CONFIG) - price = broker.get_last_price(asset=Asset('SPY')) + price = self.broker.get_last_price(asset='SPY') assert price != 0 - price = broker.get_last_price(asset=Asset(symbol='SPY')) + price = self.broker.get_last_price(asset=Asset(symbol='SPY')) assert price != 0 def test_crypto_get_last_price(self): - broker = Alpaca(ALPACA_TEST_CONFIG) base = Asset(symbol="BTC", asset_type=Asset.AssetType.CRYPTO) quote = Asset(symbol="USD", asset_type=Asset.AssetType.FOREX) - price = broker.get_last_price(asset=base, quote=quote) + price = self.broker.get_last_price(asset=base, quote=quote) assert price != 0 def test_option_get_historical_prices(self): - broker = Alpaca(ALPACA_TEST_CONFIG) - # calculate the last calendar day before today trading_days = get_trading_days( start_date=(datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), @@ -84,12 +163,8 @@ def test_option_get_historical_prices(self): ) dte = trading_days.index[-1] - spy_price = broker.get_last_price(asset=Asset('SPY')) + spy_price = self.broker.get_last_price(asset=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 + bars = self.broker.data_source.get_historical_prices(asset, 5, "day") - # This should pass. get_historical_prices should return the exact number of bars asked for - # assert len(bars.df) == 10 + assert len(bars.df) == 5