diff --git a/data/fred/BAMLH0A0HYM2.parquet b/data/fred/BAMLH0A0HYM2.parquet new file mode 100644 index 000000000..9a47097ee Binary files /dev/null and b/data/fred/BAMLH0A0HYM2.parquet differ diff --git a/data/fred/CPIAUCSL.parquet b/data/fred/CPIAUCSL.parquet new file mode 100644 index 000000000..9ba8cac63 Binary files /dev/null and b/data/fred/CPIAUCSL.parquet differ diff --git a/data/fred/DFF.parquet b/data/fred/DFF.parquet new file mode 100644 index 000000000..5eb0cfcaa Binary files /dev/null and b/data/fred/DFF.parquet differ diff --git a/data/fred/DGS10.parquet b/data/fred/DGS10.parquet new file mode 100644 index 000000000..9fed926b3 Binary files /dev/null and b/data/fred/DGS10.parquet differ diff --git a/data/fred/DGS2.parquet b/data/fred/DGS2.parquet new file mode 100644 index 000000000..9c9a32fad Binary files /dev/null and b/data/fred/DGS2.parquet differ diff --git a/data/fred/FEDFUNDS.parquet b/data/fred/FEDFUNDS.parquet new file mode 100644 index 000000000..70d42be48 Binary files /dev/null and b/data/fred/FEDFUNDS.parquet differ diff --git a/data/fred/ICSA.parquet b/data/fred/ICSA.parquet new file mode 100644 index 000000000..6567bf612 Binary files /dev/null and b/data/fred/ICSA.parquet differ diff --git a/data/fred/IPMAN.parquet b/data/fred/IPMAN.parquet new file mode 100644 index 000000000..0918076e2 Binary files /dev/null and b/data/fred/IPMAN.parquet differ diff --git a/data/fred/UNRATE.parquet b/data/fred/UNRATE.parquet new file mode 100644 index 000000000..dcb447431 Binary files /dev/null and b/data/fred/UNRATE.parquet differ diff --git a/data/fred/VIXCLS.parquet b/data/fred/VIXCLS.parquet new file mode 100644 index 000000000..674fc31ed Binary files /dev/null and b/data/fred/VIXCLS.parquet differ diff --git a/data/yfinance/spy_adj_close_1d.parquet b/data/yfinance/spy_adj_close_1d.parquet new file mode 100644 index 000000000..d554badd5 Binary files /dev/null and b/data/yfinance/spy_adj_close_1d.parquet differ diff --git a/data/yfinance/xli_adj_close_1d.parquet b/data/yfinance/xli_adj_close_1d.parquet new file mode 100644 index 000000000..127fdac35 Binary files /dev/null and b/data/yfinance/xli_adj_close_1d.parquet differ diff --git a/implementations/__pycache__/__init__.cpython-312.pyc b/implementations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..c5ab751af Binary files /dev/null and b/implementations/__pycache__/__init__.cpython-312.pyc differ diff --git a/implementations/agentic_forecasting_implementations.egg-info/PKG-INFO b/implementations/agentic_forecasting_implementations.egg-info/PKG-INFO new file mode 100644 index 000000000..c0cb18f9e --- /dev/null +++ b/implementations/agentic_forecasting_implementations.egg-info/PKG-INFO @@ -0,0 +1,69 @@ +Metadata-Version: 2.4 +Name: agentic-forecasting-implementations +Version: 0.1.0 +Summary: Reference method implementations for the Agentic Forecasting Bootcamp +Author-email: Vector AI Engineering +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: aieng-forecasting[agentic,documents,llm,numerical] +Requires-Dist: beautifulsoup4<5,>=4.12 +Requires-Dist: xgboost<4,>=2.1 + +# implementations + +Self-contained reference implementations and their helper code. + +This is a local uv workspace package. It is installed automatically when you run `uv sync` from the repository root, but it is not a separately published public API. + +Some use cases are notebook-only. Others expose a small importable helper package so shared analysis, plotting, or data-registration code can live in Python modules instead of large notebook cells. + +--- + +## Directory layout + +Numbered in the recommended order (mirrors the bootcamp progression: conventional numerical methods → LLM Processes → agents → agentic evaluation). The directories are not renamed — the numbers are an ordering convention used across the docs, and each directory stays an importable package (`from sp500_forecasting.data import ...`). + +```text +implementations/ +|-- getting_started/ # 0 · CPI gasoline hello-world (start here) +| `-- specs/ # backtest and eval YAML +|-- sp500_forecasting/ # 1 · S&P 500 multivariate numerical comparison (financial markets) +| `-- specs/ # backtest YAML (smoke + full) +|-- food_price_forecasting/ # 2 · CFPR-style food CPI experiment +| `-- specs/ # backtest YAML +|-- energy_oil_forecasting/ # 3 · Daily WTI oil price forecasting experiment +| `-- specs/ # backtest and eval YAML +|-- boc_rate_decisions/ # 4 · Discrete-event reference: BoC cut/hold/hike direction +| `-- specs/ # direction + binary backtest / eval / smoke YAML +|-- tests/ # tests for implementation-specific helper modules +`-- pyproject.toml # local workspace packaging +``` + +YAML backtest and eval specs live under each use case in `specs/`. Each directory is independent; see its `README.md` for the walkthrough. For the build-phase moves — onboarding data, standing up an experiment, customizing an agent, auditing a result — see [`guides/`](../guides/). To chat with the concierge or a domain starter in the ADK browser UI, see [`guides/05-access-adk-web-via-ssh-tunnel.md`](../guides/05-access-adk-web-via-ssh-tunnel.md) (includes the Coder SSH tunnel). + +Every domain use case (all except `getting_started`) also ships a `starter_agent/` module and a `99_starter_agent.ipynb` — a fresh, hackable **starter agent** that is the consistent "build your own" entry point for that use case (toggleable news search + code execution, two lightweight tool-usage skills, an interactive cell, and one scored forecast). + +`getting_started/` additionally ships a **`concierge_agent/`** module and **`99_repo_concierge.ipynb`** — a repo onboarding helper (not a forecaster) that answers questions about how the codebase works using a committed public-`main` knowledge digest. From the repository root: `uv run adk run implementations/getting_started/concierge_agent` (or `uv run adk web implementations/getting_started/concierge_agent` for the browser UI — [guide 5](../guides/05-access-adk-web-via-ssh-tunnel.md)). See [`getting_started/README.md`](getting_started/README.md) and the notebook for full usage. + +--- + +## Relationship to `aieng-forecasting` + +- `aieng-forecasting` (`aieng.forecasting`) owns reusable infrastructure and reusable reference predictors under `aieng.forecasting.methods`. +- `implementations/` owns use-case material: walkthrough notebooks, experiment-specific helper modules, plotting/analysis code, and task-specific framing. + +If code becomes broadly reusable across use cases, promote it into `aieng-forecasting`. + +--- + +## Adding a new use case + +1. Create `implementations//`. +2. Add a `README.md` describing the task, the data, and what the notebooks cover. +3. Add YAML specs under `implementations//specs/`. +4. Start with notebooks as the primary user surface. +5. If notebook code becomes bulky or repeated, extract small helper modules into that use-case directory. +6. Add tests under `implementations/tests//` for non-trivial helper logic. +7. Promote code into `aieng-forecasting` once it is clearly reusable across more than one use case. + +For architecture principles and cross-cutting extension ideas, see `planning-docs/roadmap.md`. diff --git a/implementations/agentic_forecasting_implementations.egg-info/SOURCES.txt b/implementations/agentic_forecasting_implementations.egg-info/SOURCES.txt new file mode 100644 index 000000000..3e509917e --- /dev/null +++ b/implementations/agentic_forecasting_implementations.egg-info/SOURCES.txt @@ -0,0 +1,76 @@ +README.md +pyproject.toml +agentic_forecasting_implementations.egg-info/PKG-INFO +agentic_forecasting_implementations.egg-info/SOURCES.txt +agentic_forecasting_implementations.egg-info/dependency_links.txt +agentic_forecasting_implementations.egg-info/requires.txt +agentic_forecasting_implementations.egg-info/top_level.txt +boc_rate_decisions/__init__.py +boc_rate_decisions/analysis.py +boc_rate_decisions/data.py +boc_rate_decisions/plots.py +boc_rate_decisions/press_releases.py +boc_rate_decisions/rationale_eval.py +boc_rate_decisions/analyst_agent/__init__.py +boc_rate_decisions/analyst_agent/agent.py +boc_rate_decisions/predictors/__init__.py +boc_rate_decisions/predictors/llmp_binary.py +boc_rate_decisions/predictors/llmp_direction.py +boc_rate_decisions/predictors/logistic_baseline.py +boc_rate_decisions/starter_agent/__init__.py +boc_rate_decisions/starter_agent/agent.py +energy_oil_forecasting/__init__.py +energy_oil_forecasting/analysis.py +energy_oil_forecasting/data.py +energy_oil_forecasting/paths.py +energy_oil_forecasting/prophet_baseline.py +energy_oil_forecasting/tasks.py +energy_oil_forecasting/viz.py +energy_oil_forecasting/adaptive_agent/__init__.py +energy_oil_forecasting/adaptive_agent/agent.py +energy_oil_forecasting/adaptive_agent/skill_state.py +energy_oil_forecasting/adaptive_agent/skill_tools.py +energy_oil_forecasting/adaptive_agent/curriculum/snapshot_utils.py +energy_oil_forecasting/analyst_agent/__init__.py +energy_oil_forecasting/analyst_agent/agent.py +energy_oil_forecasting/starter_agent/__init__.py +energy_oil_forecasting/starter_agent/agent.py +energy_oil_forecasting/starter_agent/tools.py +food_price_forecasting/__init__.py +food_price_forecasting/analysis.py +food_price_forecasting/data.py +food_price_forecasting/plots.py +food_price_forecasting/reports.py +food_price_forecasting/smoke_report.py +food_price_forecasting/predictors/__init__.py +food_price_forecasting/predictors/llmp_quantile_grid.py +food_price_forecasting/predictors/llmp_sampled_trajectory.py +food_price_forecasting/starter_agent/__init__.py +food_price_forecasting/starter_agent/agent.py +getting_started/__init__.py +getting_started/concierge_agent/__init__.py +getting_started/concierge_agent/agent.py +getting_started/concierge_agent/catalog.py +getting_started/concierge_agent/catalog_build.py +getting_started/concierge_agent/knowledge.py +manufacturing_stress_forecasting/__init__.py +manufacturing_stress_forecasting/data.py +manufacturing_stress_forecasting/features.py +manufacturing_stress_forecasting/run_agent_backtest.py +manufacturing_stress_forecasting/run_agent_prediction.py +manufacturing_stress_forecasting/run_smoke.py +manufacturing_stress_forecasting/targets.py +manufacturing_stress_forecasting/analyst_agent/__init__.py +manufacturing_stress_forecasting/analyst_agent/agent.py +manufacturing_stress_forecasting/predictors/__init__.py +manufacturing_stress_forecasting/predictors/logistic.py +manufacturing_stress_forecasting/predictors/xgboost.py +sp500_forecasting/__init__.py +sp500_forecasting/analysis.py +sp500_forecasting/data.py +sp500_forecasting/leaderboard.py +sp500_forecasting/plots.py +sp500_forecasting/predictors/__init__.py +sp500_forecasting/predictors/llmp_sampled_trajectory.py +sp500_forecasting/starter_agent/__init__.py +sp500_forecasting/starter_agent/agent.py \ No newline at end of file diff --git a/implementations/agentic_forecasting_implementations.egg-info/dependency_links.txt b/implementations/agentic_forecasting_implementations.egg-info/dependency_links.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/implementations/agentic_forecasting_implementations.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/implementations/agentic_forecasting_implementations.egg-info/requires.txt b/implementations/agentic_forecasting_implementations.egg-info/requires.txt new file mode 100644 index 000000000..0f935ba3c --- /dev/null +++ b/implementations/agentic_forecasting_implementations.egg-info/requires.txt @@ -0,0 +1,3 @@ +aieng-forecasting[agentic,documents,llm,numerical] +beautifulsoup4<5,>=4.12 +xgboost<4,>=2.1 diff --git a/implementations/agentic_forecasting_implementations.egg-info/top_level.txt b/implementations/agentic_forecasting_implementations.egg-info/top_level.txt new file mode 100644 index 000000000..328569e9e --- /dev/null +++ b/implementations/agentic_forecasting_implementations.egg-info/top_level.txt @@ -0,0 +1,6 @@ +boc_rate_decisions +energy_oil_forecasting +food_price_forecasting +getting_started +manufacturing_stress_forecasting +sp500_forecasting diff --git a/implementations/manufacturing_stress_forecasting/README.md b/implementations/manufacturing_stress_forecasting/README.md new file mode 100644 index 000000000..ad7d4573a --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/README.md @@ -0,0 +1,118 @@ +# Manufacturing stress forecasting + +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 feature service provides trailing 1-, 3-, and 6-month IPMAN changes plus +the requested FRED and Yahoo Finance fields: `FEDFUNDS`, `YC_SPREAD`, +`CPIAUCSL`, `CPI_YOY`, `UNRATE`, `ICSA`, `VIXCLS`, `HY_SPREAD`, and 3- and +12-month returns for both `SPY` and `XLI`. FRED levels are collapsed to +monthly observations, derived fields use the documented source series, and +Yahoo returns use monthly adjusted-close prices. + +## 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 + the IPMAN and macro variables. +- `ManufacturingStressXGBoostPredictor`: a small fit-at-origin gradient-boosted + tree classifier using the same IPMAN and macro variables and cutoff-safe + training rows. +- `manufacturing_stress_analyst`: a structured LLM predictor receiving the same + cutoff-safe IPMAN and macro signals plus recent IPMAN history and historical + base rates. + +All predictors return `BinaryForecast` probabilities; backtested predictors are scored with Brier score. + +## Data and cutoff assumptions +Compare XGBoost with logistic regression and historical frequency rather than judging it +in isolation, because this small monthly dataset can overfit flexible models. +`FREDAdapter` caches the required FRED series under `data/fred/`, and +`YFinanceDailyAdapter` caches `SPY` and `XLI` under `data/yfinance/`. +Yahoo refreshes request history from 1998 onward explicitly so the provider's +default recent-history window cannot replace the long-term cache. +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 + +The primary interactive entry point is +[`manufacturing_stress_workbench.ipynb`](manufacturing_stress_workbench.ipynb). +Open it in VS Code or Jupyter and use its configuration cell to refresh FRED +data, run the deterministic smoke test, and explicitly opt in to the cached +LLMP backtest without using the terminal. + +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 deterministic 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. + +Run the token-limited LLMP backtest explicitly: + +```bash +uv run --directory implementations python -m manufacturing_stress_forecasting.run_agent_backtest +``` + +This evaluates historical frequency, logistic regression, XGBoost, and the +`manufacturing_stress_analyst` agent through the same binary backtest and +Brier-score calculation. The agent run uses the default lite model, a +12-month IPMAN history, compact JSON prompts, a 384-token response cap, and +one retry per failed origin. Calendar dates are replaced by relative month +offsets in retrospective agent prompts to reduce historical-event recall. + +Complete results are cached under a specification-fingerprinted directory in +`data/predictions/`, so changing the stride, horizon, dates, or warmup cannot +silently reuse an incompatible result. Incomplete runs with skipped origins +are not cached. Use `--force-refresh` to intentionally re-run every predictor. +The command also verifies that every reported model was scored on the exact +same origin and forecast-date pairs. + +This remains a retrospective LLM pseudo-backtest: anonymizing dates reduces, +but cannot eliminate, the possibility that a modern model recognizes a +historical episode from its training knowledge. Use prospectively recorded +forecasts for a clean out-of-sample LLM evaluation. + +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. Compare the expanded macro-panel score with the earlier IPMAN-only result. +3. Compare the cached agent backtest against the deterministic baselines only + after checking scored and skipped origin counts. 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/__pycache__/__init__.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..d882a94d0 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/__init__.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/__pycache__/data.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/data.cpython-312.pyc new file mode 100644 index 000000000..511088eb4 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/data.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/__pycache__/features.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/features.cpython-312.pyc new file mode 100644 index 000000000..a2f9970b7 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/features.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/__pycache__/run_agent_backtest.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/run_agent_backtest.cpython-312.pyc new file mode 100644 index 000000000..ca5053b9e Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/run_agent_backtest.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/__pycache__/run_agent_prediction.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/run_agent_prediction.cpython-312.pyc new file mode 100644 index 000000000..7d915052b Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/run_agent_prediction.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/__pycache__/run_smoke.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/run_smoke.cpython-312.pyc new file mode 100644 index 000000000..74132fbc3 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/run_smoke.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/__pycache__/targets.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/__pycache__/targets.cpython-312.pyc new file mode 100644 index 000000000..2a095d33b Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/__pycache__/targets.cpython-312.pyc differ 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/__pycache__/__init__.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/analyst_agent/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..cf59e31ce Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/analyst_agent/__pycache__/__init__.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/analyst_agent/__pycache__/agent.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/analyst_agent/__pycache__/agent.cpython-312.pyc new file mode 100644 index 000000000..ace4c4474 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/analyst_agent/__pycache__/agent.cpython-312.pyc differ 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..a11054ee7 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/analyst_agent/agent.py @@ -0,0 +1,183 @@ +"""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, + IPMAN_FEATURE_SERIES_IDS, + MACRO_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 horizon.\n\n" + "## Rules\n\n" + "1. Use only the JSON payload. Do not use remembered events or facts after the forecast origin.\n" + " Historical backtests may anonymize calendar dates; do not try to infer the hidden dates.\n" + "2. Start from the supplied historical base rate, then adjust using the supplied IPMAN and macro 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 rationale of at most 40 words, identify 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 + ) + + +class ManufacturingStressPromptBuilder(BaseModel): + """Serialize cutoff-safe IPMAN evidence into the agent's prompt.""" + + model_config = {"extra": "forbid"} + + recent_history_months: int = Field(default=12, ge=6, le=120) + trailing_base_rate_months: int = Field(default=60, ge=12, le=240) + anonymize_dates: bool = False + + 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) + 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) + recent_rows = list( + 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, + ) + ) + if self.anonymize_dates: + origin_month = as_of.to_period("M").ordinal + recent_ipman = [ + { + "months_before_origin": origin_month - pd.Timestamp(timestamp).to_period("M").ordinal, + "value": float(value), + } + for timestamp, value, _released_at in recent_rows + ] + else: + 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 recent_rows + ] + + payload: dict[str, Any] = { + "task": { + "task_id": task.task_id, + "question": task.description, + "horizon_months": task.horizons[0], + }, + "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_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, + "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, + } + if self.anonymize_dates: + payload["timing"] = {"calendar_dates_anonymized": True} + else: + payload["as_of"] = str(as_of.date()) + payload["forecast_date"] = str(forecast_date.date()) + return json.dumps(payload, separators=(",", ":")) + + +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=384, + ) + + +def build_manufacturing_stress_agent_predictor( + config: AgentConfig | None = None, + *, + anonymize_dates: bool = False, +) -> AgentPredictor: + """Wrap the analyst in the standard binary AgentPredictor contract.""" + return AgentPredictor( + agent_config=config or build_manufacturing_stress_agent_config(), + prompt_builder=ManufacturingStressPromptBuilder(anonymize_dates=anonymize_dates), + 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/data.py b/implementations/manufacturing_stress_forecasting/data.py new file mode 100644 index 000000000..1dfd6a6e6 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/data.py @@ -0,0 +1,227 @@ +"""FRED and Yahoo Finance data service for manufacturing-stress forecasting.""" + +from __future__ import annotations + +from pathlib import Path + +from aieng.forecasting.data import DataService, SeriesMetadata +from aieng.forecasting.data.adapters import FREDAdapter, YFinanceDailyAdapter +from aieng.forecasting.data.features import StaticFrameAdapter +from manufacturing_stress_forecasting.features import ( + CPI_YOY_SERIES_ID, + CPIAUCSL_SERIES_ID, + FEATURE_PERIODS, + FED_FUNDS_SERIES_ID, + FEDFUNDS_SERIES_ID, + HY_SPREAD_SERIES_ID, + ICSA_SERIES_ID, + SPY_RETURN_3M_SERIES_ID, + SPY_RETURN_12M_SERIES_ID, + UNRATE_SERIES_ID, + VIXCLS_SERIES_ID, + XLI_RETURN_3M_SERIES_ID, + XLI_RETURN_12M_SERIES_ID, + YC_SPREAD_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, + DEFAULT_STRESS_THRESHOLD_PCT, + derive_manufacturing_stress_labels, +) + + +IPMAN_FRED_ID = "IPMAN" +FED_FUNDS_FRED_ID = "DFF" +TREASURY_10Y_FRED_ID = "DGS10" +TREASURY_2Y_FRED_ID = "DGS2" +FEDFUNDS_FRED_ID = "FEDFUNDS" +CPI_FRED_ID = "CPIAUCSL" +UNRATE_FRED_ID = "UNRATE" +ICSA_FRED_ID = "ICSA" +VIXCLS_FRED_ID = "VIXCLS" +HY_SPREAD_FRED_ID = "BAMLH0A0HYM2" +SPY_TICKER = "SPY" +XLI_TICKER = "XLI" +YAHOO_HISTORY_START = "1998-01-01" + +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" +DEFAULT_YAHOO_CACHE_DIR = _REPO_ROOT / "data" / "yfinance" + + +def build_manufacturing_stress_service( + *, + cache_dir: str | Path = DEFAULT_FRED_CACHE_DIR, + yahoo_cache_dir: str | Path = DEFAULT_YAHOO_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 the target and cutoff-aware input features.""" + raw_ipman = FREDAdapter(IPMAN_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_fed_funds = FREDAdapter(FEDFUNDS_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() + raw_cpi = FREDAdapter(CPI_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_unemployment = FREDAdapter(UNRATE_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_initial_claims = FREDAdapter(ICSA_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_vix = FREDAdapter(VIXCLS_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_high_yield_spread = FREDAdapter(HY_SPREAD_FRED_ID, cache_dir=cache_dir, refresh=refresh).fetch() + raw_spy_prices = YFinanceDailyAdapter( + SPY_TICKER, + start=YAHOO_HISTORY_START, + cache_dir=yahoo_cache_dir, + refresh=refresh, + ).fetch() + raw_xli_prices = YFinanceDailyAdapter( + XLI_TICKER, + start=YAHOO_HISTORY_START, + cache_dir=yahoo_cache_dir, + refresh=refresh, + ).fetch() + + ipman = apply_conservative_monthly_release_lag(raw_ipman, months=release_lag_months) + ipman_feature_frames = build_ipman_feature_frames(ipman) + macro_feature_frames = build_macro_feature_frames( + raw_fed_funds, + raw_treasury_10y, + raw_treasury_2y, + raw_cpi, + raw_unemployment, + raw_initial_claims, + raw_vix, + raw_high_yield_spread, + raw_spy_prices, + raw_xli_prices, + ) + 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 ipman_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( + 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 (FEDFUNDS), 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", + ), + ) + + metadata = { + FEDFUNDS_SERIES_ID: ("Effective federal funds rate", "FRED (FEDFUNDS)", "Percent"), + YC_SPREAD_SERIES_ID: ("10-year minus 2-year Treasury yield spread", "FRED (DGS10 minus DGS2)", "Percentage points"), + CPIAUCSL_SERIES_ID: ("Consumer Price Index for All Urban Consumers", "FRED (CPIAUCSL)", "Index"), + CPI_YOY_SERIES_ID: ("Consumer Price Index year-over-year change", "Derived from FRED (CPIAUCSL)", "Percent"), + UNRATE_SERIES_ID: ("U.S. unemployment rate", "FRED (UNRATE)", "Percent"), + ICSA_SERIES_ID: ("Initial claims for unemployment insurance", "FRED (ICSA)", "Number"), + VIXCLS_SERIES_ID: ("CBOE volatility index", "FRED (VIXCLS)", "Index"), + HY_SPREAD_SERIES_ID: ("U.S. high-yield option-adjusted spread", "FRED (BAMLH0A0HYM2)", "Percentage points"), + SPY_RETURN_3M_SERIES_ID: ("SPY trailing 3-month adjusted-close return", "Yahoo Finance (SPY), derived", "Percent"), + SPY_RETURN_12M_SERIES_ID: ("SPY trailing 12-month adjusted-close return", "Yahoo Finance (SPY), derived", "Percent"), + XLI_RETURN_3M_SERIES_ID: ("XLI trailing 3-month adjusted-close return", "Yahoo Finance (XLI), derived", "Percent"), + XLI_RETURN_12M_SERIES_ID: ("XLI trailing 12-month adjusted-close return", "Yahoo Finance (XLI), derived", "Percent"), + } + for series_id, frame in macro_feature_frames.items(): + if series_id not in metadata: + continue + description, source, units = metadata[series_id] + service.register( + series_id, + StaticFrameAdapter(frame), + SeriesMetadata( + series_id=series_id, + description=description, + source=source, + units=units, + 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", + "DEFAULT_YAHOO_CACHE_DIR", + "FED_FUNDS_FRED_ID", + "FEDFUNDS_FRED_ID", + "CPI_FRED_ID", + "UNRATE_FRED_ID", + "ICSA_FRED_ID", + "VIXCLS_FRED_ID", + "HY_SPREAD_FRED_ID", + "SPY_TICKER", + "XLI_TICKER", + "YAHOO_HISTORY_START", + "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 new file mode 100644 index 000000000..42cae6317 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/features.py @@ -0,0 +1,217 @@ +"""Leak-safe monthly features for manufacturing-stress forecasting.""" + +from __future__ import annotations + +from collections.abc import Sequence + +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" +FED_FUNDS_SERIES_ID = "fed_funds_rate_pct" +YIELD_CURVE_SERIES_ID = "treasury_10y_minus_2y_pct_points" +FEDFUNDS_SERIES_ID = "FEDFUNDS" +YC_SPREAD_SERIES_ID = "YC_SPREAD" +CPIAUCSL_SERIES_ID = "CPIAUCSL" +CPI_YOY_SERIES_ID = "CPI_YOY" +UNRATE_SERIES_ID = "UNRATE" +ICSA_SERIES_ID = "ICSA" +VIXCLS_SERIES_ID = "VIXCLS" +HY_SPREAD_SERIES_ID = "HY_SPREAD" +SPY_RETURN_3M_SERIES_ID = "SPY_RETURN_3M" +SPY_RETURN_12M_SERIES_ID = "SPY_RETURN_12M" +XLI_RETURN_3M_SERIES_ID = "XLI_RETURN_3M" +XLI_RETURN_12M_SERIES_ID = "XLI_RETURN_12M" + +FEATURE_PERIODS: dict[str, int] = { + IPMAN_CHANGE_1M_SERIES_ID: 1, + IPMAN_CHANGE_3M_SERIES_ID: 3, + IPMAN_CHANGE_6M_SERIES_ID: 6, +} +IPMAN_FEATURE_SERIES_IDS: tuple[str, ...] = tuple(FEATURE_PERIODS) +MACRO_FEATURE_SERIES_IDS: tuple[str, ...] = ( + FEDFUNDS_SERIES_ID, + YC_SPREAD_SERIES_ID, + CPIAUCSL_SERIES_ID, + CPI_YOY_SERIES_ID, + UNRATE_SERIES_ID, + ICSA_SERIES_ID, + VIXCLS_SERIES_ID, + HY_SPREAD_SERIES_ID, + SPY_RETURN_3M_SERIES_ID, + SPY_RETURN_12M_SERIES_ID, + XLI_RETURN_3M_SERIES_ID, + XLI_RETURN_12M_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: + """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 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 monthly_return_feature(prices: pd.DataFrame, periods: int) -> pd.DataFrame: + """Return trailing ``periods``-month percentage returns from daily prices.""" + if periods < 1: + raise ValueError(f"periods must be positive; got {periods}") + monthly = monthly_last_observation(prices) + monthly["value"] = monthly["value"].pct_change(periods=periods, fill_method=None) * 100.0 + return canonical_three_col(monthly.dropna(subset=["value"]).reset_index(drop=True)) + + +def build_macro_feature_frames( + fed_funds: pd.DataFrame, + treasury_10y: pd.DataFrame, + treasury_2y: pd.DataFrame, + cpi: pd.DataFrame | None = None, + unemployment: pd.DataFrame | None = None, + initial_claims: pd.DataFrame | None = None, + vix: pd.DataFrame | None = None, + high_yield_spread: pd.DataFrame | None = None, + spy_prices: pd.DataFrame | None = None, + xli_prices: pd.DataFrame | None = None, +) -> dict[str, pd.DataFrame]: + """Build the monthly FRED and Yahoo Finance feature panel. + + 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. + """ + def monthly_level(frame: pd.DataFrame) -> pd.DataFrame: + value = canonical_three_col(frame) + value["released_at"] = value["timestamp"] + pd.offsets.BDay(1) + return monthly_last_observation(value) + + fed_monthly = monthly_level(fed_funds) + + 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"]]) + + features = { + FED_FUNDS_SERIES_ID: fed_monthly, + YIELD_CURVE_SERIES_ID: spread_monthly, + } + if all( + frame is not None + for frame in (cpi, unemployment, initial_claims, vix, high_yield_spread, spy_prices, xli_prices) + ): + cpi_monthly = monthly_level(cpi) + cpi_yoy = cpi_monthly.copy() + cpi_yoy["value"] = cpi_yoy["value"].pct_change(periods=12, fill_method=None) * 100.0 + cpi_yoy = canonical_three_col(cpi_yoy.dropna(subset=["value"]).reset_index(drop=True)) + features.update( + { + FEDFUNDS_SERIES_ID: fed_monthly, + YC_SPREAD_SERIES_ID: spread_monthly, + CPIAUCSL_SERIES_ID: cpi_monthly, + CPI_YOY_SERIES_ID: cpi_yoy, + UNRATE_SERIES_ID: monthly_level(unemployment), + ICSA_SERIES_ID: monthly_level(initial_claims), + VIXCLS_SERIES_ID: monthly_level(vix), + HY_SPREAD_SERIES_ID: monthly_level(high_yield_spread), + SPY_RETURN_3M_SERIES_ID: monthly_return_feature(spy_prices, periods=3), + SPY_RETURN_12M_SERIES_ID: monthly_return_feature(spy_prices, periods=12), + XLI_RETURN_3M_SERIES_ID: monthly_return_feature(xli_prices, periods=3), + XLI_RETURN_12M_SERIES_ID: monthly_return_feature(xli_prices, periods=12), + } + ) + return features + + +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``. + + 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 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__ = [ + "FED_FUNDS_SERIES_ID", + "FEDFUNDS_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", + "MACRO_FEATURE_SERIES_IDS", + "YC_SPREAD_SERIES_ID", + "CPIAUCSL_SERIES_ID", + "CPI_YOY_SERIES_ID", + "UNRATE_SERIES_ID", + "ICSA_SERIES_ID", + "VIXCLS_SERIES_ID", + "HY_SPREAD_SERIES_ID", + "SPY_RETURN_3M_SERIES_ID", + "SPY_RETURN_12M_SERIES_ID", + "XLI_RETURN_3M_SERIES_ID", + "XLI_RETURN_12M_SERIES_ID", + "YIELD_CURVE_SERIES_ID", + "apply_conservative_monthly_release_lag", + "build_feature_snapshot", + "build_ipman_feature_frames", + "build_macro_feature_frames", + "monthly_last_observation", + "monthly_return_feature", + "percent_change_feature", +] diff --git a/implementations/manufacturing_stress_forecasting/manufacturing_stress_workbench.ipynb b/implementations/manufacturing_stress_forecasting/manufacturing_stress_workbench.ipynb new file mode 100644 index 000000000..3e170cb95 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/manufacturing_stress_workbench.ipynb @@ -0,0 +1,512 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 6, + "id": "988dada7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running: uv sync --all-extras --dev --all-packages\n", + "\n", + "Environment synchronized successfully.\n", + "If packages changed, restart the notebook kernel before continuing.\n" + ] + } + ], + "source": [ + "# Cell 0: synchronize the repository environment before running the workbench.\n", + "import subprocess\n", + "from pathlib import Path\n", + "\n", + "\n", + "def find_repo_root(start: Path) -> Path:\n", + " \"\"\"Find the workspace root without confusing member pyproject files for it.\"\"\"\n", + " for candidate in (start, *start.parents):\n", + " if (\n", + " (candidate / \"uv.lock\").exists()\n", + " and (candidate / \"implementations\").is_dir()\n", + " and (candidate / \"aieng-forecasting\").is_dir()\n", + " ):\n", + " return candidate\n", + " raise FileNotFoundError(\"Could not find the agentic-forecasting repository root.\")\n", + "\n", + "\n", + "SETUP_ROOT = find_repo_root(Path.cwd().resolve())\n", + "print(\"Running: uv sync --all-extras --dev --all-packages\")\n", + "setup_result = subprocess.run(\n", + " [\"uv\", \"sync\", \"--all-extras\", \"--dev\", \"--all-packages\"],\n", + " cwd=SETUP_ROOT,\n", + " check=False,\n", + " text=True,\n", + " capture_output=True,\n", + ")\n", + "print(setup_result.stdout)\n", + "if setup_result.returncode != 0:\n", + " print(setup_result.stderr)\n", + " raise RuntimeError(\n", + " \"uv sync failed. Install uv or restart the notebook with the repository environment selected.\"\n", + " )\n", + "print(\"Environment synchronized successfully.\")\n", + "print(\"If packages changed, restart the notebook kernel before continuing.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "b696c087", + "metadata": {}, + "source": [ + "# Manufacturing Stress Forecasting Workbench\n", + "\n", + "This notebook is the main interactive entry point for the manufacturing-stress experiment. It creates or refreshes the FRED data cache, runs the deterministic baselines, and optionally runs the token-limited LLMP backtest.\n", + "\n", + "All predictors use the same backtest specification and shared Brier-score implementation. Complete LLMP results are cached by backtest-specification fingerprint so repeated analysis does not make new model calls. Historical agent prompts use relative month offsets instead of calendar dates, but this remains a retrospective pseudo-backtest: model-training knowledge leakage cannot be eliminated completely.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d1b6955d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'refresh_fred_data': True, 'backtest_stride': 3, 'run_llmp_backtest': False, 'force_refresh_llmp_cache': False, 'run_current_forecast': False}\n" + ] + } + ], + "source": [ + "# Main controls: change these values, then run the notebook from top to bottom.\n", + "REFRESH_FRED_DATA = True\n", + "BACKTEST_STRIDE = 3\n", + "RUN_LLMP_BACKTEST = False\n", + "FORCE_REFRESH_LLMP_CACHE = False\n", + "RUN_CURRENT_FORECAST = False\n", + "\n", + "# BACKTEST_STRIDE = 1 evaluates every month; 3 evaluates every third month.\n", + "# False: one smoke run for historical frequency, logistic regression, and XGBoost.\n", + "# True: the same smoke run also includes the cached, token-limited LLMP.\n", + "# RUN_CURRENT_FORECAST controls the separate latest-data forecast section.\n", + "print({\n", + " \"refresh_fred_data\": REFRESH_FRED_DATA,\n", + " \"backtest_stride\": BACKTEST_STRIDE,\n", + " \"run_llmp_backtest\": RUN_LLMP_BACKTEST,\n", + " \"force_refresh_llmp_cache\": FORCE_REFRESH_LLMP_CACHE,\n", + " \"run_current_forecast\": RUN_CURRENT_FORECAST,\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "e43fbfdd", + "metadata": {}, + "source": [ + "## Imports and project paths\n", + "\n", + "Run this notebook with the repository environment selected as the Jupyter kernel. The `.env` file is loaded for the FRED key and, when needed, the LLM proxy credentials." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "2e5a002b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Repository: /home/coder/BMO-C2-agentic-forecasting\n" + ] + } + ], + "source": [ + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "import yaml\n", + "from aieng.forecasting.evaluation import BacktestSpec, backtest\n", + "from aieng.forecasting.methods import HistoricalFrequencyPredictor\n", + "from dotenv import load_dotenv\n", + "from manufacturing_stress_forecasting.analyst_agent import build_manufacturing_stress_agent_predictor\n", + "from manufacturing_stress_forecasting.data import IPMAN_SERIES_ID, build_manufacturing_stress_service\n", + "from manufacturing_stress_forecasting.predictors import (\n", + " ManufacturingStressLogisticPredictor,\n", + " ManufacturingStressXGBoostPredictor,\n", + ")\n", + "from manufacturing_stress_forecasting.run_agent_backtest import (\n", + " run_or_load_backtest,\n", + " validate_comparable_results,\n", + ")\n", + "\n", + "\n", + "REPO_ROOT = SETUP_ROOT\n", + "load_dotenv(REPO_ROOT / \".env\", override=False)\n", + "SPEC_PATH = REPO_ROOT / \"implementations\" / \"manufacturing_stress_forecasting\" / \"specs\" / \"manufacturing_stress_smoke.yaml\"\n", + "STORE_DIR = REPO_ROOT / \"data\" / \"predictions\"\n", + "print(f\"Repository: {REPO_ROOT}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "549de858", + "metadata": {}, + "source": [ + "## Create or refresh the data\n", + "\n", + "The service loads the requested FRED fields and the `SPY` and `XLI` adjusted-close histories from the local FRED and Yahoo Finance caches. Set `REFRESH_FRED_DATA = True` in the first cell when the caches should be updated from their data services.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "7cd4ad0a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " series_id description source units frequency n_obs start end\n", + " CPIAUCSL Consumer Price Index for All Urban Consumers FRED (CPIAUCSL) Index MS 955 1947-01-01 2026-08-01\n", + " CPI_YOY Consumer Price Index year-over-year change Derived from FRED (CPIAUCSL) Percent MS 943 1948-01-01 2026-08-01\n", + " FEDFUNDS Effective federal funds rate FRED (FEDFUNDS) Percent MS 866 1954-07-01 2026-08-01\n", + " HY_SPREAD U.S. high-yield option-adjusted spread FRED (BAMLH0A0HYM2) Percentage points MS 37 2023-09-01 2026-09-01\n", + " ICSA Initial claims for unemployment insurance FRED (ICSA) Number MS 717 1967-01-01 2026-09-01\n", + " SPY_RETURN_12M SPY trailing 12-month adjusted-close return Yahoo Finance (SPY), derived Percent MS 333 1999-01-01 2026-09-01\n", + " SPY_RETURN_3M SPY trailing 3-month adjusted-close return Yahoo Finance (SPY), derived Percent MS 342 1998-04-01 2026-09-01\n", + " UNRATE U.S. unemployment rate FRED (UNRATE) Percent MS 943 1948-01-01 2026-08-01\n", + " VIXCLS CBOE volatility index FRED (VIXCLS) Index MS 441 1990-01-01 2026-09-01\n", + " XLI_RETURN_12M XLI trailing 12-month adjusted-close return Yahoo Finance (XLI), derived Percent MS 322 1999-12-01 2026-09-01\n", + " XLI_RETURN_3M XLI trailing 3-month adjusted-close return Yahoo Finance (XLI), derived Percent MS 331 1999-03-01 2026-09-01\n", + " YC_SPREAD 10-year minus 2-year Treasury yield spread FRED (DGS10 minus DGS2) Percentage points MS 604 1976-06-01 2026-09-01\n", + " fed_funds_rate_pct Month-end effective federal funds rate FRED (FEDFUNDS), derived monthly Percent MS 866 1954-07-01 2026-08-01\n", + " ipman_change_1m_pct Trailing 1-month percentage change in IPMAN Derived from FRED IPMAN Percent MS 655 1972-02-01 2026-08-01\n", + " ipman_change_3m_pct Trailing 3-month percentage change in IPMAN Derived from FRED IPMAN Percent MS 653 1972-04-01 2026-08-01\n", + " ipman_change_6m_pct Trailing 6-month percentage change in IPMAN Derived from FRED IPMAN Percent MS 650 1972-07-01 2026-08-01\n", + "ipman_us_manufacturing_production U.S. manufacturing industrial production index (IPMAN) FRED (IPMAN) Index MS 656 1972-01-01 2026-08-01\n", + " manufacturing_stress Binary U.S. manufacturing stress label: 1 when trailing 3-month IPMAN change is at or below -2.0% Derived from FRED IPMAN Binary event (0=no stress, 1=stress) MS 653 1972-04-01 2026-08-01\n", + " treasury_10y_minus_2y_pct_points Month-end 10-year minus 2-year Treasury yield spread FRED (DGS10 minus DGS2), derived monthly Percentage points MS 604 1976-06-01 2026-09-01\n" + ] + } + ], + "source": [ + "import importlib\n", + "\n", + "import manufacturing_stress_forecasting.data as manufacturing_stress_data\n", + "import manufacturing_stress_forecasting.features as manufacturing_stress_features\n", + "\n", + "importlib.reload(manufacturing_stress_features)\n", + "manufacturing_stress_data = importlib.reload(manufacturing_stress_data)\n", + "\n", + "service = manufacturing_stress_data.build_manufacturing_stress_service(\n", + " cache_dir=REPO_ROOT / \"data\" / \"fred\",\n", + " yahoo_cache_dir=REPO_ROOT / \"data\" / \"yfinance\",\n", + " refresh=REFRESH_FRED_DATA,\n", + ")\n", + "print(service.summary().to_string(index=False))\n" + ] + }, + { + "cell_type": "markdown", + "id": "095e4106", + "metadata": {}, + "source": [ + "## Load the common backtest specification\n", + "\n", + "Every predictor below receives this same target, horizon, forecast-origin schedule, warmup, and cutoff-aware data service." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "36a12895", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "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.\n", + "Origins: 2018-01-01 to 2024-12-01, stride=3, warmup=60\n", + "Target: manufacturing_stress; horizon=3 MS\n" + ] + } + ], + "source": [ + "with SPEC_PATH.open() as file:\n", + " spec = BacktestSpec.model_validate(yaml.safe_load(file))\n", + "spec = spec.model_copy(update={\"stride\": BACKTEST_STRIDE})\n", + "\n", + "print(spec.task.description)\n", + "print(f\"Origins: {spec.start.date()} to {spec.end.date()}, stride={spec.stride}, warmup={spec.warmup}\")\n", + "print(f\"Target: {spec.task.target_series_id}; horizon={spec.task.horizons[0]} {spec.task.frequency}\")" + ] + }, + { + "cell_type": "markdown", + "id": "5c5d451c", + "metadata": {}, + "source": [ + "## Run one smoke test\n", + "\n", + "This is the single execution path. Before scoring, it verifies that the logistic and XGBoost predictors have all 15 registered model features: the three IPMAN momentum features plus the twelve FRED/Yahoo macro features, including 3-month and 12-month monthly-origin returns for both SPY and XLI. All are monthly model frames; daily and weekly source series are reduced to monthly observations first. The cell then runs the historical-frequency, logistic-regression, and XGBoost predictors. Set `RUN_LLMP_BACKTEST = True` in the first cell to include the token-limited LLMP in the same run. Compatible complete results are loaded from cache unless `FORCE_REFRESH_LLMP_CACHE = True`; refreshed data also forces a rerun.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "345f6b3e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Logistic/XGBoost feature contract verified: 15 features (12 FRED/Yahoo macro features) are registered at monthly frequency.\n", + "Daily/weekly source data, including Yahoo returns, is reduced to monthly observations before modeling.\n", + "historical_frequency: 0.0356 mean brier; scored=28 skipped=0\n", + "manufacturing_stress_logistic_ipman_rates: 0.0458 mean brier; scored=28 skipped=0\n", + "manufacturing_stress_xgboost_ipman_rates: 0.0416 mean brier; scored=28 skipped=0\n", + "All predictors were scored on identical forecast origins.\n" + ] + } + ], + "source": [ + "from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS\n", + "\n", + "feature_summary = service.summary().set_index(\"series_id\")\n", + "missing_features = sorted(set(FEATURE_SERIES_IDS) - set(feature_summary.index))\n", + "if missing_features:\n", + " raise RuntimeError(f\"Missing model features: {missing_features}\")\n", + "\n", + "non_monthly_features = [\n", + " series_id for series_id in FEATURE_SERIES_IDS if feature_summary.loc[series_id, \"frequency\"] != \"MS\"\n", + "]\n", + "if non_monthly_features:\n", + " raise RuntimeError(f\"Model features are not monthly: {non_monthly_features}\")\n", + "\n", + "print(\n", + " \"Logistic/XGBoost feature contract verified: \"\n", + " f\"{len(FEATURE_SERIES_IDS)} features ({len(FEATURE_SERIES_IDS) - 3} FRED/Yahoo macro features) \"\n", + " \"are registered at monthly frequency.\"\n", + ")\n", + "print(\"Daily/weekly source data, including Yahoo returns, is reduced to monthly observations before modeling.\")\n", + "\n", + "predictors = [\n", + " HistoricalFrequencyPredictor(),\n", + " ManufacturingStressLogisticPredictor(),\n", + " ManufacturingStressXGBoostPredictor(),\n", + "]\n", + "if RUN_LLMP_BACKTEST:\n", + " predictors.append(build_manufacturing_stress_agent_predictor(anonymize_dates=True))\n", + "\n", + "results = {}\n", + "for predictor in predictors:\n", + " if RUN_LLMP_BACKTEST:\n", + " result = run_or_load_backtest(\n", + " predictor,\n", + " spec,\n", + " service,\n", + " force_refresh=FORCE_REFRESH_LLMP_CACHE or REFRESH_FRED_DATA,\n", + " store_dir=STORE_DIR,\n", + " )\n", + " else:\n", + " result = backtest(predictor=predictor, spec=spec, data_service=service)\n", + " results[predictor.predictor_id] = result\n", + " print(\n", + " f\"{result.predictor_id}: {result.mean_score:.4f} mean {result.metric}; \"\n", + " f\"scored={len(result.scores)} skipped={result.skipped_origins}\"\n", + " )\n", + "\n", + "validate_comparable_results(list(results.values()))\n", + "print(\"All predictors were scored on identical forecast origins.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "b9382397", + "metadata": {}, + "source": [ + "## Compare results\n", + "\n", + "Lower Brier score is better. The notebook refuses to compare results unless every predictor was scored on the exact same forecast-origin and forecast-date pairs.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "e7c1bd4f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predictormetricmean_brierscoredskipped
0historical_frequencybrier0.035598280
2manufacturing_stress_xgboost_ipman_ratesbrier0.041616280
1manufacturing_stress_logistic_ipman_ratesbrier0.045765280
\n", + "
" + ], + "text/plain": [ + " predictor metric mean_brier scored \\\n", + "0 historical_frequency brier 0.035598 28 \n", + "2 manufacturing_stress_xgboost_ipman_rates brier 0.041616 28 \n", + "1 manufacturing_stress_logistic_ipman_rates brier 0.045765 28 \n", + "\n", + " skipped \n", + "0 0 \n", + "2 0 \n", + "1 0 " + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "comparison = pd.DataFrame(\n", + " [\n", + " {\n", + " \"predictor\": result.predictor_id,\n", + " \"metric\": result.metric,\n", + " \"mean_brier\": result.mean_score,\n", + " \"scored\": len(result.scores),\n", + " \"skipped\": result.skipped_origins,\n", + " }\n", + " for result in results.values()\n", + " ]\n", + ")\n", + "comparison.sort_values(\"mean_brier\") if not comparison.empty else comparison" + ] + }, + { + "cell_type": "markdown", + "id": "266430ff", + "metadata": {}, + "source": [ + "## Optional current forecast\n", + "\n", + "Set `RUN_CURRENT_FORECAST = True` in the first cell to run the selected predictors against the latest released IPMAN data. This is a current forecast, not a historical backtest, so it does not produce a Brier score. `RUN_LLMP_BACKTEST` controls whether the LLMP is included here too." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f360e6c3", + "metadata": {}, + "outputs": [], + "source": [ + "if RUN_CURRENT_FORECAST:\n", + " with SPEC_PATH.open() as file:\n", + " current_task = BacktestSpec.model_validate(yaml.safe_load(file)).task\n", + "\n", + " full_ipman = service.get_series(\n", + " IPMAN_SERIES_ID,\n", + " as_of=pd.Timestamp(\"2100-01-01\").to_pydatetime(),\n", + " )\n", + " current_as_of = pd.Timestamp(full_ipman[\"released_at\"].max())\n", + " current_context = service.context(as_of=current_as_of.to_pydatetime())\n", + "\n", + " current_predictors = [\n", + " HistoricalFrequencyPredictor(),\n", + " ManufacturingStressLogisticPredictor(),\n", + " ManufacturingStressXGBoostPredictor(),\n", + " ]\n", + " if RUN_LLMP_BACKTEST:\n", + " current_predictors.append(build_manufacturing_stress_agent_predictor())\n", + "\n", + " print(f\"Forecast origin: {current_as_of.date()}\")\n", + " print(\n", + " \"Latest visible IPMAN reference month: \"\n", + " f\"{pd.Timestamp(current_context.get_series(IPMAN_SERIES_ID)['timestamp'].max()).date()}\"\n", + " )\n", + " for predictor in current_predictors:\n", + " prediction = predictor.predict(current_task, current_context)[0]\n", + " print({\n", + " \"predictor\": prediction.predictor_id,\n", + " \"forecast_date\": str(pd.Timestamp(prediction.forecast_date).date()),\n", + " \"stress_probability\": prediction.payload.probability,\n", + " \"metadata\": prediction.metadata,\n", + " })\n", + "else:\n", + " print(\"Current forecast is disabled. Set RUN_CURRENT_FORECAST = True to enable it.\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "agentic-forecasting-bootcamp (3.12.3)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/implementations/manufacturing_stress_forecasting/predictors/__init__.py b/implementations/manufacturing_stress_forecasting/predictors/__init__.py new file mode 100644 index 000000000..c5cbd12ef --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/predictors/__init__.py @@ -0,0 +1,7 @@ +"""Predictors for the manufacturing-stress implementation.""" + +from manufacturing_stress_forecasting.predictors.logistic import ManufacturingStressLogisticPredictor +from manufacturing_stress_forecasting.predictors.xgboost import ManufacturingStressXGBoostPredictor + + +__all__ = ["ManufacturingStressLogisticPredictor", "ManufacturingStressXGBoostPredictor"] diff --git a/implementations/manufacturing_stress_forecasting/predictors/__pycache__/__init__.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/predictors/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..9636a6d99 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/predictors/__pycache__/__init__.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/predictors/__pycache__/logistic.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/predictors/__pycache__/logistic.cpython-312.pyc new file mode 100644 index 000000000..63d9433ea Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/predictors/__pycache__/logistic.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/predictors/__pycache__/xgboost.cpython-312.pyc b/implementations/manufacturing_stress_forecasting/predictors/__pycache__/xgboost.cpython-312.pyc new file mode 100644 index 000000000..b8a7eb8c8 Binary files /dev/null and b/implementations/manufacturing_stress_forecasting/predictors/__pycache__/xgboost.cpython-312.pyc differ diff --git a/implementations/manufacturing_stress_forecasting/predictors/logistic.py b/implementations/manufacturing_stress_forecasting/predictors/logistic.py new file mode 100644 index 000000000..557a4d98a --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/predictors/logistic.py @@ -0,0 +1,107 @@ +"""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 IPMAN and macroeconomic 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`` + 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_rates" + + 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/predictors/xgboost.py b/implementations/manufacturing_stress_forecasting/predictors/xgboost.py new file mode 100644 index 000000000..70943cb0c --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/predictors/xgboost.py @@ -0,0 +1,69 @@ +"""XGBoost predictor for three-month-ahead manufacturing stress.""" + +from __future__ import annotations + +import numpy as np +from aieng.forecasting.evaluation.prediction import BinaryForecast +from manufacturing_stress_forecasting.features import FEATURE_SERIES_IDS +from manufacturing_stress_forecasting.predictors.logistic import ManufacturingStressLogisticPredictor + + +class ManufacturingStressXGBoostPredictor(ManufacturingStressLogisticPredictor): + """Forecast manufacturing stress with a small gradient-boosted tree model.""" + + def __init__( + self, + *, + n_estimators: int = 100, + max_depth: int = 2, + learning_rate: float = 0.05, + min_training_examples: int = 24, + ) -> None: + super().__init__(min_training_examples=min_training_examples) + self._n_estimators = n_estimators + self._max_depth = max_depth + self._learning_rate = learning_rate + + @property + def predictor_id(self) -> str: + """Return the stable artifact identifier.""" + return "manufacturing_stress_xgboost_ipman_rates" + + 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 xgboost import XGBClassifier # noqa: PLC0415 + + model = XGBClassifier( + n_estimators=self._n_estimators, + max_depth=self._max_depth, + learning_rate=self._learning_rate, + objective="binary:logistic", + eval_metric="logloss", + subsample=0.8, + colsample_bytree=0.8, + random_state=42, + n_jobs=1, + ) + 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]) + return BinaryForecast(probability=probability), { + "model": "xgboost_classifier", + "features": dict(zip(FEATURE_SERIES_IDS, (float(value) for value in current_row[0]), strict=True)), + "feature_importances": dict( + zip(FEATURE_SERIES_IDS, (float(value) for value in model.feature_importances_), strict=True) + ), + } + + +__all__ = ["ManufacturingStressXGBoostPredictor"] diff --git a/implementations/manufacturing_stress_forecasting/run_agent_backtest.py b/implementations/manufacturing_stress_forecasting/run_agent_backtest.py new file mode 100644 index 000000000..488631234 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/run_agent_backtest.py @@ -0,0 +1,150 @@ +"""Run the cached, token-limited manufacturing-stress agent backtest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from datetime import datetime +from pathlib import Path + +import yaml +from aieng.forecasting.data import DataService +from aieng.forecasting.evaluation import BacktestSpec, backtest +from aieng.forecasting.evaluation.artifacts import load_backtest_result, save_backtest_result +from aieng.forecasting.evaluation.backtest import BacktestResult +from aieng.forecasting.evaluation.predictor import Predictor +from aieng.forecasting.methods import HistoricalFrequencyPredictor +from manufacturing_stress_forecasting.analyst_agent import build_manufacturing_stress_agent_predictor +from manufacturing_stress_forecasting.data import build_manufacturing_stress_service +from manufacturing_stress_forecasting.predictors import ( + ManufacturingStressLogisticPredictor, + ManufacturingStressXGBoostPredictor, +) + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SPEC_PATH = Path(__file__).resolve().parent / "specs" / "manufacturing_stress_smoke.yaml" +STORE_DIR = REPO_ROOT / "data" / "predictions" +# Bump when prompt or predictor semantics change without changing predictor IDs. +CACHE_VERSION = "v2" + + +def backtest_cache_id(spec: BacktestSpec) -> str: + """Return a versioned cache key tied to the complete backtest spec.""" + serialized = json.dumps(spec.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + fingerprint = hashlib.sha256(serialized.encode()).hexdigest()[:10] + return f"manufacturing_stress_smoke_llmp_{CACHE_VERSION}_{fingerprint}" + + +def _cache_rejection_reason(cached: BacktestResult, spec: BacktestSpec) -> str | None: + """Explain why a cached result is unsafe to reuse, if applicable.""" + if cached.spec != spec: + return "the cached backtest specification differs" + if cached.skipped_origins: + return f"the cached result skipped {cached.skipped_origins} origin(s)" + return None + + +def _parse_args() -> argparse.Namespace: + """Parse explicit controls for the paid backtest.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--force-refresh", + action="store_true", + help="Re-run predictors and overwrite cached results.", + ) + return parser.parse_args() + + +def run_or_load_backtest( + predictor: Predictor, + spec: BacktestSpec, + service: DataService, + *, + force_refresh: bool, + spec_id: str | None = None, + store_dir: Path = STORE_DIR, +) -> BacktestResult: + """Load a compatible complete result or run it and cache only if complete.""" + predictor_id = predictor.predictor_id + resolved_spec_id = spec_id or backtest_cache_id(spec) + if not force_refresh: + cached = load_backtest_result(resolved_spec_id, predictor_id, store_dir=store_dir) + if cached is not None: + rejection_reason = _cache_rejection_reason(cached, spec) + if rejection_reason is None: + path = store_dir / resolved_spec_id / f"{predictor_id}.yaml" + print(f"{predictor_id}: loaded {path}") + return cached + print(f"{predictor_id}: ignoring cache because {rejection_reason}") + + result = backtest( + predictor=predictor, + spec=spec, + data_service=service, + max_retries=1, + retry_delay=1.0, + ) + if result.skipped_origins: + print( + f"{predictor_id}: not cached because the run skipped " + f"{result.skipped_origins} origin(s)" + ) + return result + + path = save_backtest_result(result, spec_id=resolved_spec_id, store_dir=store_dir) + print(f"{predictor_id}: saved {path}") + return result + + +def scored_origin_keys(result: BacktestResult) -> set[tuple[datetime, datetime]]: + """Return the exact forecast-origin/date pairs scored in a result.""" + return {(prediction.as_of, prediction.forecast_date) for prediction in result.predictions} + + +def validate_comparable_results(results: list[BacktestResult]) -> None: + """Require all model comparisons to use identical scored origins.""" + if len(results) < 2: + return + + reference = results[0] + reference_keys = scored_origin_keys(reference) + for result in results[1:]: + result_keys = scored_origin_keys(result) + if result_keys != reference_keys: + missing = len(reference_keys - result_keys) + extra = len(result_keys - reference_keys) + raise ValueError( + f"Cannot compare {result.predictor_id} with {reference.predictor_id}: " + f"scored origins differ (missing={missing}, extra={extra})." + ) + + +def main() -> None: + """Run all predictors under the identical binary Brier-score harness.""" + args = _parse_args() + with SPEC_PATH.open() as file: + spec = BacktestSpec.model_validate(yaml.safe_load(file)) + + service = build_manufacturing_stress_service() + predictors = [ + HistoricalFrequencyPredictor(), + ManufacturingStressLogisticPredictor(), + ManufacturingStressXGBoostPredictor(), + build_manufacturing_stress_agent_predictor(anonymize_dates=True), + ] + results: list[BacktestResult] = [] + for predictor in predictors: + result = run_or_load_backtest(predictor, spec, service, force_refresh=args.force_refresh) + results.append(result) + print( + f"{result.predictor_id}: {result.mean_score:.4f} mean {result.metric}; " + f"scored={len(result.scores)} skipped={result.skipped_origins}" + ) + validate_comparable_results(results) + print("All predictors were scored on identical forecast origins.") + + +if __name__ == "__main__": + main() 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..2c2f668f7 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/run_agent_prediction.py @@ -0,0 +1,58 @@ +"""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, + ManufacturingStressXGBoostPredictor, +) + + +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(), + ManufacturingStressXGBoostPredictor(), + 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/manufacturing_stress_forecasting/run_smoke.py b/implementations/manufacturing_stress_forecasting/run_smoke.py new file mode 100644 index 000000000..3105e92d9 --- /dev/null +++ b/implementations/manufacturing_stress_forecasting/run_smoke.py @@ -0,0 +1,35 @@ +"""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, + ManufacturingStressXGBoostPredictor, +) + + +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(), + ManufacturingStressXGBoostPredictor(), + ] + 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/pyproject.toml b/implementations/pyproject.toml index 7467a9c70..0b14070c1 100644 --- a/implementations/pyproject.toml +++ b/implementations/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.12" dependencies = [ "aieng-forecasting[numerical,llm,agentic,documents]", "beautifulsoup4>=4.12,<5", # BoC press-release HTML extraction (boc_rate_decisions.press_releases) + "xgboost>=2.1,<4", ] [tool.setuptools.packages.find] diff --git a/implementations/tests/__pycache__/__init__.cpython-312.pyc b/implementations/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..5ccce137d Binary files /dev/null and b/implementations/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_agent_backtest.cpython-312-pytest-9.1.1.pyc b/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_agent_backtest.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 000000000..1c58791a3 Binary files /dev/null and b/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_agent_backtest.cpython-312-pytest-9.1.1.pyc differ diff --git a/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_agent_prompt.cpython-312-pytest-9.1.1.pyc b/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_agent_prompt.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 000000000..298d6c6d1 Binary files /dev/null and b/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_agent_prompt.cpython-312-pytest-9.1.1.pyc differ diff --git a/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_targets_and_features.cpython-312-pytest-9.1.1.pyc b/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_targets_and_features.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 000000000..4beee7c5d Binary files /dev/null and b/implementations/tests/manufacturing_stress_forecasting/__pycache__/test_targets_and_features.cpython-312-pytest-9.1.1.pyc differ diff --git a/implementations/tests/manufacturing_stress_forecasting/test_agent_backtest.py b/implementations/tests/manufacturing_stress_forecasting/test_agent_backtest.py new file mode 100644 index 000000000..5792afcb0 --- /dev/null +++ b/implementations/tests/manufacturing_stress_forecasting/test_agent_backtest.py @@ -0,0 +1,122 @@ +"""Tests for manufacturing-stress backtest caching and comparison guards.""" + +from datetime import datetime +from pathlib import Path + +import pytest +from aieng.forecasting.data import DataService +from aieng.forecasting.evaluation import BacktestSpec, ForecastingTask +from aieng.forecasting.evaluation.backtest import BacktestResult +from aieng.forecasting.evaluation.prediction import BinaryForecast, Prediction +from aieng.forecasting.methods import HistoricalFrequencyPredictor +from manufacturing_stress_forecasting import run_agent_backtest + + +def _spec(*, stride: int = 3) -> BacktestSpec: + return BacktestSpec( + task=ForecastingTask( + task_id="manufacturing_stress_3m", + target_series_id="manufacturing_stress", + horizons=[3], + frequency="MS", + payload_type="binary", + description="Manufacturing stress three months ahead", + ), + start=datetime(2020, 1, 1), + end=datetime(2020, 7, 1), + stride=stride, + ) + + +def _result( + spec: BacktestSpec, + *, + predictor_id: str = "historical_frequency", + as_of: datetime = datetime(2020, 1, 1), + skipped_origins: int = 0, +) -> BacktestResult: + prediction = Prediction( + predictor_id=predictor_id, + task_id=spec.task.task_id, + issued_at=datetime(2026, 1, 1), + as_of=as_of, + forecast_date=datetime(as_of.year, as_of.month + 3, 1), + payload=BinaryForecast(probability=0.1), + ) + return BacktestResult( + spec=spec, + predictor_id=predictor_id, + predictions=[prediction], + scores=[0.01], + metric="brier", + mean_score=0.01, + ran_at=datetime(2026, 1, 1), + skipped_origins=skipped_origins, + ) + + +def test_cache_id_changes_with_backtest_spec() -> None: + assert run_agent_backtest.backtest_cache_id(_spec(stride=1)) != run_agent_backtest.backtest_cache_id( + _spec(stride=3) + ) + + +def test_incomplete_result_is_not_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + spec = _spec() + incomplete = _result(spec, skipped_origins=1) + saved: list[BacktestResult] = [] + + monkeypatch.setattr(run_agent_backtest, "load_backtest_result", lambda *args, **kwargs: None) + monkeypatch.setattr(run_agent_backtest, "backtest", lambda **kwargs: incomplete) + monkeypatch.setattr( + run_agent_backtest, + "save_backtest_result", + lambda result, **kwargs: saved.append(result) or tmp_path / "unexpected.yaml", + ) + + result = run_agent_backtest.run_or_load_backtest( + HistoricalFrequencyPredictor(), + spec, + DataService(), + force_refresh=False, + store_dir=tmp_path, + ) + + assert result is incomplete + assert saved == [] + + +def test_comparison_rejects_different_scored_origins() -> None: + spec = _spec() + first = _result(spec, predictor_id="first", as_of=datetime(2020, 1, 1)) + second = _result(spec, predictor_id="second", as_of=datetime(2020, 2, 1)) + + with pytest.raises(ValueError, match="scored origins differ"): + run_agent_backtest.validate_comparable_results([first, second]) + + +def test_incompatible_cached_spec_is_recomputed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + requested_spec = _spec(stride=1) + stale = _result(_spec(stride=3)) + fresh = _result(requested_spec) + saved: list[BacktestResult] = [] + + monkeypatch.setattr(run_agent_backtest, "load_backtest_result", lambda *args, **kwargs: stale) + monkeypatch.setattr(run_agent_backtest, "backtest", lambda **kwargs: fresh) + monkeypatch.setattr( + run_agent_backtest, + "save_backtest_result", + lambda result, **kwargs: saved.append(result) or tmp_path / "fresh.yaml", + ) + + result = run_agent_backtest.run_or_load_backtest( + HistoricalFrequencyPredictor(), + requested_spec, + DataService(), + force_refresh=False, + spec_id="deliberately-shared-id", + store_dir=tmp_path, + ) + + assert result is fresh + assert saved == [fresh] 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..3fbd47d80 --- /dev/null +++ b/implementations/tests/manufacturing_stress_forecasting/test_agent_prompt.py @@ -0,0 +1,136 @@ +"""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, + build_manufacturing_stress_agent_config, +) +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 len(payload["recent_ipman"]) <= 12 + assert len(payload["current_ipman_signals_pct"]) == 3 + assert len(payload["current_macro_signals"]) == 12 + assert 999.0 not in payload["current_ipman_signals_pct"].values() + assert 999.0 not in payload["current_macro_signals"].values() + assert "\n" not in prompt + + +def test_agent_config_limits_output_tokens() -> None: + config = build_manufacturing_stress_agent_config() + + assert config.max_output_tokens == 384 + assert config.temperature == 0.1 + assert config.seed == 42 + + +def test_backtest_prompt_anonymizes_calendar_dates() -> 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])), + metadata(IPMAN_SERIES_ID), + ) + service.register( + STRESS_SERIES_ID, + StaticFrameAdapter(_frame([0, 0, 1, 0, 0, 0])), + metadata(STRESS_SERIES_ID), + ) + for index, series_id in enumerate(FEATURE_SERIES_IDS): + service.register( + series_id, + StaticFrameAdapter(_frame([float(index)] * 6)), + 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(anonymize_dates=True)( + task=task, + context=service.context(datetime(2020, 6, 1)), + ) + payload = json.loads(prompt) + + assert "as_of" not in payload + assert "forecast_date" not in payload + assert payload["timing"] == {"calendar_dates_anonymized": True} + assert payload["recent_ipman"][-1] == {"months_before_origin": 0, "value": 105.0} + assert "reference_month" not in payload["recent_ipman"][-1] + assert "released_at" not in payload["recent_ipman"][-1] + assert "2020-" not in prompt 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..1b1d38428 --- /dev/null +++ b/implementations/tests/manufacturing_stress_forecasting/test_targets_and_features.py @@ -0,0 +1,107 @@ +"""Focused tests for label construction and historical feature cutoffs.""" + +import pandas as pd +import pytest +from manufacturing_stress_forecasting.features import ( + CPI_YOY_SERIES_ID, + FEATURE_SERIES_IDS, + FED_FUNDS_SERIES_ID, + SPY_RETURN_3M_SERIES_ID, + SPY_RETURN_12M_SERIES_ID, + XLI_RETURN_3M_SERIES_ID, + XLI_RETURN_12M_SERIES_ID, + YIELD_CURVE_SERIES_ID, + build_feature_snapshot, + build_macro_feature_frames, +) +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)) + + +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")] + + +def test_expanded_macro_features_include_cpi_yoy_and_monthly_market_return() -> None: + timestamps = pd.date_range("2023-01-01", periods=13, freq="MS") + + def frame(values: list[float]) -> pd.DataFrame: + return pd.DataFrame({"timestamp": timestamps, "value": values, "released_at": timestamps}) + + features = build_macro_feature_frames( + frame([5.0] * 13), + frame([4.0] * 13), + frame([3.0] * 13), + frame([100.0] + [100.0] * 11 + [110.0]), + frame([4.0] * 13), + frame([200.0] * 13), + frame([15.0] * 13), + frame([300.0] * 13), + frame([100.0 + 5.0 * index for index in range(13)]), + frame([100.0] * 13), + ) + + assert features[CPI_YOY_SERIES_ID]["value"].iloc[-1] == pytest.approx(10.0) + assert features[SPY_RETURN_3M_SERIES_ID]["value"].iloc[-1] == pytest.approx(100.0 * 15.0 / 145.0) + assert features[SPY_RETURN_12M_SERIES_ID]["value"].iloc[-1] == pytest.approx(60.0) + assert features[XLI_RETURN_3M_SERIES_ID]["value"].iloc[-1] == pytest.approx(0.0) + assert features[XLI_RETURN_12M_SERIES_ID]["value"].iloc[-1] == pytest.approx(0.0) diff --git a/scripts/fetch_manufacturing_stress.py b/scripts/fetch_manufacturing_stress.py new file mode 100644 index 000000000..d5a6ce49f --- /dev/null +++ b/scripts/fetch_manufacturing_stress.py @@ -0,0 +1,28 @@ +"""Fetch/cache manufacturing-stress inputs, then print the registered-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 FRED/Yahoo Finance caches and report the registered 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..1b79f4a07 100644 --- a/uv.lock +++ b/uv.lock @@ -11,11 +11,11 @@ resolution-markers = [ "python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "(python_full_version == '3.14.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.14.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] @@ -101,12 +101,14 @@ source = { editable = "implementations" } dependencies = [ { name = "aieng-forecasting", extra = ["agentic", "documents", "llm", "numerical"] }, { name = "beautifulsoup4" }, + { name = "xgboost" }, ] [package.metadata] requires-dist = [ { name = "aieng-forecasting", extras = ["numerical", "llm", "agentic", "documents"], editable = "aieng-forecasting" }, { name = "beautifulsoup4", specifier = ">=4.12,<5" }, + { name = "xgboost", specifier = ">=2.1,<4" }, ] [[package]] @@ -3405,6 +3407,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] +[[package]] +name = "nvidia-nccl-cu13" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/c29c302036a06d27dd732588f3fd8ee89b1f7b0087e38c668ae7be8ff7b2/nvidia_nccl_cu13-2.32.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a5bee92b2f4af218c109f8d221c3c9adcc752b94ae0ffcb0ed5abf9341a7724c", size = 305107282, upload-time = "2026-09-22T08:30:28.048Z" }, + { url = "https://files.pythonhosted.org/packages/5b/29/6b277e63c92d91f9cb4d1a3a554e148983de39d54baa652bb52c798af78e/nvidia_nccl_cu13-2.32.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1459723080ac889d73a26edfa3e04383a7928ab31ac8f0ec43b3ea9548b04ff3", size = 305100071, upload-time = "2026-09-22T08:30:53.705Z" }, +] + [[package]] name = "onnxruntime" version = "1.26.0" @@ -3737,7 +3748,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -5073,16 +5084,16 @@ resolution-markers = [ "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "cloudpickle", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "numba", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "packaging", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "pandas", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "scikit-learn", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "scipy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "slicer", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "tqdm", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "cloudpickle" }, + { name = "numba" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "slicer" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/c6/9823a7f483aa9f3179fc359c10d22da9e418b1a7a3fc99a42b705d05e82a/shap-0.49.1.tar.gz", hash = "sha256:1114ecd804fff29f50d522ce6031082fcf42fe4a32fb1b5da233b2415d784c8c", size = 4084725, upload-time = "2025-10-14T10:04:49.75Z" } wheels = [ @@ -5102,24 +5113,24 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "(python_full_version == '3.14.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.14.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] dependencies = [ - { name = "cloudpickle", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "llvmlite", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "numba", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "numpy", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "packaging", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "pandas", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "scikit-learn", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "scipy", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "slicer", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "tqdm", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "cloudpickle" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "slicer" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/0a/4a3ee4b1a3654f2a9ae038a64bb3e91a42af3da07577d69b65241f010970/shap-0.51.0.tar.gz", hash = "sha256:cfa17ff213657c9d50285aa923d79b0037a62e2ee1a31bc3eec7e196b00bdb59", size = 4108336, upload-time = "2026-03-04T09:18:19.985Z" } wheels = [ @@ -5910,6 +5921,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl", hash = "sha256:d43751d9fb4a90f9249c30431684f00c41bc874f1edccd862631a40cbc0edf08", size = 1414326, upload-time = "2026-04-13T19:45:34.659Z" }, ] +[[package]] +name = "xgboost" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/a9/295320f741c5be4be996c73ee65a2a11852028c50daa7229adb0d61c330b/xgboost-3.4.1.tar.gz", hash = "sha256:6968a4c71efdfa859df0dfcad0d99211c95c28c4ffd6aecff46efff77d18026a", size = 1231819, upload-time = "2026-08-15T08:39:21.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ea/0bdcd374241a86f1986e87e272516f0a70d841c3aa86aa9ca167fb651573/xgboost-3.4.1-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:1ea15f15f661825b6a67d87674fb9604a1abb38dd0d4c5cf0486fc85f5203e83", size = 2541584, upload-time = "2026-08-15T08:38:48.484Z" }, + { url = "https://files.pythonhosted.org/packages/f7/94/e5c37a8972ad780edc1d8459d1931356344ca133f7f99ba9cfda516b5bba/xgboost-3.4.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:a7afd7dbace0951c93aa85ffe046e54bc40893f5b51cd3e7991eb157bf9c7c7c", size = 2365501, upload-time = "2026-08-15T08:38:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/4ff1f36ca5c32c642c71c88bec1508ee98b2c3b1e9eb169e8c82de303522/xgboost-3.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7faaf99de26719c22bfae883a02bd56b5a3c2203122616e563cc72b7191b5c96", size = 57196172, upload-time = "2026-08-15T08:39:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/bd05c5c430feb347aa040fcc8870135d70b256718deee9bc7d2ca74a77ff/xgboost-3.4.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6adf2afa396da2ae8ed30295b50b99d4712eed9a6e0ce6cfe069290e4335e51f", size = 57615456, upload-time = "2026-08-15T08:39:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3c/925394671f6a1668e2a71886de66e80be694eaf37f615cec74eefaf43107/xgboost-3.4.1-py3-none-win_amd64.whl", hash = "sha256:2d30fa513673101f542fdcbd18f30c8f96c064046f798635ac08663e9969f81b", size = 48942686, upload-time = "2026-08-15T08:39:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/90/2f/f2fbe984ca095709fd246546125e78834740f347e3aa7561a22a1e928510/xgboost-3.4.1-py3-none-win_arm64.whl", hash = "sha256:e9312b30e5679d27c1d8b9ee97e092b964d960a672d5d406d9fb3cd0845c9797", size = 2094178, upload-time = "2026-08-15T08:39:19.308Z" }, +] + [[package]] name = "yarl" version = "1.24.2"