From c12c9ba33540d55a696ceb841ec17a2daec05be3 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:39:45 +0800 Subject: [PATCH] fix(artifact): enforce risk data lineage --- .../2026-08-21-research-artifact-contract.md | 2 ++ src/quant_engine/artifact.py | 5 +++- tests/test_artifact.py | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/handoff/2026-08-21-research-artifact-contract.md b/docs/handoff/2026-08-21-research-artifact-contract.md index 963206c..9ec6a8f 100644 --- a/docs/handoff/2026-08-21-research-artifact-contract.md +++ b/docs/handoff/2026-08-21-research-artifact-contract.md @@ -45,6 +45,8 @@ - `prepare_asset_return_snapshot` 从规范化长表行情生成不前向填充的 simple daily returns; 显式 ingestion snapshot ID、源/字段/复权口径、价格值和缺失掩码共同形成 `asset-returns-v1:`,并把同一 ID 传给 covariance 与 run artifact。 +- artifact builder fail closed:每个 `CovarianceSnapshot.data_snapshot_id` 必须与 run 级 + `data_snapshot_id` 完全一致,禁止把其他行情快照的风险分解静默发布到当前研究运行。 - shrinkage 适配器本轮不实现:scikit-learn 尚非声明依赖,未来只允许薄适配 `LedoitWolf` / `OAS`,不复制公式、不依赖环境偶然安装状态。 diff --git a/src/quant_engine/artifact.py b/src/quant_engine/artifact.py index cae5f40..2d2b8f8 100644 --- a/src/quant_engine/artifact.py +++ b/src/quant_engine/artifact.py @@ -390,6 +390,7 @@ def _risk_trade_date(value: object) -> date: def _build_risk( result: FactorBacktestResult, run_id: str, + data_snapshot_id: str, risk_snapshots: Mapping[object, CovarianceSnapshot] | None, ) -> pd.DataFrame: if risk_snapshots is None: @@ -414,6 +415,8 @@ def _build_risk( f"covariance as_of_date {snapshot.as_of_date} must not be after trade date " f"{trade_date}" ) + if snapshot.data_snapshot_id != data_snapshot_id: + raise ValueError("covariance snapshot data lineage differs from research run") normalized[trade_date] = snapshot weights_by_date = result.position_weights @@ -572,6 +575,6 @@ def build_research_run_artifact( _positions=_build_positions(result, normalized_run_id), _attribution=attribution, _attribution_daily=attribution_daily, - _risk=_build_risk(result, normalized_run_id, risk_snapshots), + _risk=_build_risk(result, normalized_run_id, normalized_snapshot, risk_snapshots), _performance=_build_performance(result, normalized_run_id, benchmark_returns), ) diff --git a/tests/test_artifact.py b/tests/test_artifact.py index 254bd51..d208d6c 100644 --- a/tests/test_artifact.py +++ b/tests/test_artifact.py @@ -164,6 +164,7 @@ def test_research_artifact_projects_annualized_risk_from_actual_positions() -> N covariance=covariance, return_frequency="1d", periods_per_year=252, + data_snapshot_id="qtdb-pro-20260108-v1", ) artifact = _build(result, risk_snapshots={trade_date: snapshot}) @@ -182,6 +183,31 @@ def test_research_artifact_projects_annualized_risk_from_actual_positions() -> N assert risk["risk_contribution"].sum() == pytest.approx(1.0) +def test_research_artifact_rejects_risk_from_a_different_data_snapshot() -> 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="data lineage differs"): + _build( + result, + risk_snapshots={ + trade_date: CovarianceSnapshot( + snapshot_id="foreign-covariance", + as_of_date="2026-01-07", + covariance=covariance, + return_frequency="1d", + periods_per_year=252, + data_snapshot_id="different-market-snapshot", + ) + }, + ) + + def test_research_artifact_rejects_future_or_misaligned_risk_snapshots() -> None: result = _backtest_result() trade_date = result.position_weights.index[-1].date() @@ -201,6 +227,7 @@ def test_research_artifact_rejects_future_or_misaligned_risk_snapshots() -> None covariance=covariance, return_frequency="1d", periods_per_year=252, + data_snapshot_id="qtdb-pro-20260108-v1", ) }, ) @@ -215,6 +242,7 @@ def test_research_artifact_rejects_future_or_misaligned_risk_snapshots() -> None covariance=covariance.loc[["B"], ["B"]], return_frequency="1d", periods_per_year=252, + data_snapshot_id="qtdb-pro-20260108-v1", ) }, )