fix(artifact): enforce risk data lineage
This commit is contained in:
@@ -45,6 +45,8 @@
|
||||
- `prepare_asset_return_snapshot` 从规范化长表行情生成不前向填充的 simple daily returns;
|
||||
显式 ingestion snapshot ID、源/字段/复权口径、价格值和缺失掩码共同形成
|
||||
`asset-returns-v1:<sha256>`,并把同一 ID 传给 covariance 与 run artifact。
|
||||
- artifact builder fail closed:每个 `CovarianceSnapshot.data_snapshot_id` 必须与 run 级
|
||||
`data_snapshot_id` 完全一致,禁止把其他行情快照的风险分解静默发布到当前研究运行。
|
||||
- shrinkage 适配器本轮不实现:scikit-learn 尚非声明依赖,未来只允许薄适配
|
||||
`LedoitWolf` / `OAS`,不复制公式、不依赖环境偶然安装状态。
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user