"""Regression test for the /api/futures/analyze 422: "Trade plan validation failed" -> PlanValidationError: "expires_at must be a valid UTC timestamp" Root cause: trade_cycle._empty_plan() (used for every NO_TRADE / rejected analysis) set expires_at=None, while plans_repo.validate_plan() requires expires_at on every persisted plan -- including NO_TRADE ones -- before its NO_TRADE early return. Any rejected analysis therefore failed plan persistence with a 422. """ from __future__ import annotations import time import pytest from trading.domain.plans_repo import PlanValidationError, validate_plan from trading.trade_cycle import _empty_plan, _iso def _base_no_trade_plan(**overrides): plan = _empty_plan( symbol="BTCUSDT", decision="NO_TRADE", reasons=["Order book unavailable"], warnings=[], components={}, external_context={}, created_at=time.time(), risk_profile="moderate", ) # _empty_plan() doesn't set the market-context fields that # _attach_context_metadata() normally adds; fill in the rest of # REQUIRED_PLAN_FIELDS so we're testing expires_at in isolation. plan.setdefault("futuresVerified", False) plan.setdefault("trading_readiness", "blocked") plan.update(overrides) return plan def test_no_trade_plan_no_longer_ships_expires_at_none(): """This is the exact shape that used to 422 in production.""" plan = _base_no_trade_plan() assert plan["expires_at"] is not None validate_plan(plan) # must not raise def test_no_trade_plan_expiry_is_canonical_utc_and_after_creation(): plan = _base_no_trade_plan() assert plan["expires_at"].endswith("Z") parsed = time.strptime(plan["expires_at"], "%Y-%m-%dT%H:%M:%SZ") created = time.strptime(plan["created_at"], "%Y-%m-%dT%H:%M:%SZ") assert parsed >= created @pytest.mark.parametrize( "expires_at", [ None, "", "not-a-timestamp", "2026-13-40T99:99:99Z", ], ) def test_missing_or_malformed_expires_at_still_rejected(expires_at): """The fix must not silently accept genuinely invalid input.""" plan = _base_no_trade_plan(expires_at=expires_at) with pytest.raises(PlanValidationError, match="expires_at"): validate_plan(plan) @pytest.mark.parametrize( "expires_at", [ "2099-01-01T00:00:00Z", # canonical form this service emits "2099-01-01T00:00:00+00:00", # explicit UTC offset "2099-01-01T00:00:00.123456Z", # fractional seconds "2099-01-01T05:30:00+05:30", # non-UTC offset, must normalize "2099-01-01T00:00:00", # naive -- treated as UTC ], ) def test_general_iso8601_variants_are_accepted(expires_at): plan = _base_no_trade_plan(expires_at=expires_at) validate_plan(plan) # must not raise for any of these def test_iso_helper_output_matches_required_canonical_format(): ts = _iso(1900000000.0) assert ts == "2030-03-17T17:46:40Z"