test: define reproducible risk snapshot contract
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,6 +15,7 @@ from quant_engine.artifact import (
|
|||||||
)
|
)
|
||||||
from quant_engine.execution import ExecutionConfig
|
from quant_engine.execution import ExecutionConfig
|
||||||
from quant_engine.research_pipeline import FactorBacktestResult, run_factor_backtest_research
|
from quant_engine.research_pipeline import FactorBacktestResult, run_factor_backtest_research
|
||||||
|
from quant_engine.risk import CovarianceSnapshot
|
||||||
|
|
||||||
|
|
||||||
def _backtest_result() -> FactorBacktestResult:
|
def _backtest_result() -> FactorBacktestResult:
|
||||||
@@ -51,6 +53,7 @@ def _build(
|
|||||||
result: FactorBacktestResult,
|
result: FactorBacktestResult,
|
||||||
*,
|
*,
|
||||||
parameters: dict[str, object] | None = None,
|
parameters: dict[str, object] | None = None,
|
||||||
|
risk_snapshots: dict[date, CovarianceSnapshot] | None = None,
|
||||||
) -> ResearchRunArtifact:
|
) -> ResearchRunArtifact:
|
||||||
benchmark = pd.Series(
|
benchmark = pd.Series(
|
||||||
[0.0, 0.01, -0.01, 0.02],
|
[0.0, 0.01, -0.01, 0.02],
|
||||||
@@ -73,6 +76,7 @@ def _build(
|
|||||||
parameters=parameters or {"top_k": 1, "lag_sessions": 1},
|
parameters=parameters or {"top_k": 1, "lag_sessions": 1},
|
||||||
benchmark_id="000300.SH",
|
benchmark_id="000300.SH",
|
||||||
benchmark_returns=benchmark,
|
benchmark_returns=benchmark,
|
||||||
|
risk_snapshots=risk_snapshots,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +136,10 @@ def test_research_artifact_projects_versioned_queryable_fact_tables() -> None:
|
|||||||
"component_risk",
|
"component_risk",
|
||||||
"risk_contribution",
|
"risk_contribution",
|
||||||
"covariance_snapshot_id",
|
"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, "n_trades"] == len(artifact.trades)
|
||||||
assert artifact.performance.loc[0, "ir"] == pytest.approx(
|
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
|
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:
|
def test_research_artifact_serialization_and_hashes_are_deterministic() -> None:
|
||||||
result = _backtest_result()
|
result = _backtest_result()
|
||||||
first = _build(result, parameters={"top_k": 1, "lag_sessions": 1})
|
first = _build(result, parameters={"top_k": 1, "lag_sessions": 1})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from quant_engine.risk import (
|
from quant_engine.risk import (
|
||||||
ComponentRiskResult,
|
ComponentRiskResult,
|
||||||
|
CovarianceSnapshot,
|
||||||
component_var,
|
component_var,
|
||||||
labeled_component_risk,
|
labeled_component_risk,
|
||||||
marginal_risk_contribution,
|
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:
|
def test_risk_contribution_sums_to_one_for_positive_portfolio_variance() -> None:
|
||||||
weights = np.array([0.5, 0.5])
|
weights = np.array([0.5, 0.5])
|
||||||
covariance = np.diag([1.0, 4.0])
|
covariance = np.diag([1.0, 4.0])
|
||||||
|
|||||||
Reference in New Issue
Block a user