mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-31 06:22:18 +00:00
* merge branch develop with main * bump desktop version * fix the merge * sync Cargo.lock to desktop 2.0.0 * fix cli conftest.py --------- Co-authored-by: deeleeramone <>
463 lines
13 KiB
Python
463 lines
13 KiB
Python
"""Shared fixtures for the offline TMX suite."""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
def _isolated_result_cache(tmp_path_factory):
|
|
"""Keep the suite's stubbed results out of the real cache."""
|
|
import os
|
|
|
|
from openbb_tmx.utils.memo import CACHE_DIR_ENV
|
|
|
|
directory = tmp_path_factory.mktemp("tmx_results")
|
|
previous = os.environ.get(CACHE_DIR_ENV)
|
|
os.environ[CACHE_DIR_ENV] = str(directory)
|
|
|
|
yield
|
|
|
|
if previous is None:
|
|
os.environ.pop(CACHE_DIR_ENV, None)
|
|
else:
|
|
os.environ[CACHE_DIR_ENV] = previous
|
|
|
|
|
|
SAMPLES: dict = {
|
|
"getQuoteBySymbol": {
|
|
"getQuoteBySymbol": {
|
|
"symbol": "AC",
|
|
"name": "Air Canada",
|
|
"price": 23.27,
|
|
"priceChange": 0.62,
|
|
"percentChange": 2.74,
|
|
"exchangeName": "Toronto Stock Exchange",
|
|
"exShortName": "TSX",
|
|
"exchangeCode": "XTSE",
|
|
"sector": "Industrials",
|
|
"industry": "Airlines",
|
|
"volume": 1958163,
|
|
"openPrice": 22.78,
|
|
"dayHigh": 23.43,
|
|
"dayLow": 22.6,
|
|
"MarketCap": 8000000000,
|
|
"MarketCapAllClasses": 8000000000,
|
|
"peRatio": 6.1,
|
|
"prevClose": 22.65,
|
|
"beta": 1.9,
|
|
"eps": 3.8,
|
|
"longDescription": "Air Canada is an airline.",
|
|
"website": "https://aircanada.com",
|
|
"employees": 38000,
|
|
"totalSharesOutStanding": 350000000,
|
|
"vwap": 23.1,
|
|
"weeks52high": 25.0,
|
|
"weeks52low": 14.0,
|
|
"issueType": "Equity",
|
|
"currency": "CAD",
|
|
"close": 23.27,
|
|
"qmdescription": "Air Canada",
|
|
}
|
|
},
|
|
"getQuoteForSymbols": {
|
|
"getQuoteForSymbols": [
|
|
{
|
|
"symbol": "^TSX",
|
|
"longname": "S&P/TSX",
|
|
"price": 35369.1,
|
|
"percentChange": 0.4,
|
|
"priceChange": 140.0,
|
|
"volume": 1,
|
|
},
|
|
]
|
|
},
|
|
"getTimeSeriesData": {
|
|
"getTimeSeriesData": [
|
|
{
|
|
"dateTime": "2026-07-23T16:00:00-04:00",
|
|
"open": 22.9,
|
|
"high": 23.4,
|
|
"low": 22.8,
|
|
"close": 23.2,
|
|
"volume": 100,
|
|
},
|
|
{
|
|
"dateTime": "2026-07-24T16:00:00-04:00",
|
|
"open": 23.0,
|
|
"high": 23.5,
|
|
"low": 22.9,
|
|
"close": 23.27,
|
|
"volume": 120,
|
|
},
|
|
]
|
|
},
|
|
"getCompanyPriceHistory": {
|
|
"getCompanyPriceHistory": [
|
|
{
|
|
"datetime": "2026-07-24",
|
|
"openPrice": 23.0,
|
|
"closePrice": 23.27,
|
|
"high": 23.5,
|
|
"low": 22.9,
|
|
"volume": 120,
|
|
"tradeValue": 2700.0,
|
|
"numberOfTrade": 10,
|
|
"change": 0.62,
|
|
"changePercent": 2.74,
|
|
"vwap": 23.1,
|
|
},
|
|
]
|
|
},
|
|
"getCompanyMostRecentTrades": {
|
|
"getCompanyMostRecentTrades": [
|
|
{
|
|
"price": 23.27,
|
|
"volume": 95,
|
|
"datetime": "2026-07-24T16:00:00-04:00",
|
|
"sellerId": "79",
|
|
"sellerName": "CIBC",
|
|
"buyerId": "002",
|
|
"buyerName": "RBC",
|
|
"exchangeCode": "TSX",
|
|
},
|
|
]
|
|
},
|
|
"GetCompanyShortInterest": {
|
|
"getCompanyShortInterest": {
|
|
"TICKER": "AC",
|
|
"BUSINESS_DATE": "2026-07-24",
|
|
"SHORT_INTEREST": 6443193,
|
|
"SHORTINTERESTPCT": 0.0231,
|
|
"DAYSTOCOVER10DAY": None,
|
|
"DAYSTOCOVER30DAY": 2.44,
|
|
"DAYSTOCOVER90DAY": None,
|
|
}
|
|
},
|
|
"getSplitsForSymbol": {
|
|
"getSplitsForSymbol": [{"splitDate": "2004-04-02", "ratio": 0.5}]
|
|
},
|
|
"getDividendsForSymbol": {
|
|
"dividendHistory": {
|
|
"pageNumber": 1,
|
|
"hasNextPage": False,
|
|
"dividends": [
|
|
{
|
|
"exDate": "2026-04-01",
|
|
"amount": 1.06,
|
|
"currency": "CAD",
|
|
"payableDate": "2026-04-28",
|
|
"declarationDate": "2026-02-25",
|
|
"recordDate": "2026-04-02",
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"getCompanyFilings": {
|
|
"filings": [
|
|
{
|
|
"size": "1.2 MB",
|
|
"filingDate": "2026-06-01",
|
|
"description": "Annual",
|
|
"name": "AIF",
|
|
"urlToPdf": "https://example.com/a.pdf",
|
|
}
|
|
]
|
|
},
|
|
"getNewsAndEvents": {
|
|
"news": [
|
|
{
|
|
"headline": "Air Canada reports",
|
|
"datetime": "2026-07-24T10:00:00-04:00",
|
|
"source": "CNW",
|
|
"newsid": "123",
|
|
"summary": "Air Canada reports",
|
|
}
|
|
],
|
|
"events": [
|
|
{
|
|
"title": "Q2 Earnings",
|
|
"date": "2026-08-01",
|
|
"status": "Confirmed",
|
|
"type": "Earnings",
|
|
}
|
|
],
|
|
},
|
|
"getCompanyAnalysts": {
|
|
"getCompanyAnalysts": {
|
|
"totalAnalysts": 12,
|
|
"priceTarget": {
|
|
"lowPriceTarget": 20.0,
|
|
"highPriceTarget": 32.0,
|
|
"priceTarget": 27.0,
|
|
"priceTargetUpside": 0.16,
|
|
},
|
|
"consensusAnalysts": {"consensus": "Buy", "buy": 8, "sell": 1, "hold": 3},
|
|
}
|
|
},
|
|
"getEnhancedEarningsForDate": {
|
|
"getEnhancedEarningsForDate": [
|
|
{
|
|
"symbol": "AC",
|
|
"companyName": "Air Canada",
|
|
"announceTime": "After Close",
|
|
"estimatedEps": 0.5,
|
|
"actualEps": 0.6,
|
|
"epsSurprisePercent": 20.0,
|
|
"epsSurpriseDollar": 0.1,
|
|
}
|
|
]
|
|
},
|
|
"getCompanyInsidersActivities": {
|
|
"getCompanyInsidersActivities": {
|
|
"insiderActivities": [
|
|
{
|
|
"periodkey": "3",
|
|
"buy": [
|
|
{
|
|
"name": "Doe, Jane",
|
|
"trades": 1,
|
|
"shares": 100,
|
|
"sharesHeld": 500,
|
|
"tradeValue": 2300.0,
|
|
}
|
|
],
|
|
"sell": [],
|
|
}
|
|
],
|
|
"activitySummary": [
|
|
{
|
|
"periodkey": "3",
|
|
"buyShares": 100,
|
|
"soldShares": 0,
|
|
"netActivity": 100,
|
|
"totalShares": 100,
|
|
"buyTrades": 1,
|
|
"sellTrades": 0,
|
|
"totalTrades": 1,
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"getStockListSymbolsWithQuote": {
|
|
"stockList": {
|
|
"stockListId": "TOP_VOLUME",
|
|
"name": "Top Volume",
|
|
"description": "d",
|
|
"longDescription": "ld",
|
|
"metricTitle": "Volume",
|
|
"totalPriceChange": 1.0,
|
|
"totalPercentChange": 0.5,
|
|
"createdAt": "2026-01-01",
|
|
"updatedAt": "2026-07-24",
|
|
"listItems": [
|
|
{
|
|
"symbol": "AC",
|
|
"longName": "Air Canada",
|
|
"rank": 1,
|
|
"metric": 100,
|
|
"price": 23.27,
|
|
"priceChange": 0.62,
|
|
"percentChange": 2.74,
|
|
"volume": 1958163,
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"getIndexConstituents": {
|
|
"constituents": [
|
|
{
|
|
"symbol": "RY",
|
|
"longName": "Royal Bank of Canada",
|
|
"shortName": "Royal Bank Of Ca",
|
|
"exchange": "TSX",
|
|
"exLongName": "Toronto Stock Exchange",
|
|
"quotedMarketValue": 412336362030,
|
|
"weight": 8.168,
|
|
}
|
|
],
|
|
"keyData": {"numConstituents": 219},
|
|
},
|
|
"getIndexKeyData": {
|
|
"getIndexKeyData": {
|
|
"peRatio": 21.26,
|
|
"pbRatio": 2.71,
|
|
"priceToSales": 2.77,
|
|
"pcfRatio": 36.44,
|
|
"divYield": 2.13,
|
|
"numConstituents": 219,
|
|
}
|
|
},
|
|
"getMarketMovers": {
|
|
"getMarketMovers": [
|
|
{
|
|
"symbol": "AC",
|
|
"name": "Air Canada",
|
|
"exchangeName": "TSX",
|
|
"exchangeCode": "XTSE",
|
|
"price": 23.27,
|
|
"priceChange": 0.62,
|
|
"percentChange": 2.74,
|
|
"volume": 1958163,
|
|
"tradeVolume": 9452,
|
|
"open": 22.78,
|
|
"high": 23.43,
|
|
"low": 22.6,
|
|
"weeks52low": 14.0,
|
|
"weeks52high": 25.0,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def gql(monkeypatch):
|
|
"""Answer every GraphQL operation from the sample payloads."""
|
|
calls: list = []
|
|
|
|
async def fake(
|
|
operation, query, variables=None, symbol=None, use_cache=True, **kwargs
|
|
):
|
|
calls.append((operation, variables))
|
|
return SAMPLES.get(operation, {})
|
|
|
|
monkeypatch.setattr("openbb_tmx.utils.cache.amake_gql_request", fake)
|
|
|
|
return calls
|
|
|
|
|
|
@pytest.fixture
|
|
def http(monkeypatch):
|
|
"""Answer every cached HTTP request from a routing table."""
|
|
routes: dict = {}
|
|
|
|
async def fake(url, use_cache=True, accept_type="json", **kwargs):
|
|
for fragment, payload in routes.items():
|
|
if fragment in url:
|
|
return payload
|
|
|
|
return None
|
|
|
|
monkeypatch.setattr("openbb_tmx.utils.cache.amake_request", fake)
|
|
|
|
return routes
|
|
|
|
|
|
UNDERLYING_PRICE = 23.0
|
|
|
|
CHAIN_STRIKES = (19.0, 21.0, 23.0, 25.0, 27.0)
|
|
|
|
CHAIN_TENORS = (7, 35, 63)
|
|
|
|
|
|
def _contract_quote(expiry, strike, side, index, priced=True):
|
|
"""Build one vendor quote for the synthetic chain."""
|
|
distance = abs(strike - UNDERLYING_PRICE) / UNDERLYING_PRICE
|
|
premium = round(max(0.05, (UNDERLYING_PRICE * 0.04) - (distance * 6.9)), 2)
|
|
contract = f"{expiry.replace('-', '')}{side[0]}{int(strike * 1000):08d}"
|
|
|
|
return {
|
|
"contract": {
|
|
"expirydate": expiry,
|
|
"callput": side,
|
|
"strike": strike,
|
|
"type": "STANDARD",
|
|
"openinterest": 100 + index * 7,
|
|
"contracthigh": premium + 0.5,
|
|
"contractlow": max(0.01, premium - 0.5),
|
|
},
|
|
"pricedata": {
|
|
"last": premium,
|
|
"bid": round(premium - 0.02, 2),
|
|
"ask": round(premium + 0.02, 2),
|
|
"bidsize": 10,
|
|
"asksize": 12,
|
|
"contractvolume": 5 + index,
|
|
"prevclose": premium,
|
|
"change": 0.01,
|
|
"changepercent": 0.5,
|
|
"open": premium,
|
|
"high": premium + 0.1,
|
|
"low": premium - 0.1,
|
|
"tick": 1,
|
|
},
|
|
"greeks": {
|
|
"impvol": round(0.25 + distance, 4) if priced else 0.0,
|
|
"delta": round(
|
|
0.5 - distance if side == "Call" else -0.5 + distance,
|
|
4,
|
|
),
|
|
"gamma": 0.02,
|
|
"theta": -0.01,
|
|
"vega": 0.03,
|
|
"rho": 0.001,
|
|
},
|
|
"key": {"symbol": [f"@AC{contract}:CA"]},
|
|
}
|
|
|
|
|
|
GREEK_FIELDS = (
|
|
"implied_volatility",
|
|
"delta",
|
|
"gamma",
|
|
"theta",
|
|
"vega",
|
|
"rho",
|
|
)
|
|
|
|
|
|
def build_options_chain(
|
|
priced: bool = True, greeks: bool = True, expired: bool = False
|
|
):
|
|
"""Validate a TMX options chain from a synthetic vendor payload.
|
|
|
|
Parameters
|
|
----------
|
|
priced : bool
|
|
When False, every contract carries a zero implied volatility.
|
|
greeks : bool
|
|
When False, the chain is published without any greek columns, the way
|
|
the end-of-day download is.
|
|
expired : bool
|
|
When True, the chain also carries a contract that has already expired,
|
|
which the chain lists but leaves out of its frame.
|
|
|
|
Returns
|
|
-------
|
|
TmxOptionsChainsData
|
|
The validated chain.
|
|
"""
|
|
from datetime import date, timedelta
|
|
|
|
from openbb_tmx.models.options_chains import TmxOptionsChainsData, _flatten_chain
|
|
|
|
today = date.today()
|
|
index = 0
|
|
groups: list = []
|
|
tenors = (-7, *CHAIN_TENORS) if expired else CHAIN_TENORS
|
|
|
|
for tenor in tenors:
|
|
expiry = (today + timedelta(days=tenor)).isoformat()
|
|
quotes: list = []
|
|
|
|
for strike in CHAIN_STRIKES:
|
|
for side in ("Call", "Put"):
|
|
quotes.append(_contract_quote(expiry, strike, side, index, priced))
|
|
index += 1
|
|
|
|
groups.append({"expirydate": expiry, "callputgroup": [{"quote": quotes}]})
|
|
|
|
flat = _flatten_chain({"expiryGroup": groups}, "AC")
|
|
flat["underlying_price"] = [UNDERLYING_PRICE] * len(flat["contract_symbol"])
|
|
|
|
if not greeks:
|
|
for field in GREEK_FIELDS:
|
|
flat.pop(field, None)
|
|
|
|
return TmxOptionsChainsData.model_validate(flat)
|
|
|
|
|
|
@pytest.fixture
|
|
def options_chain():
|
|
"""Serve a validated TMX options chain."""
|
|
return build_options_chain()
|