test: define post-execution daily ledger contract
This commit is contained in:
@@ -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) ─────
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user