From d8982f452c2ebf92d84b726058a54df78d0845ea Mon Sep 17 00:00:00 2001 From: vbeohar Date: Wed, 2 Sep 2026 00:57:12 -0400 Subject: [PATCH 1/3] First ipman agentic forecasting commit with data --- data/fred/IPMAN.parquet | Bin 0 -> 20621 bytes .../README.md | 67 +++++++++++ .../__init__.py | 16 +++ .../manufacturing_stress_forecasting/data.py | 98 ++++++++++++++++ .../features.py | 84 ++++++++++++++ .../predictors/__init__.py | 6 + .../predictors/logistic.py | 107 ++++++++++++++++++ .../run_smoke.py | 28 +++++ .../specs/manufacturing_stress_smoke.yaml | 21 ++++ .../targets.py | 42 +++++++ .../test_targets_and_features.py | 42 +++++++ scripts/fetch_manufacturing_stress.py | 28 +++++ uv.lock | 6 +- 13 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 data/fred/IPMAN.parquet create mode 100644 implementations/manufacturing_stress_forecasting/README.md create mode 100644 implementations/manufacturing_stress_forecasting/__init__.py create mode 100644 implementations/manufacturing_stress_forecasting/data.py create mode 100644 implementations/manufacturing_stress_forecasting/features.py create mode 100644 implementations/manufacturing_stress_forecasting/predictors/__init__.py create mode 100644 implementations/manufacturing_stress_forecasting/predictors/logistic.py create mode 100644 implementations/manufacturing_stress_forecasting/run_smoke.py create mode 100644 implementations/manufacturing_stress_forecasting/specs/manufacturing_stress_smoke.yaml create mode 100644 implementations/manufacturing_stress_forecasting/targets.py create mode 100644 implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py create mode 100644 scripts/fetch_manufacturing_stress.py diff --git a/data/fred/IPMAN.parquet b/data/fred/IPMAN.parquet new file mode 100644 index 0000000000000000000000000000000000000000..0c2bd4e4e69445fc6d8e8e3230e9b7495169f9d8 GIT binary patch literal 20621 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 E0ZIcfjsO4v literal 0 HcmV?d00001 diff --git a/implementations/manufacturing_stress_forecasting/README.md b/implementations/manufacturing_stress_forecasting/README.md new file mode 100644 index 000000000..bf130759b --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/README.md @@ -0,0 +1,67 @@ +# Manufacturing stress forecasting — minimal IPMAN MVP + +This implementation asks one Track 1 question: + +> Given information available at a monthly forecast origin, what is the +> probability that U.S. manufacturing will be under stress three months later? + +The first version intentionally uses only FRED's `IPMAN` series. It excludes +Yahoo Finance, macro covariates, news, LLMs, and agents until the target and +backtest are easy to inspect. + +## Target + +A month is labelled `1` (stress) when IPMAN has declined by at least 2% over +its preceding three months; otherwise it is `0`. The threshold is a provisional +version-1 definition and should be reviewed visually before expanding the +project. + +The forecast made at month `t` predicts the stress label at `t + 3 months`. +That distinction makes this forecasting rather than current-state detection. + +## Predictors + +- `HistoricalFrequencyPredictor`: the visible historical stress rate. +- `ManufacturingStressLogisticPredictor`: fit-at-origin logistic regression on + trailing 1-, 3-, 6-, and 12-month IPMAN percentage changes. + +Both return `BinaryForecast` probabilities and are scored with Brier score. + +## Data and cutoff assumptions + +`FREDAdapter` caches `IPMAN` at `data/fred/IPMAN.parquet`. Because the standard +FRED response does not provide point-in-time release vintages, this prototype +conservatively shifts `released_at` one month beyond each reference timestamp. +The limitation remains: historical FRED observations may contain later +revisions. A production-quality study should use ALFRED vintages. + +## Run + +From the repository root, put a personal FRED key in `.env` or export it: + +```bash +export FRED_API_KEY="..." +``` + +Populate the cache and inspect the registered series: + +```bash +uv run python scripts/fetch_manufacturing_stress.py +``` + +Run the small backtest: + +```bash +uv run --directory implementations python -m manufacturing_stress_forecasting.run_smoke +``` + +The output prints one mean Brier score per predictor; lower is better. The +logistic model should be compared against historical frequency, not judged in +isolation. + +## Next steps + +1. Plot IPMAN and the derived stress months; confirm or revise the 2% threshold. +2. Add a full monthly development backtest after the smoke run is stable. +3. Add a small FRED macro panel. +4. Add an agent only after the deterministic Track 1 experiment is credible. diff --git a/implementations/manufacturing_stress_forecasting/__init__.py b/implementations/manufacturing_stress_forecasting/__init__.py new file mode 100644 index 000000000..e4cbe5929 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/__init__.py @@ -0,0 +1,16 @@ +"""Minimal IPMAN-based manufacturing-stress forecasting use case.""" + +from manufacturing_stress_forecasting.data import ( + IPMAN_SERIES_ID, + STRESS_SERIES_ID, + build_manufacturing_stress_service, +) +from manufacturing_stress_forecasting.predictors import ManufacturingStressLogisticPredictor + + +__all__ = [ + "IPMAN_SERIES_ID", + "STRESS_SERIES_ID", + "ManufacturingStressLogisticPredictor", + "build_manufacturing_stress_service", +] diff --git a/implementations/manufacturing_stress_forecasting/data.py b/implementations/manufacturing_stress_forecasting/data.py new file mode 100644 index 000000000..187254448 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/data.py @@ -0,0 +1,98 @@ +"""FRED/IPMAN data service for the manufacturing-stress MVP.""" + +from __future__ import annotations + +from pathlib import Path + +from aieng.forecasting.data import DataService, SeriesMetadata +from aieng.forecasting.data.adapters import FREDAdapter +from aieng.forecasting.data.features import StaticFrameAdapter +from manufacturing_stress_forecasting.features import ( + FEATURE_PERIODS, + apply_conservative_monthly_release_lag, + build_ipman_feature_frames, +) +from manufacturing_stress_forecasting.targets import ( + DEFAULT_LOOKBACK_MONTHS, + DEFAULT_STRESS_THRESHOLD_PCT, + derive_manufacturing_stress_labels, +) + + +IPMAN_FRED_ID = "IPMAN" +IPMAN_SERIES_ID = "ipman_us_manufacturing_production" +STRESS_SERIES_ID = "manufacturing_stress" + +_REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_FRED_CACHE_DIR = _REPO_ROOT / "data" / "fred" + + +def build_manufacturing_stress_service( + *, + cache_dir: str | Path = DEFAULT_FRED_CACHE_DIR, + refresh: bool = False, + release_lag_months: int = 1, + stress_lookback_months: int = DEFAULT_LOOKBACK_MONTHS, + stress_threshold_pct: float = DEFAULT_STRESS_THRESHOLD_PCT, +) -> DataService: + """Build a service containing IPMAN, momentum features, and stress labels.""" + raw_ipman = FREDAdapter(IPMAN_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + ipman = apply_conservative_monthly_release_lag(raw_ipman, months=release_lag_months) + feature_frames = build_ipman_feature_frames(ipman) + stress = derive_manufacturing_stress_labels( + ipman, + lookback_months=stress_lookback_months, + threshold_pct=stress_threshold_pct, + ) + + service = DataService() + service.register( + IPMAN_SERIES_ID, + StaticFrameAdapter(ipman), + SeriesMetadata( + series_id=IPMAN_SERIES_ID, + description="U.S. manufacturing industrial production index (IPMAN)", + source="FRED (IPMAN)", + units="Index", + frequency="MS", + ), + ) + + for series_id, frame in feature_frames.items(): + periods = FEATURE_PERIODS[series_id] + service.register( + series_id, + StaticFrameAdapter(frame), + SeriesMetadata( + series_id=series_id, + description=f"Trailing {periods}-month percentage change in IPMAN", + source="Derived from FRED IPMAN", + units="Percent", + frequency="MS", + ), + ) + + service.register( + STRESS_SERIES_ID, + StaticFrameAdapter(stress), + SeriesMetadata( + series_id=STRESS_SERIES_ID, + description=( + "Binary U.S. manufacturing stress label: 1 when trailing " + f"{stress_lookback_months}-month IPMAN change is at or below {stress_threshold_pct:.1f}%" + ), + source="Derived from FRED IPMAN", + units="Binary event (0=no stress, 1=stress)", + frequency="MS", + ), + ) + return service + + +__all__ = [ + "DEFAULT_FRED_CACHE_DIR", + "IPMAN_FRED_ID", + "IPMAN_SERIES_ID", + "STRESS_SERIES_ID", + "build_manufacturing_stress_service", +] diff --git a/implementations/manufacturing_stress_forecasting/features.py b/implementations/manufacturing_stress_forecasting/features.py new file mode 100644 index 000000000..535aee05d --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/features.py @@ -0,0 +1,84 @@ +"""Leak-safe monthly features for the manufacturing-stress MVP.""" + +from __future__ import annotations + +import pandas as pd +from aieng.forecasting.data.features import canonical_three_col + + +IPMAN_CHANGE_1M_SERIES_ID = "ipman_change_1m_pct" +IPMAN_CHANGE_3M_SERIES_ID = "ipman_change_3m_pct" +IPMAN_CHANGE_6M_SERIES_ID = "ipman_change_6m_pct" +IPMAN_CHANGE_12M_SERIES_ID = "ipman_change_12m_pct" + +FEATURE_PERIODS: dict[str, int] = { + IPMAN_CHANGE_1M_SERIES_ID: 1, + IPMAN_CHANGE_3M_SERIES_ID: 3, + IPMAN_CHANGE_6M_SERIES_ID: 6, + IPMAN_CHANGE_12M_SERIES_ID: 12, +} +FEATURE_SERIES_IDS: tuple[str, ...] = tuple(FEATURE_PERIODS) + + +def apply_conservative_monthly_release_lag(frame: pd.DataFrame, months: int = 1) -> pd.DataFrame: + """Stamp observations as available ``months`` after their reference month. + + The standard FRED adapter uses ``released_at = timestamp`` because it does + not retrieve release vintages. For this monthly prototype, one month is a + deliberately conservative approximation that prevents a month-start + forecast from seeing that same month's completed production observation. + """ + if months < 0: + raise ValueError(f"months must be non-negative; got {months}") + out = frame.copy() + out["released_at"] = pd.to_datetime(out["timestamp"]) + pd.offsets.MonthBegin(months) + return canonical_three_col(out) + + +def percent_change_feature(ipman: pd.DataFrame, periods: int) -> pd.DataFrame: + """Return the trailing ``periods``-month IPMAN percentage change.""" + if periods < 1: + raise ValueError(f"periods must be positive; got {periods}") + out = ipman.copy().sort_values("timestamp").reset_index(drop=True) + out["value"] = out["value"].pct_change(periods=periods, fill_method=None) * 100.0 + return canonical_three_col(out) + + +def build_ipman_feature_frames(ipman: pd.DataFrame) -> dict[str, pd.DataFrame]: + """Build the four small momentum features used by the first baseline.""" + return {series_id: percent_change_feature(ipman, periods) for series_id, periods in FEATURE_PERIODS.items()} + + +def build_feature_snapshot( + origin: pd.Timestamp, + feature_frames: dict[str, pd.DataFrame], +) -> dict[str, float] | None: + """Return the latest feature values that were published by ``origin``. + + Filtering on ``released_at`` here is essential when reconstructing older + training examples: the surrounding ``ForecastContext`` protects the current + forecast origin, while this function recreates the stricter cutoff at each + past origin used to train the fit-at-origin logistic model. + """ + snapshot: dict[str, float] = {} + for series_id in FEATURE_SERIES_IDS: + frame = feature_frames[series_id] + visible = frame[pd.to_datetime(frame["released_at"]) <= origin] + if visible.empty: + return None + snapshot[series_id] = float(visible.sort_values("timestamp")["value"].iloc[-1]) + return snapshot + + +__all__ = [ + "FEATURE_PERIODS", + "FEATURE_SERIES_IDS", + "IPMAN_CHANGE_1M_SERIES_ID", + "IPMAN_CHANGE_3M_SERIES_ID", + "IPMAN_CHANGE_6M_SERIES_ID", + "IPMAN_CHANGE_12M_SERIES_ID", + "apply_conservative_monthly_release_lag", + "build_feature_snapshot", + "build_ipman_feature_frames", + "percent_change_feature", +] diff --git a/implementations/manufacturing_stress_forecasting/predictors/__init__.py b/implementations/manufacturing_stress_forecasting/predictors/__init__.py new file mode 100644 index 000000000..a9dad38c7 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/predictors/__init__.py @@ -0,0 +1,6 @@ +"""Predictors for the manufacturing-stress implementation.""" + +from manufacturing_stress_forecasting.predictors.logistic import ManufacturingStressLogisticPredictor + + +__all__ = ["ManufacturingStressLogisticPredictor"] diff --git a/implementations/manufacturing_stress_forecasting/predictors/logistic.py b/implementations/manufacturing_stress_forecasting/predictors/logistic.py new file mode 100644 index 000000000..a0c5d3632 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/predictors/logistic.py @@ -0,0 +1,107 @@ +"""Fit-at-origin logistic baseline for three-month-ahead manufacturing stress.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import numpy as np +import pandas as pd +from aieng.forecasting.data.context import ForecastContext +from aieng.forecasting.evaluation.prediction import BinaryForecast, Prediction +from aieng.forecasting.evaluation.predictor import Predictor +from aieng.forecasting.evaluation.task import ForecastingTask +from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS, build_feature_snapshot + + +class ManufacturingStressLogisticPredictor(Predictor): + """Forecast manufacturing stress from four trailing IPMAN changes. + + The model is rebuilt at every backtest origin. For each resolved historical + outcome at month ``r``, its feature vector is reconstructed at ``r - lead`` + so the training examples obey the same three-month forecast horizon as the + current prediction. + """ + + def __init__(self, *, regularization_c: float = 1.0, min_training_examples: int = 24) -> None: + self._c = regularization_c + self._min_training_examples = min_training_examples + + @property + def predictor_id(self) -> str: + """Return the stable artifact identifier.""" + return "manufacturing_stress_logistic_ipman" + + def predict(self, task: ForecastingTask, context: ForecastContext) -> list[Prediction]: + """Fit on visible history and return one binary stress probability.""" + if task.payload_type != "binary": + raise ValueError(f"{type(self).__name__} requires payload_type='binary'.") + if len(task.horizons) != 1: + raise ValueError(f"{type(self).__name__} supports exactly one horizon; got {task.horizons}.") + + as_of = pd.Timestamp(context.as_of) + target = context.get_series(task.target_series_id) + feature_frames = {series_id: context.get_series(series_id) for series_id in FEATURE_SERIES_IDS} + lead = pd.tseries.frequencies.to_offset(task.frequency) * task.horizons[0] + + rows, outcomes = self._training_data(target, feature_frames, lead) + current = build_feature_snapshot(as_of, feature_frames) + payload, model_metadata = self._fit_and_predict(rows, outcomes, current) + + return [ + Prediction( + predictor_id=self.predictor_id, + task_id=task.task_id, + issued_at=datetime.now(tz=timezone.utc).replace(tzinfo=None), + as_of=context.as_of, + forecast_date=(as_of + lead).to_pydatetime(), + payload=payload, + metadata={"n_train": len(outcomes), **model_metadata}, + ) + ] + + def _training_data( + self, + target: pd.DataFrame, + feature_frames: dict[str, pd.DataFrame], + lead: pd.DateOffset, + ) -> tuple[list[list[float]], list[float]]: + rows: list[list[float]] = [] + outcomes: list[float] = [] + for resolution_date, outcome in zip(target["timestamp"], target["value"], strict=True): + past_origin = pd.Timestamp(resolution_date) - lead + snapshot = build_feature_snapshot(past_origin, feature_frames) + if snapshot is None: + continue + rows.append([snapshot[series_id] for series_id in FEATURE_SERIES_IDS]) + outcomes.append(float(outcome)) + return rows, outcomes + + def _fit_and_predict( + self, + rows: list[list[float]], + outcomes: list[float], + current: dict[str, float] | None, + ) -> tuple[BinaryForecast, dict[str, object]]: + base_rate = float(np.mean(outcomes)) if outcomes else 0.1 + if current is None: + return BinaryForecast(probability=base_rate), {"model": "base_rate_fallback"} + if len(outcomes) < self._min_training_examples or len(set(outcomes)) < 2: + return BinaryForecast(probability=base_rate), {"model": "base_rate_fallback"} + + from sklearn.linear_model import LogisticRegression # noqa: PLC0415 + from sklearn.pipeline import make_pipeline # noqa: PLC0415 + from sklearn.preprocessing import StandardScaler # noqa: PLC0415 + + model = make_pipeline(StandardScaler(), LogisticRegression(C=self._c, max_iter=1000)) + model.fit(np.asarray(rows), np.asarray(outcomes)) + current_row = np.asarray([[current[series_id] for series_id in FEATURE_SERIES_IDS]]) + probability = float(model.predict_proba(current_row)[0, 1]) + coefficients = model.named_steps["logisticregression"].coef_[0] + return BinaryForecast(probability=probability), { + "model": "logistic_regression", + "features": dict(zip(FEATURE_SERIES_IDS, (float(value) for value in current_row[0]), strict=True)), + "coefficients": dict(zip(FEATURE_SERIES_IDS, (float(value) for value in coefficients), strict=True)), + } + + +__all__ = ["ManufacturingStressLogisticPredictor"] diff --git a/implementations/manufacturing_stress_forecasting/run_smoke.py b/implementations/manufacturing_stress_forecasting/run_smoke.py new file mode 100644 index 000000000..fdef561b8 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/run_smoke.py @@ -0,0 +1,28 @@ +"""Run the two-predictor manufacturing-stress smoke backtest.""" + +from pathlib import Path + +import yaml +from aieng.forecasting.evaluation import BacktestSpec, backtest +from aieng.forecasting.methods import HistoricalFrequencyPredictor +from manufacturing_stress_forecasting.data import build_manufacturing_stress_service +from manufacturing_stress_forecasting.predictors import ManufacturingStressLogisticPredictor + + +SPEC_PATH = Path(__file__).resolve().parent / "specs" / "manufacturing_stress_smoke.yaml" + + +def main() -> None: + """Load data, run both baselines, and print their mean Brier scores.""" + with SPEC_PATH.open() as file: + spec = BacktestSpec.model_validate(yaml.safe_load(file)) + + service = build_manufacturing_stress_service() + predictors = [HistoricalFrequencyPredictor(), ManufacturingStressLogisticPredictor()] + for predictor in predictors: + result = backtest(predictor=predictor, spec=spec, data_service=service) + print(f"{predictor.predictor_id}: {result.mean_score:.4f} mean {result.metric}") + + +if __name__ == "__main__": + main() diff --git a/implementations/manufacturing_stress_forecasting/specs/manufacturing_stress_smoke.yaml b/implementations/manufacturing_stress_forecasting/specs/manufacturing_stress_smoke.yaml new file mode 100644 index 000000000..39cc9e5cc --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/specs/manufacturing_stress_smoke.yaml @@ -0,0 +1,21 @@ +# Initial Track 1 experiment: one binary target, one three-month horizon. + +description: >- + Development backtest for forecasting whether U.S. manufacturing will be + under IPMAN-defined stress three months after each forecast origin. + +task: + task_id: manufacturing_stress_3m + target_series_id: manufacturing_stress + horizons: [3] + frequency: MS + payload_type: binary + description: >- + Probability that U.S. manufacturing will be under stress three months + ahead. A resolved month is stressed when IPMAN has declined by at least + 2 percent over its preceding three months. + +start: "2018-01-01" +end: "2024-12-01" +stride: 3 +warmup: 60 diff --git a/implementations/manufacturing_stress_forecasting/targets.py b/implementations/manufacturing_stress_forecasting/targets.py new file mode 100644 index 000000000..d2db91aa7 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/targets.py @@ -0,0 +1,42 @@ +"""Deterministic manufacturing-stress target construction.""" + +from __future__ import annotations + +import pandas as pd +from aieng.forecasting.data.features import canonical_three_col + + +DEFAULT_LOOKBACK_MONTHS = 3 +DEFAULT_STRESS_THRESHOLD_PCT = -2.0 + + +def derive_manufacturing_stress_labels( + ipman: pd.DataFrame, + *, + lookback_months: int = DEFAULT_LOOKBACK_MONTHS, + threshold_pct: float = DEFAULT_STRESS_THRESHOLD_PCT, +) -> pd.DataFrame: + """Create a monthly 0/1 target from trailing IPMAN deterioration. + + A month is labelled stressed when IPMAN has fallen by at least + ``abs(threshold_pct)`` percent over the preceding ``lookback_months``. + The label inherits the current IPMAN observation's ``released_at`` date, + because it cannot be known before that observation is published. + """ + if lookback_months < 1: + raise ValueError(f"lookback_months must be positive; got {lookback_months}") + if threshold_pct >= 0: + raise ValueError(f"threshold_pct must be negative; got {threshold_pct}") + + out = ipman.copy().sort_values("timestamp").reset_index(drop=True) + deterioration = out["value"].pct_change(periods=lookback_months, fill_method=None) * 100.0 + out["value"] = (deterioration <= threshold_pct).astype(float) + out.loc[deterioration.isna(), "value"] = float("nan") + return canonical_three_col(out) + + +__all__ = [ + "DEFAULT_LOOKBACK_MONTHS", + "DEFAULT_STRESS_THRESHOLD_PCT", + "derive_manufacturing_stress_labels", +] diff --git a/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py b/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py new file mode 100644 index 000000000..6afdd3e1e --- /dev/null +++ b/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py @@ -0,0 +1,42 @@ +"""Focused tests for label construction and historical feature cutoffs.""" + +import pandas as pd +import pytest +from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS, build_feature_snapshot +from manufacturing_stress_forecasting.targets import derive_manufacturing_stress_labels + + +def test_stress_label_uses_trailing_three_month_decline() -> None: + dates = pd.date_range("2024-01-01", periods=5, freq="MS") + ipman = pd.DataFrame( + { + "timestamp": dates, + "value": [100.0, 100.0, 100.0, 100.0, 97.9], + "released_at": dates + pd.offsets.MonthBegin(1), + } + ) + + labels = derive_manufacturing_stress_labels(ipman) + + assert labels["value"].tolist() == [0.0, 1.0] + assert labels["timestamp"].tolist() == [pd.Timestamp("2024-04-01"), pd.Timestamp("2024-05-01")] + assert labels["released_at"].tolist() == [pd.Timestamp("2024-05-01"), pd.Timestamp("2024-06-01")] + + +def test_feature_snapshot_ignores_values_released_after_origin() -> None: + origin = pd.Timestamp("2024-03-01") + frames: dict[str, pd.DataFrame] = {} + for index, series_id in enumerate(FEATURE_SERIES_IDS): + frames[series_id] = pd.DataFrame( + { + "timestamp": [pd.Timestamp("2024-01-01"), pd.Timestamp("2024-02-01")], + "value": [float(index), 999.0], + "released_at": [pd.Timestamp("2024-02-01"), pd.Timestamp("2024-04-01")], + } + ) + + snapshot = build_feature_snapshot(origin, frames) + + assert snapshot is not None + for index, series_id in enumerate(FEATURE_SERIES_IDS): + assert snapshot[series_id] == pytest.approx(float(index)) diff --git a/scripts/fetch_manufacturing_stress.py b/scripts/fetch_manufacturing_stress.py new file mode 100644 index 000000000..af1ed1871 --- /dev/null +++ b/scripts/fetch_manufacturing_stress.py @@ -0,0 +1,28 @@ +"""Fetch/cache IPMAN and print the manufacturing-stress series summary.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "implementations")) + +from dotenv import load_dotenv + + +load_dotenv(REPO_ROOT / ".env", override=False) + +from manufacturing_stress_forecasting.data import build_manufacturing_stress_service + + +def main() -> None: + """Populate the IPMAN cache and report the derived series.""" + service = build_manufacturing_stress_service() + print(service.summary().to_string(index=False)) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 7314a4a59..a1ba2aec6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -70,7 +70,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agentic-forecasting-implementations", editable = "implementations" }, + { name = "agentic-forecasting-implementations", virtual = "implementations" }, { name = "ipykernel", specifier = ">=7.3.0" }, { name = "jupyter", specifier = ">=1.1.1" }, { name = "lightgbm", specifier = ">=4.0.0" }, @@ -97,7 +97,7 @@ dev = [ [[package]] name = "agentic-forecasting-implementations" version = "0.1.0" -source = { editable = "implementations" } +source = { virtual = "implementations" } dependencies = [ { name = "aieng-forecasting", extra = ["agentic", "documents", "llm", "numerical"] }, { name = "beautifulsoup4" }, From 19f86a9e0d467d22a16c682d9a189490f41a62a2 Mon Sep 17 00:00:00 2001 From: vbeohar Date: Mon, 21 Sep 2026 23:52:15 -0400 Subject: [PATCH 2/3] Added agent predictor --- .../analyst_agent/__init__.py | 14 ++ .../analyst_agent/agent.py | 146 ++++++++++++++++++ .../run_agent_prediction.py | 54 +++++++ .../test_agent_prompt.py | 71 +++++++++ 4 files changed, 285 insertions(+) create mode 100644 implementations/manufacturing_stress_forecasting/analyst_agent/__init__.py create mode 100644 implementations/manufacturing_stress_forecasting/analyst_agent/agent.py create mode 100644 implementations/manufacturing_stress_forecasting/run_agent_prediction.py create mode 100644 implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py diff --git a/implementations/manufacturing_stress_forecasting/analyst_agent/__init__.py b/implementations/manufacturing_stress_forecasting/analyst_agent/__init__.py new file mode 100644 index 000000000..4d704e53c --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/analyst_agent/__init__.py @@ -0,0 +1,14 @@ +"""Quantitative-only manufacturing-stress analyst agent.""" + +from manufacturing_stress_forecasting.analyst_agent.agent import ( + ManufacturingStressPromptBuilder, + build_manufacturing_stress_agent_config, + build_manufacturing_stress_agent_predictor, +) + + +__all__ = [ + "ManufacturingStressPromptBuilder", + "build_manufacturing_stress_agent_config", + "build_manufacturing_stress_agent_predictor", +] diff --git a/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py b/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py new file mode 100644 index 000000000..5fc32724c --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py @@ -0,0 +1,146 @@ +"""Quantitative-only ADK agent for binary manufacturing-stress forecasts.""" + +from __future__ import annotations + +import json +from typing import Any + +import pandas as pd +from aieng.forecasting.data.context import ForecastContext +from aieng.forecasting.evaluation.task import ForecastingTask +from aieng.forecasting.methods.agentic import ( + AgentPredictor, + DiscreteAgentForecastOutput, + build_adk_agent, +) +from aieng.forecasting.methods.agentic.agent_factory import AgentConfig +from aieng.forecasting.models import LITE_MODEL +from manufacturing_stress_forecasting.data import IPMAN_SERIES_ID +from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS, build_feature_snapshot +from manufacturing_stress_forecasting.targets import ( + DEFAULT_LOOKBACK_MONTHS, + DEFAULT_STRESS_THRESHOLD_PCT, +) +from pydantic import BaseModel, Field + + +def _build_instruction() -> str: + schema = DiscreteAgentForecastOutput.prompt_schema_json() + return ( + "## Role\n\n" + "You are a cautious U.S. manufacturing-cycle analyst. Estimate the probability that the " + "binary IPMAN stress event in the supplied task resolves to 1 at the specified forecast date.\n\n" + "## Rules\n\n" + "1. Use only the JSON payload. Do not use remembered events or facts after `as_of`.\n" + "2. Start from the supplied historical base rate, then adjust using the current IPMAN momentum signals.\n" + "3. Treat more-negative short- and medium-horizon IPMAN changes as evidence for stress, but do not " + "turn a weak signal into certainty.\n" + "4. `probability` means P(stress=1), not confidence in your explanation.\n" + "5. Give a concise rationale, identify both supporting and countervailing evidence, and remain calibrated.\n" + "6. Use `direction_bias='down'` when signals point toward manufacturing stress, `up` when they point " + "away from stress, and `neutral` when mixed.\n\n" + "## Output\n\n" + "Return exactly one JSON object matching this structure, with no markdown fence or preamble:\n\n" + schema + ) + + +class ManufacturingStressPromptBuilder(BaseModel): + """Serialize cutoff-safe IPMAN evidence into the agent's prompt.""" + + model_config = {"extra": "forbid"} + + recent_history_months: int = Field(default=24, ge=6, le=120) + trailing_base_rate_months: int = Field(default=60, ge=12, le=240) + + def __call__(self, *, task: ForecastingTask, context: ForecastContext) -> str: + """Build one structured, cutoff-safe forecast payload.""" + if task.payload_type != "binary" or len(task.horizons) != 1: + raise ValueError("ManufacturingStressPromptBuilder requires one binary forecast horizon.") + + as_of = pd.Timestamp(context.as_of) + offset = pd.tseries.frequencies.to_offset(task.frequency) + forecast_date = as_of + offset * task.horizons[0] + ipman = context.get_series(IPMAN_SERIES_ID).sort_values("timestamp") + target = context.get_series(task.target_series_id).sort_values("timestamp") + feature_frames = {series_id: context.get_series(series_id) for series_id in FEATURE_SERIES_IDS} + current_signals = build_feature_snapshot(as_of, feature_frames) + + target_values = target["value"].astype(float) + trailing_values = target_values.tail(self.trailing_base_rate_months) + recent_ipman = [ + { + "reference_month": str(pd.Timestamp(timestamp).date()), + "value": float(value), + "released_at": str(pd.Timestamp(released_at).date()), + } + for timestamp, value, released_at in zip( + ipman["timestamp"].tail(self.recent_history_months), + ipman["value"].tail(self.recent_history_months), + ipman["released_at"].tail(self.recent_history_months), + strict=True, + ) + ] + + payload: dict[str, Any] = { + "task": { + "task_id": task.task_id, + "question": task.description, + "horizon_months": task.horizons[0], + }, + "as_of": str(as_of.date()), + "forecast_date": str(forecast_date.date()), + "target_definition": { + "event": "manufacturing stress", + "stress_value": 1, + "no_stress_value": 0, + "lookback_months": DEFAULT_LOOKBACK_MONTHS, + "threshold_pct": DEFAULT_STRESS_THRESHOLD_PCT, + "rule": ("stress=1 when trailing IPMAN percentage change is less than or equal to threshold_pct"), + }, + "current_ipman_signals_pct": current_signals, + "historical_stress": { + "n_visible_months": len(target_values), + "all_history_base_rate": float(target_values.mean()) if len(target_values) else None, + "trailing_window_months": self.trailing_base_rate_months, + "trailing_base_rate": float(trailing_values.mean()) if len(trailing_values) else None, + }, + "recent_ipman": recent_ipman, + } + return json.dumps(payload, indent=2) + + +def build_manufacturing_stress_agent_config(model: str = LITE_MODEL) -> AgentConfig: + """Build the tool-free manufacturing analyst configuration.""" + return AgentConfig( + name="manufacturing_stress_analyst", + model=model, + instruction=_build_instruction(), + temperature=0.1, + seed=42, + max_output_tokens=2_048, + ) + + +def build_manufacturing_stress_agent_predictor( + config: AgentConfig | None = None, +) -> AgentPredictor: + """Wrap the analyst in the standard binary AgentPredictor contract.""" + return AgentPredictor( + agent_config=config or build_manufacturing_stress_agent_config(), + prompt_builder=ManufacturingStressPromptBuilder(), + output_schema=DiscreteAgentForecastOutput, + ) + + +def __getattr__(name: str) -> Any: + """Expose a schema-free root agent for ``adk run`` and ``adk web``.""" + if name == "root_agent": + return build_adk_agent(build_manufacturing_stress_agent_config()) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "ManufacturingStressPromptBuilder", + "build_manufacturing_stress_agent_config", + "build_manufacturing_stress_agent_predictor", +] diff --git a/implementations/manufacturing_stress_forecasting/run_agent_prediction.py b/implementations/manufacturing_stress_forecasting/run_agent_prediction.py new file mode 100644 index 000000000..5a7245df1 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/run_agent_prediction.py @@ -0,0 +1,54 @@ +"""Run one current, quantitative-only manufacturing-stress agent forecast.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd +import yaml +from aieng.forecasting.evaluation import BacktestSpec +from aieng.forecasting.methods import HistoricalFrequencyPredictor +from manufacturing_stress_forecasting.analyst_agent import build_manufacturing_stress_agent_predictor +from manufacturing_stress_forecasting.data import ( + IPMAN_SERIES_ID, + build_manufacturing_stress_service, +) +from manufacturing_stress_forecasting.predictors import ManufacturingStressLogisticPredictor + + +SPEC_PATH = Path(__file__).resolve().parent / "specs" / "manufacturing_stress_smoke.yaml" + + +def main() -> None: + """Forecast from the most recent cached IPMAN release date.""" + with SPEC_PATH.open() as file: + task = BacktestSpec.model_validate(yaml.safe_load(file)).task + + service = build_manufacturing_stress_service() + full_ipman = service.get_series(IPMAN_SERIES_ID, as_of=pd.Timestamp("2100-01-01").to_pydatetime()) + as_of = pd.Timestamp(full_ipman["released_at"].max()) + context = service.context(as_of=as_of.to_pydatetime()) + + predictors = [ + HistoricalFrequencyPredictor(), + ManufacturingStressLogisticPredictor(), + build_manufacturing_stress_agent_predictor(), + ] + print(f"Forecast origin: {as_of.date()}") + print( + f"Latest visible IPMAN reference month: {pd.Timestamp(context.get_series(IPMAN_SERIES_ID)['timestamp'].max()).date()}" + ) + for predictor in predictors: + prediction = predictor.predict(task, context)[0] + output = { + "predictor_id": prediction.predictor_id, + "forecast_date": str(pd.Timestamp(prediction.forecast_date).date()), + "stress_probability": prediction.payload.probability, + "metadata": prediction.metadata, + } + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py b/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py new file mode 100644 index 000000000..88c46263b --- /dev/null +++ b/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py @@ -0,0 +1,71 @@ +"""Tests for the manufacturing-stress agent payload.""" + +import json +from datetime import datetime + +import pandas as pd +from aieng.forecasting.data import DataService, SeriesMetadata +from aieng.forecasting.data.features import StaticFrameAdapter +from aieng.forecasting.evaluation import ForecastingTask +from manufacturing_stress_forecasting.analyst_agent import ManufacturingStressPromptBuilder +from manufacturing_stress_forecasting.data import IPMAN_SERIES_ID, STRESS_SERIES_ID +from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS + + +def _frame(values: list[float], *, future_value: float | None = None) -> pd.DataFrame: + dates = pd.date_range("2020-01-01", periods=len(values), freq="MS") + frame = pd.DataFrame({"timestamp": dates, "value": values, "released_at": dates}) + if future_value is not None: + frame = pd.concat( + [ + frame, + pd.DataFrame( + { + "timestamp": [pd.Timestamp("2020-07-01")], + "value": [future_value], + "released_at": [pd.Timestamp("2020-08-01")], + } + ), + ], + ignore_index=True, + ) + return frame + + +def test_prompt_uses_only_cutoff_visible_evidence() -> None: + service = DataService() + metadata = lambda series_id: SeriesMetadata( # noqa: E731 + series_id=series_id, + description=series_id, + source="test", + units="test", + frequency="MS", + ) + service.register( + IPMAN_SERIES_ID, + StaticFrameAdapter(_frame([100, 101, 102, 103, 104, 105], future_value=999)), + metadata(IPMAN_SERIES_ID), + ) + service.register( + STRESS_SERIES_ID, StaticFrameAdapter(_frame([0, 0, 1, 0, 0, 0], future_value=1)), metadata(STRESS_SERIES_ID) + ) + for index, series_id in enumerate(FEATURE_SERIES_IDS): + service.register( + series_id, StaticFrameAdapter(_frame([float(index)] * 6, future_value=999)), metadata(series_id) + ) + + task = ForecastingTask( + task_id="manufacturing_stress_3m", + target_series_id=STRESS_SERIES_ID, + horizons=[3], + frequency="MS", + payload_type="binary", + description="Will manufacturing be stressed three months ahead?", + ) + prompt = ManufacturingStressPromptBuilder()(task=task, context=service.context(datetime(2020, 6, 1))) + payload = json.loads(prompt) + + assert payload["as_of"] == "2020-06-01" + assert payload["forecast_date"] == "2020-09-01" + assert payload["recent_ipman"][-1]["value"] == 105.0 + assert 999.0 not in payload["current_ipman_signals_pct"].values() From 3c909b9e1a6531f2fecc5a88b9b7eb6982f15d0f Mon Sep 17 00:00:00 2001 From: vbeohar Date: Tue, 22 Sep 2026 01:08:47 -0400 Subject: [PATCH 3/3] Added 5 more macro expl vars --- .../README.md | 35 ++++++---- .../analyst_agent/agent.py | 33 +++++++--- .../manufacturing_stress_forecasting/data.py | 50 ++++++++++++-- .../features.py | 66 +++++++++++++++++-- .../predictors/logistic.py | 6 +- .../test_agent_prompt.py | 3 + .../test_targets_and_features.py | 36 +++++++++- scripts/fetch_manufacturing_stress.py | 4 +- 8 files changed, 196 insertions(+), 37 deletions(-) diff --git a/implementations/manufacturing_stress_forecasting/README.md b/implementations/manufacturing_stress_forecasting/README.md index bf130759b..c31f8b881 100644 --- a/implementations/manufacturing_stress_forecasting/README.md +++ b/implementations/manufacturing_stress_forecasting/README.md @@ -5,9 +5,10 @@ This implementation asks one Track 1 question: > Given information available at a monthly forecast origin, what is the > probability that U.S. manufacturing will be under stress three months later? -The first version intentionally uses only FRED's `IPMAN` series. It excludes -Yahoo Finance, macro covariates, news, LLMs, and agents until the target and -backtest are easy to inspect. +The model deliberately uses only five explanatory variables: trailing +1-, 3-, and 6-month IPMAN changes, the effective federal funds rate, and the +10-year minus 2-year Treasury yield spread. The small panel keeps the first +multivariate experiment interpretable. ## Target @@ -23,17 +24,20 @@ That distinction makes this forecasting rather than current-state detection. - `HistoricalFrequencyPredictor`: the visible historical stress rate. - `ManufacturingStressLogisticPredictor`: fit-at-origin logistic regression on - trailing 1-, 3-, 6-, and 12-month IPMAN percentage changes. + the five IPMAN/rate variables. +- `manufacturing_stress_analyst`: a structured LLM predictor receiving the same + five cutoff-safe signals plus recent IPMAN history and historical base rates. -Both return `BinaryForecast` probabilities and are scored with Brier score. +All predictors return `BinaryForecast` probabilities; backtested predictors are scored with Brier score. ## Data and cutoff assumptions -`FREDAdapter` caches `IPMAN` at `data/fred/IPMAN.parquet`. Because the standard -FRED response does not provide point-in-time release vintages, this prototype -conservatively shifts `released_at` one month beyond each reference timestamp. -The limitation remains: historical FRED observations may contain later -revisions. A production-quality study should use ALFRED vintages. +`FREDAdapter` caches `IPMAN`, `DFF`, `DGS10`, and `DGS2` under `data/fred/`. +IPMAN is conservatively treated as available one month after its reference +month. Daily rate observations are treated as available on the next business +day and collapsed to their final monthly observation. The standard FRED API +does not provide full point-in-time vintages, so historical observations may +still contain later revisions; a production study should use ALFRED vintages. ## Run @@ -59,9 +63,14 @@ The output prints one mean Brier score per predictor; lower is better. The logistic model should be compared against historical frequency, not judged in isolation. +Run one current forecast, including the structured agent: + +```bash +uv run --directory implementations python -m manufacturing_stress_forecasting.run_agent_prediction +``` + ## Next steps 1. Plot IPMAN and the derived stress months; confirm or revise the 2% threshold. -2. Add a full monthly development backtest after the smoke run is stable. -3. Add a small FRED macro panel. -4. Add an agent only after the deterministic Track 1 experiment is credible. +2. Compare the five-variable logistic score with the earlier IPMAN-only result. +3. Backtest the agent only after the deterministic model is stable. diff --git a/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py b/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py index 5fc32724c..1f3c7e3b9 100644 --- a/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py +++ b/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py @@ -16,7 +16,12 @@ from aieng.forecasting.methods.agentic.agent_factory import AgentConfig from aieng.forecasting.models import LITE_MODEL from manufacturing_stress_forecasting.data import IPMAN_SERIES_ID -from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS, build_feature_snapshot +from manufacturing_stress_forecasting.features import ( + FEATURE_SERIES_IDS, + IPMAN_FEATURE_SERIES_IDS, + MACRO_FEATURE_SERIES_IDS, + build_feature_snapshot, +) from manufacturing_stress_forecasting.targets import ( DEFAULT_LOOKBACK_MONTHS, DEFAULT_STRESS_THRESHOLD_PCT, @@ -32,12 +37,13 @@ def _build_instruction() -> str: "binary IPMAN stress event in the supplied task resolves to 1 at the specified forecast date.\n\n" "## Rules\n\n" "1. Use only the JSON payload. Do not use remembered events or facts after `as_of`.\n" - "2. Start from the supplied historical base rate, then adjust using the current IPMAN momentum signals.\n" - "3. Treat more-negative short- and medium-horizon IPMAN changes as evidence for stress, but do not " - "turn a weak signal into certainty.\n" - "4. `probability` means P(stress=1), not confidence in your explanation.\n" - "5. Give a concise rationale, identify both supporting and countervailing evidence, and remain calibrated.\n" - "6. Use `direction_bias='down'` when signals point toward manufacturing stress, `up` when they point " + "2. Start from the supplied historical base rate, then adjust using the five supplied signals.\n" + "3. Treat negative IPMAN momentum, a restrictive fed funds rate, and an inverted 10Y-2Y spread " + "as possible evidence for stress; explain how the signals interact.\n" + "4. Do not double-count correlated signals or turn a weak signal into certainty.\n" + "5. `probability` means P(stress=1), not confidence in your explanation.\n" + "6. Give a concise rationale, identify both supporting and countervailing evidence, and remain calibrated.\n" + "7. Use `direction_bias='down'` when signals point toward manufacturing stress, `up` when they point " "away from stress, and `neutral` when mixed.\n\n" "## Output\n\n" "Return exactly one JSON object matching this structure, with no markdown fence or preamble:\n\n" + schema @@ -64,6 +70,16 @@ def __call__(self, *, task: ForecastingTask, context: ForecastContext) -> str: target = context.get_series(task.target_series_id).sort_values("timestamp") feature_frames = {series_id: context.get_series(series_id) for series_id in FEATURE_SERIES_IDS} current_signals = build_feature_snapshot(as_of, feature_frames) + current_ipman_signals = ( + {series_id: current_signals[series_id] for series_id in IPMAN_FEATURE_SERIES_IDS} + if current_signals is not None + else None + ) + current_macro_signals = ( + {series_id: current_signals[series_id] for series_id in MACRO_FEATURE_SERIES_IDS} + if current_signals is not None + else None + ) target_values = target["value"].astype(float) trailing_values = target_values.tail(self.trailing_base_rate_months) @@ -97,7 +113,8 @@ def __call__(self, *, task: ForecastingTask, context: ForecastContext) -> str: "threshold_pct": DEFAULT_STRESS_THRESHOLD_PCT, "rule": ("stress=1 when trailing IPMAN percentage change is less than or equal to threshold_pct"), }, - "current_ipman_signals_pct": current_signals, + "current_ipman_signals_pct": current_ipman_signals, + "current_macro_signals": current_macro_signals, "historical_stress": { "n_visible_months": len(target_values), "all_history_base_rate": float(target_values.mean()) if len(target_values) else None, diff --git a/implementations/manufacturing_stress_forecasting/data.py b/implementations/manufacturing_stress_forecasting/data.py index 187254448..5a6f184d5 100644 --- a/implementations/manufacturing_stress_forecasting/data.py +++ b/implementations/manufacturing_stress_forecasting/data.py @@ -1,4 +1,4 @@ -"""FRED/IPMAN data service for the manufacturing-stress MVP.""" +"""FRED data service for the five-variable manufacturing-stress MVP.""" from __future__ import annotations @@ -9,8 +9,11 @@ from aieng.forecasting.data.features import StaticFrameAdapter from manufacturing_stress_forecasting.features import ( FEATURE_PERIODS, + FED_FUNDS_SERIES_ID, + YIELD_CURVE_SERIES_ID, apply_conservative_monthly_release_lag, build_ipman_feature_frames, + build_macro_feature_frames, ) from manufacturing_stress_forecasting.targets import ( DEFAULT_LOOKBACK_MONTHS, @@ -20,6 +23,10 @@ IPMAN_FRED_ID = "IPMAN" +FED_FUNDS_FRED_ID = "DFF" +TREASURY_10Y_FRED_ID = "DGS10" +TREASURY_2Y_FRED_ID = "DGS2" + IPMAN_SERIES_ID = "ipman_us_manufacturing_production" STRESS_SERIES_ID = "manufacturing_stress" @@ -35,10 +42,19 @@ def build_manufacturing_stress_service( stress_lookback_months: int = DEFAULT_LOOKBACK_MONTHS, stress_threshold_pct: float = DEFAULT_STRESS_THRESHOLD_PCT, ) -> DataService: - """Build a service containing IPMAN, momentum features, and stress labels.""" + """Build a service containing the target and five cutoff-aware input features.""" raw_ipman = FREDAdapter(IPMAN_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_fed_funds = FREDAdapter(FED_FUNDS_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_treasury_10y = FREDAdapter(TREASURY_10Y_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_treasury_2y = FREDAdapter(TREASURY_2Y_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + ipman = apply_conservative_monthly_release_lag(raw_ipman, months=release_lag_months) - feature_frames = build_ipman_feature_frames(ipman) + ipman_feature_frames = build_ipman_feature_frames(ipman) + macro_feature_frames = build_macro_feature_frames( + raw_fed_funds, + raw_treasury_10y, + raw_treasury_2y, + ) stress = derive_manufacturing_stress_labels( ipman, lookback_months=stress_lookback_months, @@ -58,7 +74,7 @@ def build_manufacturing_stress_service( ), ) - for series_id, frame in feature_frames.items(): + for series_id, frame in ipman_feature_frames.items(): periods = FEATURE_PERIODS[series_id] service.register( series_id, @@ -72,6 +88,29 @@ def build_manufacturing_stress_service( ), ) + service.register( + FED_FUNDS_SERIES_ID, + StaticFrameAdapter(macro_feature_frames[FED_FUNDS_SERIES_ID]), + SeriesMetadata( + series_id=FED_FUNDS_SERIES_ID, + description="Month-end effective federal funds rate", + source="FRED (DFF), derived monthly", + units="Percent", + frequency="MS", + ), + ) + service.register( + YIELD_CURVE_SERIES_ID, + StaticFrameAdapter(macro_feature_frames[YIELD_CURVE_SERIES_ID]), + SeriesMetadata( + series_id=YIELD_CURVE_SERIES_ID, + description="Month-end 10-year minus 2-year Treasury yield spread", + source="FRED (DGS10 minus DGS2), derived monthly", + units="Percentage points", + frequency="MS", + ), + ) + service.register( STRESS_SERIES_ID, StaticFrameAdapter(stress), @@ -91,8 +130,11 @@ def build_manufacturing_stress_service( __all__ = [ "DEFAULT_FRED_CACHE_DIR", + "FED_FUNDS_FRED_ID", "IPMAN_FRED_ID", "IPMAN_SERIES_ID", "STRESS_SERIES_ID", + "TREASURY_10Y_FRED_ID", + "TREASURY_2Y_FRED_ID", "build_manufacturing_stress_service", ] diff --git a/implementations/manufacturing_stress_forecasting/features.py b/implementations/manufacturing_stress_forecasting/features.py index 535aee05d..3599a2106 100644 --- a/implementations/manufacturing_stress_forecasting/features.py +++ b/implementations/manufacturing_stress_forecasting/features.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence + import pandas as pd from aieng.forecasting.data.features import canonical_three_col @@ -9,15 +11,20 @@ IPMAN_CHANGE_1M_SERIES_ID = "ipman_change_1m_pct" IPMAN_CHANGE_3M_SERIES_ID = "ipman_change_3m_pct" IPMAN_CHANGE_6M_SERIES_ID = "ipman_change_6m_pct" -IPMAN_CHANGE_12M_SERIES_ID = "ipman_change_12m_pct" +FED_FUNDS_SERIES_ID = "fed_funds_rate_pct" +YIELD_CURVE_SERIES_ID = "treasury_10y_minus_2y_pct_points" FEATURE_PERIODS: dict[str, int] = { IPMAN_CHANGE_1M_SERIES_ID: 1, IPMAN_CHANGE_3M_SERIES_ID: 3, IPMAN_CHANGE_6M_SERIES_ID: 6, - IPMAN_CHANGE_12M_SERIES_ID: 12, } -FEATURE_SERIES_IDS: tuple[str, ...] = tuple(FEATURE_PERIODS) +IPMAN_FEATURE_SERIES_IDS: tuple[str, ...] = tuple(FEATURE_PERIODS) +MACRO_FEATURE_SERIES_IDS: tuple[str, ...] = ( + FED_FUNDS_SERIES_ID, + YIELD_CURVE_SERIES_ID, +) +FEATURE_SERIES_IDS: tuple[str, ...] = IPMAN_FEATURE_SERIES_IDS + MACRO_FEATURE_SERIES_IDS def apply_conservative_monthly_release_lag(frame: pd.DataFrame, months: int = 1) -> pd.DataFrame: @@ -45,13 +52,55 @@ def percent_change_feature(ipman: pd.DataFrame, periods: int) -> pd.DataFrame: def build_ipman_feature_frames(ipman: pd.DataFrame) -> dict[str, pd.DataFrame]: - """Build the four small momentum features used by the first baseline.""" + """Build the three IPMAN momentum features used by the model.""" return {series_id: percent_change_feature(ipman, periods) for series_id, periods in FEATURE_PERIODS.items()} +def monthly_last_observation(frame: pd.DataFrame) -> pd.DataFrame: + """Collapse a daily canonical series to its final observation each month.""" + out = canonical_three_col(frame) + out["month"] = out["timestamp"].dt.to_period("M") + out = out.sort_values(["month", "timestamp"]).groupby("month", as_index=False).tail(1) + out["timestamp"] = out["month"].dt.to_timestamp() + return canonical_three_col(out) + + +def build_macro_feature_frames( + fed_funds: pd.DataFrame, + treasury_10y: pd.DataFrame, + treasury_2y: pd.DataFrame, +) -> dict[str, pd.DataFrame]: + """Build monthly fed-funds and 10Y-minus-2Y rate features. + + Daily FRED observations are treated as available on the next business day. + The model then uses the final published observation associated with each + calendar month. This keeps the monthly feature panel small while preserving + an honest ``released_at`` cutoff. + """ + fed = canonical_three_col(fed_funds) + fed["released_at"] = fed["timestamp"] + pd.offsets.BDay(1) + fed_monthly = monthly_last_observation(fed) + + ten_year = canonical_three_col(treasury_10y).rename( + columns={"value": "value_10y", "released_at": "released_at_10y"} + ) + two_year = canonical_three_col(treasury_2y).rename(columns={"value": "value_2y", "released_at": "released_at_2y"}) + spread = pd.merge(ten_year, two_year, on="timestamp", how="inner") + spread["value"] = spread["value_10y"] - spread["value_2y"] + spread["released_at"] = spread[["released_at_10y", "released_at_2y"]].max(axis=1) + pd.offsets.BDay(1) + spread_monthly = monthly_last_observation(spread[["timestamp", "value", "released_at"]]) + + return { + FED_FUNDS_SERIES_ID: fed_monthly, + YIELD_CURVE_SERIES_ID: spread_monthly, + } + + def build_feature_snapshot( origin: pd.Timestamp, feature_frames: dict[str, pd.DataFrame], + *, + series_ids: Sequence[str] = FEATURE_SERIES_IDS, ) -> dict[str, float] | None: """Return the latest feature values that were published by ``origin``. @@ -61,7 +110,7 @@ def build_feature_snapshot( past origin used to train the fit-at-origin logistic model. """ snapshot: dict[str, float] = {} - for series_id in FEATURE_SERIES_IDS: + for series_id in series_ids: frame = feature_frames[series_id] visible = frame[pd.to_datetime(frame["released_at"]) <= origin] if visible.empty: @@ -71,14 +120,19 @@ def build_feature_snapshot( __all__ = [ + "FED_FUNDS_SERIES_ID", "FEATURE_PERIODS", "FEATURE_SERIES_IDS", + "IPMAN_FEATURE_SERIES_IDS", "IPMAN_CHANGE_1M_SERIES_ID", "IPMAN_CHANGE_3M_SERIES_ID", "IPMAN_CHANGE_6M_SERIES_ID", - "IPMAN_CHANGE_12M_SERIES_ID", + "MACRO_FEATURE_SERIES_IDS", + "YIELD_CURVE_SERIES_ID", "apply_conservative_monthly_release_lag", "build_feature_snapshot", "build_ipman_feature_frames", + "build_macro_feature_frames", + "monthly_last_observation", "percent_change_feature", ] diff --git a/implementations/manufacturing_stress_forecasting/predictors/logistic.py b/implementations/manufacturing_stress_forecasting/predictors/logistic.py index a0c5d3632..bfea5de23 100644 --- a/implementations/manufacturing_stress_forecasting/predictors/logistic.py +++ b/implementations/manufacturing_stress_forecasting/predictors/logistic.py @@ -1,4 +1,4 @@ -"""Fit-at-origin logistic baseline for three-month-ahead manufacturing stress.""" +"""Five-variable logistic baseline for three-month-ahead manufacturing stress.""" from __future__ import annotations @@ -14,7 +14,7 @@ class ManufacturingStressLogisticPredictor(Predictor): - """Forecast manufacturing stress from four trailing IPMAN changes. + """Forecast manufacturing stress from three IPMAN and two rate signals. The model is rebuilt at every backtest origin. For each resolved historical outcome at month ``r``, its feature vector is reconstructed at ``r - lead`` @@ -29,7 +29,7 @@ def __init__(self, *, regularization_c: float = 1.0, min_training_examples: int @property def predictor_id(self) -> str: """Return the stable artifact identifier.""" - return "manufacturing_stress_logistic_ipman" + return "manufacturing_stress_logistic_ipman_rates" def predict(self, task: ForecastingTask, context: ForecastContext) -> list[Prediction]: """Fit on visible history and return one binary stress probability.""" diff --git a/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py b/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py index 88c46263b..d3220fe39 100644 --- a/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py +++ b/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py @@ -68,4 +68,7 @@ def test_prompt_uses_only_cutoff_visible_evidence() -> None: assert payload["as_of"] == "2020-06-01" assert payload["forecast_date"] == "2020-09-01" assert payload["recent_ipman"][-1]["value"] == 105.0 + assert len(payload["current_ipman_signals_pct"]) == 3 + assert len(payload["current_macro_signals"]) == 2 assert 999.0 not in payload["current_ipman_signals_pct"].values() + assert 999.0 not in payload["current_macro_signals"].values() diff --git a/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py b/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py index 6afdd3e1e..3b94d08ea 100644 --- a/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py +++ b/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py @@ -2,7 +2,13 @@ import pandas as pd import pytest -from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS, build_feature_snapshot +from manufacturing_stress_forecasting.features import ( + FEATURE_SERIES_IDS, + FED_FUNDS_SERIES_ID, + YIELD_CURVE_SERIES_ID, + build_feature_snapshot, + build_macro_feature_frames, +) from manufacturing_stress_forecasting.targets import derive_manufacturing_stress_labels @@ -40,3 +46,31 @@ def test_feature_snapshot_ignores_values_released_after_origin() -> None: assert snapshot is not None for index, series_id in enumerate(FEATURE_SERIES_IDS): assert snapshot[series_id] == pytest.approx(float(index)) + + +def test_macro_features_use_month_end_values_with_next_business_day_release() -> None: + timestamps = pd.to_datetime(["2024-01-30", "2024-01-31", "2024-02-28", "2024-02-29"]) + + def frame(values: list[float]) -> pd.DataFrame: + return pd.DataFrame( + { + "timestamp": timestamps, + "value": values, + "released_at": timestamps, + } + ) + + features = build_macro_feature_frames( + frame([5.30, 5.31, 5.32, 5.33]), + frame([4.00, 4.10, 4.20, 4.30]), + frame([4.40, 4.50, 4.55, 4.60]), + ) + + fed = features[FED_FUNDS_SERIES_ID] + spread = features[YIELD_CURVE_SERIES_ID] + + assert fed["timestamp"].tolist() == [pd.Timestamp("2024-01-01"), pd.Timestamp("2024-02-01")] + assert fed["value"].tolist() == pytest.approx([5.31, 5.33]) + assert fed["released_at"].tolist() == [pd.Timestamp("2024-02-01"), pd.Timestamp("2024-03-01")] + assert spread["value"].tolist() == pytest.approx([-0.40, -0.30]) + assert spread["released_at"].tolist() == [pd.Timestamp("2024-02-01"), pd.Timestamp("2024-03-01")] diff --git a/scripts/fetch_manufacturing_stress.py b/scripts/fetch_manufacturing_stress.py index af1ed1871..240114b33 100644 --- a/scripts/fetch_manufacturing_stress.py +++ b/scripts/fetch_manufacturing_stress.py @@ -1,4 +1,4 @@ -"""Fetch/cache IPMAN and print the manufacturing-stress series summary.""" +"""Fetch/cache IPMAN and rate inputs, then print the registered-series summary.""" from __future__ import annotations @@ -19,7 +19,7 @@ def main() -> None: - """Populate the IPMAN cache and report the derived series.""" + """Populate the IPMAN/rates cache and report the derived series.""" service = build_manufacturing_stress_service() print(service.summary().to_string(index=False))