diff --git a/tests/test_execution.py b/tests/test_execution.py index e1fdd6b..dc9cf15 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -20,6 +20,7 @@ from quant_engine.execution import ( compute_realized_pnl, run_end_to_end_poc, simulate_execution, + simulate_daily_ledger_with_audit, simulate_multi_day, simulate_multi_day_with_audit, simulate_with_daily_data, @@ -470,6 +471,135 @@ def test_simulate_multi_day_with_audit_requires_matching_dates(): ) +# ── 逐交易日 Ledger:成交时点与估值时点分离 ───────────────── + + +def test_daily_ledger_marks_every_session_after_sparse_open_execution() -> None: + """下一日开盘成交后,应按每日收盘价持续盯市,而非只记录调仓日。""" + config = ExecutionConfig( + commission_bps=0, + stamp_tax_bps=0, + slippage_bps=0, + min_trade_amount=0, + ) + + result = simulate_daily_ledger_with_audit( + target_weights_history=[("d1", {"A": 1.0})], + execution_price_history=[("d1", {"A": 10.0})], + valuation_price_history=[ + ("d0", {"A": 9.0}), + ("d1", {"A": 11.0}), + ("d2", {"A": 12.0}), + ], + initial_cash=1_000.0, + config=config, + ) + + assert [position.date for position in result.positions] == ["d0", "d1", "d2"] + assert [position.portfolio_value for position in result.positions] == pytest.approx( + [1_000.0, 1_100.0, 1_200.0] + ) + assert [len(day.executions) for day in result.daily_executions] == [0, 1, 0] + fill = result.daily_executions[1].executions[0] + assert fill.side == "buy" + assert fill.quantity == pytest.approx(100.0) + assert fill.price == pytest.approx(10.0) + pd.testing.assert_series_equal( + result.normalized_nav_series, + pd.Series([1.0, 1.1, 1.2], index=["d0", "d1", "d2"], dtype=float), + ) + pd.testing.assert_series_equal( + result.daily_returns, + pd.Series([0.0, 0.1, 1.2 / 1.1 - 1.0], index=["d0", "d1", "d2"]), + ) + + +def test_daily_ledger_first_session_cost_reduces_first_return() -> None: + """首个估值日发生交易时,费用必须进入相对初始资金的首日收益。""" + result = simulate_daily_ledger_with_audit( + target_weights_history=[("d0", {"A": 1.0})], + execution_price_history=[("d0", {"A": 10.0})], + valuation_price_history=[("d0", {"A": 10.0})], + initial_cash=1_000.0, + ) + + assert result.total_costs > 0 + assert result.daily_returns.iloc[0] == pytest.approx( + result.final_portfolio_value / result.initial_cash - 1.0 + ) + assert result.daily_returns.iloc[0] < 0 + + +def test_daily_ledger_nav_is_rebuildable_and_trades_are_projectable() -> None: + """Ledger 必须同时支持现金守恒校验和平台成交表投影。""" + config = ExecutionConfig( + commission_bps=0, + stamp_tax_bps=0, + slippage_bps=0, + min_trade_amount=0, + ) + result = simulate_daily_ledger_with_audit( + target_weights_history=[ + ("d1", {"A": 1.0, "B": 0.0}), + ("d2", {"A": 0.0, "B": 1.0}), + ], + execution_price_history=[ + ("d1", {"A": 10.0, "B": 20.0}), + ("d2", {"A": 11.0, "B": 22.0}), + ], + valuation_price_history=[ + ("d0", {"A": 9.0, "B": 19.0}), + ("d1", {"A": 10.5, "B": 21.0}), + ("d2", {"A": 12.0, "B": 24.0}), + ], + initial_cash=1_000.0, + config=config, + ) + + close_prices = { + "d0": {"A": 9.0, "B": 19.0}, + "d1": {"A": 10.5, "B": 21.0}, + "d2": {"A": 12.0, "B": 24.0}, + } + for position in result.positions: + rebuilt = position.cash + sum( + shares * close_prices[position.date][asset] + for asset, shares in position.holdings.items() + ) + assert position.portfolio_value == pytest.approx(rebuilt) + + trades = result.trades_frame + assert trades.columns.tolist() == [ + "trade_date", + "ts_code", + "side", + "qty", + "price", + "amount", + "fee", + "slippage", + ] + assert trades["side"].tolist() == ["buy", "sell", "buy"] + assert (trades["qty"] > 0).all() + + +def test_daily_ledger_rejects_missing_close_for_held_asset() -> None: + """已有持仓缺少收盘估值价时必须 fail closed。""" + with pytest.raises(ValueError, match="missing valuation price for held asset A"): + simulate_daily_ledger_with_audit( + target_weights_history=[("d0", {"A": 1.0})], + execution_price_history=[("d0", {"A": 10.0})], + valuation_price_history=[("d0", {"A": 10.0}), ("d1", {})], + initial_cash=1_000.0, + ) + + +def test_daily_ledger_requires_positive_initial_cash() -> None: + """可信收益曲线需要正初始资金作为归一化基准。""" + with pytest.raises(ValueError, match="initial_cash must be positive"): + simulate_daily_ledger_with_audit([], [], [], initial_cash=0.0) + + # ── v1.2.0 Phase 1:端到端 POC(run_end_to_end_poc) ───── diff --git a/tests/test_research_pipeline.py b/tests/test_research_pipeline.py index b7f4e72..98eb37e 100644 --- a/tests/test_research_pipeline.py +++ b/tests/test_research_pipeline.py @@ -7,8 +7,10 @@ import pytest from quant_engine.execution import ExecutionConfig from quant_engine.research_pipeline import ( + FactorBacktestResult, FactorExecutionResult, TargetWeightSchedule, + run_factor_backtest_research, run_factor_execution_research, schedule_target_weights, ) @@ -149,3 +151,85 @@ def test_factor_execution_research_accepts_empty_scores() -> None: assert result.schedule.execution_weights.empty assert result.execution.positions == () + + +def test_factor_backtest_research_runs_signal_to_daily_performance_without_lookahead() -> None: + """信号日保持现金,下一日开盘成交后才参与当日收盘收益。""" + dates = _calendar() + scores = pd.DataFrame({"A": [2.0], "B": [1.0]}, index=dates[:1]) + opens = pd.DataFrame( + {"A": [1.0, 10.0, 10.0, 10.0], "B": [1.0, 20.0, 20.0, 20.0]}, + index=dates, + ) + closes = pd.DataFrame( + {"A": [500.0, 11.0, 12.0, 12.0], "B": [500.0, 20.0, 20.0, 20.0]}, + index=dates, + ) + config = ExecutionConfig( + commission_bps=0, + stamp_tax_bps=0, + slippage_bps=0, + min_trade_amount=0, + ) + + result = run_factor_backtest_research( + scores, + execution_prices=opens, + valuation_prices=closes, + top_k=1, + execution_price_field="open", + valuation_price_field="close", + initial_cash=1_000.0, + config=config, + ) + + assert isinstance(result, FactorBacktestResult) + assert result.execution_price_field == "open" + assert result.valuation_price_field == "close" + pd.testing.assert_series_equal( + result.nav, + pd.Series([1.0, 1.1, 1.2, 1.2], index=dates, name="nav"), + ) + pd.testing.assert_series_equal( + result.returns, + pd.Series([0.0, 0.1, 1.2 / 1.1 - 1.0, 0.0], index=dates, name="returns"), + ) + assert result.stats()["n_days"] == 4 + assert result.execution.daily_executions[0].executions == () + assert result.execution.daily_executions[1].executions[0].price == 10.0 + + +def test_factor_backtest_result_snapshots_both_price_semantics() -> None: + scores = pd.DataFrame({"A": [1.0]}, index=_calendar()[:1]) + opens = pd.DataFrame({"A": [10.0, 10.0, 10.0, 10.0]}, index=_calendar()) + closes = pd.DataFrame({"A": [10.0, 11.0, 12.0, 13.0]}, index=_calendar()) + + result = run_factor_backtest_research( + scores, + execution_prices=opens, + valuation_prices=closes, + top_k=1, + execution_price_field="open", + valuation_price_field="close", + ) + opens.iloc[1, 0] = 999.0 + closes.iloc[1, 0] = 999.0 + + assert result.execution_prices.iloc[1, 0] == 10.0 + assert result.valuation_prices.iloc[1, 0] == 11.0 + + +def test_factor_backtest_research_requires_matching_daily_calendars() -> None: + scores = pd.DataFrame({"A": [1.0]}, index=_calendar()[:1]) + opens = pd.DataFrame({"A": [10.0, 10.0, 10.0, 10.0]}, index=_calendar()) + closes = pd.DataFrame({"A": [10.0, 11.0, 12.0]}, index=_calendar()[:3]) + + with pytest.raises(ValueError, match="matching trading calendars"): + run_factor_backtest_research( + scores, + execution_prices=opens, + valuation_prices=closes, + top_k=1, + execution_price_field="open", + valuation_price_field="close", + )