This commit was merged in pull request #7.
This commit is contained in:
+307
-14
@@ -11,6 +11,7 @@ import pytest
|
||||
from quant_engine.execution import (
|
||||
ExecutionConfig,
|
||||
ExecutionResult,
|
||||
ExecutionSimulationResult,
|
||||
apply_bid_ask_spread,
|
||||
apply_volume_constraint,
|
||||
check_price_limit,
|
||||
@@ -19,7 +20,9 @@ from quant_engine.execution import (
|
||||
compute_realized_pnl,
|
||||
run_end_to_end_poc,
|
||||
simulate_execution,
|
||||
simulate_daily_ledger_with_audit,
|
||||
simulate_multi_day,
|
||||
simulate_multi_day_with_audit,
|
||||
simulate_with_daily_data,
|
||||
total_costs,
|
||||
total_turnover,
|
||||
@@ -327,24 +330,26 @@ def test_simulate_multi_day_length_mismatch_raises():
|
||||
|
||||
|
||||
def test_simulate_multi_day_first_day_value_equals_initial():
|
||||
"""第一天 portfolio_value = initial_cash(无持仓)。"""
|
||||
"""零成本下第一天日末 NAV 等于初始资金。"""
|
||||
signals = [("d1", {"A": 1.0})]
|
||||
prices = [("d1", {"A": 10.0})]
|
||||
positions = simulate_multi_day(signals, prices, 1_000_000.0)
|
||||
# 第一天 NAV = 1_000_000(无持仓),第二天才是调仓后
|
||||
config = ExecutionConfig(
|
||||
commission_bps=0,
|
||||
stamp_tax_bps=0,
|
||||
slippage_bps=0,
|
||||
min_trade_amount=0,
|
||||
)
|
||||
positions = simulate_multi_day(signals, prices, 1_000_000.0, config)
|
||||
assert positions[0].portfolio_value == 1_000_000.0
|
||||
assert positions[0].holdings == {"A": 100_000.0}
|
||||
|
||||
|
||||
def test_simulate_multi_day_holdings_evolution():
|
||||
"""调仓后 holdings 演化。
|
||||
|
||||
注意:positions[i] 是第 i 天 rebalance 之前的快照。
|
||||
所以要看 d2 rebalance 后的 holdings,需要看 positions[2](d3 的快照)。
|
||||
"""
|
||||
"""日末快照应反映当天调仓后的 holdings。"""
|
||||
signals = [
|
||||
("d1", {"A": 0.5, "B": 0.5}),
|
||||
("d2", {"A": 1.0, "B": 0.0}), # 全仓 A
|
||||
("d3", {"A": 1.0, "B": 0.0}), # 第三天的快照才能看到 d2 rebalance 后的 holdings
|
||||
("d3", {"A": 1.0, "B": 0.0}),
|
||||
]
|
||||
prices = [
|
||||
("d1", {"A": 10.0, "B": 20.0}),
|
||||
@@ -352,9 +357,288 @@ def test_simulate_multi_day_holdings_evolution():
|
||||
("d3", {"A": 12.0, "B": 22.0}),
|
||||
]
|
||||
positions = simulate_multi_day(signals, prices, 1_000_000.0)
|
||||
# d3 的 PRE-trade snapshot 应该只有 A(B 在 d2 被平仓)
|
||||
assert "B" not in positions[2].holdings
|
||||
assert "A" in positions[2].holdings
|
||||
assert "B" not in positions[1].holdings
|
||||
assert "A" in positions[1].holdings
|
||||
|
||||
|
||||
def test_simulate_multi_day_with_audit_rebalances_target_weights_by_delta():
|
||||
"""相同目标权重不应在每个交易日重复买入。"""
|
||||
config = ExecutionConfig(
|
||||
commission_bps=0,
|
||||
stamp_tax_bps=0,
|
||||
slippage_bps=0,
|
||||
min_trade_amount=0,
|
||||
)
|
||||
targets = [(date, {"A": 1.0}) for date in ("d1", "d2", "d3")]
|
||||
prices = [(date, {"A": 10.0}) for date in ("d1", "d2", "d3")]
|
||||
|
||||
result = simulate_multi_day_with_audit(targets, prices, 1_000.0, config)
|
||||
|
||||
assert isinstance(result, ExecutionSimulationResult)
|
||||
assert [len(day.executions) for day in result.daily_executions] == [1, 0, 0]
|
||||
assert result.total_turnover == pytest.approx(1_000.0)
|
||||
assert [position.cash for position in result.positions] == pytest.approx([0.0, 0.0, 0.0])
|
||||
assert [position.holdings["A"] for position in result.positions] == pytest.approx(
|
||||
[100.0, 100.0, 100.0]
|
||||
)
|
||||
assert [position.portfolio_value for position in result.positions] == pytest.approx(
|
||||
[1_000.0, 1_000.0, 1_000.0]
|
||||
)
|
||||
|
||||
|
||||
def test_simulate_multi_day_with_audit_records_costs_without_replay():
|
||||
"""成交成本与日末 NAV 应来自同一次状态推进。"""
|
||||
targets = [("d1", {"A": 1.0}), ("d2", {"A": 1.0})]
|
||||
prices = [("d1", {"A": 10.0}), ("d2", {"A": 10.0})]
|
||||
|
||||
result = simulate_multi_day_with_audit(targets, prices, 1_000.0)
|
||||
|
||||
first_day = result.daily_executions[0]
|
||||
assert first_day.nav_before == pytest.approx(1_000.0)
|
||||
assert first_day.nav_after == pytest.approx(result.positions[0].portfolio_value)
|
||||
assert result.total_costs == pytest.approx(sum(r.total_cost for r in first_day.executions))
|
||||
assert result.final_portfolio_value == pytest.approx(1_000.0 - result.total_costs)
|
||||
assert result.daily_executions[1].executions == ()
|
||||
|
||||
|
||||
def test_simulate_multi_day_with_audit_never_spends_more_cash_than_available():
|
||||
"""满仓目标应按可用现金部分成交,不能用负现金隐式加杠杆。"""
|
||||
result = simulate_multi_day_with_audit(
|
||||
[("d1", {"A": 1.0})],
|
||||
[("d1", {"A": 10.0})],
|
||||
1_000.0,
|
||||
)
|
||||
|
||||
execution = result.daily_executions[0].executions[0]
|
||||
assert result.positions[0].cash >= -1e-9
|
||||
assert 0 < execution.partial_fill_pct < 1
|
||||
assert execution.blocked_reason == "insufficient_cash_partial_fill"
|
||||
assert result.final_portfolio_value == pytest.approx(1_000.0 - result.total_costs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"targets",
|
||||
[
|
||||
{"A": -0.1},
|
||||
{"A": 0.6, "B": 0.5},
|
||||
{"A": float("nan")},
|
||||
],
|
||||
)
|
||||
def test_simulate_multi_day_with_audit_rejects_invalid_long_only_weights(targets):
|
||||
"""多日 A 股目标必须是有限、非负且合计不超过 100% 的权重。"""
|
||||
with pytest.raises(ValueError, match="target weights"):
|
||||
simulate_multi_day_with_audit(
|
||||
[("d1", targets)],
|
||||
[("d1", {"A": 10.0, "B": 10.0})],
|
||||
1_000.0,
|
||||
)
|
||||
|
||||
|
||||
def test_simulate_multi_day_with_audit_requires_price_for_existing_holding():
|
||||
"""已有持仓缺价时无法可信估值,必须失败而不是把市值记为零。"""
|
||||
with pytest.raises(ValueError, match="missing price for held asset A"):
|
||||
simulate_multi_day_with_audit(
|
||||
[("d1", {"A": 1.0}), ("d2", {"A": 1.0})],
|
||||
[("d1", {"A": 10.0}), ("d2", {})],
|
||||
1_000.0,
|
||||
)
|
||||
|
||||
|
||||
def test_simulate_multi_day_with_audit_records_unpriced_target_rejection():
|
||||
"""缺失价格的目标不能吞掉现金,且必须留下拒绝原因。"""
|
||||
result = simulate_multi_day_with_audit(
|
||||
[("d1", {"A": 1.0})],
|
||||
[("d1", {"B": 10.0})],
|
||||
1_000.0,
|
||||
)
|
||||
|
||||
rejection = result.daily_executions[0].executions[0]
|
||||
assert rejection.stock_code == "A"
|
||||
assert rejection.executed_value == 0.0
|
||||
assert rejection.partial_fill_pct == 0.0
|
||||
assert rejection.blocked_reason == "missing_price"
|
||||
assert result.positions[0].cash == 1_000.0
|
||||
assert result.positions[0].holdings == {}
|
||||
|
||||
|
||||
def test_simulate_multi_day_with_audit_requires_matching_dates():
|
||||
"""权重与价格日期错位必须显式失败,不能按位置静默配对。"""
|
||||
with pytest.raises(ValueError, match="dates must match"):
|
||||
simulate_multi_day_with_audit(
|
||||
[("d1", {"A": 1.0})],
|
||||
[("d2", {"A": 10.0})],
|
||||
1_000.0,
|
||||
)
|
||||
|
||||
|
||||
# ── 逐交易日 Ledger:成交时点与估值时点分离 ─────────────────
|
||||
|
||||
|
||||
def test_daily_ledger_marks_every_session_after_sparse_open_execution() -> None:
|
||||
"""下一日开盘成交后,应按每日收盘价持续盯市,而非只记录调仓日。"""
|
||||
config = ExecutionConfig(
|
||||
commission_bps=0,
|
||||
stamp_tax_bps=0,
|
||||
slippage_bps=0,
|
||||
min_trade_amount=0,
|
||||
)
|
||||
|
||||
result = simulate_daily_ledger_with_audit(
|
||||
target_weights_history=[("d1", {"A": 1.0})],
|
||||
execution_price_history=[("d1", {"A": 10.0})],
|
||||
valuation_price_history=[
|
||||
("d0", {"A": 9.0}),
|
||||
("d1", {"A": 11.0}),
|
||||
("d2", {"A": 12.0}),
|
||||
],
|
||||
initial_cash=1_000.0,
|
||||
config=config,
|
||||
)
|
||||
|
||||
assert [position.date for position in result.positions] == ["d0", "d1", "d2"]
|
||||
assert [position.portfolio_value for position in result.positions] == pytest.approx(
|
||||
[1_000.0, 1_100.0, 1_200.0]
|
||||
)
|
||||
assert [len(day.executions) for day in result.daily_executions] == [0, 1, 0]
|
||||
fill = result.daily_executions[1].executions[0]
|
||||
assert fill.side == "buy"
|
||||
assert fill.quantity == pytest.approx(100.0)
|
||||
assert fill.price == pytest.approx(10.0)
|
||||
pd.testing.assert_series_equal(
|
||||
result.normalized_nav_series,
|
||||
pd.Series([1.0, 1.1, 1.2], index=["d0", "d1", "d2"], dtype=float),
|
||||
)
|
||||
pd.testing.assert_series_equal(
|
||||
result.daily_returns,
|
||||
pd.Series([0.0, 0.1, 1.2 / 1.1 - 1.0], index=["d0", "d1", "d2"]),
|
||||
)
|
||||
|
||||
|
||||
def test_daily_ledger_first_session_cost_reduces_first_return() -> None:
|
||||
"""首个估值日发生交易时,费用必须进入相对初始资金的首日收益。"""
|
||||
result = simulate_daily_ledger_with_audit(
|
||||
target_weights_history=[("d0", {"A": 1.0})],
|
||||
execution_price_history=[("d0", {"A": 10.0})],
|
||||
valuation_price_history=[("d0", {"A": 10.0})],
|
||||
initial_cash=1_000.0,
|
||||
)
|
||||
|
||||
assert result.total_costs > 0
|
||||
assert result.daily_returns.iloc[0] == pytest.approx(
|
||||
result.final_portfolio_value / result.initial_cash - 1.0
|
||||
)
|
||||
assert result.daily_returns.iloc[0] < 0
|
||||
|
||||
|
||||
def test_daily_ledger_nav_is_rebuildable_and_trades_are_projectable() -> None:
|
||||
"""Ledger 必须同时支持现金守恒校验和平台成交表投影。"""
|
||||
config = ExecutionConfig(
|
||||
commission_bps=0,
|
||||
stamp_tax_bps=0,
|
||||
slippage_bps=0,
|
||||
min_trade_amount=0,
|
||||
)
|
||||
result = simulate_daily_ledger_with_audit(
|
||||
target_weights_history=[
|
||||
("d1", {"A": 1.0, "B": 0.0}),
|
||||
("d2", {"A": 0.0, "B": 1.0}),
|
||||
],
|
||||
execution_price_history=[
|
||||
("d1", {"A": 10.0, "B": 20.0}),
|
||||
("d2", {"A": 11.0, "B": 22.0}),
|
||||
],
|
||||
valuation_price_history=[
|
||||
("d0", {"A": 9.0, "B": 19.0}),
|
||||
("d1", {"A": 10.5, "B": 21.0}),
|
||||
("d2", {"A": 12.0, "B": 24.0}),
|
||||
],
|
||||
initial_cash=1_000.0,
|
||||
config=config,
|
||||
)
|
||||
|
||||
close_prices = {
|
||||
"d0": {"A": 9.0, "B": 19.0},
|
||||
"d1": {"A": 10.5, "B": 21.0},
|
||||
"d2": {"A": 12.0, "B": 24.0},
|
||||
}
|
||||
for position in result.positions:
|
||||
rebuilt = position.cash + sum(
|
||||
shares * close_prices[position.date][asset]
|
||||
for asset, shares in position.holdings.items()
|
||||
)
|
||||
assert position.portfolio_value == pytest.approx(rebuilt)
|
||||
|
||||
trades = result.trades_frame
|
||||
assert trades.columns.tolist() == [
|
||||
"trade_date",
|
||||
"ts_code",
|
||||
"side",
|
||||
"qty",
|
||||
"price",
|
||||
"amount",
|
||||
"fee",
|
||||
"slippage",
|
||||
]
|
||||
assert trades["side"].tolist() == ["buy", "sell", "buy"]
|
||||
assert (trades["qty"] > 0).all()
|
||||
|
||||
|
||||
def test_daily_ledger_frame_matches_platform_projection_contract() -> None:
|
||||
"""核心层输出稳定日频投影,但不携带 run_id 或执行数据库写入。"""
|
||||
config = ExecutionConfig(
|
||||
commission_bps=0,
|
||||
stamp_tax_bps=0,
|
||||
slippage_bps=0,
|
||||
min_trade_amount=0,
|
||||
)
|
||||
result = simulate_daily_ledger_with_audit(
|
||||
target_weights_history=[("d1", {"A": 1.0})],
|
||||
execution_price_history=[("d1", {"A": 10.0})],
|
||||
valuation_price_history=[
|
||||
("d0", {"A": 9.0}),
|
||||
("d1", {"A": 11.0}),
|
||||
("d2", {"A": 12.0}),
|
||||
],
|
||||
initial_cash=1_000.0,
|
||||
config=config,
|
||||
)
|
||||
|
||||
ledger = result.ledger_frame
|
||||
|
||||
assert ledger.columns.tolist() == [
|
||||
"trade_date",
|
||||
"portfolio_value",
|
||||
"nav",
|
||||
"pnl",
|
||||
"pnl_pct",
|
||||
"position_value",
|
||||
"cash",
|
||||
"turnover",
|
||||
]
|
||||
assert ledger["trade_date"].tolist() == ["d0", "d1", "d2"]
|
||||
assert ledger["nav"].tolist() == pytest.approx([1.0, 1.1, 1.2])
|
||||
assert ledger["pnl"].tolist() == pytest.approx([0.0, 100.0, 100.0])
|
||||
assert ledger["pnl_pct"].tolist() == pytest.approx([0.0, 0.1, 1.2 / 1.1 - 1.0])
|
||||
assert ledger["position_value"].tolist() == pytest.approx([0.0, 1_100.0, 1_200.0])
|
||||
assert ledger["cash"].tolist() == pytest.approx([1_000.0, 0.0, 0.0])
|
||||
assert ledger["turnover"].tolist() == pytest.approx([0.0, 1.0, 0.0])
|
||||
|
||||
|
||||
def test_daily_ledger_rejects_missing_close_for_held_asset() -> None:
|
||||
"""已有持仓缺少收盘估值价时必须 fail closed。"""
|
||||
with pytest.raises(ValueError, match="missing valuation price for held asset A"):
|
||||
simulate_daily_ledger_with_audit(
|
||||
target_weights_history=[("d0", {"A": 1.0})],
|
||||
execution_price_history=[("d0", {"A": 10.0})],
|
||||
valuation_price_history=[("d0", {"A": 10.0}), ("d1", {})],
|
||||
initial_cash=1_000.0,
|
||||
)
|
||||
|
||||
|
||||
def test_daily_ledger_requires_positive_initial_cash() -> None:
|
||||
"""可信收益曲线需要正初始资金作为归一化基准。"""
|
||||
with pytest.raises(ValueError, match="initial_cash must be positive"):
|
||||
simulate_daily_ledger_with_audit([], [], [], initial_cash=0.0)
|
||||
|
||||
|
||||
# ── v1.2.0 Phase 1:端到端 POC(run_end_to_end_poc) ─────
|
||||
@@ -449,6 +733,15 @@ def test_run_end_to_end_poc_costs_recorded():
|
||||
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
|
||||
assert result["total_costs"] > 0
|
||||
assert result["total_turnover"] > 0
|
||||
executions = [
|
||||
execution
|
||||
for daily in result["daily_executions"]
|
||||
for execution in daily.executions
|
||||
]
|
||||
assert result["total_costs"] == pytest.approx(sum(item.total_cost for item in executions))
|
||||
assert result["total_turnover"] == pytest.approx(
|
||||
sum(item.executed_value for item in executions)
|
||||
)
|
||||
|
||||
|
||||
# ── v1.2.0 Phase 2: T+1 / 涨跌停 / 部分成交 / 买卖价差 ─────
|
||||
@@ -741,8 +1034,8 @@ def test_compute_realized_pnl_sell_realizes():
|
||||
target_weights_history=targets,
|
||||
)
|
||||
pnl_list = compute_realized_pnl(positions)
|
||||
# 第三天(卖出兑现)应有 realized 正利润(cash 从 -800 → 2M = +2M)
|
||||
assert pnl_list[2].realized_pnl > 0
|
||||
# 第二天日末快照已包含当日卖出,现金流入应在当天反映。
|
||||
assert pnl_list[1].realized_pnl > 0
|
||||
|
||||
|
||||
# ── O3: end-to-end 端到端测试(集成多个函数) ──────────────
|
||||
|
||||
Reference in New Issue
Block a user