sk851 Cursor commited on
Commit
81d0aa3
·
1 Parent(s): e3d5c6c

feat(indicators): chart Dispersion Index via DSPX

Browse files

Yahoo 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 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
+ }