"""The Montreal Exchange derivatives universe.""" from openbb_tmx.utils.cache import get_random_agent from openbb_tmx.utils.memo import memoize DOCUMENT_TTL = 60 * 60 * 24 SUMMARY_TTL = 60 * 5 QUOTES_URL = "https://www.m-x.ca/en/trading/data/quotes" CYCLES_URL = "https://www.m-x.ca/f_publications_en/cycles_opt_en.pdf" OPTIONS_LIST_URL = "https://www.m-x.ca/en/trading/data/options-list" COVERED_CALL_URL = "https://www.m-x.ca/en/resources/education/covered-call-screener" SUMMARY_URL = "https://www.m-x.ca/en/trading/data/intra-session-summary" LISTING_TABS = ( "equity_option", "cdr_option", "cef_option", "etf_option", "currency_option", "index_option", "weekly_option", ) HISTORICAL_URL = "https://www.m-x.ca/en/trading/data/historical" INSTRUMENT_CLASSES = { "symbolOEQ": ("equity_option", "Equity Options", "option"), "symbolCDR": ("cdr_option", "CDR Options", "option"), "symbolCEF": ("cef_option", "Closed-End Fund Options", "option"), "symbolETF": ("etf_option", "ETF Options", "option"), "symbolSSF": ("share_future", "Share Futures", "future"), "symbolESF": ("etf_future", "ETF Futures", "future"), "symbolOCU": ("currency_option", "Currency Options", "option"), "symbolID": ("index_derivative", "Index Derivatives", None), "symbolIRD": ("interest_rate_derivative", "Interest Rate Derivatives", None), "symbolBTC": ("basis_trade_on_close", "Basis Trade on Close", "future"), } OPTION_PRODUCTS = {"SXO", "SXJ", "SXV", "OCR", "OGZ", "OGF", "OGB"} INTEREST_RATE_PRODUCTS = { "COA": "One-Month CORRA Futures", "CRA": "Three-Month CORRA Futures", "BCS": "FTSE Canada Bank Credit Index Futures", "CGZ": "Two-Year Government of Canada Bond Futures", "CGF": "Five-Year Government of Canada Bond Futures", "CGB": "Ten-Year Government of Canada Bond Futures", "LGB": "Thirty-Year Government of Canada Bond Futures", "OCR": "Options on Three-Month CORRA Futures", "OGZ": "Options on Two-Year Government of Canada Bond Futures", "OGF": "Options on Five-Year Government of Canada Bond Futures", "OGB": "Options on Ten-Year Government of Canada Bond Futures", } INDEX_PRODUCTS = { "SXF": "S&P/TSX 60 Index Standard Futures", "SXM": "S&P/TSX 60 Index Mini Futures", "AXF": "Adjusted Interest Rate S&P/TSX 60 Total Return Index Futures", "SEG": "S&P/TSX 60 ESG Index Futures", "SCG": "S&P/TSX Composite ESG Index Futures", "SDV": "S&P/TSX 60 Dividend Index Futures", "SCF": "S&P/TSX Composite Index Mini Futures", "SXO": "S&P/TSX 60 Index Standard Options", "SXJ": "Sector Index Options", "SXV": "Sector Index Options", "SXA": "S&P/TSX Global Gold Index Futures", "SXB": "S&P/TSX Capped Financials Index Futures", "SXD": "S&P/TSX Composite Energy GICS Sector Total Return Index Futures", "SXG": "S&P/TSX Composite Financials GICS Level Sector Total Return Index Futures", "SXH": "S&P/TSX Capped Information Technology Index Futures", "SXK": "S&P/TSX Composite Index Banks (Industry Group) Futures", "SXR": "S&P/TSX Composite Real Estate GICS Sector Total Return Index Futures", "SXT": "S&P/TSX Composite Telecom Services GICS Level Sector" + " Total Return Index Futures", "SXU": "S&P/TSX Capped Utilities Index Futures", "SXW": "S&P/TSX Composite Insurance GICS Industry Group Total Return Index Futures", "SXY": "S&P/TSX Capped Energy Index Futures", } SUMMARY_COLUMNS = { "Open": "open", "High": "high", "Low": "low", "Last": "price", "Change": "net_change", "Volume": "volume", "Open Interest": "open_interest", "Transactions": "transactions", "Accrued Financing": "accrued_financing", "CORRA Rate": "corra_rate", } SUMMARY_PRICES = ("open", "high", "low", "price", "accrued_financing") SETTLEMENT_TTL = 60 * 60 SETTLEMENT_REQUESTS = 10 SETTLEMENT_COLUMN = "Settl. price" MONTH_CODES = { "January": "F", "February": "G", "March": "H", "April": "J", "May": "K", "June": "M", "July": "N", "August": "Q", "September": "U", "October": "V", "November": "X", "December": "Z", } QUOTE_SYMBOLS = { "COA": "/COA", "CRA": "/CRA", "CGZ": "/CGZ", "CGF": "/CGF", "CGB": "/CGB", "LGB": "/LGB", "SXF": "/SXF", } def _instrument_type(root: str, default: str | None) -> str | None: """Classify a product as an option or a future. Parameters ---------- root : str The product root symbol. default : str or None The selector's own classification, when it has one. Returns ------- str or None Either 'option' or 'future'. """ if default is not None: return default return "option" if root in OPTION_PRODUCTS else "future" def _parse_selectors(html: str) -> list[dict]: """Read every instrument selector out of the quotes page.""" import re results: list[dict] = [] for match in re.finditer( r'', html, re.S ): selector, body = match.group(1), match.group(2) if selector not in INSTRUMENT_CLASSES: continue asset_class, label, default_type = INSTRUMENT_CLASSES[selector] for value, _ in re.findall( r'', body, re.S ): if not value: continue root = value.rstrip("*") results.append( { "symbol": root, "name": INTEREST_RATE_PRODUCTS.get(root) or INDEX_PRODUCTS.get(root), "instrument_type": _instrument_type(root, default_type), "asset_class": asset_class, "asset_class_name": label, "quote_symbol": QUOTE_SYMBOLS.get(root), "option_symbol": value, "underlying_symbol": None, "expiry_cycle": None, "has_long_term_options": None, "has_weekly_options": None, } ) return results @memoize(ttl=DOCUMENT_TTL) async def get_instrument_universe(use_cache: bool = True) -> tuple[dict, ...]: """Get every instrument listed on the Montreal Exchange. Parameters ---------- use_cache : bool Whether to read and write the on-disk cache. Returns ------- tuple[dict, ...] One entry per listed derivative, across all ten instrument classes. Raises ------ OpenBBError If no instruments could be read from the page. """ from openbb_core.app.model.abstract.error import OpenBBError from openbb_tmx.utils.cache import amake_request html = await amake_request( f"{QUOTES_URL}?symbol=AC", use_cache=use_cache, accept_type="text", referer="https://www.m-x.ca/", ) results = _parse_selectors(html or "") if not results: raise OpenBBError("The Montreal Exchange instrument list came back empty.") return tuple(await _add_underlying_names(results, use_cache)) CYCLE_NAMES = {"S": "standard", "SC": "short", "": None} @memoize(ttl=DOCUMENT_TTL) async def get_options_listings(use_cache: bool = True) -> dict: """Get the listed option classes, by instrument class. Parameters ---------- use_cache : bool Whether to read and write the on-disk cache. Returns ------- dict Per-symbol name, underlying symbol, class, and weekly availability. """ from io import StringIO from pandas import read_html from openbb_tmx.utils.cache import amake_request try: html = await amake_request( OPTIONS_LIST_URL, use_cache=use_cache, accept_type="text", referer="https://www.m-x.ca/", ) tables = ( read_html( StringIO(html), flavor="lxml", keep_default_na=False, na_values=[""], ) if html else [] ) except Exception: return {} listings: dict = {} weeklies: set = set() for tab, table in zip(LISTING_TABS, tables): for row in table.to_dict(orient="records"): symbol = str(row.get("Option Symbol") or "").strip() if not symbol or symbol.lower() == "nan": continue if tab == "weekly_option": weeklies.add(symbol) continue underlying = str(row.get("Underlying Symbol") or "").strip() listings[symbol] = { "name": str(row.get("Name of Underlying Instrument") or "").strip() or None, "underlying_symbol": underlying or None, "asset_class": tab, } for symbol in weeklies: listings.setdefault(symbol, {"name": None, "underlying_symbol": None}) for symbol, listing in listings.items(): listing["has_weeklies"] = symbol in weeklies return listings def _parse_cycles(content: bytes) -> dict: """Read the expiry cycle table out of the published PDF. Parameters ---------- content : bytes The PDF document. Returns ------- dict Per-symbol name, cycle, long-term, and weekly availability. """ from io import BytesIO import pdfplumber listings: dict = {} with pdfplumber.open(BytesIO(content)) as pdf: for page in pdf.pages: for table in page.extract_tables(): for row in table: if not row or len(row) < 3: continue name, symbol, cycle = row[0], row[1], row[2] if not symbol or not name or symbol == "Symbol": continue flags = [str(cell or "") for cell in row[3:]] listings[symbol.strip()] = { "name": name.strip(), "expiry_cycle": CYCLE_NAMES.get( (cycle or "").strip(), (cycle or "").strip() or None ), "has_long_term": any("X" in c for c in flags[:-1]), "has_weeklies": bool(flags) and "X" in flags[-1], } return listings @memoize(ttl=DOCUMENT_TTL) async def get_expiry_cycles(use_cache: bool = True) -> dict: """Get the expiry cycle, LEAPS, and weekly availability per option class. Parameters ---------- use_cache : bool Whether to read and write the on-disk cache. Returns ------- dict Per-symbol listing metadata, empty when the document is unavailable. """ from openbb_tmx.utils.cache import amake_request try: content = await amake_request( CYCLES_URL, use_cache=use_cache, accept_type="binary", referer="https://www.m-x.ca/", ) return _parse_cycles(content) if content else {} except Exception: return {} async def _add_underlying_names( instruments: list[dict], use_cache: bool = True ) -> list[dict]: """Attach the underlying's name and symbol to each listed derivative. Parameters ---------- instruments : list[dict] The parsed instruments. use_cache : bool Whether to read and write the on-disk cache. Returns ------- list[dict] The instruments, with names filled in where TMX publishes one. """ listings = await get_options_listings(use_cache=use_cache) cycles = await get_expiry_cycles(use_cache=use_cache) for instrument in instruments: root = instrument["symbol"] listing = listings.get(root, {}) cycle = cycles.get(root, {}) instrument["underlying_symbol"] = listing.get("underlying_symbol") instrument["expiry_cycle"] = cycle.get("expiry_cycle") instrument["has_long_term_options"] = cycle.get("has_long_term") instrument["has_weekly_options"] = listing.get("has_weeklies") if not instrument["name"]: instrument["name"] = listing.get("name") or cycle.get("name") return instruments async def get_instruments_by_class( asset_class: str | None = None, use_cache: bool = True ) -> list[dict]: """Get the Montreal Exchange universe, optionally for one instrument class. Parameters ---------- asset_class : str or None Restrict to one class, such as 'interest_rate_derivative'. use_cache : bool Whether to read and write the on-disk cache. Returns ------- list[dict] The matching instruments. """ universe = await get_instrument_universe(use_cache=use_cache) if not asset_class: return list(universe) return [i for i in universe if i["asset_class"] == asset_class] def _plain(html: str) -> str: """Return the readable text of a markup fragment.""" import re from html import unescape return re.sub(r"\s+", " ", unescape(re.sub(r"<[^>]+>", "", html))).strip() def _summary_value(value: str | None, field: str): """Return a published cell as a number. Parameters ---------- value : str or None The cell's sort value, as published. field : str The field the column maps onto. Returns ------- float or None The published number, or None when nothing was quoted. """ try: number = float(value) # ty: ignore[invalid-argument-type] except (TypeError, ValueError): return None return None if number == 0 and field in SUMMARY_PRICES else number def _summary_product(title: str, roots: list[str]) -> dict: """Name a summary section and the products it reports. Parameters ---------- title : str The section heading, as published. roots : list[str] The root symbols named in the heading. Returns ------- dict The section name, and the name to give each root it covers. """ import re product = re.sub(r"\s*\([A-Z0-9]{2,4}(?:,\s*[A-Z0-9]{2,4})*\)\s*$", "", title) if len(roots) == 1: return {"section": product, "names": {roots[0]: product}} return { "section": product, "names": { root: INDEX_PRODUCTS.get(root) or INTEREST_RATE_PRODUCTS.get(root) or product for root in roots }, } def _summary_rows(headers: list[str], body: str, product: dict) -> list[dict]: """Read one summary table into contract or product rows. Parameters ---------- headers : list[str] The column headings, as published. body : str The table's row markup. product : dict The section name and the name of each root it covers. Returns ------- list[dict] One entry per published row. """ import re contract = re.compile(r'symbol=([A-Z0-9]+)\*?#([A-Z]+)([FGHJKMNQUVXZ])(\d{2})"') results: list[dict] = [] for row in re.findall(r"