From a9465e6479f1c086f923f906e2c8c16c1fa1cc17 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:26:51 +0800 Subject: [PATCH] test: define versioned research artifact contract --- tests/test_artifact.py | 184 +++++++++++++++++++++++++++++++++++++++++ tests/test_metrics.py | 13 +++ 2 files changed, 197 insertions(+) create mode 100644 tests/test_artifact.py diff --git a/tests/test_artifact.py b/tests/test_artifact.py new file mode 100644 index 0000000..17b5505 --- /dev/null +++ b/tests/test_artifact.py @@ -0,0 +1,184 @@ +"""Stable research-run artifact contracts for downstream persistence.""" + +from __future__ import annotations + +import json + +import pandas as pd +import pytest + +from quant_engine.artifact import ( + RESEARCH_ARTIFACT_SCHEMA_VERSION, + ResearchRunArtifact, + build_research_run_artifact, +) +from quant_engine.execution import ExecutionConfig +from quant_engine.research_pipeline import FactorBacktestResult, run_factor_backtest_research + + +def _backtest_result() -> FactorBacktestResult: + dates = pd.date_range("2026-01-05", periods=4, freq="B") + scores = pd.DataFrame( + {"A": [2.0, 0.0], "B": [1.0, 3.0]}, + index=dates[:2], + ) + opens = pd.DataFrame( + {"A": [10.0, 10.0, 15.0, 15.0], "B": [20.0, 20.0, 20.0, 21.0]}, + index=dates, + ) + closes = pd.DataFrame( + {"A": [10.0, 12.0, 15.0, 15.0], "B": [20.0, 20.0, 18.0, 21.0]}, + index=dates, + ) + return run_factor_backtest_research( + scores, + opens, + closes, + top_k=1, + execution_price_field="open", + valuation_price_field="close", + initial_cash=1_000.0, + config=ExecutionConfig( + commission_bps=0, + stamp_tax_bps=0, + slippage_bps=0, + min_trade_amount=0, + ), + ) + + +def _build( + result: FactorBacktestResult, + *, + parameters: dict[str, object] | None = None, +) -> ResearchRunArtifact: + benchmark = pd.Series( + [0.0, 0.01, -0.01, 0.02], + index=result.returns.index, + name="benchmark_return", + ) + return build_research_run_artifact( + result, + run_id="run-20260105-a", + strategy_id="alpha-top1", + strategy_name="Alpha Top 1", + strategy_version="1.0.0", + engine_version="1.2.0", + code_revision="3b1ad07", + data_snapshot_id="qtdb-pro-20260108-v1", + calendar="CN-A", + timezone="Asia/Shanghai", + started_at="2026-01-08T10:00:00+08:00", + finished_at="2026-01-08T10:01:00+08:00", + parameters=parameters or {"top_k": 1, "lag_sessions": 1}, + benchmark_id="000300.SH", + benchmark_returns=benchmark, + ) + + +def test_research_artifact_projects_versioned_queryable_fact_tables() -> None: + result = _backtest_result() + + artifact = _build(result) + + assert artifact.schema_version == RESEARCH_ARTIFACT_SCHEMA_VERSION + assert artifact.run.loc[0, "run_id"] == "run-20260105-a" + assert artifact.run.loc[0, "benchmark_alignment_policy"] == "exact_session_index" + assert artifact.nav["run_id"].unique().tolist() == ["run-20260105-a"] + assert artifact.nav["pnl_pct"].tolist() == pytest.approx(result.returns.tolist()) + assert artifact.nav["benchmark_return"].tolist() == pytest.approx( + [0.0, 0.01, -0.01, 0.02] + ) + assert set(artifact.trades["side"]) == {"buy", "sell"} + assert {"security", "cash"}.issubset(set(artifact.positions["asset_type"])) + assert artifact.positions.groupby("trade_date")["weight"].sum().tolist() == pytest.approx( + [1.0, 1.0, 1.0, 1.0] + ) + assert set(artifact.attribution.columns) == { + "run_id", + "trade_date", + "asset_id", + "overnight", + "intraday", + "asset_total", + } + assert artifact.attribution_daily["residual"].abs().max() < 1e-12 + assert artifact.risk.empty + assert artifact.risk.columns.tolist() == [ + "run_id", + "trade_date", + "asset_id", + "weight", + "marginal_risk", + "component_risk", + "risk_contribution", + "covariance_snapshot_id", + ] + assert artifact.performance.loc[0, "n_trades"] == len(artifact.trades) + assert artifact.performance.loc[0, "ir"] == pytest.approx( + result.benchmark_stats(pd.Series([0.0, 0.01, -0.01, 0.02], index=result.returns.index))[ + "information_ratio" + ] + ) + assert "sortino" in artifact.performance.columns + + +def test_research_artifact_serialization_and_hashes_are_deterministic() -> None: + result = _backtest_result() + first = _build(result, parameters={"top_k": 1, "lag_sessions": 1}) + second = _build(result, parameters={"lag_sessions": 1, "top_k": 1}) + + assert first.run.loc[0, "config_hash"] == second.run.loc[0, "config_hash"] + assert first.content_sha256 == second.content_sha256 + assert first.manifest() == second.manifest() + decoded = json.loads(first.canonical_json()) + assert decoded["schema_version"] == RESEARCH_ARTIFACT_SCHEMA_VERSION + assert decoded["tables"]["nav"][0]["trade_date"] == "2026-01-05" + + leaked_copy = first.nav + leaked_copy.loc[0, "nav"] = -999.0 + assert first.nav.loc[0, "nav"] != -999.0 + assert first.content_sha256 == second.content_sha256 + + +def test_research_artifact_requires_complete_reproducibility_identity() -> None: + result = _backtest_result() + + with pytest.raises(ValueError, match="code_revision"): + build_research_run_artifact( + result, + run_id="run-1", + strategy_id="alpha-top1", + strategy_name="Alpha Top 1", + strategy_version="1.0.0", + engine_version="1.2.0", + code_revision="", + data_snapshot_id="snapshot-1", + calendar="CN-A", + timezone="Asia/Shanghai", + started_at="2026-01-08T10:00:00+08:00", + finished_at="2026-01-08T10:01:00+08:00", + parameters={}, + ) + + +def test_research_artifact_requires_benchmark_identity_and_returns_together() -> None: + result = _backtest_result() + + with pytest.raises(ValueError, match="benchmark_id and benchmark_returns"): + build_research_run_artifact( + result, + run_id="run-1", + strategy_id="alpha-top1", + strategy_name="Alpha Top 1", + strategy_version="1.0.0", + engine_version="1.2.0", + code_revision="3b1ad07", + data_snapshot_id="snapshot-1", + calendar="CN-A", + timezone="Asia/Shanghai", + started_at="2026-01-08T10:00:00+08:00", + finished_at="2026-01-08T10:01:00+08:00", + parameters={}, + benchmark_id="000300.SH", + ) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 44a296d..69f3295 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -14,6 +14,7 @@ from quant_engine.metrics import ( calmar_ratio, max_drawdown, sharpe_ratio, + sortino_ratio, summary, win_rate, ) @@ -51,6 +52,18 @@ def test_zero_volatility_metrics_return_zero() -> None: assert calmar_ratio(returns) == 0.0 +def test_sortino_ratio_uses_all_sessions_for_downside_deviation() -> None: + returns = pd.Series([0.02, -0.01, 0.0, -0.03]) + downside = np.minimum(returns.to_numpy(), 0.0) + downside_deviation = np.sqrt(np.mean(np.square(downside))) * np.sqrt( + TRADING_DAYS_PER_YEAR + ) + + assert sortino_ratio(returns) == pytest.approx( + annualized_return(returns) / downside_deviation + ) + + def test_max_drawdown_includes_loss_from_initial_capital() -> None: returns = pd.Series([-0.20, 0.0])