feat(quant): consolidate research artifact contract
CI / lite (pull_request) Successful in 13s

This commit is contained in:
ao gong
2026-08-26 20:54:02 +08:00
parent 8a30bf5ebc
commit 3374884f57
25 changed files with 5014 additions and 200 deletions
+309
View File
@@ -0,0 +1,309 @@
"""Stable research-run artifact contracts for downstream persistence."""
from __future__ import annotations
import json
from datetime import date
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
from quant_engine.risk import CovarianceSnapshot
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,
risk_snapshots: dict[date, CovarianceSnapshot] | 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,
risk_snapshots=risk_snapshots,
)
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 artifact.signals.columns.tolist() == [
"run_id",
"signal_date",
"execution_date",
"asset_id",
"factor_score",
"target_weight",
]
first_signal = artifact.signals[
artifact.signals["signal_date"] == result.factor_scores.index[0].date()
]
assert first_signal.set_index("asset_id").loc["A", "factor_score"] == 2.0
assert first_signal.set_index("asset_id").loc["A", "target_weight"] == 1.0
assert first_signal["execution_date"].unique().tolist() == [
result.schedule.signal_to_execution.iloc[0].date()
]
assert set(artifact.trades["side"]) == {"buy", "sell"}
assert artifact.trades["trade_id"].is_unique
assert artifact.trades["trade_id"].str.startswith("run-20260105-a:").all()
assert artifact.trades["signal_id"].str.startswith("run-20260105-a:signal:").all()
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",
"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(
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_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,
data_snapshot_id="qtdb-pro-20260108-v1",
)
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_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()
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,
data_snapshot_id="qtdb-pro-20260108-v1",
)
},
)
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,
data_snapshot_id="qtdb-pro-20260108-v1",
)
},
)
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",
)
+118
View File
@@ -0,0 +1,118 @@
"""Post-execution return attribution contracts."""
from __future__ import annotations
import pandas as pd
import pytest
from quant_engine.attribution import DailyReturnAttribution
from quant_engine.execution import ExecutionConfig
from quant_engine.research_pipeline import run_factor_backtest_research
def _zero_cost_config() -> ExecutionConfig:
return ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
def test_daily_attribution_closes_across_rebalance_and_holding_days() -> None:
"""开盘换仓时,隔夜和日内贡献必须来自实际换仓前后持仓。"""
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,
)
result = run_factor_backtest_research(
scores,
opens,
closes,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
initial_cash=1_000.0,
config=_zero_cost_config(),
)
attribution = result.return_attribution()
assert isinstance(attribution, DailyReturnAttribution)
assert attribution.overnight.loc[dates[2], "A"] == pytest.approx(0.25)
assert attribution.intraday.loc[dates[2], "B"] == pytest.approx(-0.125)
assert attribution.asset_contributions.loc[dates[3], "B"] == pytest.approx(1 / 6)
pd.testing.assert_series_equal(
attribution.total_return,
result.returns.rename("total_return"),
)
pd.testing.assert_series_equal(
attribution.explained_return + attribution.residual,
attribution.total_return,
check_names=False,
)
assert attribution.residual.abs().max() < 1e-12
def test_daily_attribution_reports_execution_cost_separately() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
scores = pd.DataFrame({"A": [1.0]}, index=dates[:1])
prices = pd.DataFrame({"A": [10.0, 10.0, 10.0]}, index=dates)
config = ExecutionConfig(
commission_bps=10,
stamp_tax_bps=0,
slippage_bps=10,
min_trade_amount=0,
)
result = run_factor_backtest_research(
scores,
prices,
prices,
top_k=1,
gross_exposure=0.5,
execution_price_field="open",
valuation_price_field="close",
initial_cash=1_000.0,
config=config,
)
attribution = result.return_attribution()
execution = result.execution.daily_executions[1].executions[0]
assert attribution.asset_contributions.loc[dates[1], "A"] == 0.0
assert attribution.transaction_cost.loc[dates[1]] == pytest.approx(
-execution.total_cost / 1_000.0
)
assert attribution.total_return.loc[dates[1]] == pytest.approx(
attribution.transaction_cost.loc[dates[1]]
)
assert attribution.residual.loc[dates[1]] == pytest.approx(0.0, abs=1e-12)
def test_return_attribution_is_empty_for_empty_research_result() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
scores = pd.DataFrame(columns=["A"], index=pd.DatetimeIndex([]), dtype=float)
prices = pd.DataFrame({"A": [10.0, 10.0, 10.0]}, index=dates)
result = run_factor_backtest_research(
scores,
prices,
prices,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
)
attribution = result.return_attribution()
assert attribution.overnight.empty
assert attribution.intraday.empty
assert attribution.total_return.empty
+209
View File
@@ -0,0 +1,209 @@
"""Backtest contract tests for weights, NAV, rebalancing, and benchmarks."""
from __future__ import annotations
import pandas as pd
import pytest
from quant_engine.backtest import (
BacktestResult,
compare_to_benchmark,
compute_nav_from_weights,
compute_returns_from_nav,
rebalance_periodic,
run_weight_backtest,
weights_to_long_short,
)
def test_compute_nav_from_weights_forward_fills_rebalance_weights() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
weights = pd.DataFrame({"A": [0.5], "B": [0.5]}, index=dates[:1])
returns = pd.DataFrame({"A": [0.10, 0.00, -0.10], "B": [0.00, 0.10, 0.00]}, index=dates)
nav = compute_nav_from_weights(weights, returns, initial_capital=100.0)
expected = pd.Series([105.0, 110.25, 104.7375], index=dates)
pd.testing.assert_series_equal(nav, expected)
def test_compute_nav_stays_in_cash_before_first_rebalance() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
weights = pd.DataFrame({"A": [1.0]}, index=dates[1:2])
returns = pd.DataFrame({"A": [0.50, 0.10, 0.10]}, index=dates)
nav = compute_nav_from_weights(weights, returns)
pd.testing.assert_series_equal(nav, pd.Series([1.0, 1.1, 1.21], index=dates))
def test_compute_nav_ignores_weight_columns_without_returns() -> None:
dates = pd.date_range("2026-01-05", periods=2, freq="B")
weights = pd.DataFrame({"A": [0.5], "MISSING": [0.5]}, index=dates[:1])
returns = pd.DataFrame({"A": [0.10, 0.10]}, index=dates)
nav = compute_nav_from_weights(weights, returns)
pd.testing.assert_series_equal(nav, pd.Series([1.05, 1.1025], index=dates))
def test_compute_nav_charges_configured_turnover_cost() -> None:
dates = pd.date_range("2026-01-05", periods=2, freq="B")
weights = pd.DataFrame({"A": [1.0]}, index=dates[:1])
returns = pd.DataFrame({"A": [0.0, 0.0]}, index=dates)
nav = compute_nav_from_weights(weights, returns, tc_rate=0.01)
pd.testing.assert_series_equal(nav, pd.Series([0.995, 0.995], index=dates))
def test_compute_returns_from_nav_preserves_index_and_sets_initial_zero() -> None:
nav = pd.Series([100.0, 110.0, 99.0], index=pd.date_range("2026-01-05", periods=3))
result = compute_returns_from_nav(nav)
pd.testing.assert_series_equal(result, pd.Series([0.0, 0.1, -0.1], index=nav.index))
def test_rebalance_periodic_maps_weekend_to_previous_trading_day() -> None:
dates = pd.date_range("2026-01-05", periods=5, freq="B")
target = pd.Series({"A": 0.6, "B": 0.4})
result = rebalance_periodic(target, [pd.Timestamp("2026-01-10")], dates)
assert result.loc[pd.Timestamp("2026-01-08")].sum() == 0.0
pd.testing.assert_series_equal(
result.loc[pd.Timestamp("2026-01-09")], target, check_names=False
)
def test_rebalance_periodic_accepts_empty_trading_calendar() -> None:
target = pd.Series({"A": 1.0})
result = rebalance_periodic(
target,
[pd.Timestamp("2026-01-05")],
pd.DatetimeIndex([]),
)
assert result.empty
assert result.columns.tolist() == ["A"]
def test_weights_to_long_short_allocates_each_leg() -> None:
result = weights_to_long_short(["A", "B"], ["C"], long_weight=0.6, short_weight=0.4)
assert result["A"] == pytest.approx(0.3)
assert result["B"] == pytest.approx(0.3)
assert result["C"] == pytest.approx(-0.4)
assert result.sum() == pytest.approx(0.2)
def test_weights_to_long_short_keeps_explicit_universe() -> None:
result = weights_to_long_short(["A"], [], all_tickers=["A", "B"])
pd.testing.assert_series_equal(result, pd.Series({"A": 0.5, "B": 0.0}))
def test_compare_to_benchmark_returns_report_table() -> None:
dates = pd.date_range("2026-01-05", periods=4, freq="B")
strategy = pd.Series([1.0, 1.1, 1.0, 1.2], index=dates)
benchmark = pd.Series([1.0, 1.0, 1.05, 1.1], index=dates)
result = compare_to_benchmark(strategy, benchmark)
assert result.columns.tolist() == ["策略", "基准"]
assert result.loc["n_days", "策略"] == 4
assert result.loc["累计收益", "策略"] == pytest.approx(0.2)
assert result.loc["累计收益", "基准"] == pytest.approx(0.1)
def test_compare_to_benchmark_rejects_non_overlapping_dates() -> None:
strategy = pd.Series([1.0], index=[pd.Timestamp("2026-01-05")])
benchmark = pd.Series([1.0], index=[pd.Timestamp("2026-02-05")])
with pytest.raises(ValueError, match="overlapping dates"):
compare_to_benchmark(strategy, benchmark)
# ── 统一回测结果门面 ──────────────────────────────────────
def test_run_weight_backtest_returns_nav_returns_and_input_snapshot() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
weights = pd.DataFrame({"A": [1.0]}, index=dates[:1])
stock_returns = pd.DataFrame({"A": [0.10, -0.10, 0.20]}, index=dates)
result = run_weight_backtest(weights, stock_returns, initial_capital=100.0)
assert isinstance(result, BacktestResult)
pd.testing.assert_series_equal(
result.nav,
pd.Series([110.0, 99.0, 118.8], index=dates),
)
pd.testing.assert_series_equal(
result.returns,
pd.Series([0.0, -0.1, 0.2], index=dates),
)
pd.testing.assert_frame_equal(result.weights, weights)
def test_backtest_result_stats_reuses_standard_metrics_contract() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
result = run_weight_backtest(
pd.DataFrame({"A": [1.0]}, index=dates[:1]),
pd.DataFrame({"A": [0.10, -0.10, 0.20]}, index=dates),
)
stats = result.stats(rf=0.02)
assert stats["n_days"] == 3
assert stats["ann_return"] == pytest.approx(
(1.0 * 0.9 * 1.2) ** (252 / 3) - 1.0
)
assert "sharpe" in stats
assert stats["drawback"] == stats["max_drawdown"]
def test_backtest_result_builds_benchmark_report() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
benchmark = pd.Series([1.0, 1.05, 1.10], index=dates, name="benchmark")
result = run_weight_backtest(
pd.DataFrame({"A": [1.0]}, index=dates[:1]),
pd.DataFrame({"A": [0.10, -0.10, 0.20]}, index=dates),
benchmark_nav=benchmark,
)
report = result.benchmark_report()
assert report.columns.tolist() == ["策略", "基准"]
assert report.loc["累计收益", "基准"] == pytest.approx(0.10)
def test_backtest_result_requires_benchmark_for_comparison() -> None:
dates = pd.date_range("2026-01-05", periods=2, freq="B")
result = run_weight_backtest(
pd.DataFrame({"A": [1.0]}, index=dates[:1]),
pd.DataFrame({"A": [0.0, 0.0]}, index=dates),
)
with pytest.raises(ValueError, match="benchmark_nav"):
result.benchmark_report()
def test_backtest_result_isolated_from_mutated_caller_inputs() -> None:
dates = pd.date_range("2026-01-05", periods=2, freq="B")
weights = pd.DataFrame({"A": [1.0]}, index=dates[:1])
benchmark = pd.Series([1.0, 1.1], index=dates)
result = run_weight_backtest(
weights,
pd.DataFrame({"A": [0.0, 0.0]}, index=dates),
benchmark_nav=benchmark,
)
weights.iloc[0, 0] = 0.0
benchmark.iloc[1] = 99.0
assert result.weights.iloc[0, 0] == 1.0
assert result.benchmark_nav is not None
assert result.benchmark_nav.iloc[1] == 1.1
+184
View File
@@ -7,10 +7,12 @@ import pandas as pd
import pytest
from quant_engine.data_adapter import (
AssetReturnSnapshot,
add_vwap_proxy,
apply_adj_factor,
load_qtdb_daily,
long_to_wide,
prepare_asset_return_snapshot,
prepare_execution_inputs,
prepare_stock_series,
rename_tushare_columns,
@@ -266,6 +268,17 @@ def test_prepare_execution_inputs_basic(tushare_long: pd.DataFrame) -> None:
assert volumes.iloc[0, 0] == pytest.approx(1000.0)
def test_prepare_execution_inputs_can_select_next_session_open_price(
tushare_long: pd.DataFrame,
) -> None:
"""显式 price_col=open 时应生成开盘执行价矩阵。"""
renamed = rename_tushare_columns(tushare_long)
prices, _volumes = prepare_execution_inputs(renamed, price_col="open")
assert prices.iloc[0, 0] == pytest.approx(10.0)
def test_prepare_execution_inputs_no_volume() -> None:
"""无 volume 列 → volumes 全 1.0。"""
df = pd.DataFrame(
@@ -286,6 +299,177 @@ def test_prepare_execution_inputs_missing_close_raises() -> None:
prepare_execution_inputs(df)
def test_prepare_execution_inputs_missing_selected_price_raises() -> None:
df = pd.DataFrame({"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]})
with pytest.raises(ValueError, match="缺 open"):
prepare_execution_inputs(df, price_col="open")
# ── prepare_asset_return_snapshot ────────────────────────────
def _daily_prices() -> pd.DataFrame:
return pd.DataFrame(
{
"stock_code": ["B", "A", "B", "A", "B", "A"],
"trade_date": [
"2024-01-02",
"2024-01-01",
"2024-01-01",
"2024-01-03",
"2024-01-03",
"2024-01-02",
],
"close": [18.0, 10.0, 20.0, 12.1, 19.8, 11.0],
}
)
def test_prepare_asset_return_snapshot_is_stable_and_immutable_by_interface() -> None:
snapshot = prepare_asset_return_snapshot(
_daily_prices(),
source="qtdb_pro.hq_daily",
source_snapshot_id="hq-daily:2024-01-03:v1",
adjustment="qfq",
)
assert isinstance(snapshot, AssetReturnSnapshot)
assert snapshot.data_snapshot_id.startswith("asset-returns-v1:")
assert snapshot.source == "qtdb_pro.hq_daily"
assert snapshot.source_snapshot_id == "hq-daily:2024-01-03:v1"
assert snapshot.price_field == "close"
assert snapshot.adjustment == "qfq"
assert snapshot.return_method == "simple"
assert snapshot.start_date.isoformat() == "2024-01-01"
assert snapshot.end_date.isoformat() == "2024-01-03"
assert snapshot.sessions == 3
assert snapshot.assets == ("A", "B")
expected = pd.DataFrame(
{
"A": [np.nan, 0.1, 0.1],
"B": [np.nan, -0.1, 0.1],
},
index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]),
)
expected.index.name = "trade_date"
expected.columns.name = "stock_code"
pd.testing.assert_frame_equal(snapshot.returns, expected)
exposed = snapshot.returns
exposed.iloc[1, 0] = 999.0
assert snapshot.returns.iloc[1, 0] == pytest.approx(0.1)
def test_asset_return_snapshot_identity_is_order_independent_and_content_addressed() -> None:
kwargs = {
"source": "qtdb_pro.hq_daily",
"source_snapshot_id": "hq-daily:2024-01-03:v1",
"adjustment": "none",
}
baseline = prepare_asset_return_snapshot(_daily_prices(), **kwargs)
shuffled = prepare_asset_return_snapshot(
_daily_prices().sample(frac=1.0, random_state=7),
**kwargs,
)
changed_prices = _daily_prices().copy()
changed_prices.loc[changed_prices["close"] == 12.1, "close"] = 12.2
changed_content = prepare_asset_return_snapshot(changed_prices, **kwargs)
changed_source = prepare_asset_return_snapshot(
_daily_prices(),
source="qtdb_pro.hq_daily",
source_snapshot_id="hq-daily:2024-01-03:v2",
adjustment="none",
)
assert shuffled.data_snapshot_id == baseline.data_snapshot_id
assert changed_content.data_snapshot_id != baseline.data_snapshot_id
assert changed_source.data_snapshot_id != baseline.data_snapshot_id
def test_prepare_asset_return_snapshot_does_not_fill_missing_prices() -> None:
prices = _daily_prices()
prices.loc[
(prices["stock_code"] == "A") & (prices["trade_date"] == "2024-01-02"),
"close",
] = np.nan
snapshot = prepare_asset_return_snapshot(
prices,
source="qtdb_pro.hq_daily",
source_snapshot_id="hq-daily:missing-middle",
)
assert pd.isna(snapshot.returns.loc[pd.Timestamp("2024-01-02"), "A"])
assert pd.isna(snapshot.returns.loc[pd.Timestamp("2024-01-03"), "A"])
def test_prepare_asset_return_snapshot_rejects_duplicate_sessions() -> None:
duplicate = pd.concat([_daily_prices(), _daily_prices().iloc[[0]]], ignore_index=True)
with pytest.raises(ValueError, match="duplicate"):
prepare_asset_return_snapshot(
duplicate,
source="qtdb_pro.hq_daily",
source_snapshot_id="hq-daily:duplicate",
)
@pytest.mark.parametrize("invalid_price", [0.0, -1.0, np.inf])
def test_prepare_asset_return_snapshot_rejects_invalid_prices(invalid_price: float) -> None:
prices = _daily_prices()
prices.loc[0, "close"] = invalid_price
with pytest.raises(ValueError, match="positive finite"):
prepare_asset_return_snapshot(
prices,
source="qtdb_pro.hq_daily",
source_snapshot_id="hq-daily:invalid-price",
)
@pytest.mark.parametrize(
("source", "source_snapshot_id", "adjustment"),
[
("", "source-1", "none"),
("qtdb_pro.hq_daily", "", "none"),
("qtdb_pro.hq_daily", "source-1", ""),
],
)
def test_prepare_asset_return_snapshot_requires_explicit_identity_semantics(
source: str,
source_snapshot_id: str,
adjustment: str,
) -> None:
with pytest.raises(ValueError, match="must be non-empty"):
prepare_asset_return_snapshot(
_daily_prices(),
source=source,
source_snapshot_id=source_snapshot_id,
adjustment=adjustment,
)
def test_asset_return_snapshot_feeds_reproducible_covariance_lineage() -> None:
from quant_engine.risk import estimate_covariance_snapshot
market_snapshot = prepare_asset_return_snapshot(
_daily_prices(),
source="qtdb_pro.hq_daily",
source_snapshot_id="hq-daily:2024-01-03:v1",
)
covariance_snapshot = estimate_covariance_snapshot(
market_snapshot.returns,
as_of_date=market_snapshot.end_date,
lookback_sessions=3,
min_observations=2,
data_snapshot_id=market_snapshot.data_snapshot_id,
)
assert covariance_snapshot.data_snapshot_id == market_snapshot.data_snapshot_id
assert covariance_snapshot.snapshot_id.startswith("sample-cov-v1:")
# ── 端到端:长表 → 适配 → alpha158 + execution ──────────────
+307 -14
View File
@@ -11,6 +11,7 @@ import pytest
from quant_engine.execution import (
ExecutionConfig,
ExecutionResult,
ExecutionSimulationResult,
apply_bid_ask_spread,
apply_volume_constraint,
check_price_limit,
@@ -19,7 +20,9 @@ from quant_engine.execution import (
compute_realized_pnl,
run_end_to_end_poc,
simulate_execution,
simulate_daily_ledger_with_audit,
simulate_multi_day,
simulate_multi_day_with_audit,
simulate_with_daily_data,
total_costs,
total_turnover,
@@ -327,24 +330,26 @@ def test_simulate_multi_day_length_mismatch_raises():
def test_simulate_multi_day_first_day_value_equals_initial():
"""第一天 portfolio_value = initial_cash(无持仓)。"""
"""零成本下第一天日末 NAV 等于初始资金。"""
signals = [("d1", {"A": 1.0})]
prices = [("d1", {"A": 10.0})]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
# 第一天 NAV = 1_000_000(无持仓),第二天才是调仓后
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
positions = simulate_multi_day(signals, prices, 1_000_000.0, config)
assert positions[0].portfolio_value == 1_000_000.0
assert positions[0].holdings == {"A": 100_000.0}
def test_simulate_multi_day_holdings_evolution():
"""调仓后 holdings 演化。
注意:positions[i] 是第 i 天 rebalance 之前的快照。
所以要看 d2 rebalance 后的 holdings,需要看 positions[2](d3 的快照)。
"""
"""日末快照应反映当天调仓后的 holdings。"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 1.0, "B": 0.0}), # 全仓 A
("d3", {"A": 1.0, "B": 0.0}), # 第三天的快照才能看到 d2 rebalance 后的 holdings
("d3", {"A": 1.0, "B": 0.0}),
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
@@ -352,9 +357,288 @@ def test_simulate_multi_day_holdings_evolution():
("d3", {"A": 12.0, "B": 22.0}),
]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
# d3 的 PRE-trade snapshot 应该只有 A(B 在 d2 被平仓)
assert "B" not in positions[2].holdings
assert "A" in positions[2].holdings
assert "B" not in positions[1].holdings
assert "A" in positions[1].holdings
def test_simulate_multi_day_with_audit_rebalances_target_weights_by_delta():
"""相同目标权重不应在每个交易日重复买入。"""
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
targets = [(date, {"A": 1.0}) for date in ("d1", "d2", "d3")]
prices = [(date, {"A": 10.0}) for date in ("d1", "d2", "d3")]
result = simulate_multi_day_with_audit(targets, prices, 1_000.0, config)
assert isinstance(result, ExecutionSimulationResult)
assert [len(day.executions) for day in result.daily_executions] == [1, 0, 0]
assert result.total_turnover == pytest.approx(1_000.0)
assert [position.cash for position in result.positions] == pytest.approx([0.0, 0.0, 0.0])
assert [position.holdings["A"] for position in result.positions] == pytest.approx(
[100.0, 100.0, 100.0]
)
assert [position.portfolio_value for position in result.positions] == pytest.approx(
[1_000.0, 1_000.0, 1_000.0]
)
def test_simulate_multi_day_with_audit_records_costs_without_replay():
"""成交成本与日末 NAV 应来自同一次状态推进。"""
targets = [("d1", {"A": 1.0}), ("d2", {"A": 1.0})]
prices = [("d1", {"A": 10.0}), ("d2", {"A": 10.0})]
result = simulate_multi_day_with_audit(targets, prices, 1_000.0)
first_day = result.daily_executions[0]
assert first_day.nav_before == pytest.approx(1_000.0)
assert first_day.nav_after == pytest.approx(result.positions[0].portfolio_value)
assert result.total_costs == pytest.approx(sum(r.total_cost for r in first_day.executions))
assert result.final_portfolio_value == pytest.approx(1_000.0 - result.total_costs)
assert result.daily_executions[1].executions == ()
def test_simulate_multi_day_with_audit_never_spends_more_cash_than_available():
"""满仓目标应按可用现金部分成交,不能用负现金隐式加杠杆。"""
result = simulate_multi_day_with_audit(
[("d1", {"A": 1.0})],
[("d1", {"A": 10.0})],
1_000.0,
)
execution = result.daily_executions[0].executions[0]
assert result.positions[0].cash >= -1e-9
assert 0 < execution.partial_fill_pct < 1
assert execution.blocked_reason == "insufficient_cash_partial_fill"
assert result.final_portfolio_value == pytest.approx(1_000.0 - result.total_costs)
@pytest.mark.parametrize(
"targets",
[
{"A": -0.1},
{"A": 0.6, "B": 0.5},
{"A": float("nan")},
],
)
def test_simulate_multi_day_with_audit_rejects_invalid_long_only_weights(targets):
"""多日 A 股目标必须是有限、非负且合计不超过 100% 的权重。"""
with pytest.raises(ValueError, match="target weights"):
simulate_multi_day_with_audit(
[("d1", targets)],
[("d1", {"A": 10.0, "B": 10.0})],
1_000.0,
)
def test_simulate_multi_day_with_audit_requires_price_for_existing_holding():
"""已有持仓缺价时无法可信估值,必须失败而不是把市值记为零。"""
with pytest.raises(ValueError, match="missing price for held asset A"):
simulate_multi_day_with_audit(
[("d1", {"A": 1.0}), ("d2", {"A": 1.0})],
[("d1", {"A": 10.0}), ("d2", {})],
1_000.0,
)
def test_simulate_multi_day_with_audit_records_unpriced_target_rejection():
"""缺失价格的目标不能吞掉现金,且必须留下拒绝原因。"""
result = simulate_multi_day_with_audit(
[("d1", {"A": 1.0})],
[("d1", {"B": 10.0})],
1_000.0,
)
rejection = result.daily_executions[0].executions[0]
assert rejection.stock_code == "A"
assert rejection.executed_value == 0.0
assert rejection.partial_fill_pct == 0.0
assert rejection.blocked_reason == "missing_price"
assert result.positions[0].cash == 1_000.0
assert result.positions[0].holdings == {}
def test_simulate_multi_day_with_audit_requires_matching_dates():
"""权重与价格日期错位必须显式失败,不能按位置静默配对。"""
with pytest.raises(ValueError, match="dates must match"):
simulate_multi_day_with_audit(
[("d1", {"A": 1.0})],
[("d2", {"A": 10.0})],
1_000.0,
)
# ── 逐交易日 Ledger:成交时点与估值时点分离 ─────────────────
def test_daily_ledger_marks_every_session_after_sparse_open_execution() -> None:
"""下一日开盘成交后,应按每日收盘价持续盯市,而非只记录调仓日。"""
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
result = simulate_daily_ledger_with_audit(
target_weights_history=[("d1", {"A": 1.0})],
execution_price_history=[("d1", {"A": 10.0})],
valuation_price_history=[
("d0", {"A": 9.0}),
("d1", {"A": 11.0}),
("d2", {"A": 12.0}),
],
initial_cash=1_000.0,
config=config,
)
assert [position.date for position in result.positions] == ["d0", "d1", "d2"]
assert [position.portfolio_value for position in result.positions] == pytest.approx(
[1_000.0, 1_100.0, 1_200.0]
)
assert [len(day.executions) for day in result.daily_executions] == [0, 1, 0]
fill = result.daily_executions[1].executions[0]
assert fill.side == "buy"
assert fill.quantity == pytest.approx(100.0)
assert fill.price == pytest.approx(10.0)
pd.testing.assert_series_equal(
result.normalized_nav_series,
pd.Series([1.0, 1.1, 1.2], index=["d0", "d1", "d2"], dtype=float),
)
pd.testing.assert_series_equal(
result.daily_returns,
pd.Series([0.0, 0.1, 1.2 / 1.1 - 1.0], index=["d0", "d1", "d2"]),
)
def test_daily_ledger_first_session_cost_reduces_first_return() -> None:
"""首个估值日发生交易时,费用必须进入相对初始资金的首日收益。"""
result = simulate_daily_ledger_with_audit(
target_weights_history=[("d0", {"A": 1.0})],
execution_price_history=[("d0", {"A": 10.0})],
valuation_price_history=[("d0", {"A": 10.0})],
initial_cash=1_000.0,
)
assert result.total_costs > 0
assert result.daily_returns.iloc[0] == pytest.approx(
result.final_portfolio_value / result.initial_cash - 1.0
)
assert result.daily_returns.iloc[0] < 0
def test_daily_ledger_nav_is_rebuildable_and_trades_are_projectable() -> None:
"""Ledger 必须同时支持现金守恒校验和平台成交表投影。"""
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
result = simulate_daily_ledger_with_audit(
target_weights_history=[
("d1", {"A": 1.0, "B": 0.0}),
("d2", {"A": 0.0, "B": 1.0}),
],
execution_price_history=[
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 22.0}),
],
valuation_price_history=[
("d0", {"A": 9.0, "B": 19.0}),
("d1", {"A": 10.5, "B": 21.0}),
("d2", {"A": 12.0, "B": 24.0}),
],
initial_cash=1_000.0,
config=config,
)
close_prices = {
"d0": {"A": 9.0, "B": 19.0},
"d1": {"A": 10.5, "B": 21.0},
"d2": {"A": 12.0, "B": 24.0},
}
for position in result.positions:
rebuilt = position.cash + sum(
shares * close_prices[position.date][asset]
for asset, shares in position.holdings.items()
)
assert position.portfolio_value == pytest.approx(rebuilt)
trades = result.trades_frame
assert trades.columns.tolist() == [
"trade_date",
"ts_code",
"side",
"qty",
"price",
"amount",
"fee",
"slippage",
]
assert trades["side"].tolist() == ["buy", "sell", "buy"]
assert (trades["qty"] > 0).all()
def test_daily_ledger_frame_matches_platform_projection_contract() -> None:
"""核心层输出稳定日频投影,但不携带 run_id 或执行数据库写入。"""
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
result = simulate_daily_ledger_with_audit(
target_weights_history=[("d1", {"A": 1.0})],
execution_price_history=[("d1", {"A": 10.0})],
valuation_price_history=[
("d0", {"A": 9.0}),
("d1", {"A": 11.0}),
("d2", {"A": 12.0}),
],
initial_cash=1_000.0,
config=config,
)
ledger = result.ledger_frame
assert ledger.columns.tolist() == [
"trade_date",
"portfolio_value",
"nav",
"pnl",
"pnl_pct",
"position_value",
"cash",
"turnover",
]
assert ledger["trade_date"].tolist() == ["d0", "d1", "d2"]
assert ledger["nav"].tolist() == pytest.approx([1.0, 1.1, 1.2])
assert ledger["pnl"].tolist() == pytest.approx([0.0, 100.0, 100.0])
assert ledger["pnl_pct"].tolist() == pytest.approx([0.0, 0.1, 1.2 / 1.1 - 1.0])
assert ledger["position_value"].tolist() == pytest.approx([0.0, 1_100.0, 1_200.0])
assert ledger["cash"].tolist() == pytest.approx([1_000.0, 0.0, 0.0])
assert ledger["turnover"].tolist() == pytest.approx([0.0, 1.0, 0.0])
def test_daily_ledger_rejects_missing_close_for_held_asset() -> None:
"""已有持仓缺少收盘估值价时必须 fail closed。"""
with pytest.raises(ValueError, match="missing valuation price for held asset A"):
simulate_daily_ledger_with_audit(
target_weights_history=[("d0", {"A": 1.0})],
execution_price_history=[("d0", {"A": 10.0})],
valuation_price_history=[("d0", {"A": 10.0}), ("d1", {})],
initial_cash=1_000.0,
)
def test_daily_ledger_requires_positive_initial_cash() -> None:
"""可信收益曲线需要正初始资金作为归一化基准。"""
with pytest.raises(ValueError, match="initial_cash must be positive"):
simulate_daily_ledger_with_audit([], [], [], initial_cash=0.0)
# ── v1.2.0 Phase 1:端到端 POC(run_end_to_end_poc) ─────
@@ -449,6 +733,15 @@ def test_run_end_to_end_poc_costs_recorded():
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
assert result["total_costs"] > 0
assert result["total_turnover"] > 0
executions = [
execution
for daily in result["daily_executions"]
for execution in daily.executions
]
assert result["total_costs"] == pytest.approx(sum(item.total_cost for item in executions))
assert result["total_turnover"] == pytest.approx(
sum(item.executed_value for item in executions)
)
# ── v1.2.0 Phase 2: T+1 / 涨跌停 / 部分成交 / 买卖价差 ─────
@@ -741,8 +1034,8 @@ def test_compute_realized_pnl_sell_realizes():
target_weights_history=targets,
)
pnl_list = compute_realized_pnl(positions)
# 第三天(卖出兑现)应有 realized 正利润(cash 从 -800 → 2M = +2M)
assert pnl_list[2].realized_pnl > 0
# 第二天日末快照已包含当日卖出,现金流入应在当天反映。
assert pnl_list[1].realized_pnl > 0
# ── O3: end-to-end 端到端测试(集成多个函数) ──────────────
+173
View File
@@ -0,0 +1,173 @@
"""Contracts for reusable factor diagnostics and transformations."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine.factor_library import (
annualized_sharpe,
apply_factor_direction,
cross_sectional_momentum,
cross_sectional_pct_rank,
cross_sectional_rank_with_direction,
ic_summary,
jb_test,
kurtosis,
ols_regress,
rolling_annual_vol,
rolling_zscore,
skewness,
spearman_ic,
time_series_momentum,
turnover,
winsorize,
)
def test_turnover_supports_one_way_and_round_trip_conventions() -> None:
weights = pd.DataFrame({"A": [1.0, 0.0], "B": [0.0, 1.0]})
pd.testing.assert_series_equal(turnover(weights), pd.Series([1.0], index=[1]))
pd.testing.assert_series_equal(
turnover(weights, divide_by_two=False), pd.Series([2.0], index=[1])
)
assert turnover(weights.iloc[:1]).empty
def test_ic_functions_measure_monotonic_relationship() -> None:
factor = pd.Series([1.0, 2.0, 3.0, 4.0])
forward = pd.Series([10.0, 20.0, 30.0, 40.0])
assert spearman_ic(factor, forward) == pytest.approx(1.0)
result = ic_summary(factor, forward, periods=(1,), method="pearson")
assert result.loc[1, "ic_mean"] == pytest.approx(1.0)
assert result.loc[1, "n"] == 4
def test_ic_summary_rejects_unknown_method() -> None:
with pytest.raises(ValueError, match="not supported"):
ic_summary(pd.Series([1, 2, 3]), pd.Series([1, 2, 3]), method="kendall")
def test_winsorize_clips_tails_and_preserves_nan() -> None:
values = pd.Series([0.0, 1.0, 2.0, 100.0, np.nan])
result = winsorize(values, lower=0.25, upper=0.75)
assert result.iloc[0] == pytest.approx(0.75)
assert result.iloc[3] == pytest.approx(26.5)
assert pd.isna(result.iloc[4])
def test_distribution_diagnostics_handle_short_samples() -> None:
assert np.isnan(skewness(pd.Series([1.0, 2.0])))
assert np.isnan(kurtosis(pd.Series([1.0, 2.0, 3.0])))
jb, p_value = jb_test(pd.Series(range(7), dtype=float))
assert np.isnan(jb)
assert np.isnan(p_value)
def test_distribution_diagnostics_return_finite_values() -> None:
values = pd.Series([-2.0, -1.0, -0.5, 0.0, 0.25, 0.75, 1.0, 3.0])
assert np.isfinite(skewness(values))
assert np.isfinite(kurtosis(values))
jb, p_value = jb_test(values)
assert jb >= 0
assert 0 <= p_value <= 1
def test_ols_recovers_linear_coefficients_and_residual_index() -> None:
index = pd.date_range("2026-01-01", periods=8)
factor = pd.Series(np.arange(8, dtype=float), index=index, name="factor")
target = 1.5 + 2.0 * factor
result = ols_regress(target, factor)
assert result.alpha == pytest.approx(1.5)
assert result.beta["factor"] == pytest.approx(2.0)
assert result.r_squared == pytest.approx(1.0)
assert result.n == 8
assert result.resid.index.equals(index)
def test_ols_handles_collinear_factors_without_crashing() -> None:
x = pd.DataFrame({"a": np.arange(8, dtype=float), "b": np.arange(8, dtype=float)})
y = pd.Series(1.0 + x["a"])
result = ols_regress(y, x)
assert result.n == 8
assert np.isfinite(result.beta).all()
np.testing.assert_allclose(result.resid, 0.0, atol=1e-12)
def test_ols_short_sample_returns_empty_estimate() -> None:
result = ols_regress(pd.Series([1.0, 2.0]), pd.Series([1.0, 2.0], name="x"))
assert np.isnan(result.alpha)
assert result.beta.empty
assert result.n == 2
def test_momentum_and_rolling_transforms_match_manual_values() -> None:
prices = pd.DataFrame({"A": [100.0, 110.0, 121.0, 133.1]})
momentum = cross_sectional_momentum(prices, lookback=2, skip=0)
assert momentum.iloc[2, 0] == pytest.approx(0.21)
returns = pd.Series([0.1, 0.1, -0.5, -0.5])
pd.testing.assert_series_equal(
time_series_momentum(returns, lookback=2),
pd.Series([0, 1, -1, -1]),
)
values = pd.Series([1.0, 2.0, 3.0])
zscore = rolling_zscore(values, window=3)
assert zscore.iloc[-1] == pytest.approx(1.0)
annual_vol = rolling_annual_vol(returns, window=2, min_periods=2, trading_days=4)
assert annual_vol.iloc[1] == pytest.approx(0.0)
def test_rank_helpers_support_global_and_grouped_ranking() -> None:
frame = pd.DataFrame(
{"factor": [3.0, 1.0, 2.0, 4.0], "industry": ["x", "x", "y", "y"]}
)
global_rank = cross_sectional_pct_rank(frame, "factor", ascending=True)
grouped_rank = cross_sectional_pct_rank(
frame, "factor", group_col="industry", ascending=True
)
assert global_rank.tolist() == [0.75, 0.25, 0.5, 1.0]
assert grouped_rank.tolist() == [1.0, 0.5, 0.5, 1.0]
assert cross_sectional_pct_rank(frame, "missing").empty
def test_factor_direction_and_directional_rank() -> None:
pe = pd.Series([10.0, 20.0], name="pe_ttm")
pd.testing.assert_series_equal(apply_factor_direction(pe), -pe)
frame = pd.DataFrame({"pe_ttm": [10.0, 20.0], "roe": [0.1, 0.2]})
assert cross_sectional_rank_with_direction(frame, "pe_ttm").tolist() == [1.0, 0.5]
assert cross_sectional_rank_with_direction(frame, "roe").tolist() == [0.5, 1.0]
@pytest.mark.parametrize("direction", ["sideways", "", "REVERSE"])
def test_factor_direction_rejects_unknown_values(direction: str) -> None:
factor = pd.Series([1.0, 2.0], name="roe")
with pytest.raises(ValueError, match="direction"):
apply_factor_direction(factor, direction=direction)
with pytest.raises(ValueError, match="direction"):
cross_sectional_rank_with_direction(
pd.DataFrame({"roe": factor}), "roe", direction=direction
)
def test_annualized_sharpe_handles_empty_and_nonzero_returns() -> None:
assert annualized_sharpe(pd.Series(dtype=float)) == 0.0
returns = pd.Series([0.01, -0.01, 0.02, 0.0])
expected = returns.mean() * 252 / (returns.std() * np.sqrt(252))
assert annualized_sharpe(returns) == pytest.approx(expected)
+163
View File
@@ -0,0 +1,163 @@
"""Mathematical contracts for the standard performance metrics."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine.metrics import (
TRADING_DAYS_PER_YEAR,
annualized_return,
annualized_volatility,
benchmark_summary,
calmar_ratio,
max_drawdown,
sharpe_ratio,
sortino_ratio,
summary,
win_rate,
)
def test_annualized_return_uses_compounded_simple_returns() -> None:
returns = pd.Series([0.10, -0.10])
expected = 0.99 ** (TRADING_DAYS_PER_YEAR / 2) - 1.0
assert annualized_return(returns) == pytest.approx(expected)
def test_annualized_volatility_uses_sample_standard_deviation() -> None:
returns = pd.Series([0.01, 0.03, 0.02])
assert annualized_volatility(returns) == pytest.approx(
returns.std() * np.sqrt(TRADING_DAYS_PER_YEAR)
)
def test_sharpe_ratio_subtracts_annual_risk_free_rate() -> None:
returns = pd.Series([0.01, -0.005, 0.02, 0.0])
result = sharpe_ratio(returns, rf=0.02)
assert result == pytest.approx(
(annualized_return(returns) - 0.02) / annualized_volatility(returns)
)
def test_zero_volatility_metrics_return_zero() -> None:
returns = pd.Series([0.0, 0.0, 0.0])
assert sharpe_ratio(returns) == 0.0
assert sortino_ratio(returns) == 0.0
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])
assert max_drawdown(returns) == pytest.approx(-0.20)
def test_max_drawdown_tracks_peak_to_trough_loss() -> None:
returns = pd.Series([0.10, -0.20, 0.05])
assert max_drawdown(returns) == pytest.approx(-0.20)
def test_metrics_clean_nan_and_infinite_values() -> None:
returns = pd.Series([0.10, np.nan, np.inf, -0.05, -np.inf])
assert win_rate(returns) == 0.5
assert summary(returns)["n_days"] == 2
def test_summary_aliases_match_canonical_fields() -> None:
result = summary(pd.Series([0.01, -0.02, 0.03]))
assert result["annual_yield"] == result["ann_return"]
assert result["annual_sd"] == result["ann_volatility"]
assert result["drawback"] == result["max_drawdown"]
@pytest.mark.parametrize(
"metric",
[
annualized_return,
annualized_volatility,
sharpe_ratio,
sortino_ratio,
max_drawdown,
calmar_ratio,
win_rate,
],
)
def test_metrics_reject_non_series_input(metric) -> None:
with pytest.raises(TypeError, match=r"expected pd\.Series"):
metric([0.01, 0.02])
def test_short_and_empty_series_return_zero() -> None:
assert annualized_return(pd.Series(dtype=float)) == 0.0
assert annualized_volatility(pd.Series([0.01])) == 0.0
assert max_drawdown(pd.Series([0.01])) == 0.0
assert win_rate(pd.Series(dtype=float)) == 0.0
def test_benchmark_summary_uses_aligned_active_returns_and_regression() -> None:
dates = pd.date_range("2026-01-05", periods=4, freq="B")
benchmark = pd.Series([-0.01, 0.0, 0.01, 0.02], index=dates)
portfolio = 0.001 + 1.5 * benchmark
active = portfolio - benchmark
result = benchmark_summary(portfolio, benchmark)
assert result["n_observations"] == 4
assert result["tracking_error"] == pytest.approx(
active.std() * np.sqrt(TRADING_DAYS_PER_YEAR)
)
assert result["information_ratio"] == pytest.approx(
active.mean() / active.std() * np.sqrt(TRADING_DAYS_PER_YEAR)
)
assert result["beta"] == pytest.approx(1.5)
assert result["alpha"] == pytest.approx(1.001**TRADING_DAYS_PER_YEAR - 1.0)
def test_benchmark_summary_rejects_silent_calendar_alignment() -> None:
portfolio = pd.Series([0.01, 0.02], index=pd.date_range("2026-01-05", periods=2))
benchmark = pd.Series([0.01, 0.02], index=pd.date_range("2026-01-06", periods=2))
with pytest.raises(ValueError, match="matching indexes"):
benchmark_summary(portfolio, benchmark)
def test_benchmark_summary_rejects_missing_observations() -> None:
dates = pd.date_range("2026-01-05", periods=2)
portfolio = pd.Series([0.01, np.nan], index=dates)
benchmark = pd.Series([0.0, 0.01], index=dates)
with pytest.raises(ValueError, match="finite"):
benchmark_summary(portfolio, benchmark)
def test_benchmark_summary_marks_constant_benchmark_regression_unestimable() -> None:
dates = pd.date_range("2026-01-05", periods=3)
portfolio = pd.Series([0.01, -0.01, 0.02], index=dates)
benchmark = pd.Series([0.0, 0.0, 0.0], index=dates)
result = benchmark_summary(portfolio, benchmark)
assert np.isnan(result["alpha"])
assert np.isnan(result["beta"])
+157
View File
@@ -0,0 +1,157 @@
"""Factor-score portfolio construction and backtest integration contracts."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine.backtest import run_weight_backtest
from quant_engine.portfolio_construction import (
equal_weight,
scores_to_target_weights,
scores_to_weight_table,
select_top_k,
)
def test_select_top_k_ignores_nan_and_breaks_ties_by_input_order() -> None:
scores = pd.Series([1.0, 1.0, np.nan, 0.5], index=["B", "A", "C", "D"])
selected = select_top_k(scores, top_k=2)
assert selected.tolist() == ["B", "A"]
def test_select_top_k_can_select_lowest_scores() -> None:
scores = pd.Series([3.0, 1.0, 2.0], index=["A", "B", "C"])
selected = select_top_k(scores, top_k=2, largest=False)
assert selected.tolist() == ["B", "C"]
def test_equal_weight_allocates_requested_gross_exposure() -> None:
result = equal_weight(pd.Index(["A", "B", "C"]), gross_exposure=0.9)
pd.testing.assert_series_equal(
result,
pd.Series([0.3, 0.3, 0.3], index=["A", "B", "C"], name="weight"),
)
def test_equal_weight_returns_empty_float_series_for_no_assets() -> None:
result = equal_weight(pd.Index([], dtype=object))
assert result.empty
assert result.dtype == float
assert result.name == "weight"
def test_scores_to_target_weights_keeps_full_universe_with_zero_for_unselected() -> None:
scores = pd.Series([0.2, 0.8, 0.5], index=["A", "B", "C"])
result = scores_to_target_weights(scores, top_k=2)
pd.testing.assert_series_equal(
result,
pd.Series([0.0, 0.5, 0.5], index=scores.index, name="weight"),
)
def test_scores_to_target_weights_divides_exposure_over_available_scores() -> None:
scores = pd.Series([1.0, np.nan, 0.5], index=["A", "B", "C"])
result = scores_to_target_weights(scores, top_k=5, gross_exposure=0.8)
pd.testing.assert_series_equal(
result,
pd.Series([0.4, 0.0, 0.4], index=scores.index, name="weight"),
)
def test_scores_to_weight_table_constructs_each_rebalance_independently() -> None:
dates = pd.to_datetime(["2026-01-05", "2026-01-07"])
scores = pd.DataFrame(
{"A": [3.0, 1.0], "B": [2.0, 3.0], "C": [1.0, 2.0]},
index=dates,
)
result = scores_to_weight_table(scores, top_k=2)
expected = pd.DataFrame(
{"A": [0.5, 0.0], "B": [0.5, 0.5], "C": [0.0, 0.5]},
index=dates,
)
pd.testing.assert_frame_equal(result, expected)
changed_future = scores.copy()
changed_future.iloc[1] = [100.0, -100.0, 0.0]
changed_result = scores_to_weight_table(changed_future, top_k=2)
pd.testing.assert_series_equal(result.iloc[0], changed_result.iloc[0])
def test_effective_holding_weights_flow_into_weight_backtest() -> None:
dates = pd.date_range("2026-01-05", periods=3, freq="B")
effective_weights = pd.DataFrame(
{"A": [1.0, 0.0], "B": [0.0, 1.0]},
index=dates[[0, 2]],
)
stock_returns = pd.DataFrame(
{"A": [0.10, 0.0, 0.0], "B": [0.0, 0.0, 0.20]},
index=dates,
)
result = run_weight_backtest(effective_weights, stock_returns)
pd.testing.assert_series_equal(result.nav, pd.Series([1.1, 1.1, 1.32], index=dates))
pd.testing.assert_frame_equal(result.weights, effective_weights)
@pytest.mark.parametrize("top_k", [0, -1])
def test_portfolio_construction_rejects_non_positive_top_k(top_k: int) -> None:
scores = pd.Series([1.0], index=["A"])
with pytest.raises(ValueError, match="top_k must be positive"):
select_top_k(scores, top_k=top_k)
@pytest.mark.parametrize("gross_exposure", [-0.1, np.inf, np.nan])
def test_equal_weight_rejects_invalid_gross_exposure(gross_exposure: float) -> None:
with pytest.raises(ValueError, match="gross_exposure"):
equal_weight(pd.Index(["A"]), gross_exposure=gross_exposure)
def test_portfolio_construction_rejects_duplicate_assets() -> None:
duplicate_scores = pd.Series([1.0, 2.0], index=["A", "A"])
with pytest.raises(ValueError, match="unique asset labels"):
scores_to_target_weights(duplicate_scores, top_k=1)
def test_weight_table_rejects_duplicate_rebalance_dates() -> None:
duplicate_date = pd.Timestamp("2026-01-05")
scores = pd.DataFrame(
{"A": [1.0, 2.0]},
index=[duplicate_date, duplicate_date],
)
with pytest.raises(ValueError, match="unique rebalance dates"):
scores_to_weight_table(scores, top_k=1)
def test_weight_table_rejects_unsorted_rebalance_dates() -> None:
scores = pd.DataFrame(
{"A": [1.0, 2.0]},
index=pd.to_datetime(["2026-01-07", "2026-01-05"]),
)
with pytest.raises(ValueError, match="chronological order"):
scores_to_weight_table(scores, top_k=1)
def test_weight_table_rejects_non_numeric_scores() -> None:
scores = pd.DataFrame({"A": ["high"], "B": ["low"]})
with pytest.raises(TypeError, match="numeric"):
scores_to_weight_table(scores, top_k=1)
+336
View File
@@ -0,0 +1,336 @@
"""No-lookahead factor-score to execution-audit integration contracts."""
from __future__ import annotations
import pandas as pd
import pytest
from quant_engine.execution import ExecutionConfig
from quant_engine.research_pipeline import (
FactorBacktestResult,
FactorExecutionResult,
TargetWeightSchedule,
run_factor_backtest_research,
run_factor_execution_research,
schedule_target_weights,
)
def _calendar() -> pd.DatetimeIndex:
return pd.date_range("2026-01-05", periods=4, freq="B")
def _factor_scores() -> pd.DataFrame:
dates = _calendar()
return pd.DataFrame(
{"A": [2.0, 0.0], "B": [1.0, 3.0]},
index=dates[:2],
)
def _next_session_open_prices() -> pd.DataFrame:
dates = _calendar()
return pd.DataFrame(
{"A": [1.0, 10.0, 10.0, 10.0], "B": [1.0, 10.0, 20.0, 20.0]},
index=dates,
)
def test_schedule_target_weights_maps_signal_to_next_trading_session() -> None:
dates = _calendar()
decision_weights = pd.DataFrame(
{"A": [1.0, 0.0], "B": [0.0, 1.0]},
index=dates[:2],
)
schedule = schedule_target_weights(decision_weights, dates, lag_sessions=1)
assert isinstance(schedule, TargetWeightSchedule)
assert schedule.lag_sessions == 1
pd.testing.assert_series_equal(
schedule.signal_to_execution,
pd.Series(dates[1:3], index=dates[:2], name="execution_date"),
)
expected = decision_weights.copy()
expected.index = dates[1:3]
expected.index.name = "execution_date"
pd.testing.assert_frame_equal(schedule.execution_weights, expected)
assert (schedule.execution_weights.index > schedule.signal_to_execution.index).all()
def test_factor_execution_research_uses_next_session_prices() -> None:
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
result = run_factor_execution_research(
_factor_scores(),
_next_session_open_prices(),
top_k=1,
execution_price_field="open",
initial_cash=1_000.0,
config=config,
)
assert isinstance(result, FactorExecutionResult)
assert result.execution_price_field == "open"
assert result.execution.daily_executions[0].date == str(_calendar()[1])
assert result.execution.positions[0].holdings == {"A": 100.0}
assert result.execution.positions[1].holdings == {"B": 50.0}
assert result.execution.final_portfolio_value == pytest.approx(1_000.0)
def test_factor_execution_result_snapshots_research_inputs() -> None:
scores = _factor_scores()
prices = _next_session_open_prices()
result = run_factor_execution_research(
scores,
prices,
top_k=1,
execution_price_field="open",
)
scores.iloc[0, 0] = -999.0
prices.iloc[1, 0] = 999.0
assert result.factor_scores.iloc[0, 0] == 2.0
assert result.execution_prices.loc[_calendar()[1], "A"] == 10.0
assert result.execution.positions[0].holdings["A"] < 200_000.0
@pytest.mark.parametrize("lag_sessions", [0, -1, True])
def test_schedule_target_weights_requires_positive_integer_lag(lag_sessions: int) -> None:
with pytest.raises(ValueError, match="lag_sessions"):
schedule_target_weights(
pd.DataFrame({"A": [1.0]}, index=_calendar()[:1]),
_calendar(),
lag_sessions=lag_sessions,
)
def test_schedule_target_weights_rejects_signal_outside_trading_calendar() -> None:
weekend = pd.Timestamp("2026-01-10")
with pytest.raises(ValueError, match="signal dates must be trading sessions"):
schedule_target_weights(
pd.DataFrame({"A": [1.0]}, index=[weekend]),
_calendar(),
)
def test_schedule_target_weights_rejects_missing_future_execution_session() -> None:
dates = _calendar()
with pytest.raises(ValueError, match="future execution session"):
schedule_target_weights(
pd.DataFrame({"A": [1.0]}, index=dates[-1:]),
dates,
)
def test_factor_execution_research_requires_explicit_price_field() -> None:
with pytest.raises(ValueError, match="execution_price_field"):
run_factor_execution_research(
_factor_scores(),
_next_session_open_prices(),
top_k=1,
execution_price_field="",
)
def test_factor_execution_research_accepts_empty_scores() -> None:
scores = pd.DataFrame(columns=["A", "B"], index=pd.DatetimeIndex([]), dtype=float)
result = run_factor_execution_research(
scores,
_next_session_open_prices(),
top_k=1,
execution_price_field="open",
)
assert result.schedule.execution_weights.empty
assert result.execution.positions == ()
def test_factor_backtest_research_runs_signal_to_daily_performance_without_lookahead() -> None:
"""信号日保持现金,下一日开盘成交后才参与当日收盘收益。"""
dates = _calendar()
scores = pd.DataFrame({"A": [2.0], "B": [1.0]}, index=dates[:1])
opens = pd.DataFrame(
{"A": [1.0, 10.0, 10.0, 10.0], "B": [1.0, 20.0, 20.0, 20.0]},
index=dates,
)
closes = pd.DataFrame(
{"A": [500.0, 11.0, 12.0, 12.0], "B": [500.0, 20.0, 20.0, 20.0]},
index=dates,
)
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
result = run_factor_backtest_research(
scores,
execution_prices=opens,
valuation_prices=closes,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
initial_cash=1_000.0,
config=config,
)
assert isinstance(result, FactorBacktestResult)
assert result.execution_price_field == "open"
assert result.valuation_price_field == "close"
pd.testing.assert_series_equal(
result.nav,
pd.Series([1.0, 1.1, 1.2, 1.2], index=dates, name="nav"),
)
pd.testing.assert_series_equal(
result.returns,
pd.Series([0.0, 0.1, 1.2 / 1.1 - 1.0, 0.0], index=dates, name="returns"),
)
assert result.stats()["n_days"] == 4
assert result.execution.daily_executions[0].executions == ()
assert result.execution.daily_executions[1].executions[0].price == 10.0
def test_factor_backtest_result_snapshots_both_price_semantics() -> None:
scores = pd.DataFrame({"A": [1.0]}, index=_calendar()[:1])
opens = pd.DataFrame({"A": [10.0, 10.0, 10.0, 10.0]}, index=_calendar())
closes = pd.DataFrame({"A": [10.0, 11.0, 12.0, 13.0]}, index=_calendar())
result = run_factor_backtest_research(
scores,
execution_prices=opens,
valuation_prices=closes,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
)
opens.iloc[1, 0] = 999.0
closes.iloc[1, 0] = 999.0
assert result.execution_prices.iloc[1, 0] == 10.0
assert result.valuation_prices.iloc[1, 0] == 11.0
def test_factor_backtest_research_requires_matching_daily_calendars() -> None:
scores = pd.DataFrame({"A": [1.0]}, index=_calendar()[:1])
opens = pd.DataFrame({"A": [10.0, 10.0, 10.0, 10.0]}, index=_calendar())
closes = pd.DataFrame({"A": [10.0, 11.0, 12.0]}, index=_calendar()[:3])
with pytest.raises(ValueError, match="matching trading calendars"):
run_factor_backtest_research(
scores,
execution_prices=opens,
valuation_prices=closes,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
)
def test_factor_backtest_starts_at_first_signal_instead_of_price_warmup() -> None:
"""因子预热行情不能作为空仓日混入研究绩效区间。"""
dates = pd.date_range("2026-01-05", periods=5, freq="B")
scores = pd.DataFrame({"A": [1.0]}, index=dates[2:3])
opens = pd.DataFrame({"A": [1.0, 1.0, 1.0, 10.0, 10.0]}, index=dates)
closes = pd.DataFrame({"A": [100.0, 200.0, 300.0, 11.0, 12.0]}, index=dates)
config = ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
)
result = run_factor_backtest_research(
scores,
execution_prices=opens,
valuation_prices=closes,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
initial_cash=1_000.0,
config=config,
)
assert result.nav.index.equals(dates[2:])
pd.testing.assert_series_equal(
result.nav,
pd.Series([1.0, 1.1, 1.2], index=dates[2:], name="nav"),
)
assert result.stats()["n_days"] == 3
def test_factor_backtest_exposes_net_benchmark_metrics() -> None:
dates = _calendar()
scores = pd.DataFrame({"A": [1.0]}, index=dates[:1])
prices = pd.DataFrame({"A": [10.0, 10.0, 11.0, 11.0]}, index=dates)
result = run_factor_backtest_research(
scores,
prices,
prices,
top_k=1,
execution_price_field="open",
valuation_price_field="close",
config=ExecutionConfig(
commission_bps=0,
stamp_tax_bps=0,
slippage_bps=0,
min_trade_amount=0,
),
)
benchmark = pd.Series([0.0, 0.01, -0.01, 0.0], index=dates)
relative = result.benchmark_stats(benchmark)
assert relative["n_observations"] == len(result.returns)
assert relative["tracking_error"] > 0
def test_factor_backtest_projects_actual_close_weights_from_ledger() -> None:
dates = _calendar()
scores = pd.DataFrame({"A": [1.0], "B": [0.0]}, index=dates[:1])
opens = pd.DataFrame(
{"A": [10.0, 10.0, 10.0, 10.0], "B": [20.0, 20.0, 20.0, 20.0]},
index=dates,
)
closes = pd.DataFrame(
{"A": [10.0, 11.0, 12.0, 12.0], "B": [20.0, 20.0, 20.0, 20.0]},
index=dates,
)
result = run_factor_backtest_research(
scores,
opens,
closes,
top_k=1,
gross_exposure=0.5,
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,
),
)
weights = result.position_weights
cash = result.cash_weights
assert weights.index.equals(result.nav.index)
assert weights.columns.tolist() == ["A", "B"]
assert weights.loc[dates[0]].sum() == 0.0
assert cash.loc[dates[0]] == 1.0
assert weights.loc[dates[1], "A"] == pytest.approx(550.0 / 1_050.0)
pd.testing.assert_series_equal(
weights.sum(axis=1) + cash,
pd.Series(1.0, index=dates),
check_names=False,
)
+270
View File
@@ -0,0 +1,270 @@
"""Risk contribution contracts and validation tests."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine.risk import (
ComponentRiskResult,
CovarianceSnapshot,
component_var,
estimate_covariance_snapshot,
labeled_component_risk,
marginal_risk_contribution,
risk_contribution,
)
def test_estimate_covariance_snapshot_is_complete_case_and_reproducible() -> None:
dates = pd.date_range("2026-01-05", periods=6, freq="B")
returns = pd.DataFrame(
{
"A": [0.01, 0.02, 0.03, 0.04, 0.05, 99.0],
"B": [0.02, 0.01, np.nan, 0.03, 0.04, -99.0],
},
index=dates,
)
as_of = dates[4]
snapshot = estimate_covariance_snapshot(
returns,
as_of_date=as_of,
lookback_sessions=4,
min_observations=3,
data_snapshot_id="market-returns-20260109-v1",
return_frequency="1d",
periods_per_year=252,
)
expected_window = returns.loc[:as_of].tail(4)
expected = expected_window.dropna(how="any").cov()
pd.testing.assert_frame_equal(snapshot.covariance, expected)
assert snapshot.snapshot_id.startswith("sample-cov-v1:")
assert snapshot.as_of_date == as_of.date()
assert snapshot.method == "sample"
assert snapshot.window_start_date == expected_window.index[0].date()
assert snapshot.window_end_date == as_of.date()
assert snapshot.observations == 3
assert snapshot.lookback_sessions == 4
assert snapshot.missing_policy == "complete_case"
assert snapshot.data_snapshot_id == "market-returns-20260109-v1"
assert len(snapshot.input_sha256) == 64
future_changed = returns.copy()
future_changed.loc[dates[-1], :] = [1_000_000.0, -1_000_000.0]
repeated = estimate_covariance_snapshot(
future_changed,
as_of_date=as_of,
lookback_sessions=4,
min_observations=3,
data_snapshot_id="market-returns-20260109-v1",
return_frequency="1d",
periods_per_year=252,
)
assert repeated.snapshot_id == snapshot.snapshot_id
pd.testing.assert_frame_equal(repeated.covariance, snapshot.covariance)
def test_covariance_snapshot_identity_captures_data_and_estimator_contract() -> None:
dates = pd.date_range("2026-01-05", periods=4, freq="B")
returns = pd.DataFrame(
{"A": [0.01, 0.02, -0.01, 0.03], "B": [0.02, -0.01, 0.01, 0.04]},
index=dates,
)
base = estimate_covariance_snapshot(
returns,
as_of_date=dates[-1],
lookback_sessions=4,
min_observations=3,
data_snapshot_id="snapshot-a",
)
different_source = estimate_covariance_snapshot(
returns,
as_of_date=dates[-1],
lookback_sessions=4,
min_observations=3,
data_snapshot_id="snapshot-b",
)
assert base.snapshot_id != different_source.snapshot_id
assert base.covariance.equals(different_source.covariance)
def test_estimate_covariance_snapshot_rejects_ambiguous_or_insufficient_history() -> None:
dates = pd.date_range("2026-01-05", periods=4, freq="B")
returns = pd.DataFrame(
{"A": [0.01, np.nan, 0.03, 0.04], "B": [0.02, 0.01, np.nan, 0.03]},
index=dates,
)
with pytest.raises(ValueError, match="complete observations"):
estimate_covariance_snapshot(
returns,
as_of_date=dates[-1],
lookback_sessions=4,
min_observations=3,
data_snapshot_id="snapshot-a",
)
with pytest.raises(ValueError, match="strictly increasing"):
estimate_covariance_snapshot(
returns.iloc[::-1],
as_of_date=dates[-1],
lookback_sessions=4,
min_observations=2,
data_snapshot_id="snapshot-a",
)
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])
result = risk_contribution(weights, covariance)
np.testing.assert_allclose(result, [0.2, 0.8])
assert result.sum() == pytest.approx(1.0)
def test_zero_variance_portfolio_falls_back_to_equal_contribution() -> None:
result = risk_contribution(np.array([0.2, 0.3, 0.5]), np.zeros((3, 3)))
np.testing.assert_allclose(result, np.full(3, 1 / 3))
def test_marginal_and_component_risk_follow_matrix_identities() -> None:
weights = np.array([0.25, 0.75])
covariance = np.array([[0.04, 0.01], [0.01, 0.09]])
marginal = marginal_risk_contribution(weights, covariance)
component = component_var(weights, covariance)
np.testing.assert_allclose(marginal, covariance @ weights)
np.testing.assert_allclose(component, weights * marginal)
assert component.sum() == pytest.approx(weights @ covariance @ weights)
@pytest.mark.parametrize(
"function",
[risk_contribution, marginal_risk_contribution, component_var],
)
def test_risk_functions_reject_covariance_shape_mismatch(function) -> None:
with pytest.raises(ValueError, match="does not match weights length"):
function(np.array([0.5, 0.5]), np.eye(3))
@pytest.mark.parametrize(
"function",
[risk_contribution, marginal_risk_contribution, component_var],
)
def test_risk_functions_reject_empty_portfolio(function) -> None:
with pytest.raises(ValueError, match="at least one asset"):
function(np.array([]), np.empty((0, 0)))
def test_labeled_component_risk_aligns_covariance_and_closes_to_volatility() -> None:
weights = pd.Series({"A": 0.25, "B": 0.75}, name="weight")
covariance = pd.DataFrame(
[[0.09, 0.01], [0.01, 0.04]],
index=["B", "A"],
columns=["B", "A"],
)
result = labeled_component_risk(weights, covariance)
aligned = covariance.reindex(index=weights.index, columns=weights.index)
expected_volatility = float(np.sqrt(weights @ aligned @ weights))
assert isinstance(result, ComponentRiskResult)
assert result.component.index.tolist() == ["A", "B"]
assert result.portfolio_volatility == pytest.approx(expected_volatility)
assert result.component.sum() == pytest.approx(expected_volatility)
assert result.percentage.sum() == pytest.approx(1.0)
def test_component_risk_groups_actual_asset_contributions_by_label() -> None:
weights = pd.Series({"A": 0.2, "B": 0.3, "C": 0.5})
covariance = pd.DataFrame(np.diag([0.04, 0.09, 0.16]), index=weights.index, columns=weights.index)
groups = pd.Series({"C": "growth", "A": "value", "B": "value"})
result = labeled_component_risk(weights, covariance)
grouped = result.grouped_component(groups)
assert grouped.index.tolist() == ["growth", "value"]
assert grouped.loc["value"] == pytest.approx(
result.component.loc["A"] + result.component.loc["B"]
)
assert grouped.sum() == pytest.approx(result.portfolio_volatility)
def test_labeled_component_risk_rejects_asset_label_mismatch() -> None:
weights = pd.Series({"A": 0.5, "B": 0.5})
covariance = pd.DataFrame(np.eye(2), index=["A", "C"], columns=["A", "C"])
with pytest.raises(ValueError, match="same asset labels"):
labeled_component_risk(weights, covariance)
def test_labeled_component_risk_rejects_invalid_covariance() -> None:
weights = pd.Series({"A": 0.5, "B": 0.5})
asymmetric = pd.DataFrame([[1.0, 0.2], [0.1, 1.0]], index=weights.index, columns=weights.index)
with pytest.raises(ValueError, match="symmetric"):
labeled_component_risk(weights, asymmetric)
def test_labeled_component_risk_rejects_zero_variance_portfolio() -> None:
weights = pd.Series({"A": 0.5, "B": 0.5})
covariance = pd.DataFrame(np.zeros((2, 2)), index=weights.index, columns=weights.index)
with pytest.raises(ValueError, match="positive portfolio variance"):
labeled_component_risk(weights, covariance)