# Feature: trading-feedback-engine, Property 2: Report serialization round-trip """Property-based tests for report serialization round-trip. Feature: trading-feedback-engine Tests the report serialization round-trip property from the design specification: for any valid ReportData object (with valid P&L, recommendation accuracy, position performance, risk metrics, and model quality sections), serializing to JSON and then deserializing back SHALL produce a ReportData object equivalent to the original. All datetime fields in the serialized JSON SHALL be in ISO 8601 format. """ from __future__ import annotations import json import re from datetime import date, datetime, timezone from hypothesis import given, settings from hypothesis import strategies as st from services.reporting.models import ( ModelQualitySection, ModelQualityWindow, PLSection, PositionDetail, PositionPerformanceSection, RecommendationAccuracySection, ReportData, ReportType, RiskMetricsSection, ValidationStatus, ValidationWarning, ) # --------------------------------------------------------------------------- # Property 2: Report Serialization Round-Trip # Validates: Requirements 8.1, 8.2, 8.3, 8.4 # --------------------------------------------------------------------------- # ISO 8601 datetime pattern (covers both datetime and date formats) _ISO8601_DATETIME_RE = re.compile( r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}" # YYYY-MM-DDTHH:MM:SS r"(?:\.\d+)?" # optional fractional seconds r"(?:Z|[+-]\d{2}:\d{2})?$" # optional timezone ) _ISO8601_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") # --------------------------------------------------------------------------- # Hypothesis strategies for each model # --------------------------------------------------------------------------- _finite_float = st.floats(allow_nan=False, allow_infinity=False) _non_negative_finite_float = st.floats( min_value=0.0, allow_nan=False, allow_infinity=False, ) _rate_float = st.floats( min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False, ) _optional_finite_float = st.one_of(st.none(), _finite_float) _validation_warning_strategy = st.builds( ValidationWarning, field_name=st.text(min_size=1, max_size=50), computed_value=_finite_float, snapshot_value=_finite_float, pct_difference=_non_negative_finite_float, ) _pnl_section_strategy = st.builds( PLSection, realized_pnl=_finite_float, unrealized_pnl=_finite_float, daily_return=_finite_float, cumulative_return=_finite_float, win_count=st.integers(min_value=0, max_value=10000), loss_count=st.integers(min_value=0, max_value=10000), win_rate=_rate_float, profit_factor=_non_negative_finite_float, sharpe_ratio=_finite_float, summary=st.text(max_size=200), validation_warnings=st.lists( _validation_warning_strategy, min_size=0, max_size=3, ), ) _recommendation_accuracy_strategy = st.builds( RecommendationAccuracySection, total_evaluated=st.integers(min_value=0, max_value=10000), act_count=st.integers(min_value=0, max_value=10000), skip_count=st.integers(min_value=0, max_value=10000), acted_win_rate=_rate_float, avg_confidence_acted=_rate_float, avg_confidence_skipped=_rate_float, summary=st.text(max_size=200), validation_warnings=st.lists( _validation_warning_strategy, min_size=0, max_size=3, ), ) _position_detail_strategy = st.builds( PositionDetail, ticker=st.text(min_size=1, max_size=10), entry_price=_finite_float, current_or_exit_price=_finite_float, pnl=_finite_float, pnl_pct=_finite_float, hold_duration_hours=_non_negative_finite_float, status=st.sampled_from(["open", "closed"]), ) _position_performance_strategy = st.builds( PositionPerformanceSection, positions=st.lists(_position_detail_strategy, min_size=0, max_size=5), summary=st.text(max_size=200), ) _risk_metrics_strategy = st.builds( RiskMetricsSection, current_risk_tier=st.sampled_from(["low", "moderate", "high", "critical"]), portfolio_heat=_non_negative_finite_float, max_drawdown=_non_negative_finite_float, current_drawdown_pct=_non_negative_finite_float, reserve_pool_balance=_non_negative_finite_float, circuit_breaker_event_count=st.integers(min_value=0, max_value=100), summary=st.text(max_size=200), ) _model_quality_window_strategy = st.builds( ModelQualityWindow, lookback=st.sampled_from(["7d", "30d", "90d"]), win_rate=_optional_finite_float, directional_accuracy=_optional_finite_float, information_coefficient=_optional_finite_float, calibration_error=_optional_finite_float, brier_score=_optional_finite_float, ) _model_quality_strategy = st.builds( ModelQualitySection, windows=st.lists(_model_quality_window_strategy, min_size=0, max_size=3), summary=st.text(max_size=200), validation_warnings=st.lists( _validation_warning_strategy, min_size=0, max_size=3, ), ) # Use timezone-aware datetimes for generated_at _aware_datetime_strategy = st.datetimes( min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31), timezones=st.just(timezone.utc), ) _date_strategy = st.dates( min_value=date(2020, 1, 1), max_value=date(2030, 12, 31), ) _report_data_strategy = st.builds( ReportData, pnl=_pnl_section_strategy, recommendation_accuracy=_recommendation_accuracy_strategy, position_performance=_position_performance_strategy, risk_metrics=_risk_metrics_strategy, model_quality=_model_quality_strategy, executive_summary=st.text(max_size=300), validation_status=st.sampled_from(list(ValidationStatus)), generated_at=_aware_datetime_strategy, period_start=_date_strategy, period_end=_date_strategy, report_type=st.sampled_from(list(ReportType)), ) # --------------------------------------------------------------------------- # Helper: recursively find all datetime-like string values in parsed JSON # --------------------------------------------------------------------------- _DATETIME_FIELD_NAMES = {"generated_at"} _DATE_FIELD_NAMES = {"period_start", "period_end"} def _collect_datetime_strings( obj: object, key: str | None = None, ) -> list[tuple[str, str]]: """Walk parsed JSON and collect (field_name, value) for datetime fields.""" results: list[tuple[str, str]] = [] if isinstance(obj, dict): for k, v in obj.items(): results.extend(_collect_datetime_strings(v, k)) elif isinstance(obj, list): for item in obj: results.extend(_collect_datetime_strings(item, key)) elif isinstance(obj, str) and key is not None: if key in _DATETIME_FIELD_NAMES or key in _DATE_FIELD_NAMES: results.append((key, obj)) return results # --------------------------------------------------------------------------- # Property tests # --------------------------------------------------------------------------- @given(report=_report_data_strategy) @settings(max_examples=100) def test_report_serialization_round_trip(report: ReportData) -> None: """**Validates: Requirements 8.1, 8.2, 8.3, 8.4** For any valid ReportData object, serializing to JSON and then deserializing back SHALL produce a ReportData object equivalent to the original. """ json_str = report.model_dump_json() restored = ReportData.model_validate_json(json_str) assert restored == report, ( f"Round-trip failed: deserialized report differs from original.\n" f" report_type: {report.report_type}\n" f" period: {report.period_start} → {report.period_end}\n" f" generated_at: {report.generated_at}" ) @given(report=_report_data_strategy) @settings(max_examples=100) def test_report_datetime_fields_iso8601(report: ReportData) -> None: """**Validates: Requirements 8.4** All datetime fields in the serialized JSON SHALL be in ISO 8601 format. """ json_str = report.model_dump_json() parsed = json.loads(json_str) dt_fields = _collect_datetime_strings(parsed) for field_name, value in dt_fields: if field_name in _DATETIME_FIELD_NAMES: assert _ISO8601_DATETIME_RE.match(value), ( f"Datetime field '{field_name}' is not ISO 8601: {value!r}" ) elif field_name in _DATE_FIELD_NAMES: assert _ISO8601_DATE_RE.match(value), ( f"Date field '{field_name}' is not ISO 8601: {value!r}" )