From 0c3a375b1e380a4ec0319d5db7173afeb83fc58b Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:57:49 +0800 Subject: [PATCH 01/24] test: add red contracts for quant core boundaries --- tests/test_backtest.py | 124 +++++++++++++++++++++++++ tests/test_factor_library.py | 173 +++++++++++++++++++++++++++++++++++ tests/test_metrics.py | 93 +++++++++++++++++++ tests/test_risk.py | 54 +++++++++++ 4 files changed, 444 insertions(+) create mode 100644 tests/test_backtest.py create mode 100644 tests/test_factor_library.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_risk.py diff --git a/tests/test_backtest.py b/tests/test_backtest.py new file mode 100644 index 0000000..f642d4f --- /dev/null +++ b/tests/test_backtest.py @@ -0,0 +1,124 @@ +"""Backtest contract tests for weights, NAV, rebalancing, and benchmarks.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from quant_engine.backtest import ( + compare_to_benchmark, + compute_nav_from_weights, + compute_returns_from_nav, + rebalance_periodic, + 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) diff --git a/tests/test_factor_library.py b/tests/test_factor_library.py new file mode 100644 index 0000000..25591fe --- /dev/null +++ b/tests/test_factor_library.py @@ -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) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..2f29cf2 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,93 @@ +"""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, + calmar_ratio, + max_drawdown, + sharpe_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 calmar_ratio(returns) == 0.0 + + +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, max_drawdown, calmar_ratio, win_rate], +) +def test_metrics_reject_non_series_input(metric) -> None: + with pytest.raises(TypeError, match="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 diff --git a/tests/test_risk.py b/tests/test_risk.py new file mode 100644 index 0000000..f1899be --- /dev/null +++ b/tests/test_risk.py @@ -0,0 +1,54 @@ +"""Risk contribution contracts and validation tests.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from quant_engine.risk import component_var, marginal_risk_contribution, risk_contribution + + +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))) -- 2.54.0 From e359792ef50dcb9918b1a886bdcf0dfffa0ad2be Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:58:30 +0800 Subject: [PATCH 02/24] fix: harden quant core calculation boundaries --- src/quant_engine/backtest.py | 5 +++++ src/quant_engine/factor_library.py | 6 +++++- src/quant_engine/metrics.py | 3 ++- src/quant_engine/risk.py | 27 +++++++++++++++++++-------- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/quant_engine/backtest.py b/src/quant_engine/backtest.py index 6becddb..e083d36 100644 --- a/src/quant_engine/backtest.py +++ b/src/quant_engine/backtest.py @@ -131,6 +131,9 @@ def rebalance_periodic( Returns: 调仓表 DataFrame(all_dates × 股票代码) """ + if all_dates.empty: + return pd.DataFrame(index=all_dates, columns=target_weights.index, dtype=float) + table = pd.DataFrame(0.0, index=all_dates, columns=target_weights.index) for date in rebalance_dates: if date not in all_dates: @@ -201,6 +204,8 @@ def compare_to_benchmark( """ # 对齐 index common = strategy_nav.index.intersection(benchmark_nav.index) + if common.empty: + raise ValueError("strategy and benchmark must have overlapping dates") s = strategy_nav.loc[common] b = benchmark_nav.loc[common] diff --git a/src/quant_engine/factor_library.py b/src/quant_engine/factor_library.py index 4fa4c59..68f6bad 100644 --- a/src/quant_engine/factor_library.py +++ b/src/quant_engine/factor_library.py @@ -313,7 +313,7 @@ def ols_regress( r_sq = 1.0 - ss_res / ss_tot if ss_tot > 0 else np.nan sigma2 = ss_res / max(n - k, 1) # 协方差矩阵 = sigma2 * (X'X)^-1 - xtx_inv = np.linalg.inv(x_arr.T @ x_arr) if sigma2 > 0 else np.full((k, k), np.nan) + xtx_inv = np.linalg.pinv(x_arr.T @ x_arr) if sigma2 > 0 else np.full((k, k), np.nan) se = np.sqrt(np.diag(xtx_inv) * sigma2) t_vals = coef / se if sigma2 > 0 else np.full_like(coef, np.nan) if add_constant: @@ -513,6 +513,8 @@ def apply_factor_direction( Returns: 方向调整后的因子(同向 = 越大越好) """ + if direction not in {"auto", "forward", "reverse"}: + raise ValueError(f"direction={direction!r} not supported (auto / forward / reverse)") if factor.empty: return factor.copy() if direction == "auto": @@ -541,6 +543,8 @@ def cross_sectional_rank_with_direction( Returns: pd.Series(百分位排名 [0, 1],越大越优) """ + if direction not in {"auto", "forward", "reverse"}: + raise ValueError(f"direction={direction!r} not supported (auto / forward / reverse)") if df.empty or factor_col not in df.columns: return pd.Series(dtype=float) factor = df[factor_col] diff --git a/src/quant_engine/metrics.py b/src/quant_engine/metrics.py index 270ceea..3f80b5e 100644 --- a/src/quant_engine/metrics.py +++ b/src/quant_engine/metrics.py @@ -71,7 +71,8 @@ def max_drawdown(r: pd.Series) -> float: if len(r) < 2: return 0.0 nav = (1 + r).cumprod() - peak = nav.cummax() + # 初始资金净值为 1;否则首个观测日的亏损会被误当成新的历史高点。 + peak = nav.cummax().clip(lower=1.0) drawdown = (nav - peak) / peak return float(drawdown.min()) diff --git a/src/quant_engine/risk.py b/src/quant_engine/risk.py index 2ca6a69..ea659e0 100644 --- a/src/quant_engine/risk.py +++ b/src/quant_engine/risk.py @@ -10,6 +10,22 @@ from numpy.typing import NDArray from typing import Any +def _validate_inputs( + weights: NDArray[Any], cov: NDArray[Any] +) -> tuple[NDArray[Any], NDArray[Any]]: + """Normalize a portfolio vector and its covariance matrix.""" + w = np.asarray(weights, dtype=float).ravel() + covariance = np.asarray(cov, dtype=float) + k = w.size + if k == 0: + raise ValueError("weights must contain at least one asset") + if covariance.shape != (k, k): + raise ValueError( + f"cov shape {covariance.shape} does not match weights length {k}" + ) + return w, covariance + + def risk_contribution(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]: """风险贡献率 (RC_i): w_i * (Σw)_i / w'Σw。 @@ -25,11 +41,8 @@ def risk_contribution(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]: Returns: RC: 风险贡献向量 (k,), Σ=1 """ - w = np.asarray(weights, dtype=float).ravel() - cov = np.asarray(cov, dtype=float) + w, cov = _validate_inputs(weights, cov) k = w.size - if cov.shape != (k, k): - raise ValueError(f"cov 形状 {cov.shape} 与 weights 长度 {k} 不匹配") port_var = float(w @ cov @ w) if port_var <= 0: @@ -41,13 +54,11 @@ def risk_contribution(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]: def marginal_risk_contribution(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]: """边际风险贡献 (MRC_i): (Σw)_i。""" - w = np.asarray(weights, dtype=float).ravel() - cov = np.asarray(cov, dtype=float) + w, cov = _validate_inputs(weights, cov) return cov @ w # type: ignore[no-any-return] def component_var(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]: """成分方差: w_i · (Σw)_i; 与 RC 的关系 RC_i = CV_i / w'Σw。""" - w = np.asarray(weights, dtype=float).ravel() - cov = np.asarray(cov, dtype=float) + w, cov = _validate_inputs(weights, cov) return w * (cov @ w) # type: ignore[no-any-return] -- 2.54.0 From bae4dedf70a07523df2f89af0ac24465ef67b260 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:59:27 +0800 Subject: [PATCH 03/24] test: satisfy metric contract lint --- tests/test_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 2f29cf2..959e6d8 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -82,7 +82,7 @@ def test_summary_aliases_match_canonical_fields() -> None: [annualized_return, annualized_volatility, sharpe_ratio, max_drawdown, calmar_ratio, win_rate], ) def test_metrics_reject_non_series_input(metric) -> None: - with pytest.raises(TypeError, match="expected pd.Series"): + with pytest.raises(TypeError, match=r"expected pd\.Series"): metric([0.01, 0.02]) -- 2.54.0 From 2ff0630973a671f44160d55d82fe93514ddc1eea Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:00:19 +0800 Subject: [PATCH 04/24] refactor: clarify quant core validation internals --- src/quant_engine/factor_library.py | 2 +- src/quant_engine/risk.py | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/quant_engine/factor_library.py b/src/quant_engine/factor_library.py index 68f6bad..2de5538 100644 --- a/src/quant_engine/factor_library.py +++ b/src/quant_engine/factor_library.py @@ -312,7 +312,7 @@ def ols_regress( ss_tot = float(((y_arr - y_arr.mean()) ** 2).sum()) r_sq = 1.0 - ss_res / ss_tot if ss_tot > 0 else np.nan sigma2 = ss_res / max(n - k, 1) - # 协方差矩阵 = sigma2 * (X'X)^-1 + # 广义协方差矩阵 = sigma2 * (X'X)^+,伪逆兼容共线因子。 xtx_inv = np.linalg.pinv(x_arr.T @ x_arr) if sigma2 > 0 else np.full((k, k), np.nan) se = np.sqrt(np.diag(xtx_inv) * sigma2) t_vals = coef / se if sigma2 > 0 else np.full_like(coef, np.nan) diff --git a/src/quant_engine/risk.py b/src/quant_engine/risk.py index ea659e0..95a1825 100644 --- a/src/quant_engine/risk.py +++ b/src/quant_engine/risk.py @@ -5,14 +5,13 @@ from __future__ import annotations -import numpy as np -from numpy.typing import NDArray from typing import Any +import numpy as np +from numpy.typing import NDArray -def _validate_inputs( - weights: NDArray[Any], cov: NDArray[Any] -) -> tuple[NDArray[Any], NDArray[Any]]: + +def _validate_inputs(weights: NDArray[Any], cov: NDArray[Any]) -> tuple[NDArray[Any], NDArray[Any]]: """Normalize a portfolio vector and its covariance matrix.""" w = np.asarray(weights, dtype=float).ravel() covariance = np.asarray(cov, dtype=float) @@ -20,9 +19,7 @@ def _validate_inputs( if k == 0: raise ValueError("weights must contain at least one asset") if covariance.shape != (k, k): - raise ValueError( - f"cov shape {covariance.shape} does not match weights length {k}" - ) + raise ValueError(f"cov shape {covariance.shape} does not match weights length {k}") return w, covariance -- 2.54.0 From 585a635797a6c26cb7d787f4f5655e654e25474f Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:10:33 +0800 Subject: [PATCH 05/24] test: add red contract for unified backtest result --- tests/test_backtest.py | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index f642d4f..9f8d4a8 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -6,10 +6,12 @@ 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, ) @@ -122,3 +124,86 @@ def test_compare_to_benchmark_rejects_non_overlapping_dates() -> None: 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 -- 2.54.0 From cdf41edf1f33ebd06de302637129e0006b4edbe7 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:11:06 +0800 Subject: [PATCH 06/24] feat: add unified weight backtest result facade --- src/quant_engine/backtest.py | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/quant_engine/backtest.py b/src/quant_engine/backtest.py index e083d36..f7c5e11 100644 --- a/src/quant_engine/backtest.py +++ b/src/quant_engine/backtest.py @@ -34,8 +34,8 @@ print(result.summary()) from __future__ import annotations -from pathlib import Path from collections.abc import Mapping, Sequence +from dataclasses import dataclass import numpy as np import pandas as pd @@ -46,6 +46,26 @@ from quant_engine.metrics import summary as metrics_summary logger = get_logger(__name__) +@dataclass(frozen=True, slots=True, eq=False) +class BacktestResult: + """一次权重回测的稳定结果快照。""" + + nav: pd.Series + returns: pd.Series + weights: pd.DataFrame + benchmark_nav: pd.Series | None = None + + def stats(self, rf: float = 0.0) -> Mapping[str, float]: + """返回标准绩效指标。""" + return metrics_summary(self.returns, rf) + + def benchmark_report(self, rf: float = 0.0) -> pd.DataFrame: + """返回策略与基准的对比报告。""" + if self.benchmark_nav is None: + raise ValueError("benchmark_nav is required for benchmark comparison") + return compare_to_benchmark(self.nav, self.benchmark_nav, rf) + + # ── 调仓表 → 净值 ────────────────────────────────────── @@ -113,6 +133,29 @@ def compute_returns_from_nav(nav: pd.Series) -> pd.Series: return nav.pct_change().fillna(0.0) +def run_weight_backtest( + weights: pd.DataFrame, + stock_returns: pd.DataFrame, + initial_capital: float = 1.0, + tc_rate: float = 0.0, + benchmark_nav: pd.Series | None = None, +) -> BacktestResult: + """执行权重回测并返回隔离于调用方输入的结果快照。""" + weights_snapshot = weights.copy(deep=True) + nav = compute_nav_from_weights( + weights=weights_snapshot, + stock_returns=stock_returns, + initial_capital=initial_capital, + tc_rate=tc_rate, + ) + return BacktestResult( + nav=nav, + returns=compute_returns_from_nav(nav), + weights=weights_snapshot, + benchmark_nav=None if benchmark_nav is None else benchmark_nav.copy(deep=True), + ) + + # ── 调仓工具 ────────────────────────────────────── -- 2.54.0 From 979166ad1616392242ac5f74a9f89e702c02b0e4 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:11:52 +0800 Subject: [PATCH 07/24] docs: document unified backtest result workflow --- README.md | 11 +++++++++++ src/quant_engine/backtest.py | 17 ++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7714123..526b1a2 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ from quant_engine.alpha_factors import alpha_001, alpha_005, ALPHA158_REGISTRY from quant_engine.execution import ( ExecutionConfig, simulate_with_daily_data, compute_realized_pnl, ) +from quant_engine.backtest import run_weight_backtest from quant_engine.indicators import macd, bollinger, kdj from quant_engine.data_adapter import ( long_to_wide, wide_to_long, rename_tushare_columns, @@ -72,6 +73,16 @@ from quant_engine.data_adapter import ( df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True) prices, volumes = prepare_execution_inputs(df) result = simulate_with_daily_data(prices, initial_cash=1_000_000.0) + +# 权重回测 → 稳定结果对象 → 绩效/基准分析 +backtest = run_weight_backtest( + weights=rebalance_weights, + stock_returns=daily_returns, + initial_capital=1_000_000.0, + benchmark_nav=benchmark_nav, +) +print(backtest.stats()) +print(backtest.benchmark_report()) ``` ## 与 research_results 的关系 diff --git a/src/quant_engine/backtest.py b/src/quant_engine/backtest.py index f7c5e11..a705c4a 100644 --- a/src/quant_engine/backtest.py +++ b/src/quant_engine/backtest.py @@ -13,22 +13,21 @@ ```python from quant_engine.backtest import ( - compute_nav_from_weights, # 调仓表 → 净值 - rebalance_table, # 周期性再平衡 - compare_to_benchmark, # 策略 vs 基准 + rebalance_periodic, # 周期性再平衡 + run_weight_backtest, # 权重 → 统一结果对象 weights_to_long_short, # 多空组合 ) -# 1. 调仓表 → 净值 -nav = compute_nav_from_weights( +# 调仓表 → 净值、收益、绩效与基准报告 +rebalance_table = rebalance_periodic(target_weights, rebalance_dates, returns.index) +result = run_weight_backtest( weights=rebalance_table, # 每周/每月调仓 stock_returns=returns, # 个股日收益 initial_capital=1.0, + benchmark_nav=benchmark_nav, ) - -# 2. 跟基准比 -result = compare_to_benchmark(nav, benchmark_nav) -print(result.summary()) +print(result.stats()) +print(result.benchmark_report()) ``` """ -- 2.54.0 From ecf6b4e4bf5fe161bdb485af31c51cd5764f7a80 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:22:36 +0800 Subject: [PATCH 08/24] test: add red factor-to-weights portfolio contract --- tests/test_portfolio_construction.py | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/test_portfolio_construction.py diff --git a/tests/test_portfolio_construction.py b/tests/test_portfolio_construction.py new file mode 100644 index 0000000..43cd606 --- /dev/null +++ b/tests/test_portfolio_construction.py @@ -0,0 +1,137 @@ +"""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_factor_scores_flow_directly_into_weight_backtest() -> None: + dates = pd.date_range("2026-01-05", periods=3, freq="B") + scores = pd.DataFrame( + {"A": [2.0, 0.0], "B": [1.0, 3.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, + ) + + weights = scores_to_weight_table(scores, top_k=1) + result = run_weight_backtest(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, 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_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) -- 2.54.0 From 96e8f1ad625e83f4389b69f4ca5ab5df25ca053f Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:23:25 +0800 Subject: [PATCH 09/24] feat: build target weights from factor scores --- src/quant_engine/portfolio_construction.py | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/quant_engine/portfolio_construction.py diff --git a/src/quant_engine/portfolio_construction.py b/src/quant_engine/portfolio_construction.py new file mode 100644 index 0000000..90e2d7d --- /dev/null +++ b/src/quant_engine/portfolio_construction.py @@ -0,0 +1,100 @@ +"""因子分数到目标权重的轻量组合构建闭环。""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +from pandas.api.types import is_numeric_dtype + +__all__ = [ + "select_top_k", + "equal_weight", + "scores_to_target_weights", + "scores_to_weight_table", +] + + +def _validate_top_k(top_k: int) -> None: + if isinstance(top_k, bool) or not isinstance(top_k, int) or top_k <= 0: + raise ValueError("top_k must be positive") + + +def _validate_gross_exposure(gross_exposure: float) -> None: + if not np.isfinite(gross_exposure) or gross_exposure < 0: + raise ValueError("gross_exposure must be finite and non-negative") + + +def _validate_score_series(scores: pd.Series) -> None: + if not isinstance(scores, pd.Series): + raise TypeError(f"scores must be a pandas Series, got {type(scores).__name__}") + if not scores.index.is_unique: + raise ValueError("scores must contain unique asset labels") + if not is_numeric_dtype(scores.dtype): + raise TypeError("scores must contain numeric values") + + +def select_top_k(scores: pd.Series, top_k: int, *, largest: bool = True) -> pd.Index: + """稳定选择最高或最低的 K 个有效因子分数。""" + _validate_top_k(top_k) + _validate_score_series(scores) + valid_scores = scores.dropna() + ordered = valid_scores.sort_values(ascending=not largest, kind="mergesort") + return ordered.iloc[:top_k].index.copy() + + +def equal_weight(assets: pd.Index, *, gross_exposure: float = 1.0) -> pd.Series: + """在已选资产间等权分配指定总敞口。""" + _validate_gross_exposure(gross_exposure) + if not assets.is_unique: + raise ValueError("assets must contain unique asset labels") + if assets.empty: + return pd.Series(index=assets.copy(), dtype=float, name="weight") + weight = gross_exposure / len(assets) + return pd.Series(weight, index=assets.copy(), dtype=float, name="weight") + + +def scores_to_target_weights( + scores: pd.Series, + top_k: int, + *, + gross_exposure: float = 1.0, + largest: bool = True, +) -> pd.Series: + """把单期因子分数转换为完整股票池目标权重。""" + _validate_score_series(scores) + selected = select_top_k(scores, top_k, largest=largest) + selected_weights = equal_weight(selected, gross_exposure=gross_exposure) + result = pd.Series(0.0, index=scores.index.copy(), dtype=float, name="weight") + result.loc[selected_weights.index] = selected_weights + return result + + +def scores_to_weight_table( + scores: pd.DataFrame, + top_k: int, + *, + gross_exposure: float = 1.0, + largest: bool = True, +) -> pd.DataFrame: + """逐调仓日独立构建目标权重表,避免使用未来分数。""" + if not isinstance(scores, pd.DataFrame): + raise TypeError(f"scores must be a pandas DataFrame, got {type(scores).__name__}") + _validate_top_k(top_k) + _validate_gross_exposure(gross_exposure) + if not scores.columns.is_unique: + raise ValueError("scores must contain unique asset labels") + if not all(is_numeric_dtype(dtype) for dtype in scores.dtypes): + raise TypeError("scores must contain numeric values") + if scores.empty: + return pd.DataFrame(index=scores.index.copy(), columns=scores.columns.copy(), dtype=float) + + rows = [ + scores_to_target_weights( + row, + top_k, + gross_exposure=gross_exposure, + largest=largest, + ).to_numpy() + for _, row in scores.iterrows() + ] + return pd.DataFrame(rows, index=scores.index.copy(), columns=scores.columns.copy(), dtype=float) -- 2.54.0 From a1130ea43bf9df7718fdec5e27fa4f6bd33cf151 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:24:03 +0800 Subject: [PATCH 10/24] test: reject ambiguous duplicate rebalance dates --- tests/test_portfolio_construction.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_portfolio_construction.py b/tests/test_portfolio_construction.py index 43cd606..32756d2 100644 --- a/tests/test_portfolio_construction.py +++ b/tests/test_portfolio_construction.py @@ -130,6 +130,17 @@ def test_portfolio_construction_rejects_duplicate_assets() -> None: 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_non_numeric_scores() -> None: scores = pd.DataFrame({"A": ["high"], "B": ["low"]}) -- 2.54.0 From 88af157ee713301fbbe823b6b05e6eb4fbaa584e Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:24:17 +0800 Subject: [PATCH 11/24] fix: validate unique rebalance dates --- src/quant_engine/portfolio_construction.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/quant_engine/portfolio_construction.py b/src/quant_engine/portfolio_construction.py index 90e2d7d..88487de 100644 --- a/src/quant_engine/portfolio_construction.py +++ b/src/quant_engine/portfolio_construction.py @@ -81,6 +81,8 @@ def scores_to_weight_table( raise TypeError(f"scores must be a pandas DataFrame, got {type(scores).__name__}") _validate_top_k(top_k) _validate_gross_exposure(gross_exposure) + if not scores.index.is_unique: + raise ValueError("scores must contain unique rebalance dates") if not scores.columns.is_unique: raise ValueError("scores must contain unique asset labels") if not all(is_numeric_dtype(dtype) for dtype in scores.dtypes): -- 2.54.0 From 0a236622f3f08ca52ff4c98c96760b130447d505 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:24:41 +0800 Subject: [PATCH 12/24] docs: add factor-to-backtest portfolio workflow --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 526b1a2..9fec119 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - `indicators` — 50+ 技术指标(MACD / KDJ / 布林 / ATR / ADX / 等) - `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理) - `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark) +- `portfolio_construction` — 多期因子分数 → Top-K → 等权目标权重表 - `metrics` — 绩效(年化收益 / 波动率 / Sharpe / 最大回撤 / Calmar) - `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test) - `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因) @@ -61,6 +62,7 @@ from quant_engine.execution import ( ExecutionConfig, simulate_with_daily_data, compute_realized_pnl, ) from quant_engine.backtest import run_weight_backtest +from quant_engine.portfolio_construction import scores_to_weight_table from quant_engine.indicators import macd, bollinger, kdj from quant_engine.data_adapter import ( long_to_wide, wide_to_long, rename_tushare_columns, @@ -74,7 +76,12 @@ df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True) prices, volumes = prepare_execution_inputs(df) result = simulate_with_daily_data(prices, initial_cash=1_000_000.0) -# 权重回测 → 稳定结果对象 → 绩效/基准分析 +# 多期因子分数 → Top-K 等权组合 → 稳定回测结果 +rebalance_weights = scores_to_weight_table( + factor_scores, + top_k=20, + gross_exposure=1.0, +) backtest = run_weight_backtest( weights=rebalance_weights, stock_returns=daily_returns, -- 2.54.0 From b4f7b74c04e8777996a86438aa2fd0a9509fdb1c Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:25:11 +0800 Subject: [PATCH 13/24] test: reject unsorted rebalance dates --- tests/test_portfolio_construction.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_portfolio_construction.py b/tests/test_portfolio_construction.py index 32756d2..cd43af1 100644 --- a/tests/test_portfolio_construction.py +++ b/tests/test_portfolio_construction.py @@ -141,6 +141,16 @@ def test_weight_table_rejects_duplicate_rebalance_dates() -> None: 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"]}) -- 2.54.0 From 5578851d853c495bc04e1fed278d84b7c5b33d6a Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:25:27 +0800 Subject: [PATCH 14/24] fix: enforce chronological rebalance scores --- src/quant_engine/portfolio_construction.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/quant_engine/portfolio_construction.py b/src/quant_engine/portfolio_construction.py index 88487de..a58cad4 100644 --- a/src/quant_engine/portfolio_construction.py +++ b/src/quant_engine/portfolio_construction.py @@ -83,6 +83,8 @@ def scores_to_weight_table( _validate_gross_exposure(gross_exposure) if not scores.index.is_unique: raise ValueError("scores must contain unique rebalance dates") + if not scores.index.is_monotonic_increasing: + raise ValueError("scores rebalance dates must be in chronological order") if not scores.columns.is_unique: raise ValueError("scores must contain unique asset labels") if not all(is_numeric_dtype(dtype) for dtype in scores.dtypes): -- 2.54.0 From c51d803dafb899eab75ef6eb371f3a05d95815d3 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:31:52 +0800 Subject: [PATCH 15/24] test: reproduce multi-day execution audit gaps --- tests/test_execution.py | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_execution.py b/tests/test_execution.py index d244ad2..ad26c50 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -11,6 +11,7 @@ import pytest from quant_engine.execution import ( ExecutionConfig, ExecutionResult, + ExecutionSimulationResult, apply_bid_ask_spread, apply_volume_constraint, check_price_limit, @@ -20,6 +21,7 @@ from quant_engine.execution import ( run_end_to_end_poc, simulate_execution, simulate_multi_day, + simulate_multi_day_with_audit, simulate_with_daily_data, total_costs, total_turnover, @@ -357,6 +359,73 @@ def test_simulate_multi_day_holdings_evolution(): assert "A" in positions[2].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_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, + ) + + # ── v1.2.0 Phase 1:端到端 POC(run_end_to_end_poc) ───── -- 2.54.0 From 17b680604d997fb77122117895c305db34d27c06 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:35:15 +0800 Subject: [PATCH 16/24] fix: derive multi-day trades from target-weight deltas --- src/quant_engine/execution.py | 316 +++++++++++++++++++--------------- tests/test_execution.py | 16 +- 2 files changed, 191 insertions(+), 141 deletions(-) diff --git a/src/quant_engine/execution.py b/src/quant_engine/execution.py index 279c030..b26d4c2 100644 --- a/src/quant_engine/execution.py +++ b/src/quant_engine/execution.py @@ -19,6 +19,7 @@ from __future__ import annotations +import math from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -344,19 +345,90 @@ class DailyExecution: """单日执行记录。""" date: str - executions: list[ExecutionResult] + executions: tuple[ExecutionResult, ...] nav_before: float nav_after: float rebalance_triggered: bool -def simulate_multi_day( +@dataclass(frozen=True) +class ExecutionSimulationResult: + """单次多日仿真的持仓与执行审计结果。""" + + initial_cash: float + positions: tuple[DailyPosition, ...] + daily_executions: tuple[DailyExecution, ...] + + @property + def nav_series(self) -> pd.Series: + """返回按日期索引的日末 NAV 副本。""" + return pd.Series( + [position.portfolio_value for position in self.positions], + index=[position.date for position in self.positions], + dtype=float, + ) + + @property + def total_costs(self) -> float: + """汇总实际成交产生的成本。""" + return sum( + execution.total_cost + for daily in self.daily_executions + for execution in daily.executions + ) + + @property + def total_turnover(self) -> float: + """汇总实际成交金额。""" + return sum( + execution.executed_value + for daily in self.daily_executions + for execution in daily.executions + ) + + @property + def total_rebalances(self) -> int: + """返回至少有一笔实际成交的调仓日数量。""" + return sum(daily.rebalance_triggered for daily in self.daily_executions) + + @property + def final_portfolio_value(self) -> float: + """返回最后一个日末 NAV;空输入时返回初始资金。""" + if not self.positions: + return self.initial_cash + return self.positions[-1].portfolio_value + + @property + def return_pct(self) -> float: + """返回相对初始资金的百分比收益。""" + if self.initial_cash == 0: + return 0.0 + return (self.final_portfolio_value / self.initial_cash - 1.0) * 100.0 + + +def _blocked_execution(stock_code: str, target_value: float, reason: str) -> ExecutionResult: + """构造未成交但可审计的执行记录。""" + return ExecutionResult( + stock_code=stock_code, + target_value=target_value, + executed_value=0.0, + commission=0.0, + stamp_tax=0.0, + slippage_cost=0.0, + total_cost=0.0, + net_cash_flow=0.0, + partial_fill_pct=0.0, + blocked_reason=reason, + ) + + +def simulate_multi_day_with_audit( target_weights_history: list[tuple[str, dict[str, float]]], price_history: list[tuple[str, dict[str, float]]], initial_cash: float, config: ExecutionConfig | None = None, -) -> list[DailyPosition]: - """多日组合仿真(NAV 序列)。 +) -> ExecutionSimulationResult: + """按目标权重差额推进组合,并返回唯一事实来源的审计结果。 Args: target_weights_history: [(date, {stock_code: target_weight})] @@ -365,97 +437,115 @@ def simulate_multi_day( config: 执行配置 Returns: - DailyPosition 列表(每日 NAV 快照)。 + 日末持仓快照与逐日成交记录组成的不可变结果。 Note: - 调仓频率 = target_weights_history 的频率(每日 / 每周 / 每月都行) - - 每日先按当日 close 估值,再按当日 target 调仓(下一交易日生效) - - 此处简化:调仓使用当日 close 价格 + - 每日先按当日 close 估值,再交易“目标市值 - 当前市值”的差额 + - 此处简化为当日 close 成交;调用方必须传入已正确滞后的目标权重 """ if config is None: config = ExecutionConfig() + if not math.isfinite(initial_cash) or initial_cash < 0: + raise ValueError(f"initial_cash must be finite and non-negative, got {initial_cash}") if len(target_weights_history) != len(price_history): raise ValueError("target_weights_history and price_history must have same length") if not target_weights_history: - return [] + return ExecutionSimulationResult(initial_cash, (), ()) + cash = initial_cash holdings: dict[str, float] = {} positions: list[DailyPosition] = [] - for (date, targets), (_, prices) in zip(target_weights_history, price_history, strict=True): - # 1) 先按当日收盘价估值 - portfolio_value = cash + sum( - shares * prices.get(code, 0.0) for code, shares in holdings.items() + daily_executions: list[DailyExecution] = [] + + for (date, targets), (price_date, prices) in zip( + target_weights_history, price_history, strict=True + ): + if date != price_date: + raise ValueError( + f"target and price dates must match, got {date!r} and {price_date!r}" + ) + + nav_before = cash + sum( + shares * prices.get(stock_code, 0.0) + for stock_code, shares in holdings.items() ) - positions.append( - DailyPosition( + effective_targets = dict.fromkeys(holdings, 0.0) + effective_targets.update(targets) + trade_weights: dict[str, float] = {} + rejected: list[ExecutionResult] = [] + + for stock_code, target_weight in effective_targets.items(): + price = prices.get(stock_code) + target_value = float(target_weight) * nav_before + if price is None or not math.isfinite(price) or price <= 0: + if target_value != 0 or holdings.get(stock_code, 0.0) != 0: + rejected.append(_blocked_execution(stock_code, target_value, "missing_price")) + continue + + current_value = holdings.get(stock_code, 0.0) * price + trade_value = target_value - current_value + if abs(trade_value) < config.min_trade_amount or math.isclose( + trade_value, 0.0, abs_tol=1e-12 + ): + continue + if nav_before == 0: + rejected.append(_blocked_execution(stock_code, trade_value, "zero_nav")) + continue + trade_weights[stock_code] = trade_value / nav_before + + filled = simulate_execution(trade_weights, nav_before, config) + for execution in filled: + price = prices[execution.stock_code] + share_change = abs(execution.target_value) / price + if execution.target_value > 0: + holdings[execution.stock_code] = ( + holdings.get(execution.stock_code, 0.0) + share_change + ) + else: + held = holdings.get(execution.stock_code, 0.0) + holdings[execution.stock_code] = max(0.0, held - share_change) + if holdings[execution.stock_code] < 1e-6: + del holdings[execution.stock_code] + cash += execution.net_cash_flow + + executions = (*filled, *rejected) + nav_after = cash + sum( + shares * prices.get(stock_code, 0.0) + for stock_code, shares in holdings.items() + ) + positions.append(DailyPosition(date, cash, dict(holdings), nav_after)) + daily_executions.append( + DailyExecution( date=date, - cash=cash, - holdings=dict(holdings), - portfolio_value=portfolio_value, + executions=executions, + nav_before=nav_before, + nav_after=nav_after, + rebalance_triggered=bool(filled), ) ) - # 2) 计算 effective_targets(包含需要平仓的零权重) - effective_targets: dict[str, float] = dict(targets) - for held_code in holdings: - if held_code not in effective_targets: - effective_targets[held_code] = 0.0 - # 3) 调仓(只对非零目标调用 simulate_execution) - non_zero_targets = {k: v for k, v in effective_targets.items() if v != 0} - results = simulate_execution(non_zero_targets, portfolio_value, config) - # 4) 处理零目标(平仓):构造 ExecutionResult,shares = held(全部卖出) - for stock_code, weight in effective_targets.items(): - if weight == 0 and stock_code in holdings and holdings[stock_code] > 0: - price = prices.get(stock_code, 0.0) - if price > 0: - held = holdings[stock_code] - # 全部卖出:target_shares = held - # executed_value = held * price(考虑滑点) - slippage_factor = 1.0 - config.slippage_bps / 10000.0 - target_value = -held * price - executed_value = target_value * slippage_factor - commission = abs(executed_value) * config.commission_bps / 10000.0 - stamp_tax = abs(executed_value) * config.stamp_tax_bps / 10000.0 - slippage_cost = abs(executed_value - target_value) - # 标记净卖出 shares = held - results.append( - ExecutionResult( - stock_code=stock_code, - target_value=target_value, - executed_value=executed_value, - commission=commission, - stamp_tax=stamp_tax, - slippage_cost=slippage_cost, - total_cost=commission + stamp_tax + slippage_cost, - net_cash_flow=executed_value - commission - stamp_tax, - ) - ) - # 5) 应用执行结果到持仓 - for r in results: - cost = r.executed_value + r.commission + r.stamp_tax - proceeds = r.executed_value - r.commission - r.stamp_tax - price = prices.get(r.stock_code, 0.0) - if r.target_value > 0: - # 买入:shares = 正数 executed_value / price,cash 减少 cost - shares = r.executed_value / price if price > 0 else 0.0 - holdings[r.stock_code] = holdings.get(r.stock_code, 0.0) + shares - cash -= cost - else: - # 卖出:cash 增加 proceeds 的绝对值(proceeds 本是负的) - held = holdings.get(r.stock_code, 0.0) - if held > 0: - # 如果是 zero-target 触发的全卖(target_value 与持仓市值近似),全部卖出 - if abs(r.target_value) >= held * price * 0.95: - sell_shares = held - else: - target_shares = abs(r.executed_value) / price if price > 0 else held - sell_shares = min(held, target_shares) - holdings[r.stock_code] = held - sell_shares - if holdings[r.stock_code] < 1e-6: - del holdings[r.stock_code] - # proceeds 是负的(target_value 负),cash += proceeds 实际是减去 - # 但卖出是现金流入,所以应该 cash += abs(proceeds) - cash += abs(proceeds) - return positions + + return ExecutionSimulationResult( + initial_cash=initial_cash, + positions=tuple(positions), + daily_executions=tuple(daily_executions), + ) + + +def simulate_multi_day( + target_weights_history: list[tuple[str, dict[str, float]]], + price_history: list[tuple[str, dict[str, float]]], + initial_cash: float, + config: ExecutionConfig | None = None, +) -> list[DailyPosition]: + """兼容入口:返回多日仿真的日末持仓快照。""" + result = simulate_multi_day_with_audit( + target_weights_history, + price_history, + initial_cash, + config, + ) + return list(result.positions) def run_end_to_end_poc( @@ -486,64 +576,16 @@ def run_end_to_end_poc( config = ExecutionConfig() if len(signals) != len(prices): raise ValueError("signals and prices must have same length") - positions = simulate_multi_day(signals, prices, initial_cash, config) - nav_series = pd.Series( - [p.portfolio_value for p in positions], index=[p.date for p in positions] - ) - # 计算 total_costs / total_turnover(重放所有执行) - total_cost_acc = 0.0 - total_turnover_acc = 0.0 - rebalance_count = 0 - cash = initial_cash - holdings: dict[str, float] = {} - for (date, targets), (_, price_map) in zip(signals, prices, strict=True): - portfolio_value = cash + sum( - shares * price_map.get(code, 0.0) for code, shares in holdings.items() - ) - if targets: - rebalance_count += 1 - # 自动平仓:持仓但不在 target 中的股票 - effective_targets: dict[str, float] = dict(targets) - for held_code in holdings: - if held_code not in effective_targets: - effective_targets[held_code] = 0.0 - results = simulate_execution(effective_targets, portfolio_value, config) - total_cost_acc += total_costs(results) - total_turnover_acc += total_turnover(results) - for r in results: - cost = r.executed_value + r.commission + r.stamp_tax - proceeds = r.executed_value - r.commission - r.stamp_tax - if r.target_value > 0: - shares = ( - r.executed_value / price_map[r.stock_code] - if price_map[r.stock_code] > 0 - else 0.0 - ) - holdings[r.stock_code] = holdings.get(r.stock_code, 0.0) + shares - cash -= cost - else: - held = holdings.get(r.stock_code, 0.0) - if held > 0: - sell_shares = min( - held, - abs(r.executed_value / price_map[r.stock_code]) - if price_map[r.stock_code] > 0 - else held, - ) - holdings[r.stock_code] = held - sell_shares - if holdings[r.stock_code] < 1e-6: - del holdings[r.stock_code] - cash += proceeds + audit = simulate_multi_day_with_audit(signals, prices, initial_cash, config) return { - "positions": positions, - "nav_series": nav_series, - "total_costs": total_cost_acc, - "total_turnover": total_turnover_acc, - "total_rebalances": rebalance_count, - "final_portfolio_value": nav_series.iloc[-1] if len(nav_series) > 0 else initial_cash, - "return_pct": ((nav_series.iloc[-1] / initial_cash) - 1) * 100 - if len(nav_series) > 0 - else 0.0, + "positions": list(audit.positions), + "daily_executions": list(audit.daily_executions), + "nav_series": audit.nav_series, + "total_costs": audit.total_costs, + "total_turnover": audit.total_turnover, + "total_rebalances": audit.total_rebalances, + "final_portfolio_value": audit.final_portfolio_value, + "return_pct": audit.return_pct, } @@ -667,7 +709,9 @@ __all__ = [ "apply_bid_ask_spread", "DailyPosition", "DailyExecution", + "ExecutionSimulationResult", "simulate_multi_day", + "simulate_multi_day_with_audit", "run_end_to_end_poc", "DailyPnL", "simulate_with_daily_data", diff --git a/tests/test_execution.py b/tests/test_execution.py index ad26c50..c90eacc 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -329,12 +329,18 @@ 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(): @@ -810,8 +816,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 端到端测试(集成多个函数) ────────────── -- 2.54.0 From ee22d1eb9d93109808c7fea6cf7f5d9ad8043fae Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:35:47 +0800 Subject: [PATCH 17/24] test: reproduce execution cash and long-only violations --- tests/test_execution.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_execution.py b/tests/test_execution.py index c90eacc..508db96 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -405,6 +405,49 @@ def test_simulate_multi_day_with_audit_records_costs_without_replay(): 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( -- 2.54.0 From da2ca51ff77281a83c41b49a6f7d63e18f1c4864 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:37:02 +0800 Subject: [PATCH 18/24] fix: enforce cash-backed long-only rebalancing --- src/quant_engine/execution.py | 102 +++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 14 deletions(-) diff --git a/src/quant_engine/execution.py b/src/quant_engine/execution.py index b26d4c2..0d137e5 100644 --- a/src/quant_engine/execution.py +++ b/src/quant_engine/execution.py @@ -422,6 +422,49 @@ def _blocked_execution(stock_code: str, target_value: float, reason: str) -> Exe ) +def _validate_target_weights(date: str, targets: Mapping[str, float]) -> dict[str, float]: + """校验并复制单日长仓目标权重。""" + normalized: dict[str, float] = {} + for stock_code, raw_weight in targets.items(): + try: + weight = float(raw_weight) + except (TypeError, ValueError) as error: + raise ValueError(f"target weights on {date!r} must be numeric") from error + if not math.isfinite(weight) or weight < 0: + raise ValueError(f"target weights on {date!r} must be finite and non-negative") + normalized[stock_code] = weight + if sum(normalized.values()) > 1.0 + 1e-12: + raise ValueError(f"target weights on {date!r} must sum to at most 1.0") + return normalized + + +def _partially_fill_buy( + desired: ExecutionResult, + fill_pct: float, + config: ExecutionConfig, +) -> ExecutionResult: + """按同一比例缩放买入,保留原始目标金额供审计。""" + actual_target_value = desired.target_value * fill_pct + executed_value, commission, stamp_tax, slippage_cost = _apply_costs( + actual_target_value, + True, + config, + ) + total_cost = commission + stamp_tax + slippage_cost + return ExecutionResult( + stock_code=desired.stock_code, + target_value=desired.target_value, + executed_value=executed_value, + commission=commission, + stamp_tax=stamp_tax, + slippage_cost=slippage_cost, + total_cost=total_cost, + net_cash_flow=-(executed_value + commission + stamp_tax), + partial_fill_pct=fill_pct, + blocked_reason="insufficient_cash_partial_fill", + ) + + def simulate_multi_day_with_audit( target_weights_history: list[tuple[str, dict[str, float]]], price_history: list[tuple[str, dict[str, float]]], @@ -466,13 +509,20 @@ def simulate_multi_day_with_audit( f"target and price dates must match, got {date!r} and {price_date!r}" ) + normalized_targets = _validate_target_weights(date, targets) + for held_code in holdings: + held_price = prices.get(held_code) + if held_price is None or not math.isfinite(held_price) or held_price <= 0: + raise ValueError(f"missing price for held asset {held_code} on {date!r}") + nav_before = cash + sum( shares * prices.get(stock_code, 0.0) for stock_code, shares in holdings.items() ) effective_targets = dict.fromkeys(holdings, 0.0) - effective_targets.update(targets) - trade_weights: dict[str, float] = {} + effective_targets.update(normalized_targets) + buy_weights: dict[str, float] = {} + sell_weights: dict[str, float] = {} rejected: list[ExecutionResult] = [] for stock_code, target_weight in effective_targets.items(): @@ -492,22 +542,46 @@ def simulate_multi_day_with_audit( if nav_before == 0: rejected.append(_blocked_execution(stock_code, trade_value, "zero_nav")) continue - trade_weights[stock_code] = trade_value / nav_before + destination = buy_weights if trade_value > 0 else sell_weights + destination[stock_code] = trade_value / nav_before - filled = simulate_execution(trade_weights, nav_before, config) - for execution in filled: + sell_executions = simulate_execution(sell_weights, nav_before, config) + filled: list[ExecutionResult] = [] + for execution in sell_executions: price = prices[execution.stock_code] share_change = abs(execution.target_value) / price - if execution.target_value > 0: - holdings[execution.stock_code] = ( - holdings.get(execution.stock_code, 0.0) + share_change - ) - else: - held = holdings.get(execution.stock_code, 0.0) - holdings[execution.stock_code] = max(0.0, held - share_change) - if holdings[execution.stock_code] < 1e-6: - del holdings[execution.stock_code] + held = holdings.get(execution.stock_code, 0.0) + holdings[execution.stock_code] = max(0.0, held - share_change) + if holdings[execution.stock_code] < 1e-6: + del holdings[execution.stock_code] cash += execution.net_cash_flow + filled.append(execution) + + desired_buys = simulate_execution(buy_weights, nav_before, config) + required_cash = sum(-execution.net_cash_flow for execution in desired_buys) + buy_fill_pct = min(1.0, max(cash, 0.0) / required_cash) if required_cash > 0 else 1.0 + for desired in desired_buys: + if buy_fill_pct == 0: + rejected.append( + _blocked_execution(desired.stock_code, desired.target_value, "insufficient_cash") + ) + continue + execution = ( + desired + if buy_fill_pct == 1.0 + else _partially_fill_buy(desired, buy_fill_pct, config) + ) + price = prices[execution.stock_code] + share_change = ( + execution.target_value * execution.partial_fill_pct / price + ) + holdings[execution.stock_code] = ( + holdings.get(execution.stock_code, 0.0) + share_change + ) + cash += execution.net_cash_flow + if math.isclose(cash, 0.0, abs_tol=1e-9): + cash = 0.0 + filled.append(execution) executions = (*filled, *rejected) nav_after = cash + sum( -- 2.54.0 From b2af2a10a680dc9441337bd6a17468d83bba0c8a Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:38:08 +0800 Subject: [PATCH 19/24] docs: document auditable execution workflow --- README.md | 20 ++++++++++++++++++-- tests/test_execution.py | 22 +++++++++++++--------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9fec119..e31922d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## 模块 - `alpha_factors` — 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158) -- `execution` — 执行仿真(成本/滑点/T+1/涨跌停/部分成交/价差)+ 多日 NAV + PnL 拆解(借鉴 hikyuu 部件化思想) +- `execution` — A 股长仓执行仿真(成本/滑点/现金约束)+ 逐日成交/拒绝/持仓/NAV 审计;T+1、涨跌停、成交量与价差提供独立约束函数 - `indicators` — 50+ 技术指标(MACD / KDJ / 布林 / ATR / ADX / 等) - `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理) - `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark) @@ -59,7 +59,7 @@ ruff check src/ tests/ # lint ```python from quant_engine.alpha_factors import alpha_001, alpha_005, ALPHA158_REGISTRY from quant_engine.execution import ( - ExecutionConfig, simulate_with_daily_data, compute_realized_pnl, + ExecutionConfig, simulate_multi_day_with_audit, simulate_with_daily_data, ) from quant_engine.backtest import run_weight_backtest from quant_engine.portfolio_construction import scores_to_weight_table @@ -76,6 +76,22 @@ df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True) prices, volumes = prepare_execution_inputs(df) result = simulate_with_daily_data(prices, initial_cash=1_000_000.0) +# 已正确滞后的目标权重 → 现金约束执行 → 唯一来源的成交/拒绝/日末持仓/NAV +execution = simulate_multi_day_with_audit( + target_weights_history=[ + ("2024-01-02", {"000001.SZ": 1.0}), + ("2024-01-03", {"000001.SZ": 1.0}), + ], + price_history=[ + ("2024-01-02", {"000001.SZ": 10.0}), + ("2024-01-03", {"000001.SZ": 10.5}), + ], + initial_cash=1_000_000.0, + config=ExecutionConfig(), +) +print(execution.nav_series) +print(execution.daily_executions) + # 多期因子分数 → Top-K 等权组合 → 稳定回测结果 rebalance_weights = scores_to_weight_table( factor_scores, diff --git a/tests/test_execution.py b/tests/test_execution.py index 508db96..e1fdd6b 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -344,15 +344,11 @@ def test_simulate_multi_day_first_day_value_equals_initial(): 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}), @@ -360,9 +356,8 @@ 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(): @@ -567,6 +562,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 / 涨跌停 / 部分成交 / 买卖价差 ───── -- 2.54.0 From c9fb5978f046cdde5e13179afb211a075c4231d3 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:39:12 +0800 Subject: [PATCH 20/24] docs: clarify execution audit result contract --- src/quant_engine/execution.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/quant_engine/execution.py b/src/quant_engine/execution.py index 0d137e5..f6c93a0 100644 --- a/src/quant_engine/execution.py +++ b/src/quant_engine/execution.py @@ -10,7 +10,8 @@ 借鉴 hikyuu SG/MM/CN/PG 部件化思想(不引入 hikyuu 框架): - ExecutionConfig:佣金 + 印花税 + 滑点 + 最小交易额 + 止损/止盈阈值 - simulate_execution():从目标权重 → 实际成交金额(应用成本/滑点) -- simulate_multi_day():多日组合仿真(NAV 序列 + 调仓记录) +- simulate_multi_day_with_audit():目标权重差额调仓(成交/拒绝/持仓/NAV) +- simulate_multi_day():兼容的多日日末持仓快照入口 - check_stop_loss_take_profit():止损/止盈触发判定 - run_end_to_end_poc():signal → 调仓 → 执行 → NAV 端到端 POC @@ -480,7 +481,7 @@ def simulate_multi_day_with_audit( config: 执行配置 Returns: - 日末持仓快照与逐日成交记录组成的不可变结果。 + 日末持仓快照与逐日成交记录组成的结构化审计结果。 Note: - 调仓频率 = target_weights_history 的频率(每日 / 每周 / 每月都行) -- 2.54.0 From b7f77e2a6cb1ad8a5759be4c7f127676b0401c8c Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:46:00 +0800 Subject: [PATCH 21/24] test: define lagged factor-to-execution contract --- tests/test_data_adapter.py | 19 ++++ tests/test_research_pipeline.py | 150 ++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/test_research_pipeline.py diff --git a/tests/test_data_adapter.py b/tests/test_data_adapter.py index 0f101d9..df21754 100644 --- a/tests/test_data_adapter.py +++ b/tests/test_data_adapter.py @@ -266,6 +266,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 +297,14 @@ 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") + + # ── 端到端:长表 → 适配 → alpha158 + execution ────────────── diff --git a/tests/test_research_pipeline.py b/tests/test_research_pipeline.py new file mode 100644 index 0000000..22c8701 --- /dev/null +++ b/tests/test_research_pipeline.py @@ -0,0 +1,150 @@ +"""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 ( + FactorExecutionResult, + TargetWeightSchedule, + 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.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 == () -- 2.54.0 From 213aa88deb4100a28a1d7b5ee88ff73fbf09b323 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:46:47 +0800 Subject: [PATCH 22/24] test: require execution-price input snapshot --- tests/test_research_pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_research_pipeline.py b/tests/test_research_pipeline.py index 22c8701..b7f4e72 100644 --- a/tests/test_research_pipeline.py +++ b/tests/test_research_pipeline.py @@ -95,6 +95,7 @@ def test_factor_execution_result_snapshots_research_inputs() -> None: 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 -- 2.54.0 From f9b7f2ab1a7cf9067606a993633d0969ef29b9c8 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:47:53 +0800 Subject: [PATCH 23/24] feat: schedule factor weights for next-session execution --- src/quant_engine/data_adapter.py | 11 +- src/quant_engine/research_pipeline.py | 217 ++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 src/quant_engine/research_pipeline.py diff --git a/src/quant_engine/data_adapter.py b/src/quant_engine/data_adapter.py index 9181700..041b6c3 100644 --- a/src/quant_engine/data_adapter.py +++ b/src/quant_engine/data_adapter.py @@ -287,6 +287,8 @@ def prepare_execution_inputs( df: pd.DataFrame, stock_col: str = "stock_code", date_col: str = "trade_date", + *, + price_col: str = "close", ) -> tuple[pd.DataFrame, pd.DataFrame]: """长表行情 → execution 输入(prices + volumes 宽表)。 @@ -294,10 +296,11 @@ def prepare_execution_inputs( df: 长表行情(含 close / volume 列,Tushare rename 后) stock_col: 股票代码列名 date_col: 日期列名 + price_col: 执行价字段,默认 close;防前视研究可显式选择下一交易日 open Returns: (prices_wide, volumes_wide): - - prices_wide: date × stock_code,值=close + - prices_wide: date × stock_code,值=price_col - volumes_wide: date × stock_code,值=volume(若无 volume 列则全 1.0) Examples: @@ -313,9 +316,9 @@ def prepare_execution_inputs( """ if df.empty: return pd.DataFrame(), pd.DataFrame() - if "close" not in df.columns: - raise ValueError(f"prepare_execution_inputs: 缺 close 列,实际列={list(df.columns)}") - prices = long_to_wide(df, value_col="close", date_col=date_col, stock_col=stock_col) + if price_col not in df.columns: + raise ValueError(f"prepare_execution_inputs: 缺 {price_col} 列,实际列={list(df.columns)}") + prices = long_to_wide(df, value_col=price_col, date_col=date_col, stock_col=stock_col) if "volume" in df.columns: volumes = long_to_wide(df, value_col="volume", date_col=date_col, stock_col=stock_col) else: diff --git a/src/quant_engine/research_pipeline.py b/src/quant_engine/research_pipeline.py new file mode 100644 index 0000000..0d96496 --- /dev/null +++ b/src/quant_engine/research_pipeline.py @@ -0,0 +1,217 @@ +"""可信研究链路:因子分数经交易日历滞后后进入执行审计。 + +本模块只编排现有组合构建与执行组件,不连接账户、券商或实盘订单。 +时间契约借鉴 Qlib 的 prediction/trade time 分离与 Backtrader 的 next-bar +执行语义:signal_date 上形成的目标权重,默认最早在下一交易时点执行。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from pandas.api.types import is_numeric_dtype + +from quant_engine.execution import ( + ExecutionConfig, + ExecutionSimulationResult, + simulate_multi_day_with_audit, +) +from quant_engine.portfolio_construction import scores_to_weight_table + +__all__ = [ + "TargetWeightSchedule", + "FactorExecutionResult", + "schedule_target_weights", + "run_factor_execution_research", +] + + +@dataclass(frozen=True, slots=True, eq=False) +class TargetWeightSchedule: + """保留决策时间和执行时间的目标权重调度快照。""" + + decision_weights: pd.DataFrame + signal_to_execution: pd.Series + execution_weights: pd.DataFrame + lag_sessions: int + + +@dataclass(frozen=True, slots=True, eq=False) +class FactorExecutionResult: + """因子到执行审计的一次可复现研究结果。""" + + factor_scores: pd.DataFrame + execution_prices: pd.DataFrame + schedule: TargetWeightSchedule + execution_price_field: str + execution: ExecutionSimulationResult + + +def _validate_datetime_index(index: pd.Index, name: str) -> pd.DatetimeIndex: + if not isinstance(index, pd.DatetimeIndex): + raise TypeError(f"{name} must use a DatetimeIndex") + if not index.is_unique: + raise ValueError(f"{name} must contain unique sessions") + if not index.is_monotonic_increasing: + raise ValueError(f"{name} must be in chronological order") + return index + + +def _validate_decision_weights(decision_weights: pd.DataFrame) -> None: + if not isinstance(decision_weights, pd.DataFrame): + raise TypeError( + f"decision_weights must be a pandas DataFrame, got {type(decision_weights).__name__}" + ) + _validate_datetime_index(decision_weights.index, "decision_weights index") + if not decision_weights.columns.is_unique: + raise ValueError("decision_weights must contain unique asset labels") + if not all(is_numeric_dtype(dtype) for dtype in decision_weights.dtypes): + raise TypeError("decision_weights must contain numeric values") + values = decision_weights.to_numpy(dtype=float) + if not np.isfinite(values).all() or (values < 0).any(): + raise ValueError("decision_weights must be finite and non-negative") + if (decision_weights.sum(axis=1) > 1.0 + 1e-12).any(): + raise ValueError("decision_weights rows must sum to at most 1.0") + + +def _validate_execution_prices(execution_prices: pd.DataFrame) -> pd.DatetimeIndex: + if not isinstance(execution_prices, pd.DataFrame): + raise TypeError( + f"execution_prices must be a pandas DataFrame, got {type(execution_prices).__name__}" + ) + calendar = _validate_datetime_index(execution_prices.index, "execution_prices index") + if not execution_prices.columns.is_unique: + raise ValueError("execution_prices must contain unique asset labels") + if not all(is_numeric_dtype(dtype) for dtype in execution_prices.dtypes): + raise TypeError("execution_prices must contain numeric values") + return calendar + + +def schedule_target_weights( + decision_weights: pd.DataFrame, + trading_calendar: pd.DatetimeIndex, + *, + lag_sessions: int = 1, +) -> TargetWeightSchedule: + """将信号日目标权重映射到后续真实交易日,不做整数行盲移位。 + + 所有信号日必须属于 ``trading_calendar``,且日历必须包含每个信号对应的 + 未来执行日;无法执行的末尾信号会显式失败,避免被静默丢弃。 + """ + _validate_decision_weights(decision_weights) + calendar = _validate_datetime_index(trading_calendar, "trading_calendar") + if isinstance(lag_sessions, bool) or not isinstance(lag_sessions, int) or lag_sessions <= 0: + raise ValueError("lag_sessions must be a positive integer") + + decision_snapshot = decision_weights.copy(deep=True) + if decision_snapshot.empty: + execution_weights = decision_snapshot.copy(deep=True) + execution_weights.index = pd.DatetimeIndex([], name="execution_date") + mapping = pd.Series( + calendar[:0], + index=decision_snapshot.index.copy(), + name="execution_date", + ) + return TargetWeightSchedule( + decision_weights=decision_snapshot, + signal_to_execution=mapping, + execution_weights=execution_weights, + lag_sessions=lag_sessions, + ) + + signal_positions = calendar.get_indexer(decision_snapshot.index) + if (signal_positions < 0).any(): + missing = decision_snapshot.index[signal_positions < 0] + raise ValueError( + "signal dates must be trading sessions; missing=" + + ", ".join(str(date) for date in missing) + ) + + execution_positions = signal_positions + lag_sessions + if (execution_positions >= len(calendar)).any(): + unavailable = decision_snapshot.index[execution_positions >= len(calendar)] + raise ValueError( + "trading_calendar lacks a future execution session for signal dates: " + + ", ".join(str(date) for date in unavailable) + ) + + execution_dates = calendar.take(execution_positions) + signal_to_execution = pd.Series( + execution_dates, + index=decision_snapshot.index.copy(), + name="execution_date", + ) + execution_weights = decision_snapshot.copy(deep=True) + execution_weights.index = pd.DatetimeIndex(execution_dates, name="execution_date") + return TargetWeightSchedule( + decision_weights=decision_snapshot, + signal_to_execution=signal_to_execution, + execution_weights=execution_weights, + lag_sessions=lag_sessions, + ) + + +def run_factor_execution_research( + factor_scores: pd.DataFrame, + execution_prices: pd.DataFrame, + *, + top_k: int, + execution_price_field: str, + lag_sessions: int = 1, + gross_exposure: float = 1.0, + largest: bool = True, + initial_cash: float = 1_000_000.0, + config: ExecutionConfig | None = None, +) -> FactorExecutionResult: + """运行因子分数 → 目标权重 → 下一交易时点 → 执行审计链路。 + + ``execution_prices`` 必须代表实际拟执行时点的价格矩阵,例如日频研究中 + signal 日收盘生成分数后使用下一交易日 ``open``。价格字段名称被保存在 + 结果元数据中,但函数不会猜测或重写价格语义。 + """ + price_field = execution_price_field.strip() + if not price_field: + raise ValueError("execution_price_field must be non-empty") + calendar = _validate_execution_prices(execution_prices) + + factor_snapshot = factor_scores.copy(deep=True) + decision_weights = scores_to_weight_table( + factor_snapshot, + top_k, + gross_exposure=gross_exposure, + largest=largest, + ) + schedule = schedule_target_weights( + decision_weights, + calendar, + lag_sessions=lag_sessions, + ) + price_snapshot = execution_prices.copy(deep=True) + + target_history: list[tuple[str, dict[str, float]]] = [] + price_history: list[tuple[str, dict[str, float]]] = [] + for execution_date, weights in schedule.execution_weights.iterrows(): + date_label = str(pd.Timestamp(execution_date)) + target_history.append( + (date_label, {asset: float(weight) for asset, weight in weights.items()}) + ) + prices = price_snapshot.loc[execution_date] + price_history.append( + (date_label, {asset: float(price) for asset, price in prices.items()}) + ) + + execution = simulate_multi_day_with_audit( + target_history, + price_history, + initial_cash, + config, + ) + return FactorExecutionResult( + factor_scores=factor_snapshot, + execution_prices=price_snapshot, + schedule=schedule, + execution_price_field=price_field, + execution=execution, + ) -- 2.54.0 From 15b283bdf9bfd27c4bb6daf51134489506b7a25f Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:49:07 +0800 Subject: [PATCH 24/24] docs: distinguish signal execution and holding times --- README.md | 23 ++++++++++++++++------- src/quant_engine/backtest.py | 5 +++-- tests/test_portfolio_construction.py | 11 +++++------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e31922d..93d3103 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理) - `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark) - `portfolio_construction` — 多期因子分数 → Top-K → 等权目标权重表 +- `research_pipeline` — 因子日 → 下一真实交易日 → 显式执行价 → 执行审计(防前视编排) - `metrics` — 绩效(年化收益 / 波动率 / Sharpe / 最大回撤 / Calmar) - `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test) - `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因) @@ -61,8 +62,8 @@ from quant_engine.alpha_factors import alpha_001, alpha_005, ALPHA158_REGISTRY from quant_engine.execution import ( ExecutionConfig, simulate_multi_day_with_audit, simulate_with_daily_data, ) +from quant_engine.research_pipeline import run_factor_execution_research from quant_engine.backtest import run_weight_backtest -from quant_engine.portfolio_construction import scores_to_weight_table from quant_engine.indicators import macd, bollinger, kdj from quant_engine.data_adapter import ( long_to_wide, wide_to_long, rename_tushare_columns, @@ -73,8 +74,9 @@ from quant_engine.data_adapter import ( # 端到端:qtdb_pro 长表 → 适配 → alpha158 → execution df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True) -prices, volumes = prepare_execution_inputs(df) -result = simulate_with_daily_data(prices, initial_cash=1_000_000.0) +close_prices, volumes = prepare_execution_inputs(df) +open_prices, _ = prepare_execution_inputs(df, price_col="open") +result = simulate_with_daily_data(close_prices, initial_cash=1_000_000.0) # 已正确滞后的目标权重 → 现金约束执行 → 唯一来源的成交/拒绝/日末持仓/NAV execution = simulate_multi_day_with_audit( @@ -92,18 +94,25 @@ execution = simulate_multi_day_with_audit( print(execution.nav_series) print(execution.daily_executions) -# 多期因子分数 → Top-K 等权组合 → 稳定回测结果 -rebalance_weights = scores_to_weight_table( +# 多期因子分数(必须是 point-in-time 数据)→ Top-K → 下一交易日 open 执行 +factor_execution = run_factor_execution_research( factor_scores, top_k=20, - gross_exposure=1.0, + execution_prices=open_prices, + execution_price_field="open", + initial_cash=1_000_000.0, ) + +# run_weight_backtest 是低层算子:只接受收益区间开始前已经生效的持仓权重。 +# 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。 backtest = run_weight_backtest( - weights=rebalance_weights, + weights=effective_holding_weights, stock_returns=daily_returns, initial_capital=1_000_000.0, benchmark_nav=benchmark_nav, ) +print(factor_execution.schedule.signal_to_execution) +print(factor_execution.execution.daily_executions) print(backtest.stats()) print(backtest.benchmark_report()) ``` diff --git a/src/quant_engine/backtest.py b/src/quant_engine/backtest.py index a705c4a..46058c2 100644 --- a/src/quant_engine/backtest.py +++ b/src/quant_engine/backtest.py @@ -76,8 +76,9 @@ def compute_nav_from_weights( ) -> pd.Series: """从调仓表(日期 × 股票权重)+ 个股日收益 → 净值曲线。 - 假设:在调仓日之间权重不变(**前向填充**)。 - 调仓日的权重 = `weights.loc[rebalance_date]`。 + 假设:输入是该收益测量区间开始前已经生效的持仓权重,并在调仓日之间 + 保持不变(**前向填充**)。本函数不会把信号日自动解释为执行日;因子分数 + 应先经交易日历调度和实际执行时点处理,避免把同一时点未知的收益计入。 Args: weights: 调仓日 × 股票代码 的权重 DataFrame(**0~1**,行和 ≤ 1) diff --git a/tests/test_portfolio_construction.py b/tests/test_portfolio_construction.py index cd43af1..b12a345 100644 --- a/tests/test_portfolio_construction.py +++ b/tests/test_portfolio_construction.py @@ -91,10 +91,10 @@ def test_scores_to_weight_table_constructs_each_rebalance_independently() -> Non pd.testing.assert_series_equal(result.iloc[0], changed_result.iloc[0]) -def test_factor_scores_flow_directly_into_weight_backtest() -> None: +def test_effective_holding_weights_flow_into_weight_backtest() -> None: dates = pd.date_range("2026-01-05", periods=3, freq="B") - scores = pd.DataFrame( - {"A": [2.0, 0.0], "B": [1.0, 3.0]}, + effective_weights = pd.DataFrame( + {"A": [1.0, 0.0], "B": [0.0, 1.0]}, index=dates[[0, 2]], ) stock_returns = pd.DataFrame( @@ -102,11 +102,10 @@ def test_factor_scores_flow_directly_into_weight_backtest() -> None: index=dates, ) - weights = scores_to_weight_table(scores, top_k=1) - result = run_weight_backtest(weights, stock_returns) + 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, weights) + pd.testing.assert_frame_equal(result.weights, effective_weights) @pytest.mark.parametrize("top_k", [0, -1]) -- 2.54.0