| """Unit tests for src/policy/features.py. |
| |
| Covers: empty history, short history, with/without forecast, with/without |
| exogenous, and duck-typed DP object support. All returned values must be |
| finite; no NaN is allowed in any path. |
| """ |
| from __future__ import annotations |
|
|
| import math |
| from dataclasses import dataclass |
| from datetime import date, timedelta |
|
|
| import pytest |
|
|
| from src.policy.features import FEATURE_NAMES, build_dp_features |
|
|
|
|
| def _finite_dict(d: dict) -> bool: |
| for v in d.values(): |
| if isinstance(v, float) and not math.isfinite(v): |
| return False |
| return True |
|
|
|
|
| def _synth_history(n: int, start_price: float = 4500.0, start_date: date | None = None): |
| start_date = start_date or date(2024, 1, 1) |
| out = [] |
| price = start_price |
| for i in range(n): |
| price *= 1.0 + 0.01 * ((i % 5) - 2) |
| out.append({"date": (start_date + timedelta(days=i)).isoformat(), |
| "modal_price_rs": round(price, 2)}) |
| return out |
|
|
|
|
| def _base_dp(commodity: str = "Dry maize", mandi: str = "Nakuru", spot: float = 5000.0): |
| return { |
| "id": "mi-kenya_maize_daily_v0_1-test-20240115", |
| "event_id": "mi-kenya-maize-2024-el-nino-floods", |
| "mandi": mandi, |
| "commodity": commodity, |
| "decision_date": "2024-01-15", |
| "spot_price_rs_per_quintal": spot, |
| "realized_prices": {"0": spot, "7": spot, "14": spot, "30": spot}, |
| } |
|
|
|
|
| def test_empty_history_produces_no_nan(): |
| dp = _base_dp() |
| f = build_dp_features(dp, history=[]) |
| assert _finite_dict(f) |
| assert set(f.keys()) == set(FEATURE_NAMES) |
| |
| assert f["forecast_q50_7d"] == pytest.approx(dp["spot_price_rs_per_quintal"]) |
| |
| assert f["region_flag"] == 1 |
|
|
|
|
| def test_short_history_degrades_gracefully(): |
| dp = _base_dp() |
| |
| hist = _synth_history(3, start_price=4800.0, start_date=date(2024, 1, 12)) |
| f = build_dp_features(dp, history=hist) |
| assert _finite_dict(f) |
| |
| assert math.isfinite(f["z_score_14d"]) |
| assert math.isfinite(f["z_score_30d"]) |
|
|
|
|
| def test_full_history_with_forecast_and_exogenous(): |
| dp = _base_dp() |
| hist = _synth_history(60, start_price=4500.0, |
| start_date=date(2023, 11, 16)) |
| forecast = { |
| 7: {"q10": 4900.0, "q50": 5050.0, "q90": 5200.0}, |
| 14: {"q10": 4800.0, "q50": 5100.0, "q90": 5300.0}, |
| 30: {"q10": 4700.0, "q50": 5200.0, "q90": 5500.0}, |
| } |
| exog = { |
| "rainfall_anomaly_90d": -1.3, |
| "fx_30d_return_local": 0.02, |
| "global_price_momentum": 0.15, |
| } |
| f = build_dp_features(dp, history=hist, forecast=forecast, exogenous=exog) |
| assert _finite_dict(f) |
| assert f["forecast_q50_7d"] == pytest.approx(5050.0) |
| assert f["forecast_q90_30d"] == pytest.approx(5500.0) |
| assert f["rainfall_anomaly_90d"] == pytest.approx(-1.3) |
| assert f["fx_30d_return_local"] == pytest.approx(0.02) |
| |
| assert f["month"] == 1 |
| assert f["seasonal_flag"] == 3 |
|
|
|
|
| def test_duck_typed_dp_object_supported(): |
| @dataclass |
| class DPObj: |
| commodity: str |
| mandi: str |
| decision_date: date |
| spot_price_rs_per_quintal: float |
|
|
| dp = DPObj( |
| commodity="Tur", |
| mandi="Akola", |
| decision_date=date(2015, 7, 1), |
| spot_price_rs_per_quintal=7800.0, |
| ) |
| f = build_dp_features(dp, history=_synth_history(20)) |
| assert _finite_dict(f) |
| |
| assert f["region_flag"] == 0 |
| |
| assert f["seasonal_flag"] == 1 |
|
|
|
|
| def test_missing_exogenous_fills_zero_and_feature_set_is_stable(): |
| dp = _base_dp(commodity="Masur", mandi="Indore", spot=4300.0) |
| f_none = build_dp_features(dp, history=[], forecast=None, exogenous=None) |
| assert _finite_dict(f_none) |
| assert f_none["rainfall_anomaly_90d"] == 0.0 |
| assert f_none["fx_30d_return_local"] == 0.0 |
| assert f_none["global_price_momentum"] == 0.0 |
| |
| assert tuple(sorted(f_none.keys())) == tuple(sorted(FEATURE_NAMES)) |
| |
| f_again = build_dp_features(dp, history=[]) |
| assert f_again["commodity_hash"] == f_none["commodity_hash"] |
|
|