From a5dc04bf7ec82ebd678d6318af37640d7973ae96 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:41 +0800 Subject: [PATCH] test: define reproducible risk snapshot contract --- tests/test_artifact.py | 78 ++++++++++++++++++++++++++++++++++++++++++ tests/test_risk.py | 49 ++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/tests/test_artifact.py b/tests/test_artifact.py index d2b2a6c..254bd51 100644 --- a/tests/test_artifact.py +++ b/tests/test_artifact.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import date import pandas as pd import pytest @@ -14,6 +15,7 @@ from quant_engine.artifact import ( ) from quant_engine.execution import ExecutionConfig from quant_engine.research_pipeline import FactorBacktestResult, run_factor_backtest_research +from quant_engine.risk import CovarianceSnapshot def _backtest_result() -> FactorBacktestResult: @@ -51,6 +53,7 @@ def _build( result: FactorBacktestResult, *, parameters: dict[str, object] | None = None, + risk_snapshots: dict[date, CovarianceSnapshot] | None = None, ) -> ResearchRunArtifact: benchmark = pd.Series( [0.0, 0.01, -0.01, 0.02], @@ -73,6 +76,7 @@ def _build( parameters=parameters or {"top_k": 1, "lag_sessions": 1}, benchmark_id="000300.SH", benchmark_returns=benchmark, + risk_snapshots=risk_snapshots, ) @@ -132,6 +136,10 @@ def test_research_artifact_projects_versioned_queryable_fact_tables() -> None: "component_risk", "risk_contribution", "covariance_snapshot_id", + "covariance_as_of_date", + "risk_measure", + "return_frequency", + "periods_per_year", ] assert artifact.performance.loc[0, "n_trades"] == len(artifact.trades) assert artifact.performance.loc[0, "ir"] == pytest.approx( @@ -142,6 +150,76 @@ def test_research_artifact_projects_versioned_queryable_fact_tables() -> None: assert "sortino" in artifact.performance.columns +def test_research_artifact_projects_annualized_risk_from_actual_positions() -> None: + result = _backtest_result() + trade_date = result.position_weights.index[-1].date() + covariance = pd.DataFrame( + [[0.0001, 0.00002], [0.00002, 0.0004]], + index=["A", "B"], + columns=["A", "B"], + ) + snapshot = CovarianceSnapshot( + snapshot_id="cov-20260107-v1", + as_of_date="2026-01-07", + covariance=covariance, + return_frequency="1d", + periods_per_year=252, + ) + + artifact = _build(result, risk_snapshots={trade_date: snapshot}) + + risk = artifact.risk.set_index("asset_id") + expected_weights = result.position_weights.loc[pd.Timestamp(trade_date)] + assert artifact.schema_version == "1.1.0" + assert risk.index.tolist() == ["A", "B"] + assert risk["weight"].tolist() == pytest.approx(expected_weights.tolist()) + assert risk["covariance_snapshot_id"].unique().tolist() == ["cov-20260107-v1"] + assert risk["covariance_as_of_date"].unique().tolist() == [date(2026, 1, 7)] + assert risk["risk_measure"].unique().tolist() == ["annualized_volatility"] + assert risk["return_frequency"].unique().tolist() == ["1d"] + assert risk["periods_per_year"].unique().tolist() == [252] + assert risk["component_risk"].sum() == pytest.approx((0.0004 * 252) ** 0.5) + assert risk["risk_contribution"].sum() == pytest.approx(1.0) + + +def test_research_artifact_rejects_future_or_misaligned_risk_snapshots() -> None: + result = _backtest_result() + trade_date = result.position_weights.index[-1].date() + covariance = pd.DataFrame( + [[0.0001, 0.0], [0.0, 0.0004]], + index=["A", "B"], + columns=["A", "B"], + ) + + with pytest.raises(ValueError, match="must not be after trade date"): + _build( + result, + risk_snapshots={ + trade_date: CovarianceSnapshot( + snapshot_id="future-covariance", + as_of_date="2026-01-09", + covariance=covariance, + return_frequency="1d", + periods_per_year=252, + ) + }, + ) + + with pytest.raises(ValueError, match="same asset labels"): + _build( + result, + risk_snapshots={ + trade_date: CovarianceSnapshot( + snapshot_id="incomplete-universe", + as_of_date="2026-01-07", + covariance=covariance.loc[["B"], ["B"]], + return_frequency="1d", + periods_per_year=252, + ) + }, + ) + + def test_research_artifact_serialization_and_hashes_are_deterministic() -> None: result = _backtest_result() first = _build(result, parameters={"top_k": 1, "lag_sessions": 1}) diff --git a/tests/test_risk.py b/tests/test_risk.py index 2de4cab..e69272b 100644 --- a/tests/test_risk.py +++ b/tests/test_risk.py @@ -8,6 +8,7 @@ import pytest from quant_engine.risk import ( ComponentRiskResult, + CovarianceSnapshot, component_var, labeled_component_risk, marginal_risk_contribution, @@ -15,6 +16,54 @@ from quant_engine.risk import ( ) +def test_covariance_snapshot_is_validated_and_immutable_by_interface() -> None: + covariance = pd.DataFrame( + [[0.04, 0.01], [0.01, 0.09]], + index=["A", "B"], + columns=["A", "B"], + ) + snapshot = CovarianceSnapshot( + snapshot_id="cov-20260107-v1", + as_of_date="2026-01-07", + covariance=covariance, + return_frequency="1d", + periods_per_year=252, + ) + + covariance.loc["A", "A"] = 999.0 + leaked_copy = snapshot.covariance + leaked_copy.loc["B", "B"] = 999.0 + + assert snapshot.as_of_date == pd.Timestamp("2026-01-07").date() + assert snapshot.covariance.loc["A", "A"] == pytest.approx(0.04) + assert snapshot.covariance.loc["B", "B"] == pytest.approx(0.09) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"snapshot_id": ""}, "snapshot_id"), + ({"return_frequency": ""}, "return_frequency"), + ({"periods_per_year": 0}, "periods_per_year"), + ], +) +def test_covariance_snapshot_rejects_incomplete_identity( + kwargs: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "snapshot_id": "cov-20260107-v1", + "as_of_date": "2026-01-07", + "covariance": pd.DataFrame([[0.04]], index=["A"], columns=["A"]), + "return_frequency": "1d", + "periods_per_year": 252, + } + values.update(kwargs) + + with pytest.raises((TypeError, ValueError), match=message): + CovarianceSnapshot(**values) + + def test_risk_contribution_sums_to_one_for_positive_portfolio_variance() -> None: weights = np.array([0.5, 0.5]) covariance = np.diag([1.0, 4.0])