| import inspect |
|
|
| import pandas as pd |
| import pytest |
|
|
| import TerraFin.data.factory as factory_module |
| import TerraFin.data.providers.market.market_indicator as market_indicator_module |
| from TerraFin.data import DataFactory |
| from TerraFin.data.contracts import HistoryChunk |
| from TerraFin.data.contracts.dataframes import TimeSeriesDataFrame |
|
|
|
|
| def _assert_timeseries_contract(df: TimeSeriesDataFrame) -> None: |
| assert isinstance(df, TimeSeriesDataFrame) |
|
|
| if df.empty: |
| |
| return |
|
|
| assert "time" in df.columns |
| assert "close" in df.columns |
| assert pd.api.types.is_datetime64_any_dtype(df["time"]) |
| assert df["time"].is_monotonic_increasing |
| assert not df["time"].duplicated().any() |
|
|
|
|
| def test_timeseries_dataframe_normalizes_index_based_data() -> None: |
| raw = pd.DataFrame( |
| {"Close": [1.0, 1.1, 1.2]}, |
| index=["2024-01-03", "2024-01-01", "2024-01-01"], |
| ) |
| df = TimeSeriesDataFrame(raw) |
|
|
| _assert_timeseries_contract(df) |
| assert list(df.columns) == ["time", "close"] |
| assert len(df) == 2 |
|
|
|
|
| def test_chart_output_methods_are_marked() -> None: |
| chart_methods = { |
| name |
| for name, method in inspect.getmembers(DataFactory, predicate=inspect.isfunction) |
| if getattr(method, "__chart_output__", False) |
| } |
| assert chart_methods == {"get", "get_fred_data", "get_economic_data", "get_market_data"} |
|
|
|
|
| def test_get_market_data_contract_stubbed(monkeypatch) -> None: |
| def _stub_market_data(_name: str) -> TimeSeriesDataFrame: |
| return TimeSeriesDataFrame(pd.DataFrame({"Close": [100.0, 101.5, 103.0]}, index=["2026-01-01", "2026-01-02", "2026-01-03"])) |
|
|
| monkeypatch.setattr(factory_module, "get_market_data", _stub_market_data) |
| factory = DataFactory() |
| df = factory.get_market_data("S&P 500") |
| _assert_timeseries_contract(df) |
|
|
|
|
| def test_get_fred_data_contract_stubbed(monkeypatch) -> None: |
| def _stub_fred_data(_name: str) -> TimeSeriesDataFrame: |
| return TimeSeriesDataFrame(pd.DataFrame({"Close": [2.5, 2.6, 2.7]}, index=["2026-01-01", "2026-01-02", "2026-01-03"])) |
|
|
| monkeypatch.setattr(factory_module, "get_fred_data", _stub_fred_data) |
| factory = DataFactory() |
| df = factory.get_fred_data("T10Y2Y") |
| _assert_timeseries_contract(df) |
|
|
|
|
| def test_get_economic_data_contract_stubbed(monkeypatch) -> None: |
| def _stub_economic_data(_name: str) -> TimeSeriesDataFrame: |
| return TimeSeriesDataFrame(pd.DataFrame({"Close": [1.0, 0.9, 0.8]}, index=["2026-01-01", "2026-01-02", "2026-01-03"])) |
|
|
| monkeypatch.setattr(factory_module, "get_economic_indicator", _stub_economic_data) |
| factory = DataFactory() |
| df = factory.get_economic_data("Term Spread") |
| _assert_timeseries_contract(df) |
|
|
|
|
| def test_get_recent_history_contract_stubbed(monkeypatch) -> None: |
| def _stub_recent(_ticker: str, *, period: str = "3y", force_refresh: bool = False) -> HistoryChunk: |
| _ = force_refresh |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": ["2024-01-01", "2025-01-01", "2026-01-01"], |
| "close": [100.0, 120.0, 140.0], |
| } |
| ) |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2024-01-01", |
| loaded_end="2026-01-01", |
| requested_period=period, |
| is_complete=False, |
| has_older=True, |
| source_version="test", |
| ) |
|
|
| monkeypatch.setattr(factory_module, "get_yf_recent_history", _stub_recent) |
|
|
| chunk = DataFactory().get_recent_history("AAPL", period="3y") |
|
|
| _assert_timeseries_contract(chunk.frame) |
| assert chunk.loaded_start == "2024-01-01" |
| assert chunk.loaded_end == "2026-01-01" |
| assert chunk.has_older is True |
| assert chunk.requested_period == "3y" |
|
|
|
|
| def test_get_recent_history_force_refresh_propagates_upstream_failure(monkeypatch) -> None: |
| """``force_refresh=True`` is the freshness-verification lever — if |
| the upstream fetch fails the caller must learn about it, not get a |
| silently-stale fallback.""" |
|
|
| def _exploding_recent(_ticker: str, *, period: str = "3y", force_refresh: bool = False) -> HistoryChunk: |
| _ = period, force_refresh |
| raise RuntimeError("upstream blew up") |
|
|
| monkeypatch.setattr(factory_module, "get_yf_recent_history", _exploding_recent) |
|
|
| with pytest.raises(RuntimeError, match="upstream blew up"): |
| DataFactory().get_recent_history("AAPL", period="3y", force_refresh=True) |
|
|
|
|
| def test_get_recent_history_default_swallows_upstream_failure(monkeypatch) -> None: |
| """Default ``force_refresh=False`` keeps the graceful-degradation |
| behavior — upstream errors are logged and the factory falls back to |
| its own ``self.get(name)``-based slice.""" |
|
|
| def _exploding_recent(_ticker: str, *, period: str = "3y", force_refresh: bool = False) -> HistoryChunk: |
| _ = period, force_refresh |
| raise RuntimeError("upstream blew up") |
|
|
| def _stub_get(_self, _name: str) -> TimeSeriesDataFrame: |
| return TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), |
| "close": [10.0, 11.0, 12.0], |
| } |
| ) |
| ) |
|
|
| monkeypatch.setattr(factory_module, "get_yf_recent_history", _exploding_recent) |
| monkeypatch.setattr(DataFactory, "get", _stub_get) |
|
|
| chunk = DataFactory().get_recent_history("AAPL", period="3y") |
| |
| assert chunk.source_version == "factory-fallback" |
| assert not chunk.frame.empty |
|
|
|
|
| def test_get_full_history_backfill_contract_stubbed(monkeypatch) -> None: |
| def _stub_backfill(_ticker: str, *, loaded_start: str | None = None) -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": ["2021-01-01", "2022-01-01", "2023-01-01"], |
| "close": [80.0, 90.0, 95.0], |
| } |
| ) |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2021-01-01", |
| loaded_end="2026-01-01", |
| requested_period=None, |
| is_complete=True, |
| has_older=False, |
| source_version="test", |
| ) |
|
|
| monkeypatch.setattr(factory_module, "get_yf_full_history_backfill", _stub_backfill) |
|
|
| chunk = DataFactory().get_full_history_backfill("AAPL", loaded_start="2024-01-01") |
|
|
| _assert_timeseries_contract(chunk.frame) |
| assert chunk.loaded_start == "2021-01-01" |
| assert chunk.loaded_end == "2026-01-01" |
| assert chunk.is_complete is True |
|
|
|
|
| def test_get_recent_history_uses_custom_market_indicator_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["Vol Regime"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_recent(_key: str, *, period: str = "3y") -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.date_range("2023-01-01", periods=5, freq="D"), |
| "close": [10.0, 15.0, 25.0, 45.0, 85.0], |
| } |
| ), |
| name="Vol Regime", |
| chart_meta={"zones": market_indicator_module.VOL_REGIME_ZONES}, |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2023-01-01", |
| loaded_end="2023-01-05", |
| requested_period=period, |
| is_complete=False, |
| has_older=True, |
| source_version="custom-recent", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_recent_history", _stub_recent) |
|
|
| chunk = DataFactory().get_recent_history("Vol Regime", period="3y") |
|
|
| assert chunk.source_version == "custom-recent" |
| assert chunk.frame.name == "Vol Regime" |
| assert chunk.frame.chart_meta["zones"] == market_indicator_module.VOL_REGIME_ZONES |
| assert chunk.has_older is True |
|
|
|
|
| def test_get_full_history_backfill_uses_custom_market_indicator_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["VVIX/VIX Ratio"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_backfill(_key: str, *, loaded_start: str | None = None) -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.date_range("2021-01-01", periods=3, freq="D"), |
| "close": [4.9, 5.1, 5.3], |
| } |
| ), |
| name="VVIX/VIX Ratio", |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2021-01-01", |
| loaded_end="2026-01-01", |
| requested_period=None, |
| is_complete=True, |
| has_older=False, |
| source_version="custom-full", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_full_history_backfill", _stub_backfill) |
|
|
| chunk = DataFactory().get_full_history_backfill("VVIX/VIX Ratio", loaded_start="2024-01-01") |
|
|
| assert chunk.source_version == "custom-full" |
| assert chunk.frame.name == "VVIX/VIX Ratio" |
| assert chunk.loaded_start == "2021-01-01" |
| assert chunk.is_complete is True |
|
|
|
|
| def test_get_recent_history_uses_fear_greed_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["Fear & Greed"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_recent(_key: str, *, period: str = "3y") -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.date_range("2024-01-01", periods=3, freq="D"), |
| "close": [25.0, 45.0, 65.0], |
| } |
| ), |
| name="Fear & Greed", |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2024-01-01", |
| loaded_end="2024-01-03", |
| requested_period=period, |
| is_complete=False, |
| has_older=True, |
| source_version="fear-greed-recent", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_recent_history", _stub_recent) |
|
|
| chunk = DataFactory().get_recent_history("Fear & Greed", period="3y") |
|
|
| assert chunk.source_version == "fear-greed-recent" |
| assert chunk.frame.name == "Fear & Greed" |
| assert chunk.has_older is True |
|
|
|
|
| def test_get_recent_history_uses_cape_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["CAPE"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_recent(_key: str, *, period: str = "3y") -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.to_datetime(["2024-01-01", "2025-01-01", "2026-01-01"]), |
| "close": [28.5, 31.2, 33.1], |
| } |
| ), |
| name="CAPE", |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2024-01-01", |
| loaded_end="2026-01-01", |
| requested_period=period, |
| is_complete=False, |
| has_older=True, |
| source_version="cape-recent", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_recent_history", _stub_recent) |
|
|
| chunk = DataFactory().get_recent_history("CAPE", period="3y") |
|
|
| assert chunk.source_version == "cape-recent" |
| assert chunk.frame.name == "CAPE" |
| assert chunk.has_older is True |
|
|
|
|
| def test_get_recent_history_uses_net_breadth_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["Net Breadth"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_recent(_key: str, *, period: str = "3y") -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), |
| "close": [-12.0, 4.5, 16.2], |
| } |
| ), |
| name="Net Breadth", |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2024-01-01", |
| loaded_end="2024-01-03", |
| requested_period=period, |
| is_complete=False, |
| has_older=True, |
| source_version="net-breadth-recent", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_recent_history", _stub_recent) |
|
|
| chunk = DataFactory().get_recent_history("Net Breadth", period="3y") |
|
|
| assert chunk.source_version == "net-breadth-recent" |
| assert chunk.frame.name == "Net Breadth" |
| assert chunk.has_older is True |
|
|
|
|
| def test_get_recent_history_uses_economic_indicator_for_rrp(monkeypatch) -> None: |
| def _forbid_recent_history(_ticker: str, *, period: str = "3y") -> HistoryChunk: |
| _ = period |
| raise AssertionError("yfinance recent-history loader should not run for economic indicators") |
|
|
| def _stub_economic_indicator(name: str) -> TimeSeriesDataFrame: |
| assert name == "RRP" |
| return TimeSeriesDataFrame(pd.DataFrame( |
| { |
| "Close": [2.0, 4.0, 6.0, 8.0], |
| }, |
| index=["2020-01-01", "2022-01-01", "2024-01-01", "2026-01-01"], |
| )) |
|
|
| monkeypatch.setattr(factory_module, "get_yf_recent_history", _forbid_recent_history) |
| monkeypatch.setattr(factory_module, "get_economic_indicator", _stub_economic_indicator) |
|
|
| chunk = DataFactory().get_recent_history("RRP", period="3y") |
|
|
| assert chunk.frame.name == "RRP" |
| assert chunk.loaded_start == "2024-01-01" |
| assert chunk.loaded_end == "2026-01-01" |
| assert chunk.has_older is True |
| assert chunk.requested_period == "3y" |
| assert chunk.frame["close"].tolist() == [6.0, 8.0] |
|
|
|
|
| def test_get_full_history_backfill_uses_trailing_forward_pe_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["Trailing-Forward P/E Spread"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_backfill(_key: str, *, loaded_start: str | None = None) -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.to_datetime(["2022-01-01", "2023-01-01", "2024-01-01"]), |
| "close": [1.1, 0.8, 0.5], |
| } |
| ), |
| name="Trailing-Forward P/E Spread", |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2022-01-01", |
| loaded_end="2024-01-01", |
| requested_period=None, |
| is_complete=True, |
| has_older=False, |
| source_version="pe-spread-full", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_full_history_backfill", _stub_backfill) |
|
|
| chunk = DataFactory().get_full_history_backfill("Trailing-Forward P/E Spread", loaded_start="2025-01-01") |
|
|
| assert chunk.source_version == "pe-spread-full" |
| assert chunk.frame.name == "Trailing-Forward P/E Spread" |
| assert chunk.is_complete is True |
|
|
|
|
| def test_get_full_history_backfill_uses_economic_indicator_for_rrp(monkeypatch) -> None: |
| def _forbid_full_history(_ticker: str, *, loaded_start: str | None = None) -> HistoryChunk: |
| _ = loaded_start |
| raise AssertionError("yfinance full-history loader should not run for economic indicators") |
|
|
| def _stub_economic_indicator(name: str) -> TimeSeriesDataFrame: |
| assert name == "RRP" |
| return TimeSeriesDataFrame(pd.DataFrame( |
| { |
| "Close": [2.0, 4.0, 6.0, 8.0], |
| }, |
| index=["2020-01-01", "2022-01-01", "2024-01-01", "2026-01-01"], |
| )) |
|
|
| monkeypatch.setattr(factory_module, "get_yf_full_history_backfill", _forbid_full_history) |
| monkeypatch.setattr(factory_module, "get_economic_indicator", _stub_economic_indicator) |
|
|
| chunk = DataFactory().get_full_history_backfill("RRP", loaded_start="2024-01-01") |
|
|
| assert chunk.frame.name == "RRP" |
| assert chunk.loaded_start == "2020-01-01" |
| assert chunk.loaded_end == "2026-01-01" |
| assert chunk.is_complete is True |
| assert chunk.frame["close"].tolist() == [2.0, 4.0] |
|
|
|
|
| def test_get_full_history_backfill_uses_net_breadth_progressive_hook(monkeypatch) -> None: |
| indicator = factory_module.MARKET_INDICATOR_REGISTRY["Net Breadth"] |
|
|
| def _forbid_fallback(_key: str): |
| raise AssertionError("fallback market-data loader should not run") |
|
|
| def _stub_backfill(_key: str, *, loaded_start: str | None = None) -> HistoryChunk: |
| frame = TimeSeriesDataFrame( |
| pd.DataFrame( |
| { |
| "time": pd.to_datetime(["2023-01-01", "2023-01-02", "2023-01-03"]), |
| "close": [-18.0, -6.0, 9.0], |
| } |
| ), |
| name="Net Breadth", |
| ) |
| return HistoryChunk( |
| frame=frame, |
| loaded_start="2023-01-01", |
| loaded_end="2023-01-03", |
| requested_period=None, |
| is_complete=True, |
| has_older=False, |
| source_version="net-breadth-full", |
| ) |
|
|
| monkeypatch.setattr(indicator, "get_data", _forbid_fallback) |
| monkeypatch.setattr(indicator, "get_full_history_backfill", _stub_backfill) |
|
|
| chunk = DataFactory().get_full_history_backfill("Net Breadth", loaded_start="2025-01-01") |
|
|
| assert chunk.source_version == "net-breadth-full" |
| assert chunk.frame.name == "Net Breadth" |
| assert chunk.is_complete is True |
|
|