diff --git a/docs/stubs/pyrh.Robinhood.rst b/docs/stubs/pyrh.Robinhood.rst index 6917bfe8..88b5ac36 100644 --- a/docs/stubs/pyrh.Robinhood.rst +++ b/docs/stubs/pyrh.Robinhood.rst @@ -33,6 +33,7 @@ pyrh.Robinhood ~Robinhood.get_account ~Robinhood.get_fundamentals ~Robinhood.get_historical_quotes + ~Robinhood.get_instruments ~Robinhood.get_news ~Robinhood.get_open_orders ~Robinhood.get_option_chainid diff --git a/newsfragments/201.feature b/newsfragments/201.feature new file mode 100644 index 00000000..31a879b4 --- /dev/null +++ b/newsfragments/201.feature @@ -0,0 +1 @@ +Add option model, refactor endpoints diff --git a/newsfragments/224.feature b/newsfragments/224.feature new file mode 100644 index 00000000..62a8cd68 --- /dev/null +++ b/newsfragments/224.feature @@ -0,0 +1 @@ +Add options related models and add new options related functions to robinhood.py. diff --git a/pyproject.toml b/pyproject.toml index ddcd7a3a..751c2c99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ exclude = ''' [tool.isort] known_first_party = 'robinhood' -known_third_party = ["certifi", "dateutil", "freezegun", "marshmallow", "pytest", "pytz", "requests", "requests_mock", "yarl"] +known_third_party = ["certifi", "dateutil", "freezegun", "marshmallow", "pytest", "pytz", "requests", "requests_mock", "six", "yarl"] multi_line_output = 3 lines_after_imports = 2 force_grid_wrap = 0 diff --git a/pyrh/models/__init__.py b/pyrh/models/__init__.py index 1d3a90ba..4182fdd6 100644 --- a/pyrh/models/__init__.py +++ b/pyrh/models/__init__.py @@ -8,6 +8,17 @@ InstrumentSchema, ) from .oauth import Challenge, ChallengeSchema, OAuth, OAuthSchema +from .option import ( + Option, + OptionManager, + OptionPaginator, + OptionPaginatorSchema, + OptionPosition, + OptionPositionSchema, + OptionQuote, + OptionQuoteSchema, + OptionSchema, +) from .portfolio import Portfolio, PortfolioSchema from .sessionmanager import SessionManager, SessionManagerSchema @@ -26,4 +37,13 @@ "InstrumentManager", "InstrumentPaginator", "InstrumentPaginatorSchema", + "Option", + "OptionSchema", + "OptionPosition", + "OptionPositionSchema", + "OptionQuote", + "OptionQuoteSchema", + "OptionManager", + "OptionPaginator", + "OptionPaginatorSchema", ] diff --git a/pyrh/models/chain.py b/pyrh/models/chain.py new file mode 100644 index 00000000..c4e70a8f --- /dev/null +++ b/pyrh/models/chain.py @@ -0,0 +1,27 @@ +"""Option Chain data class.""" + +from marshmallow import fields + +from .base import BaseModel, BaseSchema +from .option import MinTicksSchema + + +class Chain(BaseModel): + """Chain data class. Represents an option chain.""" + + pass + + +class ChainSchema(BaseSchema): + """Chain schema data loader.""" + + __model__ = Chain + + id = fields.UUID() + symbol = fields.Str() + can_open_position = fields.Bool() + cash_component = fields.Float(allow_none=True) + expiration_dates = fields.List(fields.Str()) + trade_value_multiplier = fields.Float() + underlying_instruments = fields.List(fields.Dict()) + min_ticks = fields.Nested(MinTicksSchema) diff --git a/pyrh/models/option.py b/pyrh/models/option.py new file mode 100644 index 00000000..a37088c4 --- /dev/null +++ b/pyrh/models/option.py @@ -0,0 +1,291 @@ +"""Option data class.""" +from typing import Any, Iterable, cast + +from marshmallow import fields +from yarl import URL + +from pyrh import exceptions, urls + +from .base import ( + BaseModel, + BasePaginator, + BasePaginatorSchema, + BaseSchema, + base_paginator, +) +from .sessionmanager import SessionManager + + +class MinTicks(BaseModel): + """Min ticks data class. Describes min increments the option can be traded at.""" + + pass + + +class MinTicksSchema(BaseSchema): + """Min ticks schema data loader.""" + + __model__ = MinTicks + + above_tick = fields.Float() + below_tick = fields.Float() + cutoff_price = fields.Float() + + +class Option(BaseModel): + """Robinhood Option data class. Represents an options instrument.""" + + def __repr__(self) -> str: + """Return the object as a string. + + Returns: + The string representation of the object. + + """ + return ( + f"Option<{self.chain_symbol}|{self.strike_price}|" + + f"{self.type}|{self.expiration_date}>" + ) + + +class OptionSchema(BaseSchema): + """Robinhood Option schema data loader.""" + + __model__ = Option + + chain_id = fields.UUID() + chain_symbol = fields.String() + created_at = fields.DateTime() + expiration_date = fields.Date() + id = fields.UUID() + issue_date = fields.Date() + min_ticks = fields.Nested(MinTicksSchema) + rhs_tradability = fields.String() + state = fields.String() + strike_price = fields.Float() + tradability = fields.String() + type = fields.String() + updated_at = fields.DateTime() + url = fields.URL() + + +class OptionPaginator(BasePaginator): + """Thin wrapper around `self.results`, a list of `Option` objs.""" + + pass + + +class OptionPaginatorSchema(BasePaginatorSchema): + """Schema class for the OptionPaginator. + + The nested results are of type `Option`. + + """ + + __model__ = OptionPaginator + + results = fields.List(fields.Nested(OptionSchema)) + + +class OptionPosition(BaseModel): + """Robinhood Option position data class. Represents an option position.""" + + def __repr__(self) -> str: + """Return the object as a string. + + Returns: + The string representation of the object. + + """ + underlying = getattr(self, "underlying", self.option) + return f"OptionPosition<{underlying}|{self.quantity}|{self.type}>" + + +class OptionPositionSchema(BaseSchema): + """Robinhood Option position schema data loader.""" + + __model__ = OptionPosition + + account = (fields.URL(),) + average_price = (fields.Float(),) + chain_id = (fields.UUID(),) + chain_symbol = (fields.String(),) + id = (fields.UUID(),) + option = (fields.URL(),) + type = (fields.String(),) # should this be an enum? + pending_buy_quantity = (fields.Float(),) + pending_expired_quantity = (fields.Float(),) + pending_expiration_quantity = (fields.Float(),) + pending_exercise_quantity = (fields.Float(),) + pending_assignment_quantity = (fields.Float(),) + pending_sell_quantity = (fields.Float(),) + quantity = (fields.Float(),) + intraday_quantity = (fields.Float(),) + intraday_average_open_price = (fields.Float(),) + created_at = (fields.DateTime(),) + trade_value_multiplier = (fields.Float(),) + updated_at = (fields.DateTime(),) + url = (fields.URL(),) + + +class OptionQuote(BaseModel): + """Robinhood Option quote data class. Represents an option quote.""" + + def __repr__(self) -> str: + """Return the object as a string. + + Returns: + The string representation of the object. + + """ + return f"""OptionQuote< + Ask: {self.ask_size} x {self.ask_price} + Bid: {self.bid_size} x {self.bid_price} + Low: {self.low_price} | High: {self.high_price} + Volume: {self.volume} | Open Interest: {self.open_interest} + Implied Volatility: {self.implied_volatility} + Delta: {self.delta} | Gamma: {self.gamma} | Rho: {self.rho} + Theta: {self.theta} | Vega: {self.vega} + >""" + + +class OptionQuoteSchema(BaseSchema): + """Robinhood Option quote schema data loader.""" + + __model__ = OptionQuote + + adjusted_mark_price = (fields.Float(),) + ask_price = (fields.Float(),) + ask_size = (fields.Integer(),) + bid_price = (fields.Float(),) + bid_size = (fields.Integer(),) + break_even_price = (fields.Float(),) + high_price = (fields.Float(),) + instrument = (fields.URL(),) + last_trade_price = (fields.Float(),) + last_trade_size = (fields.Integer(),) + low_price = (fields.Float(),) + mark_price = (fields.Float(),) + open_interest = (fields.Integer(),) + previous_close_date = (fields.Date(),) + previous_close_price = (fields.Float(),) + volume = (fields.Integer(),) + chance_of_profit_long = (fields.Float(),) + chance_of_profit_short = (fields.Float(),) + delta = (fields.Float(),) + gamma = (fields.Float(),) + implied_volatility = (fields.Float(),) + rho = (fields.Float(),) + theta = (fields.Float(),) + vega = (fields.Float(),) + high_fill_rate_buy_price = (fields.Float(),) + high_fill_rate_sell_price = (fields.Float(),) + low_fill_rate_buy_price = (fields.Float(),) + low_fill_rate_sell_price = (fields.Float(),) + + +class OptionPositionPaginator(BasePaginator): + """Thin wrapper around `self.results`, a list of `Option` objs.""" + + pass + + +class OptionPositionPaginatorSchema(BasePaginatorSchema): + """Schema class for the OptionPaginator. + + The nested results are of type `OptionPosition`. + + """ + + __model__ = OptionPositionPaginator + + results = fields.List(fields.Nested(OptionPositionSchema)) + + +class OptionManager(SessionManager): + """Group together methods that manipulate an options.""" + + def _get_option_from_url(self, option_url: URL) -> Option: + """Get option from option_url. + + Args: + option_url: url to the option, used for getting the underlying option + for an options position. + + Returns: + An Option object. + """ + option_data = self.get(option_url) + return cast(Option, OptionSchema().load(option_data)) + + def _get_option_quote(self, option_id: str) -> OptionQuote: + """Get quote from option id. + + Args: + option_id: underlying option id to get quote for + + Returns: + An OptionQuote object. + """ + quote_data = self.get_url( + urls.MARKET_DATA_BASE.join(URL(f"options/{option_id}/")) + ) + return cast(OptionQuote, OptionQuoteSchema().load(quote_data)) + + def _get_option_positions(self, open_pos: bool = True) -> Iterable[OptionPosition]: + # TODO figure out what /?nonzero=true is, returns quantity = 0... + url = urls.OPTIONS_BASE.join(URL("positions/?nonzero=true")) + positions = base_paginator(url, self, OptionPositionPaginatorSchema()) + if open_pos: + positions = [p for p in positions if float(p.quantity) > 0] + for p in positions: + p.underlying = self._get_option_from_url(p.option) + return positions + + def _get_option_id( + self, symbol: str, strike: str, expiry: str, otype: str, state: str = "active" + ) -> str: + url = urls.OPTIONS_BASE.join(URL("instruments/")) + params = { + "chain_symbol": symbol, + "strike_price": strike, + "expiration_dates": expiry, + "type": otype, + "state": state, + } + results = self.get_url(url.with_query(**params)).get("results") + if not results: + e = """ + Couldn't find option with symbol={}, strike={}, expiry={}, type={}, state={} + """.format( + symbol, strike, expiry, otype, state + ) + raise exceptions.InvalidOptionId(e) + return str(results[0]["id"]) + + def get_options(self, **kwargs: Any) -> Iterable[Option]: + """Get a generator of options. + + Args: + **kwargs: If the query argument is provided, the returned values will + be restricted to option instruments that match the query. Possible + query parameters: chain_symbol, chain_id, state (active), + tradability, type (call vs put), expiration_dates, strike_price + + Returns: + A generator of Options. + """ + valid_params = frozenset( + [ + "chain_symbol", + "state", + "tradability", + "type", + "expiration_dates", + "strike_price", + "chain_id", + ] + ) + query = {k: v for k, v in kwargs.items() if k in valid_params} + url = urls.OPTIONS_INSTRUMENTS_BASE.with_query(**query) + return base_paginator(url, self, OptionPaginatorSchema()) diff --git a/pyrh/robinhood.py b/pyrh/robinhood.py index d49f31cf..67efee63 100644 --- a/pyrh/robinhood.py +++ b/pyrh/robinhood.py @@ -1,15 +1,20 @@ """robinhood.py: a collection of utilities for working with Robinhood's Private API.""" from enum import Enum +from typing import Iterable from urllib.parse import unquote import dateutil import requests +from yarl import URL from pyrh import urls from pyrh.exceptions import InvalidTickerSymbol from pyrh.models import ( InstrumentManager, + OptionManager, + OptionPosition, + OptionQuote, PortfolioSchema, SessionManager, SessionManagerSchema, @@ -33,7 +38,7 @@ class Transaction(Enum): SELL = "sell" -class Robinhood(InstrumentManager, SessionManager): +class Robinhood(InstrumentManager, OptionManager, SessionManager): """Wrapper class for fetching/parsing Robinhood endpoints. Please see :py:class:`SessionManager` for login functionality. @@ -185,16 +190,13 @@ def get_historical_quotes(self, stock, interval, span, bounds=Bounds.REGULAR): if isinstance(bounds, str): # recast to Enum bounds = Bounds(bounds) - historicals = ( - urls.HISTORICALS - + "/?symbols=" - + ",".join(stock).upper() - + "&interval=" - + interval - + "&span=" - + span - + "&bounds=" - + bounds.name.lower() + historicals = urls.HISTORICALS.with_query( + [ + ("symbols", ",".join(stock).upper()), + ("interval", interval), + ("span", span), + ("bounds", bounds.name.lower()), + ] ) return self.get(historicals) @@ -522,23 +524,30 @@ def get_options(self, stock, expiration_dates, option_type): # raise InvalidOptionId() # return market_data + def get_option_positions(self, open_pos: bool = True) -> Iterable[OptionPosition]: + # TODO figure out what /?nonzero=true is, returns quantity = 0... + return self._get_option_positions(open_pos) + # url = urls.OPTIONS_BASE.join(URL("positions/?nonzero=true")) + # options = base_paginator(url, self, OptionPaginatorSchema()) + # if open_pos: + # filter_ = lambda x: float(x.get("quantity")) > 0 + # options = [o for o in options if filter_(o)] + # return options + def options_owned(self): - options = self.get_url(urls.options_base() + "positions/?nonzero=true") + options = self.get_url(urls.OPTIONS_BASE.join(URL("positions/?nonzero=true"))) options = options["results"] return options - def get_option_marketdata(self, instrument): - info = self.get_url( - urls.build_market_data() + "options/?instruments=" + instrument - ) - return info["results"][0] + def get_option_marketdata(self, option_id): + info = self.get_url(urls.MARKET_DATA_BASE.join(URL(f"options/{option_id}/"))) + return info def get_option_chainid(self, symbol): - stock_info = self.get_url(self.endpoints["instruments"] + "?symbol=" + symbol) - stock_id = stock_info["results"][0]["id"] - params = {} - params["equity_instrument_ids"] = stock_id - chains = self.get_url(urls.options_base() + "chains/", params=params) + stock_info = self.get_url(urls.INSTRUMENTS_BASE.with_query(symbol=symbol)) + instrument_id = stock_info["results"][0]["id"] + url = urls.OPTIONS_BASE.join(URL("chains/")) + chains = self.get_url(url.with_query(equity_instrument_ids=instrument_id)) chains = chains["results"] chain_id = None @@ -548,24 +557,11 @@ def get_option_chainid(self, symbol): return chain_id - def get_option_quote(self, arg_dict): - chain_id = self.get_option_chainid(arg_dict.pop("symbol", None)) - arg_dict["chain_id"] = chain_id - option_info = self.get_url( - self.endpoints.options_base() + "instruments/", params=arg_dict - ) - option_info = option_info["results"] - exp_price_list = [] - - for op in option_info: - mrkt_data = self.get_option_marketdata(op["url"]) - op_price = mrkt_data["adjusted_mark_price"] - exp = op["expiration_date"] - exp_price_list.append((exp, op_price)) - - exp_price_list.sort() - - return exp_price_list + def get_option_quote( + self, symbol, strike, expiry, otype, state="active" + ) -> OptionQuote: + option_id = self._get_option_id(symbol, strike, expiry, otype, state) + return self._get_option_quote(option_id) ########################################################################### # GET FUNDAMENTALS diff --git a/pyrh/urls.py b/pyrh/urls.py index a611d4ac..e6dc2877 100755 --- a/pyrh/urls.py +++ b/pyrh/urls.py @@ -21,7 +21,7 @@ INSTRUMENTS_BASE = API_BASE / "instruments/" MARGIN_UPGRADES = API_BASE / "margin/upgrades/" # not implemented MARKETS = API_BASE / "markets/" # not implemented -MARKET_DATA_BASE = API_BASE / "marketdata/options/" +MARKET_DATA_BASE = API_BASE / "marketdata/" NEWS_BASE = API_BASE / "midlands/news/" NOTIFICATIONS = API_BASE / "notifications/" # not implemented ORDERS_BASE = API_BASE / "orders/" diff --git a/tests/test_chain.py b/tests/test_chain.py new file mode 100644 index 00000000..ea5aa51a --- /dev/null +++ b/tests/test_chain.py @@ -0,0 +1,36 @@ +import datetime as dt +import numbers +from os import path + +import requests +from six import string_types + +from pyrh.models.chain import Chain, ChainSchema +from pyrh.models.option import MinTicks + + +HERE = path.abspath(path.dirname(__file__)) +ROOT = path.dirname(HERE) + +# Setup +sample_url = ( + "https://api.robinhood.com/options/chains/cee01a93-626e-4ee6-9b04-60e2fd1392d1/" +) +response = requests.get(sample_url) +data = response.json() +chain_schema = ChainSchema() +chain_obj = chain_schema.load(data) + + +def test_chain_symbol(): + symbol = chain_obj.symbol + assert isinstance(symbol, string_types) + assert symbol == "AAPL" + + +def test_min_ticks(): + min_ticks = chain_obj.min_ticks + assert isinstance(min_ticks, MinTicks) + assert min_ticks.above_tick == 0.05 + assert min_ticks.below_tick == 0.01 + assert min_ticks.cutoff_price == 3.00 diff --git a/tests/test_option.py b/tests/test_option.py new file mode 100644 index 00000000..f73730ca --- /dev/null +++ b/tests/test_option.py @@ -0,0 +1,52 @@ +import datetime as dt +import numbers +from os import path + +import requests +from dateutil import parser as p +from six import string_types + +from pyrh.models.option import MinTicks, OptionSchema + + +HERE = path.abspath(path.dirname(__file__)) +ROOT = path.dirname(HERE) + +# Setup +sample_url = "https://api.robinhood.com/options/instruments/f098f169-74f9-4b91-b955-6834e1b67a12/" +response = requests.get(sample_url) +data = response.json() +option_schema = OptionSchema() +option_obj = option_schema.load(data) + + +def test_chain_symbol(): + symbol = option_obj.chain_symbol + assert isinstance(symbol, string_types) + assert symbol == "AAPL" + + +def test_strike_price(): + strike = option_obj.strike_price + assert isinstance(strike, numbers.Real) + assert strike == 232.5 + + +def test_expiration_date(): + expiry = option_obj.expiration_date + assert isinstance(expiry, dt.date) + assert expiry == dt.date(2020, 4, 17) + + +def test_created_at(): + created_at = option_obj.created_at + assert isinstance(created_at, dt.datetime) + assert created_at == p.parse("2020-03-31T01:27:43.249339Z") + + +def test_min_ticks(): + min_ticks = option_obj.min_ticks + assert isinstance(min_ticks, MinTicks) + assert min_ticks.above_tick == 0.05 + assert min_ticks.below_tick == 0.01 + assert min_ticks.cutoff_price == 3.00