fred-loader 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fred_loader/__init__.py +96 -0
- fred_loader/load.py +143 -0
- fred_loader/macro_scores.py +833 -0
- fred_loader/series.py +553 -0
- fred_loader/utils.py +303 -0
- fred_loader-0.1.0.dist-info/METADATA +64 -0
- fred_loader-0.1.0.dist-info/RECORD +10 -0
- fred_loader-0.1.0.dist-info/WHEEL +5 -0
- fred_loader-0.1.0.dist-info/licenses/LICENSE +21 -0
- fred_loader-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Macro Scoring Module
|
|
3
|
+
====================
|
|
4
|
+
|
|
5
|
+
Registry-based scoring layer. Each score declares its input dependencies
|
|
6
|
+
and output columns upfront. At runtime the resolver figures out which
|
|
7
|
+
scores can be computed from the columns actually present, runs them in
|
|
8
|
+
dependency order, and skips the rest.
|
|
9
|
+
|
|
10
|
+
Adding a new score:
|
|
11
|
+
1. Write a function that takes a DataFrame and returns it with new columns.
|
|
12
|
+
2. Decorate it with @_register(...) or use a helper like _reg_yoy().
|
|
13
|
+
3. That's it — resolution, ordering, and catalog are automatic.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from fred_loader import score, available_scores, list_scored_columns
|
|
17
|
+
|
|
18
|
+
scored = score(df) # compute everything possible
|
|
19
|
+
preview = available_scores(df) # dry run — what WOULD be computed
|
|
20
|
+
catalog = list_scored_columns() # full catalog grouped by category
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Callable, Optional, Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
# CONFIGURATION
|
|
31
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
_W52 = 52
|
|
34
|
+
_W26 = 26
|
|
35
|
+
_W13 = 13
|
|
36
|
+
_W78 = 78
|
|
37
|
+
|
|
38
|
+
_PI_STAR = 2.0
|
|
39
|
+
_U_STAR = 4.0
|
|
40
|
+
_R_STAR_DEFAULT = 0.5
|
|
41
|
+
_SAHM_THRESHOLD = 0.50
|
|
42
|
+
_TAYLOR_ALPHA = 0.5
|
|
43
|
+
_TAYLOR_BETA = 0.5
|
|
44
|
+
_DEANCHOR_MILD = 1.0
|
|
45
|
+
_DEANCHOR_SEVERE = 2.0
|
|
46
|
+
_M2_NORMAL_LOW = 2.0
|
|
47
|
+
_M2_NORMAL_HIGH = 8.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def blank(df: Any) -> Any:
|
|
51
|
+
"""Helper for specs that produce columns but have no actual logic."""
|
|
52
|
+
return df
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
56
|
+
# REGISTRY INFRASTRUCTURE
|
|
57
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class _ScoreSpec:
|
|
62
|
+
"""
|
|
63
|
+
A single registered scoring block.
|
|
64
|
+
|
|
65
|
+
Attributes
|
|
66
|
+
----------
|
|
67
|
+
name : str
|
|
68
|
+
Short identifier (used in logs and available_scores output).
|
|
69
|
+
category : str
|
|
70
|
+
One of 'derived', 'flags', 'continuous', 'leads_lags'.
|
|
71
|
+
requires : set[str]
|
|
72
|
+
Column names that MUST ALL exist before this block can run.
|
|
73
|
+
Can include raw series names OR columns produced by other specs.
|
|
74
|
+
requires_any : set[str]
|
|
75
|
+
If non-empty, at least ONE member must exist before this block can run.
|
|
76
|
+
produces : list[str]
|
|
77
|
+
Column names this block adds to the DataFrame.
|
|
78
|
+
fn : callable
|
|
79
|
+
fn(df) -> df — mutates and returns the DataFrame.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
category: str
|
|
84
|
+
requires: set = field(default_factory=set)
|
|
85
|
+
requires_any: set = field(default_factory=set)
|
|
86
|
+
produces: list = field(default_factory=list)
|
|
87
|
+
fn: Callable = field(default=blank)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_REGISTRY: list[_ScoreSpec] = []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _register(name: str, category: str, requires: set, produces: list, requires_any: set = None):
|
|
94
|
+
"""Decorator that registers a scoring function."""
|
|
95
|
+
|
|
96
|
+
def decorator(fn):
|
|
97
|
+
_REGISTRY.append(
|
|
98
|
+
_ScoreSpec(
|
|
99
|
+
name=name,
|
|
100
|
+
category=category,
|
|
101
|
+
requires=set(requires),
|
|
102
|
+
requires_any=set(requires_any) if requires_any else set(),
|
|
103
|
+
produces=list(produces),
|
|
104
|
+
fn=fn,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return fn
|
|
108
|
+
|
|
109
|
+
return decorator
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve(columns: set[str]) -> list[_ScoreSpec]:
|
|
113
|
+
"""
|
|
114
|
+
Topological dependency resolution.
|
|
115
|
+
|
|
116
|
+
Starting from the columns present in the DataFrame, iteratively
|
|
117
|
+
find specs whose requirements are met, add their outputs to the
|
|
118
|
+
available set, and repeat until no new specs can be resolved.
|
|
119
|
+
|
|
120
|
+
Returns specs in a valid execution order.
|
|
121
|
+
"""
|
|
122
|
+
available = set(columns)
|
|
123
|
+
resolved = []
|
|
124
|
+
remaining = list(_REGISTRY)
|
|
125
|
+
|
|
126
|
+
changed = True
|
|
127
|
+
while changed:
|
|
128
|
+
changed = False
|
|
129
|
+
still_remaining = []
|
|
130
|
+
for spec in remaining:
|
|
131
|
+
if spec.requires.issubset(available) and (
|
|
132
|
+
not spec.requires_any or spec.requires_any & available
|
|
133
|
+
):
|
|
134
|
+
resolved.append(spec)
|
|
135
|
+
available.update(spec.produces)
|
|
136
|
+
changed = True
|
|
137
|
+
else:
|
|
138
|
+
still_remaining.append(spec)
|
|
139
|
+
remaining = still_remaining
|
|
140
|
+
|
|
141
|
+
return resolved
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
145
|
+
# REGISTRATION HELPERS
|
|
146
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _reg_yoy(src: str, dst: str, window: int = _W52):
|
|
150
|
+
"""Register a simple year-over-year pct_change score."""
|
|
151
|
+
|
|
152
|
+
def fn(df, _s=src, _d=dst, _w=window):
|
|
153
|
+
df[_d] = df[_s].pct_change(_w) * 100
|
|
154
|
+
return df
|
|
155
|
+
|
|
156
|
+
_REGISTRY.append(
|
|
157
|
+
_ScoreSpec(
|
|
158
|
+
name=dst,
|
|
159
|
+
category="derived",
|
|
160
|
+
requires={src},
|
|
161
|
+
produces=[dst],
|
|
162
|
+
fn=fn,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _reg_annualized(src: str, dst: str, window: int = _W13):
|
|
168
|
+
"""Register an annualized short-window growth rate."""
|
|
169
|
+
|
|
170
|
+
def fn(df, _s=src, _d=dst, _w=window):
|
|
171
|
+
df[_d] = df[_s].pct_change(_w) * (52 / _w) * 100
|
|
172
|
+
return df
|
|
173
|
+
|
|
174
|
+
_REGISTRY.append(
|
|
175
|
+
_ScoreSpec(
|
|
176
|
+
name=dst,
|
|
177
|
+
category="derived",
|
|
178
|
+
requires={src},
|
|
179
|
+
produces=[dst],
|
|
180
|
+
fn=fn,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _reg_diff(src: str, dst: str, periods: int, category: str = "derived"):
|
|
186
|
+
"""Register a simple first-difference."""
|
|
187
|
+
|
|
188
|
+
def fn(df, _s=src, _d=dst, _p=periods):
|
|
189
|
+
df[_d] = df[_s].diff(_p)
|
|
190
|
+
return df
|
|
191
|
+
|
|
192
|
+
_REGISTRY.append(
|
|
193
|
+
_ScoreSpec(
|
|
194
|
+
name=dst,
|
|
195
|
+
category=category,
|
|
196
|
+
requires={src},
|
|
197
|
+
produces=[dst],
|
|
198
|
+
fn=fn,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _reg_shift(src: str, dst: str, periods: int, category: str = "leads_lags"):
|
|
204
|
+
"""Register a lag (positive) or lead (negative)."""
|
|
205
|
+
|
|
206
|
+
def fn(df, _s=src, _d=dst, _p=periods):
|
|
207
|
+
df[_d] = df[_s].shift(_p)
|
|
208
|
+
return df
|
|
209
|
+
|
|
210
|
+
_REGISTRY.append(
|
|
211
|
+
_ScoreSpec(
|
|
212
|
+
name=dst,
|
|
213
|
+
category=category,
|
|
214
|
+
requires={src},
|
|
215
|
+
produces=[dst],
|
|
216
|
+
fn=fn,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
222
|
+
# 1. DERIVED SERIES
|
|
223
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
224
|
+
|
|
225
|
+
# ── YoY inflation rates (each independent) ────────────────────────────
|
|
226
|
+
_reg_yoy("Headline_CPI", "CPI_YoY")
|
|
227
|
+
_reg_yoy("Core_CPI", "Core_CPI_YoY")
|
|
228
|
+
_reg_yoy("Core_PCE", "Core_PCE_YoY")
|
|
229
|
+
_reg_yoy("PCE_Price_Index", "PCE_YoY")
|
|
230
|
+
_reg_yoy("CPI_Food", "CPI_Food_YoY")
|
|
231
|
+
_reg_yoy("CPI_Energy", "CPI_Energy_YoY")
|
|
232
|
+
|
|
233
|
+
# ── Inflation momentum ────────────────────────────────────────────────
|
|
234
|
+
_reg_annualized("Core_PCE", "Core_PCE_3m_ann", _W13)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@_register(
|
|
238
|
+
"inflation_momentum",
|
|
239
|
+
"derived",
|
|
240
|
+
requires={"Core_PCE_3m_ann", "Core_PCE_YoY"},
|
|
241
|
+
produces=["Inflation_Momentum"],
|
|
242
|
+
)
|
|
243
|
+
def _inflation_momentum(df):
|
|
244
|
+
df["Inflation_Momentum"] = df["Core_PCE_3m_ann"] - df["Core_PCE_YoY"]
|
|
245
|
+
return df
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ── Money supply growth ───────────────────────────────────────────────
|
|
249
|
+
_reg_yoy("M2_Money_Stock", "M2_YoY")
|
|
250
|
+
_reg_annualized("M2_Money_Stock", "M2_6m_ann", _W26)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ── Real rates ────────────────────────────────────────────────────────
|
|
254
|
+
@_register(
|
|
255
|
+
"real_ffr_cpi",
|
|
256
|
+
"derived",
|
|
257
|
+
requires={"FedFunds_Rate", "CPI_YoY"},
|
|
258
|
+
produces=["Real_FFR_CPI"],
|
|
259
|
+
)
|
|
260
|
+
def _real_ffr_cpi(df):
|
|
261
|
+
df["Real_FFR_CPI"] = df["FedFunds_Rate"] - df["CPI_YoY"]
|
|
262
|
+
return df
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@_register(
|
|
266
|
+
"real_ffr_pce",
|
|
267
|
+
"derived",
|
|
268
|
+
requires={"FedFunds_Rate", "Core_PCE_YoY"},
|
|
269
|
+
produces=["Real_FFR_PCE"],
|
|
270
|
+
)
|
|
271
|
+
def _real_ffr_pce(df):
|
|
272
|
+
df["Real_FFR_PCE"] = df["FedFunds_Rate"] - df["Core_PCE_YoY"]
|
|
273
|
+
return df
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@_register(
|
|
277
|
+
"real_10y_cpi",
|
|
278
|
+
"derived",
|
|
279
|
+
requires={"Treasury_10Y", "CPI_YoY"},
|
|
280
|
+
produces=["Real_10Y_CPI"],
|
|
281
|
+
)
|
|
282
|
+
def _real_10y_cpi(df):
|
|
283
|
+
df["Real_10Y_CPI"] = df["Treasury_10Y"] - df["CPI_YoY"]
|
|
284
|
+
return df
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── Labor market derived ──────────────────────────────────────────────
|
|
288
|
+
_reg_diff("Nonfarm_Payrolls", "Payroll_Change_4w", 4)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@_register(
|
|
292
|
+
"sahm_components",
|
|
293
|
+
"derived",
|
|
294
|
+
requires={"Unemployment_Rate"},
|
|
295
|
+
produces=["UE_3m_avg", "UE_12m_min", "Sahm_Indicator"],
|
|
296
|
+
)
|
|
297
|
+
def _sahm_components(df):
|
|
298
|
+
df["UE_3m_avg"] = df["Unemployment_Rate"].rolling(_W13, min_periods=8).mean()
|
|
299
|
+
df["UE_12m_min"] = df["Unemployment_Rate"].rolling(_W52, min_periods=26).min()
|
|
300
|
+
df["Sahm_Indicator"] = df["UE_3m_avg"] - df["UE_12m_min"]
|
|
301
|
+
return df
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@_register(
|
|
305
|
+
"jolts_ue_ratio",
|
|
306
|
+
"derived",
|
|
307
|
+
requires={"JOLTS_Job_Openings", "Unemployment_Rate"},
|
|
308
|
+
produces=["Unemployed_Approx", "JOLTS_UE_Ratio"],
|
|
309
|
+
)
|
|
310
|
+
def _jolts_ue_ratio(df):
|
|
311
|
+
_labor_force = 160_000
|
|
312
|
+
df["Unemployed_Approx"] = df["Unemployment_Rate"] / 100 * _labor_force
|
|
313
|
+
df["JOLTS_UE_Ratio"] = df["JOLTS_Job_Openings"] / df["Unemployed_Approx"]
|
|
314
|
+
return df
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Fiscal derived ────────────────────────────────────────────────────
|
|
318
|
+
@_register(
|
|
319
|
+
"debt_to_gdp",
|
|
320
|
+
"derived",
|
|
321
|
+
requires={"Federal_Debt_Total", "Nominal_GDP"},
|
|
322
|
+
produces=["Debt_to_GDP"],
|
|
323
|
+
)
|
|
324
|
+
def _debt_to_gdp(df):
|
|
325
|
+
df["Debt_to_GDP"] = (df["Federal_Debt_Total"] / 1000) / df["Nominal_GDP"] * 100
|
|
326
|
+
return df
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@_register(
|
|
330
|
+
"deficit_gdp_pct",
|
|
331
|
+
"derived",
|
|
332
|
+
requires={"Monthly_Treasury_Statement_Deficit", "Nominal_GDP"},
|
|
333
|
+
produces=["Deficit_GDP_Pct"],
|
|
334
|
+
)
|
|
335
|
+
def _deficit_gdp_pct(df):
|
|
336
|
+
df["Deficit_GDP_Pct"] = (
|
|
337
|
+
(df["Monthly_Treasury_Statement_Deficit"] * 12 / 1000) / df["Nominal_GDP"] * 100
|
|
338
|
+
)
|
|
339
|
+
return df
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── SEP-based derived ─────────────────────────────────────────────────
|
|
343
|
+
@_register(
|
|
344
|
+
"sep_r_star",
|
|
345
|
+
"derived",
|
|
346
|
+
requires={"SEP_FedFunds_Median_LongerRun"},
|
|
347
|
+
produces=["SEP_LR_FFR", "SEP_r_star"],
|
|
348
|
+
)
|
|
349
|
+
def _sep_r_star(df):
|
|
350
|
+
df["SEP_LR_FFR"] = df["SEP_FedFunds_Median_LongerRun"].ffill()
|
|
351
|
+
df["SEP_r_star"] = df["SEP_LR_FFR"] - _PI_STAR
|
|
352
|
+
return df
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@_register(
|
|
356
|
+
"policy_stance_sep",
|
|
357
|
+
"derived",
|
|
358
|
+
requires={"FedFunds_Rate", "SEP_LR_FFR"},
|
|
359
|
+
produces=["Policy_Stance_SEP"],
|
|
360
|
+
)
|
|
361
|
+
def _policy_stance_sep(df):
|
|
362
|
+
df["Policy_Stance_SEP"] = df["FedFunds_Rate"] - df["SEP_LR_FFR"]
|
|
363
|
+
return df
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@_register(
|
|
367
|
+
"sep_ffr_med", "derived", requires={"SEP_FedFunds_Median"}, produces=["SEP_FFR_Med"]
|
|
368
|
+
)
|
|
369
|
+
def _sep_ffr_med(df):
|
|
370
|
+
df["SEP_FFR_Med"] = df["SEP_FedFunds_Median"].ffill()
|
|
371
|
+
return df
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@_register(
|
|
375
|
+
"term_premium_proxy",
|
|
376
|
+
"derived",
|
|
377
|
+
requires={"Treasury_10Y", "SEP_FFR_Med"},
|
|
378
|
+
produces=["Term_Premium_Proxy"],
|
|
379
|
+
)
|
|
380
|
+
def _term_premium_proxy(df):
|
|
381
|
+
df["Term_Premium_Proxy"] = df["Treasury_10Y"] - df["SEP_FFR_Med"]
|
|
382
|
+
return df
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
386
|
+
# 2. REGIME & ANOMALY FLAGS
|
|
387
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@_register(
|
|
391
|
+
"curve_inversion_10y2y",
|
|
392
|
+
"flags",
|
|
393
|
+
requires={"Yield_Curve_10Y_2Y"},
|
|
394
|
+
produces=["Flag_Curve_Inverted_10Y2Y"],
|
|
395
|
+
)
|
|
396
|
+
def _curve_inv_10y2y(df):
|
|
397
|
+
df["Flag_Curve_Inverted_10Y2Y"] = (df["Yield_Curve_10Y_2Y"] < 0).astype(int)
|
|
398
|
+
return df
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@_register(
|
|
402
|
+
"curve_inversion_10y3m",
|
|
403
|
+
"flags",
|
|
404
|
+
requires={"Yield_Curve_10Y_3M"},
|
|
405
|
+
produces=["Flag_Curve_Inverted_10Y3M"],
|
|
406
|
+
)
|
|
407
|
+
def _curve_inv_10y3m(df):
|
|
408
|
+
df["Flag_Curve_Inverted_10Y3M"] = (df["Yield_Curve_10Y_3M"] < 0).astype(int)
|
|
409
|
+
return df
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@_register(
|
|
413
|
+
"sahm_flag", "flags", requires={"Sahm_Indicator"}, produces=["Flag_Sahm_Triggered"]
|
|
414
|
+
)
|
|
415
|
+
def _sahm_flag(df):
|
|
416
|
+
df["Flag_Sahm_Triggered"] = (df["Sahm_Indicator"] >= _SAHM_THRESHOLD).astype(int)
|
|
417
|
+
return df
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@_register(
|
|
421
|
+
"fci_contradiction_flag",
|
|
422
|
+
"flags",
|
|
423
|
+
requires={"Chicago_Fed_Financial_Conditions", "Policy_Stance_SEP"},
|
|
424
|
+
produces=["Flag_FCI_Contradiction"],
|
|
425
|
+
)
|
|
426
|
+
def _fci_contradiction_flag(df):
|
|
427
|
+
df["Flag_FCI_Contradiction"] = (
|
|
428
|
+
(df["Chicago_Fed_Financial_Conditions"] < -0.2)
|
|
429
|
+
& (df["Policy_Stance_SEP"] > 0.5)
|
|
430
|
+
).astype(int)
|
|
431
|
+
return df
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@_register(
|
|
435
|
+
"deanchor_flags",
|
|
436
|
+
"flags",
|
|
437
|
+
requires={"UMich_Inflation_Expectations", "CPI_YoY"},
|
|
438
|
+
produces=[
|
|
439
|
+
"Expect_Gap_UMich",
|
|
440
|
+
"Flag_Deanchor_Mild",
|
|
441
|
+
"Flag_Deanchor_Severe",
|
|
442
|
+
"Flag_Deanchor_Hot",
|
|
443
|
+
"Flag_Deanchor_Cold",
|
|
444
|
+
],
|
|
445
|
+
)
|
|
446
|
+
def _deanchor_flags(df):
|
|
447
|
+
df["Expect_Gap_UMich"] = df["UMich_Inflation_Expectations"] - df["CPI_YoY"]
|
|
448
|
+
df["Flag_Deanchor_Mild"] = (df["Expect_Gap_UMich"].abs() > _DEANCHOR_MILD).astype(
|
|
449
|
+
int
|
|
450
|
+
)
|
|
451
|
+
df["Flag_Deanchor_Severe"] = (
|
|
452
|
+
df["Expect_Gap_UMich"].abs() > _DEANCHOR_SEVERE
|
|
453
|
+
).astype(int)
|
|
454
|
+
df["Flag_Deanchor_Hot"] = (df["Expect_Gap_UMich"] > _DEANCHOR_MILD).astype(int)
|
|
455
|
+
df["Flag_Deanchor_Cold"] = (df["Expect_Gap_UMich"] < -_DEANCHOR_MILD).astype(int)
|
|
456
|
+
return df
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@_register(
|
|
460
|
+
"m2_anomaly_flags",
|
|
461
|
+
"flags",
|
|
462
|
+
requires={"M2_YoY"},
|
|
463
|
+
produces=["Flag_M2_Excess", "Flag_M2_Contraction"],
|
|
464
|
+
)
|
|
465
|
+
def _m2_anomaly_flags(df):
|
|
466
|
+
df["Flag_M2_Excess"] = (df["M2_YoY"] > _M2_NORMAL_HIGH).astype(int)
|
|
467
|
+
df["Flag_M2_Contraction"] = (df["M2_YoY"] < _M2_NORMAL_LOW).astype(int)
|
|
468
|
+
return df
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@_register(
|
|
472
|
+
"labor_divergence_flag",
|
|
473
|
+
"flags",
|
|
474
|
+
requires={"JOLTS_UE_Ratio", "Unemployment_Rate"},
|
|
475
|
+
produces=["JOLTS_UE_Ratio_chg", "UE_chg_13w", "Flag_Labor_Divergence"],
|
|
476
|
+
)
|
|
477
|
+
def _labor_divergence_flag(df):
|
|
478
|
+
df["JOLTS_UE_Ratio_chg"] = df["JOLTS_UE_Ratio"].diff(_W13)
|
|
479
|
+
df["UE_chg_13w"] = df["Unemployment_Rate"].diff(_W13)
|
|
480
|
+
df["Flag_Labor_Divergence"] = (
|
|
481
|
+
(df["UE_chg_13w"] > 0.2) & (df["JOLTS_UE_Ratio_chg"] < -0.1)
|
|
482
|
+
| (df["UE_chg_13w"] < -0.2) & (df["JOLTS_UE_Ratio_chg"] > 0.1)
|
|
483
|
+
).astype(int)
|
|
484
|
+
return df
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@_register(
|
|
488
|
+
"neg_real_rate_flag",
|
|
489
|
+
"flags",
|
|
490
|
+
requires={"Real_FFR_PCE", "Core_PCE_YoY"},
|
|
491
|
+
produces=["Flag_Neg_Real_Rate_HighInflation"],
|
|
492
|
+
)
|
|
493
|
+
def _neg_real_rate_flag(df):
|
|
494
|
+
df["Flag_Neg_Real_Rate_HighInflation"] = (
|
|
495
|
+
(df["Real_FFR_PCE"] < 0) & (df["Core_PCE_YoY"] > _PI_STAR)
|
|
496
|
+
).astype(int)
|
|
497
|
+
return df
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@_register(
|
|
501
|
+
"hy_stress_flag", "flags", requires={"HY_OAS_Spread"}, produces=["Flag_HY_Stress"]
|
|
502
|
+
)
|
|
503
|
+
def _hy_stress_flag(df):
|
|
504
|
+
df["Flag_HY_Stress"] = (df["HY_OAS_Spread"] > 5.0).astype(int)
|
|
505
|
+
return df
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@_register(
|
|
509
|
+
"fiscal_dominance_flag",
|
|
510
|
+
"flags",
|
|
511
|
+
requires={"Debt_to_GDP", "FedFunds_Rate", "Core_PCE_YoY"},
|
|
512
|
+
produces=["Debt_GDP_chg_26w", "FFR_chg_26w", "Flag_Fiscal_Dominance_Risk"],
|
|
513
|
+
)
|
|
514
|
+
def _fiscal_dominance_flag(df):
|
|
515
|
+
df["Debt_GDP_chg_26w"] = df["Debt_to_GDP"].diff(_W26)
|
|
516
|
+
df["FFR_chg_26w"] = df["FedFunds_Rate"].diff(_W26)
|
|
517
|
+
df["Flag_Fiscal_Dominance_Risk"] = (
|
|
518
|
+
(df["Debt_GDP_chg_26w"] > 0)
|
|
519
|
+
& (df["FFR_chg_26w"] < -0.25)
|
|
520
|
+
& (df["Core_PCE_YoY"] > _PI_STAR)
|
|
521
|
+
).astype(int)
|
|
522
|
+
return df
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
526
|
+
# 3. CONTINUOUS SCORES
|
|
527
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@_register(
|
|
531
|
+
"taylor_rule",
|
|
532
|
+
"continuous",
|
|
533
|
+
requires={"Core_PCE_YoY", "Unemployment_Rate", "SEP_r_star", "FedFunds_Rate"},
|
|
534
|
+
produces=[
|
|
535
|
+
"Taylor_Prescribed",
|
|
536
|
+
"Taylor_Gap",
|
|
537
|
+
"Flag_Taylor_Below_100bp",
|
|
538
|
+
"Flag_Taylor_Above_100bp",
|
|
539
|
+
],
|
|
540
|
+
)
|
|
541
|
+
def _taylor_rule(df):
|
|
542
|
+
r_star = df["SEP_r_star"].fillna(_R_STAR_DEFAULT)
|
|
543
|
+
pi = df["Core_PCE_YoY"]
|
|
544
|
+
u_gap = _U_STAR - df["Unemployment_Rate"]
|
|
545
|
+
df["Taylor_Prescribed"] = (
|
|
546
|
+
r_star + pi + _TAYLOR_ALPHA * (pi - _PI_STAR) + _TAYLOR_BETA * u_gap
|
|
547
|
+
)
|
|
548
|
+
df["Taylor_Gap"] = df["Taylor_Prescribed"] - df["FedFunds_Rate"]
|
|
549
|
+
df["Flag_Taylor_Below_100bp"] = (df["Taylor_Gap"] > 1.0).astype(int)
|
|
550
|
+
df["Flag_Taylor_Above_100bp"] = (df["Taylor_Gap"] < -1.0).astype(int)
|
|
551
|
+
return df
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@_register(
|
|
555
|
+
"mandate_tension",
|
|
556
|
+
"continuous",
|
|
557
|
+
requires={"Core_PCE_YoY", "Unemployment_Rate"},
|
|
558
|
+
produces=[
|
|
559
|
+
"Inflation_Pressure",
|
|
560
|
+
"Employment_Pressure",
|
|
561
|
+
"Mandate_Tension",
|
|
562
|
+
"Mandate_Regime",
|
|
563
|
+
],
|
|
564
|
+
)
|
|
565
|
+
def _mandate_tension(df):
|
|
566
|
+
ip = df["Core_PCE_YoY"] - _PI_STAR
|
|
567
|
+
ep = df["Unemployment_Rate"] - _U_STAR
|
|
568
|
+
df["Inflation_Pressure"] = ip
|
|
569
|
+
df["Employment_Pressure"] = ep
|
|
570
|
+
df["Mandate_Tension"] = ip * ep
|
|
571
|
+
|
|
572
|
+
def _classify(row):
|
|
573
|
+
i, e = row["Inflation_Pressure"], row["Employment_Pressure"]
|
|
574
|
+
if pd.isna(i) or pd.isna(e):
|
|
575
|
+
return np.nan
|
|
576
|
+
if i > 0.3 and e > 0.3:
|
|
577
|
+
return 3
|
|
578
|
+
elif i < -0.3 and e < -0.3:
|
|
579
|
+
return -3
|
|
580
|
+
elif i > 0.3 and e < -0.3:
|
|
581
|
+
return 1
|
|
582
|
+
elif i < -0.3 and e > 0.3:
|
|
583
|
+
return -1
|
|
584
|
+
return 0
|
|
585
|
+
|
|
586
|
+
df["Mandate_Regime"] = df.apply(_classify, axis=1)
|
|
587
|
+
return df
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@_register(
|
|
591
|
+
"fiscal_monetary_conflict",
|
|
592
|
+
"continuous",
|
|
593
|
+
requires={"Policy_Stance_SEP", "Deficit_GDP_Pct"},
|
|
594
|
+
produces=["Fiscal_Monetary_Conflict"],
|
|
595
|
+
)
|
|
596
|
+
def _fiscal_monetary_conflict(df):
|
|
597
|
+
df["Fiscal_Monetary_Conflict"] = df["Policy_Stance_SEP"] * df["Deficit_GDP_Pct"]
|
|
598
|
+
return df
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@_register(
|
|
602
|
+
"expectations_anchoring",
|
|
603
|
+
"continuous",
|
|
604
|
+
requires=set(),
|
|
605
|
+
requires_any={"UMich_Inflation_Expectations", "Breakeven_Inflation_5Y", "Breakeven_Inflation_10Y"},
|
|
606
|
+
produces=["Expect_Anchor_Score", "Expect_Anchor_Signed"],
|
|
607
|
+
)
|
|
608
|
+
def _expectations_anchoring(df):
|
|
609
|
+
abs_devs, signed_devs = [], []
|
|
610
|
+
for col in [
|
|
611
|
+
"UMich_Inflation_Expectations",
|
|
612
|
+
"Breakeven_Inflation_5Y",
|
|
613
|
+
"Breakeven_Inflation_10Y",
|
|
614
|
+
]:
|
|
615
|
+
if col in df.columns:
|
|
616
|
+
abs_devs.append((df[col] - _PI_STAR).abs())
|
|
617
|
+
signed_devs.append(df[col] - _PI_STAR)
|
|
618
|
+
if not abs_devs:
|
|
619
|
+
return df
|
|
620
|
+
df["Expect_Anchor_Score"] = pd.concat(abs_devs, axis=1).mean(axis=1)
|
|
621
|
+
df["Expect_Anchor_Signed"] = pd.concat(signed_devs, axis=1).mean(axis=1)
|
|
622
|
+
return df
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@_register(
|
|
626
|
+
"fci_transmission",
|
|
627
|
+
"continuous",
|
|
628
|
+
requires={"Policy_Stance_SEP", "Chicago_Fed_Financial_Conditions"},
|
|
629
|
+
produces=["FCI_Transmission"],
|
|
630
|
+
)
|
|
631
|
+
def _fci_transmission(df):
|
|
632
|
+
df["FCI_Transmission"] = df["Policy_Stance_SEP"] * (
|
|
633
|
+
-df["Chicago_Fed_Financial_Conditions"]
|
|
634
|
+
)
|
|
635
|
+
return df
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@_register(
|
|
639
|
+
"credit_impulse",
|
|
640
|
+
"continuous",
|
|
641
|
+
requires={"Consumer_Credit_Total"},
|
|
642
|
+
produces=["Credit_Impulse"],
|
|
643
|
+
)
|
|
644
|
+
def _credit_impulse(df):
|
|
645
|
+
cg = df["Consumer_Credit_Total"].pct_change(_W13) * (52 / 13) * 100
|
|
646
|
+
df["Credit_Impulse"] = cg.diff(_W13)
|
|
647
|
+
return df
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@_register(
|
|
651
|
+
"housing_pressure",
|
|
652
|
+
"continuous",
|
|
653
|
+
requires={"Case_Shiller_Home_Price", "Mortgage_Rate_30Y"},
|
|
654
|
+
produces=["Home_Price_YoY", "Housing_Pressure"],
|
|
655
|
+
)
|
|
656
|
+
def _housing_pressure(df):
|
|
657
|
+
hp = df["Case_Shiller_Home_Price"].pct_change(_W52) * 100
|
|
658
|
+
df["Home_Price_YoY"] = hp
|
|
659
|
+
df["Housing_Pressure"] = (
|
|
660
|
+
df["Mortgage_Rate_30Y"]
|
|
661
|
+
- df["Mortgage_Rate_30Y"].rolling(_W52 * 5, min_periods=52).mean()
|
|
662
|
+
) + (hp - hp.rolling(_W52 * 5, min_periods=52).mean())
|
|
663
|
+
return df
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@_register(
|
|
667
|
+
"activity_momentum",
|
|
668
|
+
"continuous",
|
|
669
|
+
requires=set(),
|
|
670
|
+
requires_any={"Industrial_Production", "Retail_Sales", "Personal_Consumption_Expenditures", "Nonfarm_Payrolls"},
|
|
671
|
+
produces=["Activity_Momentum"],
|
|
672
|
+
)
|
|
673
|
+
def _activity_momentum(df):
|
|
674
|
+
def _trailing_z(s, window=_W52 * 3):
|
|
675
|
+
mu = s.rolling(window, min_periods=_W52).mean()
|
|
676
|
+
sigma = s.rolling(window, min_periods=_W52).std()
|
|
677
|
+
return (s - mu) / sigma.replace(0, np.nan)
|
|
678
|
+
|
|
679
|
+
components = []
|
|
680
|
+
for col in [
|
|
681
|
+
"Industrial_Production",
|
|
682
|
+
"Retail_Sales",
|
|
683
|
+
"Personal_Consumption_Expenditures",
|
|
684
|
+
"Nonfarm_Payrolls",
|
|
685
|
+
]:
|
|
686
|
+
if col in df.columns:
|
|
687
|
+
components.append(_trailing_z(df[col]))
|
|
688
|
+
if not components:
|
|
689
|
+
return df
|
|
690
|
+
df["Activity_Momentum"] = pd.concat(components, axis=1).mean(axis=1)
|
|
691
|
+
return df
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
695
|
+
# 4. LEAD / LAG PREDICTIVE SIGNALS
|
|
696
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
697
|
+
|
|
698
|
+
# M2 → Inflation
|
|
699
|
+
_reg_shift("M2_YoY", "M2_YoY_Lag_78w", _W78)
|
|
700
|
+
_reg_shift("CPI_YoY", "CPI_YoY_Lead_78w", -_W78)
|
|
701
|
+
_reg_shift("Core_PCE_YoY", "Core_PCE_YoY_Lead_52w", -_W52)
|
|
702
|
+
|
|
703
|
+
# Claims → Unemployment
|
|
704
|
+
_reg_shift("Initial_Jobless_Claims", "Claims_Lag_13w", _W13)
|
|
705
|
+
_reg_shift("Initial_Jobless_Claims", "Claims_Lag_26w", _W26)
|
|
706
|
+
_reg_shift("Unemployment_Rate", "UE_Lead_26w", -_W26)
|
|
707
|
+
|
|
708
|
+
# Yield Curve → Recession
|
|
709
|
+
_reg_shift("Yield_Curve_10Y_2Y", "Curve_10Y2Y_Lag_52w", _W52)
|
|
710
|
+
_reg_shift("Yield_Curve_10Y_3M", "Curve_10Y3M_Lag_52w", _W52)
|
|
711
|
+
|
|
712
|
+
# Breakeven → Realized Inflation
|
|
713
|
+
_reg_shift("Breakeven_Inflation_5Y", "Breakeven_5Y_Lag_26w", _W26)
|
|
714
|
+
_reg_shift("Breakeven_Inflation_5Y", "Breakeven_5Y_Lag_52w", _W52)
|
|
715
|
+
|
|
716
|
+
# Housing → CPI Shelter
|
|
717
|
+
_reg_shift("Home_Price_YoY", "Home_Price_YoY_Lag_78w", _W78)
|
|
718
|
+
|
|
719
|
+
# Credit → Consumption
|
|
720
|
+
_reg_shift("Credit_Impulse", "Credit_Impulse_Lag_13w", _W13)
|
|
721
|
+
_reg_shift("Credit_Impulse", "Credit_Impulse_Lag_26w", _W26)
|
|
722
|
+
|
|
723
|
+
# FFR → Unemployment
|
|
724
|
+
_reg_diff("FedFunds_Rate", "FFR_Chg_52w", _W52, category="leads_lags")
|
|
725
|
+
_reg_shift("FFR_Chg_52w", "FFR_Chg_52w_Lag_52w", _W52)
|
|
726
|
+
|
|
727
|
+
# Sentiment → Activity
|
|
728
|
+
_reg_shift("UMich_Consumer_Sentiment", "Sentiment_Lag_13w", _W13)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
732
|
+
# PUBLIC API
|
|
733
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def score(
|
|
737
|
+
df: pd.DataFrame,
|
|
738
|
+
*,
|
|
739
|
+
pi_star: float = 2.0,
|
|
740
|
+
u_star: float = 4.0,
|
|
741
|
+
copy: bool = True,
|
|
742
|
+
) -> pd.DataFrame:
|
|
743
|
+
"""
|
|
744
|
+
Apply the scoring layer to a FRED DataFrame.
|
|
745
|
+
|
|
746
|
+
Only scores whose input dependencies are satisfied will run.
|
|
747
|
+
If no scores can be computed, returns the DataFrame unchanged
|
|
748
|
+
with a warning.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
df: DataFrame from loader.py (any subset of series).
|
|
752
|
+
pi_star: inflation target override (default 2.0).
|
|
753
|
+
u_star: NAIRU override (default 4.0).
|
|
754
|
+
copy: if True, operate on a copy (default).
|
|
755
|
+
|
|
756
|
+
Returns:
|
|
757
|
+
DataFrame with original columns plus all computable scored columns.
|
|
758
|
+
"""
|
|
759
|
+
global _PI_STAR, _U_STAR
|
|
760
|
+
_PI_STAR = pi_star
|
|
761
|
+
_U_STAR = u_star
|
|
762
|
+
|
|
763
|
+
if copy:
|
|
764
|
+
df = df.copy()
|
|
765
|
+
|
|
766
|
+
if df.index.name == "date":
|
|
767
|
+
pass
|
|
768
|
+
elif "date" in df.columns:
|
|
769
|
+
df["date"] = pd.to_datetime(df["date"])
|
|
770
|
+
df = df.set_index("date")
|
|
771
|
+
resolved = _resolve(set(df.columns))
|
|
772
|
+
meaningful = [s for s in resolved if s.requires]
|
|
773
|
+
if not meaningful:
|
|
774
|
+
print(
|
|
775
|
+
"\n⚠ No scores can be computed from the columns present.\n"
|
|
776
|
+
" Scoring requires series from categories like INFLATION, LABOR,\n"
|
|
777
|
+
" RATES, or SEP. Returning raw data unchanged."
|
|
778
|
+
)
|
|
779
|
+
df = df.reset_index()
|
|
780
|
+
return df
|
|
781
|
+
|
|
782
|
+
cols_before = set(df.columns)
|
|
783
|
+
for spec in resolved:
|
|
784
|
+
df = spec.fn(df)
|
|
785
|
+
cols_added = set(df.columns) - cols_before
|
|
786
|
+
|
|
787
|
+
skipped = len(_REGISTRY) - len(resolved)
|
|
788
|
+
print(
|
|
789
|
+
f"\nScoring: {len(resolved)}/{len(_REGISTRY)} blocks resolved | "
|
|
790
|
+
f"{len(cols_added)} columns added | {skipped} skipped (missing deps)"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
df = df.reset_index()
|
|
794
|
+
return df
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def available_scores(df: pd.DataFrame) -> dict:
|
|
798
|
+
"""
|
|
799
|
+
Dry-run: show which scores WOULD be computed for a given DataFrame.
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
dict with 'resolved', 'skipped', 'columns_added',
|
|
803
|
+
'resolved_count', 'skipped_count'.
|
|
804
|
+
"""
|
|
805
|
+
columns = set(df.columns)
|
|
806
|
+
if df.index.name:
|
|
807
|
+
columns.add(str(df.index.name))
|
|
808
|
+
|
|
809
|
+
resolved = _resolve(columns)
|
|
810
|
+
resolved_names = {s.name for s in resolved}
|
|
811
|
+
skipped = [s.name for s in _REGISTRY if s.name not in resolved_names]
|
|
812
|
+
|
|
813
|
+
columns_added = []
|
|
814
|
+
for s in resolved:
|
|
815
|
+
columns_added.extend(s.produces)
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
"resolved": [s.name for s in resolved],
|
|
819
|
+
"skipped": skipped,
|
|
820
|
+
"columns_added": columns_added,
|
|
821
|
+
"resolved_count": len(resolved),
|
|
822
|
+
"skipped_count": len(skipped),
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def list_scored_columns() -> dict[str, list[str]]:
|
|
827
|
+
"""Full catalog of all registered scored columns, grouped by category."""
|
|
828
|
+
catalog: dict[str, list[str]] = {}
|
|
829
|
+
for spec in _REGISTRY:
|
|
830
|
+
if spec.category not in catalog:
|
|
831
|
+
catalog[spec.category] = []
|
|
832
|
+
catalog[spec.category].extend(spec.produces)
|
|
833
|
+
return catalog
|