diff --git a/openbb_platform/providers/cftc/openbb_cftc/utils/fx_valuation.py b/openbb_platform/providers/cftc/openbb_cftc/utils/fx_valuation.py index 8785206da4b8..dfb9bfbe45e9 100644 --- a/openbb_platform/providers/cftc/openbb_cftc/utils/fx_valuation.py +++ b/openbb_platform/providers/cftc/openbb_cftc/utils/fx_valuation.py @@ -281,8 +281,38 @@ def _discount_for( return memo[key] +def _pair_curve( + fx_records: Iterable[dict], trade: dict, curve_date: dateType, memo: dict +) -> dict: + """Return the pair's forward curve for the day, built once and shared by its prints.""" + from openbb_cftc.utils.fx import extract_fx_observations + from openbb_cftc.utils.fx_vol import ndf_forward_curve + + key = f"fx-curve:{trade['pair']}:{curve_date.isoformat()}" + + if key not in memo: + records = _pair_subset(fx_records, trade, memo) + observations = extract_fx_observations( + records, + pair=trade["pair"], + trade_date=curve_date, + min_notional=CURVE_MIN_NOTIONAL, + ) + spot, forward = ndf_forward_curve( + records, trade["pair"], curve_date, min_notional=CURVE_MIN_NOTIONAL + ) + memo[key] = { + "nodes": len({o["days"] for o in observations if o["days"] > 2}), + "near": [o["rate"] for o in observations if o["days"] <= 9], + "spot": spot, + "forward": forward, + } + + return memo[key] + + def _cip_market( - peers: list[dict], + near: list[float], rates_records: Iterable[dict], trade: dict, curve_date: dateType, @@ -292,19 +322,6 @@ def _cip_market( """Both legs' curves and a covered-interest-parity forward, when prints are sparse.""" from statistics import median - from openbb_cftc.utils.fx import extract_fx_observations - - near = [ - observation["rate"] - for observation in extract_fx_observations( - peers, - pair=trade["pair"], - trade_date=curve_date, - min_notional=CURVE_MIN_NOTIONAL, - ) - if observation["days"] <= 9 - ] - if not near: return None @@ -327,46 +344,17 @@ def forward(years: float, _s=spot, _b=df_base, _q=df_quote) -> float: return {"spot": spot, "forward": forward, "df_base": df_base, "df_quote": df_quote} -def fx_market( - fx_records: Iterable[dict], +def _curve_market( + spot: float, + forward: Callable[[float], float], rates_records: Iterable[dict], trade: dict, curve_date: dateType, - min_trades: int = 1, - memo: dict | None = None, + min_trades: int, + memo: dict, ) -> dict | None: - """Spot, forward interpolator and both legs' discount curves for a pair, or None.""" - from openbb_cftc.utils.fx import extract_fx_observations - from openbb_cftc.utils.fx_vol import implied_df, ndf_forward_curve - - memo = {} if memo is None else memo - peers = [ - record - for record in _pair_subset(fx_records, trade, memo) - if (record.get("Dissemination Identifier") or "").strip() - != trade["dissemination_identifier"] - and not is_package_leg(record) - ] - nodes = { - observation["days"] - for observation in extract_fx_observations( - peers, - pair=trade["pair"], - trade_date=curve_date, - min_notional=CURVE_MIN_NOTIONAL, - ) - if observation["days"] > 2 - } - - if len(nodes) < MIN_FORWARD_NODES: - return _cip_market(peers, rates_records, trade, curve_date, min_trades, memo) - - spot, forward = ndf_forward_curve( - peers, trade["pair"], curve_date, min_notional=CURVE_MIN_NOTIONAL - ) - - if spot is None or forward is None or spot <= 0.0: - return _cip_market(peers, rates_records, trade, curve_date, min_trades, memo) + """Both legs' discount curves around an observed forward curve.""" + from openbb_cftc.utils.fx_vol import implied_df df_base = _discount_for(rates_records, trade["base"], curve_date, min_trades, memo) @@ -388,6 +376,46 @@ def _base_df(years: float, _quote=df_quote) -> float: return {"spot": spot, "forward": forward, "df_base": df_base, "df_quote": df_quote} +def fx_market( + fx_records: Iterable[dict], + rates_records: Iterable[dict], + trade: dict, + curve_date: dateType, + min_trades: int = 1, + memo: dict | None = None, +) -> dict | None: + """Spot, forward interpolator and both legs' discount curves for a pair, or None.""" + memo = {} if memo is None else memo + key = f"fx-market:{trade['pair']}:{curve_date.isoformat()}:{min_trades}" + + if key in memo: + return memo[key] + + curve = _pair_curve(fx_records, trade, curve_date, memo) + spot, forward = curve["spot"], curve["forward"] + + if ( + curve["nodes"] < MIN_FORWARD_NODES + or spot is None + or forward is None + or spot <= 0.0 + ): + market = _cip_market( + curve["near"], rates_records, trade, curve_date, min_trades, memo + ) + else: + market = _curve_market( + spot, forward, rates_records, trade, curve_date, min_trades, memo + ) + + if market is not None: + market["key"] = key + + memo[key] = market + + return market + + def value_fx_forward(trade: dict, market: dict) -> dict: """Mark a forward to the pair's forward curve and discount to the curve date.""" years = trade["days"] / DAYS_PER_YEAR @@ -422,17 +450,25 @@ def option_vol( return None memo = {} if memo is None else memo - options = extract_vanilla_options( - _pair_subset(fx_records, trade, memo), - trade["base"], - trade["quote"], - curve_date, - market["spot"], - family=family, - ) - priced = price_options( - options, market["spot"], market["df_quote"], market["df_base"] - ) + key = f"fx-options:{market['key']}:{family}" if market.get("key") else None + + if key is not None and key in memo: + priced = memo[key] + else: + options = extract_vanilla_options( + _pair_subset(fx_records, trade, memo), + trade["base"], + trade["quote"], + curve_date, + market["spot"], + family=family, + ) + priced = price_options( + options, market["spot"], market["df_quote"], market["df_base"] + ) + + if key is not None: + memo[key] = priced if not priced: return None diff --git a/openbb_platform/providers/cftc/tests/test_fx_valuation.py b/openbb_platform/providers/cftc/tests/test_fx_valuation.py index e36834978369..8455264189bd 100644 --- a/openbb_platform/providers/cftc/tests/test_fx_valuation.py +++ b/openbb_platform/providers/cftc/tests/test_fx_valuation.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta import pytest @@ -438,18 +438,18 @@ def test_fx_market_cip_fallback_rejects_a_nonpositive_spot(monkeypatch): assert fv.fx_market([], [], trade, DAY) is None -def test_fx_market_excludes_the_trades_own_print(monkeypatch): +def test_fx_market_uses_the_same_curve_as_the_vol_surface(monkeypatch): seen = {} - def _observe(records, **kwargs): + def _curve(records, pair, as_of, **kwargs): seen["ids"] = [r.get("Dissemination Identifier") for r in records] - return _observations([7, 38, 90]) + return 1460.0, lambda years: 1460.0 - monkeypatch.setattr("openbb_cftc.utils.fx.extract_fx_observations", _observe) monkeypatch.setattr( - "openbb_cftc.utils.fx_vol.ndf_forward_curve", - lambda *a, **k: (1460.0, lambda years: 1460.0), + "openbb_cftc.utils.fx.extract_fx_observations", + lambda *a, **k: _observations([7, 38, 90]), ) + monkeypatch.setattr("openbb_cftc.utils.fx_vol.ndf_forward_curve", _curve) monkeypatch.setattr( "openbb_cftc.utils.fx_vol.rate_discount_factor", lambda *a, **k: lambda years: 0.98**years, @@ -459,10 +459,48 @@ def _observe(records, **kwargs): ) own = _forward() peer = _forward(**{"Dissemination Identifier": "4402190022000000201"}) + leg = _forward( + **{ + "Dissemination Identifier": "4402190022000000202", + "Package indicator": "TRUE", + } + ) trade = fv.extract_fx_trade(own, DAY) - fv.fx_market([own, peer], [], trade, DAY) + fv.fx_market([own, peer, leg], [], trade, DAY) + + assert seen["ids"] == [ + own["Dissemination Identifier"], + "4402190022000000201", + "4402190022000000202", + ] + - assert seen["ids"] == ["4402190022000000201"] +def test_fx_market_builds_the_curve_once_per_pair_and_day(monkeypatch): + _patch_market(monkeypatch, [7, 38, 90]) + calls: list = [] + + def _curve(records, pair, as_of, **kwargs): + calls.append(as_of) + return 1460.0, lambda years: 1460.0 + + monkeypatch.setattr("openbb_cftc.utils.fx_vol.ndf_forward_curve", _curve) + first = _forward() + second = _forward(**{"Dissemination Identifier": "4402190022000000201"}) + memo: dict = {} + + for record in (first, second): + fv.fx_market( + [first, second], [], fv.extract_fx_trade(record, DAY), DAY, memo=memo + ) + + assert calls == [DAY] + + later = DAY + timedelta(days=1) + fv.fx_market( + [first, second], [], fv.extract_fx_trade(first, later), later, memo=memo + ) + + assert calls == [DAY, later] def test_fx_market_memo_builds_the_pair_subset_and_curves_once(monkeypatch): @@ -528,6 +566,27 @@ def test_option_vol_returns_none_without_a_family(): assert fv.option_vol([], trade, _market(1.14), DAY) is None +def test_option_vol_prices_a_family_once_per_market(monkeypatch): + calls: list = [] + + def _price(*args, **kwargs): + calls.append(args) + return [{"tenor_days": 38, "implied_vol": 0.10}] + + monkeypatch.setattr( + "openbb_cftc.utils.fx_vol.extract_vanilla_options", lambda *a, **k: [{}] + ) + monkeypatch.setattr("openbb_cftc.utils.fx_vol.price_options", _price) + trade = fv.extract_fx_trade(_option(), DAY) + market = {**_market(1.14), "key": "fx-market:EURUSD:2026-07-24:1"} + memo: dict = {} + + for _ in range(3): + assert fv.option_vol([], trade, market, DAY, memo) == pytest.approx(0.10) + + assert len(calls) == 1 + + def test_value_fx_trade_prices_a_forward(monkeypatch): _patch_market(monkeypatch, [7, 38, 90]) valued = fv.value_fx_trade(_forward(), [], [], DAY)