import asyncio from datetime import date, datetime, timedelta, timezone import pytest from openbb_core.app.model.abstract.error import OpenBBError from openbb_cftc.utils import fixings, store @pytest.mark.parametrize( ("underlier", "expected"), [ ("USD-SOFR-COMPOUND", "SOFR"), ("USD-SOFR-OIS Compound", "SOFR"), ("USD-Federal Funds-H.15-OIS-COMPOUND", "EFFR"), ("CAD-CORRA-OIS-COMPOUND", "CORRA"), ("EUR-EuroSTR-OIS Compound", "ESTR"), ("GBP-SONIA-COMPOUND", "SONIA"), ("GBP-SONIA-OIS Compound", "SONIA"), ("JPY-TONA-OIS-COMPOUND", "TONA"), ("JPY-TONA Compounded Index", "TONA"), ("MXN-TIIE ON-OIS Compound", "TIIE"), ("MXN-TIIE-OIS-COMPOUND", "TIIE"), ("BRL-CDI", "CDI"), ("CHF-SARON-OIS-COMPOUND", "SARON"), ("CHF-SARON-OIS Compound", "SARON"), ("SGD-SORA-COMPOUND", "SORA"), ("SGD-SORA-OIS Compound", "SORA"), ("INR-MIBOR-OIS Compound", "MIBOR"), ("INR-MIBOR-OIS-COMPOUND", "MIBOR"), ("COP-IBR-OIS Compound", "IBR"), ("COP-IBR-OIS-COMPOUND", "IBR"), ("ZAR-ZARONIA-OIS Compound", "ZARONIA"), ("ZAR-ZARONIA-OIS-COMPOUND", "ZARONIA"), ("CLP-ICP", "ICP"), ("CL-CLICP-Bloomberg", "ICP"), ("THB-THOR-OIS Compound", "THOR"), ("THB-THOR-COMPOUND", "THOR"), ("ILS-SHIR-OIS Compound", "SHIR"), ("ILS-SHIR-OIS-COMPOUND", "SHIR"), ("NZD-NZIONA-OIS Compound", "NZIONA"), ("NZD-NZIONA-OIS-COMPOUND", "NZIONA"), ("AUD-AONIA-OIS Compound", "AONIA"), ("AUD-AONIA-OIS-COMPOUND", "AONIA"), ("CNY-CNREPOFIX=CFXS-Reuters", "FR007"), ("CNY-Fixing Repo Rate", "FR007"), ("PLN-WIBOR", "WIBOR"), ("HUF-BUBOR-Reuters", "BUBOR"), ("AUD-BBSW", "BBSW"), ("ZAR-JIBAR-SAFEX", "JIBAR"), ("HKD-HIBOR-HKAB", "HIBOR"), ("KRW-CD 91D", "KRWCD"), ("MYR-KLIBOR-BNM", "KLIBOR"), ("CZK-PRIBOR-PRBO", "PRIBOR"), ("EUR-EURIBOR-Reuters", "EURIBOR"), ("NZD-BBR-FRA", "BKBM"), ("NA/Swap OIS USD", "SOFR"), ("NA/Swap Fxd Flt EUR", "EURIBOR"), ("OTHER vs Basket", None), (None, None), ], ) def test_index_for_underlier(underlier, expected): assert fixings.index_for_underlier(underlier) == expected def test_compound_fixings_spans_gaps_and_compounds_daily(): series = { date(2026, 7, 16): 0.036, date(2026, 7, 17): 0.036, date(2026, 7, 20): 0.0357, } accrued = fixings.compound_fixings(series, date(2026, 7, 16), date(2026, 7, 21)) expected = (1 + 0.036 / 360) * (1 + 0.036 * 3 / 360) * (1 + 0.0357 / 360) - 1 assert accrued == pytest.approx(expected) def test_compound_fixings_uses_the_fixing_in_force_at_the_start(): series = {date(2026, 7, 17): 0.036} accrued = fixings.compound_fixings(series, date(2026, 7, 18), date(2026, 7, 20)) assert accrued == pytest.approx(0.036 * 2 / 360) def test_compound_fixings_refuses_an_uncovered_window(): series = {date(2026, 7, 17): 0.036} assert fixings.compound_fixings({}, date(2026, 7, 16), date(2026, 7, 21)) is None assert ( fixings.compound_fixings(series, date(2026, 7, 15), date(2026, 7, 21)) is None ) def test_compound_fixings_at_basis_252_applies_once_per_business_day(): series = { date(2026, 6, 18): 0.00052531, date(2026, 6, 19): 0.00052531, date(2026, 6, 22): 0.00052531, } accrued = fixings.compound_fixings( series, date(2026, 6, 18), date(2026, 6, 23), 252.0 ) expected = (1.00052531) ** 3 - 1 assert accrued == pytest.approx(expected) def test_compound_fixings_at_basis_252_refuses_an_uncovered_window(): assert ( fixings.compound_fixings({}, date(2026, 6, 18), date(2026, 6, 23), 252.0) is None ) def test_get_fixings_fetches_parses_and_caches(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) calls: list = [] async def _request(url, **kwargs): calls.append(url) return { "refRates": [ {"effectiveDate": "2026-07-16", "type": "SOFR", "percentRate": 3.62}, {"effectiveDate": "2026-07-17", "type": "SOFR", "percentRate": 3.59}, ] } monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) series = asyncio.run( fixings.get_fixings("SOFR", date(2026, 7, 16), date(2026, 7, 19)) ) assert series == {date(2026, 7, 16): 0.0362, date(2026, 7, 17): 0.0359} assert len(calls) == 1 assert "secured/sofr" in calls[0] assert f"startDate={fixings.FULL_HISTORY_START}" in calls[0] again = asyncio.run( fixings.get_fixings("SOFR", date(2026, 7, 16), date(2026, 7, 19)) ) assert again == series assert len(calls) == 1 store.reset() def test_get_fixings_serves_a_different_window_of_the_same_index_from_cache( monkeypatch, tmp_path ): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) calls: list = [] async def _request(url, **kwargs): calls.append(url) return { "refRates": [ {"effectiveDate": "2019-01-02", "type": "SOFR", "percentRate": 2.4}, {"effectiveDate": "2026-07-16", "type": "SOFR", "percentRate": 3.62}, ] } monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) asyncio.run(fixings.get_fixings("SOFR", date(2026, 7, 16), date(2026, 7, 16))) assert len(calls) == 1 older = asyncio.run(fixings.get_fixings("SOFR", date(2019, 1, 2), date(2019, 1, 2))) assert older == {date(2019, 1, 2): 0.024} assert len(calls) == 1 store.reset() def test_get_fixings_without_cache_always_fetches(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) calls: list = [] async def _request(url, **kwargs): calls.append(url) return {"refRates": []} monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) for _ in range(2): series = asyncio.run( fixings.get_fixings( "SOFR", date(2026, 7, 16), date(2026, 7, 17), use_cache=False ) ) assert series == {} assert len(calls) == 2 store.reset() def test_get_fixings_serves_cached_history_through_a_source_outage( monkeypatch, tmp_path ): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) today = datetime.now(timezone.utc).date() yesterday = today - timedelta(days=1) async def _request(url, **kwargs): return { "refRates": [ { "effectiveDate": yesterday.isoformat(), "type": "SOFR", "percentRate": 3.62, } ] } monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) first = asyncio.run(fixings.get_fixings("SOFR", yesterday, today)) assert first == {yesterday: 0.0362} key = "fixings-SOFR" monkeypatch.setattr(store, "SEARCH_INTRADAY_TTL_SECONDS", -1) store.put_search_days(key, {today.isoformat(): []}) async def _boom(url, **kwargs): raise RuntimeError("down") monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _boom) stale = asyncio.run(fixings.get_fixings("SOFR", yesterday, today)) assert stale == {yesterday: 0.0362} store.reset() def test_get_fixings_wraps_transport_and_shape_errors(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _boom(url, **kwargs): raise RuntimeError("down") monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _boom) with pytest.raises(OpenBBError, match="Failed to fetch SOFR fixings"): asyncio.run( fixings.get_fixings( "SOFR", date(2026, 7, 16), date(2026, 7, 17), use_cache=False ) ) async def _shape(url, **kwargs): return ["not-a-dict"] monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _shape) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "SOFR", date(2026, 7, 16), date(2026, 7, 17), use_cache=False ) ) def test_fixing_basis_is_the_index_convention(): assert fixings.fixing_basis("CORRA") == 365.0 assert fixings.fixing_basis("SONIA") == 365.0 assert fixings.fixing_basis("TONA") == 365.0 assert fixings.fixing_basis("SOFR") == 360.0 assert fixings.fixing_basis("ESTR") == 360.0 assert fixings.fixing_basis("TIIE") == 360.0 assert fixings.fixing_basis("CDI") == 252.0 assert fixings.fixing_basis("SARON") == 360.0 assert fixings.fixing_basis("SORA") == 365.0 assert fixings.fixing_basis("MIBOR") == 365.0 assert fixings.fixing_basis("IBR") == 360.0 assert fixings.fixing_basis("ZARONIA") == 365.0 assert fixings.fixing_basis("ICP") == 360.0 assert fixings.fixing_basis("THOR") == 365.0 assert fixings.fixing_basis("SHIR") == 365.0 assert fixings.fixing_basis("NZIONA") == 365.0 assert fixings.fixing_basis("AONIA") == 365.0 assert fixings.fixing_basis(None) == 360.0 def test_get_fixings_parses_the_boc_valet_shape(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _request(url, **kwargs): assert "bankofcanada.ca/valet" in url return { "observations": [ {"d": "2026-07-20", "AVG.INTWO": {"v": "2.36"}}, {"d": "2026-07-21", "AVG.INTWO": {"v": "2.35"}}, {"d": "2026-07-22"}, ] } monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) series = asyncio.run( fixings.get_fixings( "CORRA", date(2026, 7, 20), date(2026, 7, 22), use_cache=False ) ) assert series == {date(2026, 7, 20): 0.0236, date(2026, 7, 21): 0.0235} class _EcbResponse: def __init__(self, body): self._body = body def raise_for_status(self): return None async def text(self): return self._body async def __aenter__(self): return self async def __aexit__(self, *args): return False class _EcbSession: def __init__(self, body, seen): self._body = body self._seen = seen def get(self, url, **kwargs): self._seen.append(url) return _EcbResponse(self._body) async def __aenter__(self): return self async def __aexit__(self, *args): return False def _patch_ecb(monkeypatch, body): import aiohttp seen: list = [] monkeypatch.setattr( aiohttp, "ClientSession", lambda *a, **k: _EcbSession(body, seen) ) return seen def test_get_fixings_parses_the_ecb_csv_shape(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) seen = _patch_ecb( monkeypatch, "KEY,FREQ,TIME_PERIOD,OBS_VALUE,OBS_STATUS\n" "EST.B.EU000A2X2A25.WT,B,2026-07-15,2.184,A\n" "EST.B.EU000A2X2A25.WT,B,2026-07-16,2.186,A\n", ) series = asyncio.run( fixings.get_fixings( "ESTR", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) assert series == { date(2026, 7, 15): pytest.approx(0.02184), date(2026, 7, 16): pytest.approx(0.02186), } assert "data-api.ecb.europa.eu" in seen[0] def test_get_fixings_rejects_malformed_boc_and_ecb_payloads(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _bad(url, **kwargs): return {"nope": True} monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _bad) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "CORRA", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) _patch_ecb(monkeypatch, "error") with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "ESTR", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) def test_get_fixings_parses_the_boe_csv_shape(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) seen = _patch_ecb( monkeypatch, "DATE,IUDSOIA\n15 Jul 2026,3.7308\n16 Jul 2026,3.7308\n17 Jul 2026,\n", ) series = asyncio.run( fixings.get_fixings( "SONIA", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) assert series == { date(2026, 7, 15): pytest.approx(0.037308), date(2026, 7, 16): pytest.approx(0.037308), } assert "Datefrom=01/Jan/2000" in seen[0] and "SeriesCodes=IUDSOIA" in seen[0] def test_get_fixings_rejects_a_malformed_boe_payload(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) _patch_ecb(monkeypatch, "maintenance") with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "SONIA", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) class _BojResponse: def __init__(self, body): self._body = body def raise_for_status(self): return None async def text(self): return self._body async def __aenter__(self): return self async def __aexit__(self, *args): return False class _BojSession: def __init__(self, seen): self._seen = seen def post(self, url, data=None): self._seen.append(("POST", url, dict(data or {}))) if (data or {}).get("cgi") == "$nme_r000_en": return _BojResponse("...DLform_DD...") return _BojResponse('csv') def get(self, url): self._seen.append(("GET", url, None)) return _BojResponse( "Series code,FM01'STRDCLUCON\n\n" 'Name of time-series,"Call Rate"\n' "2026/07/15,0.981\n" "2026/07/16,NA\n" "2026/07/17,\n" ) async def __aenter__(self): return self async def __aexit__(self, *args): return False def _patch_boj(monkeypatch): import aiohttp seen: list = [] monkeypatch.setattr(aiohttp, "ClientSession", lambda *a, **k: _BojSession(seen)) return seen def test_get_fixings_parses_the_boj_two_step_csv_flow(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) seen = _patch_boj(monkeypatch) series = asyncio.run( fixings.get_fixings( "TONA", date(2026, 7, 15), date(2026, 7, 17), use_cache=False ) ) assert series == {date(2026, 7, 15): pytest.approx(0.00981)} assert seen[0][0] == "POST" and seen[0][2]["cgi"] == "$nme_r000_en" assert seen[0][2]["lstCode"] == "FM01'STRDCLUCON" assert seen[0][2]["obj_name"] == "FM01" assert seen[1][0] == "POST" and seen[1][2]["cgi"] == "$nme_r030_en" assert seen[2][0] == "GET" and seen[2][1].endswith("nme_R031.123.20260101.01.csv") def test_get_fixings_rejects_a_malformed_boj_search_step(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) import aiohttp class _BadSearchSession(_BojSession): def post(self, url, data=None): self._seen.append(("POST", url, dict(data or {}))) return _BojResponse("no form here") monkeypatch.setattr(aiohttp, "ClientSession", lambda *a, **k: _BadSearchSession([])) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "TONA", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) def test_get_fixings_rejects_a_malformed_boj_download_step(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) import aiohttp class _BadDownloadSession(_BojSession): def post(self, url, data=None): self._seen.append(("POST", url, dict(data or {}))) if (data or {}).get("cgi") == "$nme_r000_en": return _BojResponse("...DLform_DD...") return _BojResponse("no csv link") monkeypatch.setattr( aiohttp, "ClientSession", lambda *a, **k: _BadDownloadSession([]) ) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "TONA", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) class _BanxicoSession: def __init__(self, body, seen): self._body = body self._seen = seen def post(self, url, data=None): self._seen.append((url, dict(data or {}))) return _BojResponse(self._body) async def __aenter__(self): return self async def __aexit__(self, *args): return False def _patch_banxico(monkeypatch, body): import aiohttp seen: list = [] monkeypatch.setattr( aiohttp, "ClientSession", lambda *a, **k: _BanxicoSession(body, seen) ) return seen def test_get_fixings_parses_the_banxico_csv_export(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) seen = _patch_banxico( monkeypatch, '"Title","Overnight TIIE Funding Rate, Volume-weighted median."\n' '"Date","SF331451"\n' "07/15/2026,6.5000\n" "07/16/2026,6.4900\n", ) series = asyncio.run( fixings.get_fixings( "TIIE", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) assert series == { date(2026, 7, 15): pytest.approx(0.065), date(2026, 7, 16): pytest.approx(0.0649), } assert seen[0][1]["series"] == "SF331451" this_year = str(datetime.now(timezone.utc).date().year) assert seen[0][1]["anoInicial"] == "2000" and seen[0][1]["anoFinal"] == this_year def test_get_fixings_rejects_a_malformed_banxico_payload(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) _patch_banxico(monkeypatch, "maintenance") with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "TIIE", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) def test_get_fixings_parses_the_bcb_sgs_shape(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _request(url, **kwargs): assert "api.bcb.gov.br/dados/serie/bcdata.sgs.12" in url return [ {"data": "18/06/2026", "valor": "0.052531"}, {"data": "19/06/2026", "valor": "0.052531"}, ] monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) series = asyncio.run( fixings.get_fixings( "CDI", date(2026, 6, 18), date(2026, 6, 19), use_cache=False ) ) assert series == { date(2026, 6, 18): pytest.approx(0.00052531), date(2026, 6, 19): pytest.approx(0.00052531), } def test_get_fixings_rejects_a_malformed_bcb_payload(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _bad(url, **kwargs): return {"nope": True} monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _bad) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "CDI", date(2026, 6, 18), date(2026, 6, 19), use_cache=False ) ) def test_get_fixings_parses_the_snb_data_portal_shape(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _request(url, **kwargs): assert "data.snb.ch/api/cube/zirepo" in url return { "timeseries": [ { "header": [{"dimItem": "Overnight (SARON), close of trading"}], "metadata": {"key": "EPB@SNB.zirepo{H0}"}, "values": [ {"date": "2026-07-15", "value": -0.043818}, {"date": "2026-07-16", "value": -0.04}, ], }, { "header": [{"dimItem": "SARON 1M Compound Rate"}], "metadata": {"key": "EPB@SNB.zirepo{H6}"}, "values": [{"date": "2026-07-15", "value": -0.0387}], }, ] } monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _request) series = asyncio.run( fixings.get_fixings( "SARON", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) assert series == { date(2026, 7, 15): pytest.approx(-0.00043818), date(2026, 7, 16): pytest.approx(-0.0004), } def test_get_fixings_rejects_a_malformed_snb_payload(monkeypatch, tmp_path): monkeypatch.setenv("OPENBB_CACHE_DIRECTORY", str(tmp_path)) async def _no_timeseries(url, **kwargs): return {"nope": True} monkeypatch.setattr( "openbb_core.provider.utils.helpers.amake_request", _no_timeseries ) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "SARON", date(2026, 7, 15), date(2026, 7, 16), use_cache=False ) ) async def _no_h0(url, **kwargs): return { "timeseries": [ { "header": [{"dimItem": "other"}], "metadata": {"key": "{H6}"}, "values": [], } ] } monkeypatch.setattr("openbb_core.provider.utils.helpers.amake_request", _no_h0) with pytest.raises(OpenBBError, match="Unexpected fixings response"): asyncio.run( fixings.get_fixings( "SARON", date(2026, 7, 17), date(2026, 7, 18), use_cache=False ) ) class _MasResponse: def __init__(self, body): self._body = body def raise_for_status(self): return None async def text(self): return self._body async def __aenter__(self): return self async def __aexit__(self, *args): return False class _MasSession: def __init__(self, token_page, result_page, seen): self._token_page = token_page self._result_page = result_page self._seen = seen def get(self, url): self._seen.append(("GET", url, None)) return _MasResponse(self._token_page) def post(self, url, data=None): self._seen.append(("POST", url, dict(data or {}))) return _MasResponse(self._result_page) async def __aenter__(self): return self async def __aexit__(self, *args): return False def _patch_mas(monkeypatch, token_page, result_page): import aiohttp seen: list = [] monkeypatch.setattr( aiohttp, "ClientSession", lambda *a, **k: _MasSession(token_page, result_page, seen), ) return seen # noqa: S105 _MAS_TOKEN_PAGE = ( '' # noqa: S105 '' '' ) _MAS_RESULT_PAGE = """
| SORA VALUE DATE | SORA PUBLICATION DATE | SORA | ||
|---|---|---|---|---|
| 2026 | Jul | 15 | 16 Jul 2026 | 1.0910 |
| 16 | 17 Jul 2026 | 1.1200 | ||
| 17 | ||||