feat(indicators): chart Dispersion Index via DSPX
Browse filesYahoo history is too short; search and charts consume DataFactory's Cboe archive plus yfinance tail.
Co-authored-by: Cursor <cursoragent@cursor.com>
- docs/data-layer.md +2 -1
- src/TerraFin/data/factory.py +1 -1
- src/TerraFin/data/providers/market/market_indicator.py +19 -0
- src/TerraFin/data/providers/private_access/series_registry.py +6 -0
- tests/data/test_credit_risk_indicators.py +10 -0
- tests/data/test_dspx_history.py +13 -0
- tests/interface/test_search_routing_api.py +14 -0
docs/data-layer.md
CHANGED
|
@@ -212,7 +212,7 @@ working with them in practice:
|
|
| 212 |
| Domain | Backing source | Typical access path | Notes |
|
| 213 |
|--------|----------------|---------------------|-------|
|
| 214 |
| Market prices | yfinance | `get("AAPL")`, `get("S&P 500")`, `get("Shanghai Composite")` | Handles tickers and index aliases |
|
| 215 |
-
| Market indicators | Registry-backed market series | `get("VIX")`, `get("MOVE")`, `get("Net Breadth")` | Mix of yfinance-backed and private-series-backed names resolved before raw tickers |
|
| 216 |
| Economic series | FRED | `get_fred_data("UNRATE")`, `get("Unemployment Rate")` | Human-readable names map to FRED codes |
|
| 217 |
| Computed macro indicators | FRED-derived logic | `get("Buffett Indicator")` | Built from public series |
|
| 218 |
| Credit and risk indicators | FRED and FRED-derived | `get("High Yield Spread")`, `get("Net Liquidity")` | HY spread, RRP, net liquidity, 18M forward rate spread, credit spread |
|
|
@@ -324,6 +324,7 @@ Examples:
|
|
| 324 |
|
| 325 |
- `Fear & Greed` when used as a chart/searchable series
|
| 326 |
- `Net Breadth` as a chart/searchable breadth history series
|
|
|
|
| 327 |
- future chartable private series such as `CAPE` or
|
| 328 |
`Trailing-Forward P/E Spread`, if promoted into the chart/search flow
|
| 329 |
|
|
|
|
| 212 |
| Domain | Backing source | Typical access path | Notes |
|
| 213 |
|--------|----------------|---------------------|-------|
|
| 214 |
| Market prices | yfinance | `get("AAPL")`, `get("S&P 500")`, `get("Shanghai Composite")` | Handles tickers and index aliases |
|
| 215 |
+
| Market indicators | Registry-backed market series | `get("VIX")`, `get("MOVE")`, `get("Net Breadth")`, `get("Dispersion Index")` | Mix of yfinance-backed and private-series-backed names resolved before raw tickers |
|
| 216 |
| Economic series | FRED | `get_fred_data("UNRATE")`, `get("Unemployment Rate")` | Human-readable names map to FRED codes |
|
| 217 |
| Computed macro indicators | FRED-derived logic | `get("Buffett Indicator")` | Built from public series |
|
| 218 |
| Credit and risk indicators | FRED and FRED-derived | `get("High Yield Spread")`, `get("Net Liquidity")` | HY spread, RRP, net liquidity, 18M forward rate spread, credit spread |
|
|
|
|
| 324 |
|
| 325 |
- `Fear & Greed` when used as a chart/searchable series
|
| 326 |
- `Net Breadth` as a chart/searchable breadth history series
|
| 327 |
+
- `Dispersion Index` (Cboe DSPX archive + yfinance tail) as a chart/searchable series
|
| 328 |
- future chartable private series such as `CAPE` or
|
| 329 |
`Trailing-Forward P/E Spread`, if promoted into the chart/search flow
|
| 330 |
|
src/TerraFin/data/factory.py
CHANGED
|
@@ -226,7 +226,7 @@ class DataFactory:
|
|
| 226 |
on fetch failure — the next non-force caller still sees the prior
|
| 227 |
value. Currently only the yfinance-backed path honors the flag;
|
| 228 |
composite/private indicators (Vol Regime, VVIX/VIX, Fear & Greed,
|
| 229 |
-
CAPE, SPX GEX, Net Breadth) ignore it.
|
| 230 |
|
| 231 |
Exception semantics:
|
| 232 |
* ``force_refresh=False`` (default): swallow upstream fetch errors
|
|
|
|
| 226 |
on fetch failure — the next non-force caller still sees the prior
|
| 227 |
value. Currently only the yfinance-backed path honors the flag;
|
| 228 |
composite/private indicators (Vol Regime, VVIX/VIX, Fear & Greed,
|
| 229 |
+
CAPE, SPX GEX, Dispersion Index, Net Breadth) ignore it.
|
| 230 |
|
| 231 |
Exception semantics:
|
| 232 |
* ``force_refresh=False`` (default): swallow upstream fetch errors
|
src/TerraFin/data/providers/market/market_indicator.py
CHANGED
|
@@ -292,6 +292,18 @@ def _trailing_forward_pe_full_history_backfill(_key: str, *, loaded_start: str |
|
|
| 292 |
return _private_backfill("trailing_forward_pe", loaded_start)
|
| 293 |
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
def _spx_gex_frame() -> TimeSeriesDataFrame:
|
| 296 |
from TerraFin.data.providers.market.spx_gex_history import get_spx_gex_history
|
| 297 |
|
|
@@ -358,6 +370,13 @@ MARKET_INDICATOR_REGISTRY = {
|
|
| 358 |
"Treasury-10Y": MarketIndicator(description="Treasury-10Y", key="^TNX"),
|
| 359 |
"Treasury-30Y": MarketIndicator(description="Treasury-30Y", key="^TYX"),
|
| 360 |
"MOVE": MarketIndicator(description="MOVE: ICE BofA MOVE Index (bond market implied volatility)", key="^MOVE"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
"Vol Regime": MarketIndicator(
|
| 362 |
description="VIX 6-month percentile rank (0-100). Calm below 20 and elevated above 80.",
|
| 363 |
key="vol-regime",
|
|
|
|
| 292 |
return _private_backfill("trailing_forward_pe", loaded_start)
|
| 293 |
|
| 294 |
|
| 295 |
+
def _fetch_dspx(_key: str):
|
| 296 |
+
return _private_frame("dspx")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _dspx_recent_history(_key: str, *, period: str = "3y") -> HistoryChunk:
|
| 300 |
+
return _private_recent("dspx", period)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _dspx_full_history_backfill(_key: str, *, loaded_start: str | None = None) -> HistoryChunk:
|
| 304 |
+
return _private_backfill("dspx", loaded_start)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
def _spx_gex_frame() -> TimeSeriesDataFrame:
|
| 308 |
from TerraFin.data.providers.market.spx_gex_history import get_spx_gex_history
|
| 309 |
|
|
|
|
| 370 |
"Treasury-10Y": MarketIndicator(description="Treasury-10Y", key="^TNX"),
|
| 371 |
"Treasury-30Y": MarketIndicator(description="Treasury-30Y", key="^TYX"),
|
| 372 |
"MOVE": MarketIndicator(description="MOVE: ICE BofA MOVE Index (bond market implied volatility)", key="^MOVE"),
|
| 373 |
+
"Dispersion Index": MarketIndicator(
|
| 374 |
+
description="DSPX: Cboe S&P 500 Dispersion Index. Expected 30-day idiosyncratic dispersion from SPX and constituent options.",
|
| 375 |
+
key="dspx",
|
| 376 |
+
get_data=_fetch_dspx,
|
| 377 |
+
get_recent_history=_dspx_recent_history,
|
| 378 |
+
get_full_history_backfill=_dspx_full_history_backfill,
|
| 379 |
+
),
|
| 380 |
"Vol Regime": MarketIndicator(
|
| 381 |
description="VIX 6-month percentile rank (0-100). Calm below 20 and elevated above 80.",
|
| 382 |
key="vol-regime",
|
src/TerraFin/data/providers/private_access/series_registry.py
CHANGED
|
@@ -24,4 +24,10 @@ PRIVATE_SERIES: dict[str, PrivateSeriesSpec] = {
|
|
| 24 |
display_name="Trailing-Forward P/E Spread",
|
| 25 |
history_cache_namespace="private_trailing_forward_pe_history",
|
| 26 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
|
|
|
| 24 |
display_name="Trailing-Forward P/E Spread",
|
| 25 |
history_cache_namespace="private_trailing_forward_pe_history",
|
| 26 |
),
|
| 27 |
+
"dspx": PrivateSeriesSpec(
|
| 28 |
+
key="dspx",
|
| 29 |
+
display_name="Dispersion Index",
|
| 30 |
+
history_cache_namespace="private_dspx_history",
|
| 31 |
+
current_cache_namespace="private_dspx_current",
|
| 32 |
+
),
|
| 33 |
}
|
tests/data/test_credit_risk_indicators.py
CHANGED
|
@@ -69,6 +69,16 @@ def test_net_breadth_in_market_registry():
|
|
| 69 |
assert MARKET_INDICATOR_REGISTRY["Net Breadth"].key == "net-breadth"
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def test_vol_regime_recent_history_uses_progressive_yfinance_seed(monkeypatch) -> None:
|
| 73 |
def _stub_recent_history(ticker: str, *, period: str = "3y") -> HistoryChunk:
|
| 74 |
assert ticker == "^VIX"
|
|
|
|
| 69 |
assert MARKET_INDICATOR_REGISTRY["Net Breadth"].key == "net-breadth"
|
| 70 |
|
| 71 |
|
| 72 |
+
def test_dispersion_index_in_market_registry():
|
| 73 |
+
"""Dispersion Index (DSPX) should be exposed as a searchable market series."""
|
| 74 |
+
from TerraFin.data.providers.market import MARKET_INDICATOR_REGISTRY
|
| 75 |
+
|
| 76 |
+
assert "Dispersion Index" in MARKET_INDICATOR_REGISTRY
|
| 77 |
+
indicator = MARKET_INDICATOR_REGISTRY["Dispersion Index"]
|
| 78 |
+
assert indicator.key == "dspx"
|
| 79 |
+
assert "DSPX" in indicator.description
|
| 80 |
+
|
| 81 |
+
|
| 82 |
def test_vol_regime_recent_history_uses_progressive_yfinance_seed(monkeypatch) -> None:
|
| 83 |
def _stub_recent_history(ticker: str, *, period: str = "3y") -> HistoryChunk:
|
| 84 |
assert ticker == "^VIX"
|
tests/data/test_dspx_history.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dispersion Index is a searchable private-series market indicator (DSPX)."""
|
| 2 |
+
|
| 3 |
+
from TerraFin.interface.infra.ticker_search.routes import _search_indicators
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_search_indicators_finds_dispersion_and_dspx():
|
| 7 |
+
by_dispersion = _search_indicators("dispersion")
|
| 8 |
+
by_dspx = _search_indicators("dspx")
|
| 9 |
+
assert any(entry["symbol"] == "Dispersion Index" for entry in by_dispersion)
|
| 10 |
+
assert any(entry["symbol"] == "Dispersion Index" for entry in by_dspx)
|
| 11 |
+
match = next(entry for entry in by_dspx if entry["symbol"] == "Dispersion Index")
|
| 12 |
+
assert match["group"] == "Market"
|
| 13 |
+
assert "DSPX" in match["name"]
|
tests/interface/test_search_routing_api.py
CHANGED
|
@@ -43,3 +43,17 @@ def test_resolve_ticker_routes_net_breadth_to_market_insights() -> None:
|
|
| 43 |
"name": "Net Breadth",
|
| 44 |
"path": "/market-insights?ticker=Net%20Breadth",
|
| 45 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
"name": "Net Breadth",
|
| 44 |
"path": "/market-insights?ticker=Net%20Breadth",
|
| 45 |
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_resolve_ticker_routes_dispersion_index_to_market_insights() -> None:
|
| 49 |
+
client = TestClient(create_app())
|
| 50 |
+
|
| 51 |
+
response = client.get("/resolve-ticker?q=dispersion%20index")
|
| 52 |
+
assert response.status_code == 200
|
| 53 |
+
payload = response.json()
|
| 54 |
+
|
| 55 |
+
assert payload == {
|
| 56 |
+
"type": "macro",
|
| 57 |
+
"name": "Dispersion Index",
|
| 58 |
+
"path": "/market-insights?ticker=Dispersion%20Index",
|
| 59 |
+
}
|