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.
@@ -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