market-intelligence / tests /test_features.py
jtlevine's picture
Phase 2: Decision-Focused Learning (DFL) hold/sell policy
21e3777
"""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) # gentle wiggle
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)
# With no history and no forecast, forecast tail should equal spot.
assert f["forecast_q50_7d"] == pytest.approx(dp["spot_price_rs_per_quintal"])
# Kenya mandi -> region_flag = 1.
assert f["region_flag"] == 1
def test_short_history_degrades_gracefully():
dp = _base_dp()
# Only 3 prior days -- not enough for a 30-day window.
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)
# 14/30 day z-scores should still be finite (stdev could be 0 -> 0).
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)
# Jan 15 -> month=1, seasonal_flag=3 (maize short-rains harvest window).
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)
# Non-Kenya mandi -> region_flag = 0.
assert f["region_flag"] == 0
# July in a kharif pulse -> planting (1).
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
# Key set must match FEATURE_NAMES exactly, in every path.
assert tuple(sorted(f_none.keys())) == tuple(sorted(FEATURE_NAMES))
# Commodity hash stable across calls.
f_again = build_dp_features(dp, history=[])
assert f_again["commodity_hash"] == f_none["commodity_hash"]