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: # Empty is an accepted fallback contract. 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"], # duplicate time included intentionally ) 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") # Fallback path returned a chunk rather than raising. 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