Files
quant_engine/tests/test_execution.py
T

1033 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""src/shared/execution.py 单元测试(v1.2.0 Phase 0 骨架)。"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import numpy as np
import pandas as pd
import pytest
from quant_engine.execution import (
ExecutionConfig,
ExecutionResult,
ExecutionSimulationResult,
apply_bid_ask_spread,
apply_volume_constraint,
check_price_limit,
check_stop_loss_take_profit,
check_t1_settlement,
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,
)
# ── ExecutionConfig 校验 ──────────────────────────────
def test_default_config():
"""默认配置:A 股典型值。"""
cfg = ExecutionConfig()
assert cfg.commission_bps == 3.0
assert cfg.stamp_tax_bps == 10.0
assert cfg.slippage_bps == 5.0
assert cfg.min_trade_amount == 100.0
def test_config_negative_commission_raises():
"""负佣金应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(commission_bps=-1.0)
def test_config_negative_stamp_tax_raises():
"""负印花税应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(stamp_tax_bps=-1.0)
def test_config_negative_slippage_raises():
"""负滑点应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(slippage_bps=-1.0)
def test_config_negative_min_amount_raises():
"""负最小成交额应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(min_trade_amount=-1.0)
def test_config_is_frozen():
"""dataclass(frozen=True) 不允许修改。"""
cfg = ExecutionConfig()
with pytest.raises(FrozenInstanceError):
cfg.commission_bps = 100.0 # type: ignore[misc]
# ── simulate_execution 基础 ──────────────────────────────
def test_simulate_execution_basic_buy():
"""买入:目标权重 0.5,组合 100 万 → 目标 50 万 → 实际成交(含滑点)> 50 万。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"000001.SZ": 0.5},
portfolio_value=1_000_000.0,
config=cfg,
)
assert len(results) == 1
r = results[0]
assert r.stock_code == "000001.SZ"
assert r.target_value == 500_000.0
# 滑点 5 bps = 0.0005,500000 * 1.0005 = 500250
assert r.executed_value == pytest.approx(500_250.0)
# 佣金 3 bps,按 500250 算 = 150.075
assert r.commission == pytest.approx(500_250.0 * 3 / 10000)
# 买入无印花税
assert r.stamp_tax == 0.0
# 滑点成本 = executed_value - abs(target_value)
assert r.slippage_cost == pytest.approx(250.0)
# 净现金流 = -(executed_value + commission + stamp_tax)
assert r.net_cash_flow == pytest.approx(-(500_250.0 + 500_250.0 * 3 / 10000))
def test_simulate_execution_basic_sell():
"""卖出:目标权重 -0.3(减仓),组合 100 万 → 目标 -30 万。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"600000.SH": -0.3},
portfolio_value=1_000_000.0,
config=cfg,
)
assert len(results) == 1
r = results[0]
assert r.stock_code == "600000.SH"
assert r.target_value == -300_000.0
# 滑点 5 bps = 0.0005,300000 * (1 - 0.0005) = 299850
assert r.executed_value == pytest.approx(299_850.0)
# 卖出有印花税 10 bps
assert r.stamp_tax == pytest.approx(299_850.0 * 10 / 10000)
# 净现金流 = executed_value - commission - stamp_tax
assert r.net_cash_flow == pytest.approx(
299_850.0 - 299_850.0 * 3 / 10000 - 299_850.0 * 10 / 10000,
)
def test_simulate_execution_skip_below_min():
"""成交金额低于 min_trade_amount → 跳过(不返回 ExecutionResult)。"""
cfg = ExecutionConfig(min_trade_amount=10_000.0)
results = simulate_execution(
target_weights={"000001.SZ": 0.005}, # 100万 * 0.005 = 5000 < 10000
portfolio_value=1_000_000.0,
config=cfg,
)
assert results == []
def test_simulate_execution_empty_weights():
"""空权重字典 → 空结果列表。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={},
portfolio_value=1_000_000.0,
config=cfg,
)
assert results == []
def test_simulate_execution_multiple_stocks():
"""多支股票:每支一个 ExecutionResult。"""
cfg = ExecutionConfig()
targets = {
"000001.SZ": 0.3,
"600000.SH": 0.2,
"000002.SZ": 0.5,
}
results = simulate_execution(
target_weights=targets,
portfolio_value=1_000_000.0,
config=cfg,
)
assert len(results) == 3
codes = {r.stock_code for r in results}
assert codes == {"000001.SZ", "600000.SH", "000002.SZ"}
def test_simulate_execution_default_config():
"""config=None → 使用默认 A 股配置。"""
results = simulate_execution(
target_weights={"000001.SZ": 0.5},
portfolio_value=1_000_000.0,
)
assert len(results) == 1
def test_simulate_execution_zero_costs_config():
"""零成本配置:executed_value == target_value。"""
cfg = ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0)
results = simulate_execution(
target_weights={"000001.SZ": 0.5},
portfolio_value=1_000_000.0,
config=cfg,
)
assert results[0].executed_value == 500_000.0
assert results[0].commission == 0.0
assert results[0].stamp_tax == 0.0
assert results[0].slippage_cost == 0.0
# ── total_costs / total_turnover ──────────────────────────────
def test_total_costs_empty():
"""空结果列表 → 总成本 0。"""
assert total_costs([]) == 0.0
def test_total_costs_sum():
"""总成本 = sum(r.total_cost)。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"000001.SZ": 0.5, "600000.SH": -0.3},
portfolio_value=1_000_000.0,
config=cfg,
)
expected = sum(r.total_cost for r in results)
assert total_costs(results) == expected
def test_total_turnover_sum():
"""总换手 = sum(r.executed_value)。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"000001.SZ": 0.5, "600000.SH": -0.3},
portfolio_value=1_000_000.0,
config=cfg,
)
expected = sum(r.executed_value for r in results)
assert total_turnover(results) == expected
# ── ExecutionResult 数据类 ──────────────────────────────
def test_execution_result_dataclass():
"""ExecutionResult 是 dataclass。"""
r = ExecutionResult(
stock_code="000001.SZ",
target_value=500_000.0,
executed_value=500_250.0,
commission=150.075,
stamp_tax=0.0,
slippage_cost=250.0,
total_cost=400.075,
net_cash_flow=-500_400.075,
)
assert r.stock_code == "000001.SZ"
assert r.total_cost == pytest.approx(400.075)
# ── v1.2.0 Phase 1:止损/止盈(check_stop_loss_take_profit) ──
def test_stop_loss_triggered():
"""亏损超过阈值 → 止损。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(100.0, 89.0, cfg) == "stop_loss"
def test_stop_loss_exact_boundary():
"""恰好 10% 亏损 → 止损触发(<= -10%)。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(100.0, 90.0, cfg) == "stop_loss"
def test_stop_loss_not_triggered():
"""9% 亏损 → 未触发。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(100.0, 91.0, cfg) is None
def test_take_profit_triggered():
"""盈利超过阈值 → 止盈。"""
cfg = ExecutionConfig(take_profit_pct=0.20)
assert check_stop_loss_take_profit(100.0, 121.0, cfg) == "take_profit"
def test_take_profit_not_triggered():
"""15% 盈利 → 未触发(阈值 20%)。"""
cfg = ExecutionConfig(take_profit_pct=0.20)
assert check_stop_loss_take_profit(100.0, 115.0, cfg) is None
def test_no_stop_loss_no_take_profit():
"""默认配置(inf)→ 永不触发。"""
cfg = ExecutionConfig() # 默认 inf
assert check_stop_loss_take_profit(100.0, 50.0, cfg) is None
assert check_stop_loss_take_profit(100.0, 200.0, cfg) is None
def test_zero_cost_basis_no_trigger():
"""成本价 <= 0 → 不触发(避免除零)。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(0.0, 5.0, cfg) is None
def test_negative_stop_loss_raises():
"""stop_loss_pct <= 0 应报错。"""
with pytest.raises(ValueError, match="stop_loss_pct"):
ExecutionConfig(stop_loss_pct=0.0)
with pytest.raises(ValueError, match="stop_loss_pct"):
ExecutionConfig(stop_loss_pct=-0.1)
def test_negative_take_profit_raises():
"""take_profit_pct <= 0 应报错。"""
with pytest.raises(ValueError, match="take_profit_pct"):
ExecutionConfig(take_profit_pct=0.0)
# ── v1.2.0 Phase 1:多日仿真(simulate_multi_day) ────────
def test_simulate_multi_day_basic():
"""基本多日仿真。"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 0.6, "B": 0.4}),
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 21.0}),
]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
assert len(positions) == 2
assert positions[0].date == "d1"
assert positions[1].date == "d2"
def test_simulate_multi_day_empty():
"""空输入 → 空输出。"""
assert simulate_multi_day([], [], 1_000_000.0) == []
def test_simulate_multi_day_length_mismatch_raises():
"""signals / prices 长度不一致 → 报错。"""
with pytest.raises(ValueError, match="same length"):
simulate_multi_day(
[("d1", {"A": 0.5})],
[("d1", {"A": 10.0}), ("d2", {"A": 11.0})],
1_000_000.0,
)
def test_simulate_multi_day_first_day_value_equals_initial():
"""零成本下第一天日末 NAV 等于初始资金。"""
signals = [("d1", {"A": 1.0})]
prices = [("d1", {"A": 10.0})]
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。"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 1.0, "B": 0.0}), # 全仓 A
("d3", {"A": 1.0, "B": 0.0}),
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 21.0}),
("d3", {"A": 12.0, "B": 22.0}),
]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
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_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) ─────
def test_run_end_to_end_poc_basic():
"""端到端 POC 基本 smoke test。"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 0.6, "B": 0.4}),
("d3", {"A": 0.5, "B": 0.5}),
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 21.0}),
("d3", {"A": 12.0, "B": 22.0}),
]
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
assert "positions" in result
assert "nav_series" in result
assert "total_costs" in result
assert "total_turnover" in result
assert "total_rebalances" in result
assert "final_portfolio_value" in result
assert "return_pct" in result
def test_run_end_to_end_poc_nav_increases_with_bull_market():
"""上涨行情:NAV 上升。"""
signals = [
("d1", {"A": 1.0}),
("d2", {"A": 1.0}),
("d3", {"A": 1.0}),
]
prices = [
("d1", {"A": 10.0}),
("d2", {"A": 11.0}),
("d3", {"A": 12.1}),
]
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
nav = result["nav_series"]
# 持续上涨
assert nav.iloc[-1] > nav.iloc[0]
def test_run_end_to_end_poc_with_alpha158_signal():
"""与 alpha158 因子联动(端到端 POC 真实场景)。"""
import numpy as np
from quant_engine.alpha_factors import alpha_005
np.random.seed(123)
n_days = 30
# 模拟单只股票
close = pd.Series(np.cumsum(np.random.randn(n_days)) + 100)
volume = pd.Series(np.random.rand(n_days) * 1000 + 1000)
# 计算 alpha_005(相关性)
alpha_series = alpha_005(close, volume).fillna(0)
# 信号:alpha > 0 时持有,否则空仓
signals = []
prices = []
for i, (date_idx, alpha_val) in enumerate(zip(close.index, alpha_series)):
date = f"d{i + 1}"
target = {"A": 1.0} if alpha_val > 0 else {"A": 0.0}
signals.append((date, target))
prices.append((date, {"A": float(close.iloc[date_idx])}))
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
# 至少有一次调仓
assert result["total_rebalances"] > 0
def test_run_end_to_end_poc_empty_signals():
"""空 signals → NAV 全是 initial_cash。"""
result = run_end_to_end_poc([], [], 1_000_000.0)
assert result["final_portfolio_value"] == 1_000_000.0
assert result["return_pct"] == 0.0
def test_run_end_to_end_poc_length_mismatch_raises():
"""signals / prices 长度不一致 → 报错。"""
with pytest.raises(ValueError, match="same length"):
run_end_to_end_poc(
[("d1", {"A": 1.0})],
[("d1", {"A": 10.0}), ("d2", {"A": 11.0})],
1_000_000.0,
)
def test_run_end_to_end_poc_costs_recorded():
"""有调仓 → total_costs > 0。"""
signals = [("d1", {"A": 1.0})]
prices = [("d1", {"A": 10.0})]
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 / 涨跌停 / 部分成交 / 买卖价差 ─────
# ── check_t1_settlement ────────────────────────────────────────
def test_t1_disabled_always_allows():
"""T+1 关闭 → 总是可以卖。"""
assert check_t1_settlement("2024-01-01", "2024-01-01", enable_t1=False) is True
assert check_t1_settlement("2024-01-01", "2024-01-02", enable_t1=False) is True
def test_t1_enabled_same_day_blocked():
"""T+1 启用 + 同日 → 不能卖。"""
assert check_t1_settlement("2024-01-01", "2024-01-01", enable_t1=True) is False
def test_t1_enabled_next_day_allowed():
"""T+1 启用 + 次日 → 可以卖。"""
assert check_t1_settlement("2024-01-01", "2024-01-02", enable_t1=True) is True
# ── check_price_limit ────────────────────────────────────────
def test_price_limit_normal_trade():
"""正常价格(昨收±5%)→ 可以成交。"""
cfg = ExecutionConfig()
can_trade, reason = check_price_limit(
prev_close=10.0, current_price=10.3, config=cfg, is_sell=True
)
assert can_trade is True
assert reason == ""
def test_price_limit_up_blocks_buy():
"""涨停(+10%)→ 不能买。"""
cfg = ExecutionConfig(price_limit_up_pct=0.10)
can_trade, reason = check_price_limit(
prev_close=10.0, current_price=11.5, config=cfg, is_sell=False
)
assert can_trade is False
assert "limit_up" in reason
def test_price_limit_down_blocks_sell():
"""跌停(-10%)→ 不能卖。"""
cfg = ExecutionConfig(price_limit_down_pct=0.10)
can_trade, reason = check_price_limit(
prev_close=10.0, current_price=8.5, config=cfg, is_sell=True
)
assert can_trade is False
assert "limit_down" in reason
def test_price_limit_chinext_20pct():
"""创业板 ±20% 限制。"""
cfg = ExecutionConfig(price_limit_up_pct=0.20, price_limit_down_pct=0.20)
# +15% 在创业板允许范围
can, _ = check_price_limit(10.0, 11.5, cfg, is_sell=False)
assert can is True
# +25% 触发涨停
can, _reason = check_price_limit(10.0, 12.5, cfg, is_sell=False)
assert can is False
def test_price_limit_invalid_prev_close():
"""prev_close <= 0 → 不做检查(返回 True)。"""
cfg = ExecutionConfig()
can, _ = check_price_limit(0.0, 10.0, cfg, is_sell=True)
assert can is True
# ── apply_volume_constraint ────────────────────────────────────────
def test_volume_constraint_default_unlimited():
"""默认 max_volume_pct=1.0 → 不限制。"""
cfg = ExecutionConfig()
actual = apply_volume_constraint(target_shares=1000, daily_volume_shares=100, config=cfg)
assert actual == 100.0 # 100 * 1.0 = 100(限制是 100%)
def test_volume_constraint_caps():
"""max_volume_pct=0.05 → 单笔最大 5% 当日量。"""
cfg = ExecutionConfig(max_volume_pct=0.05)
actual = apply_volume_constraint(target_shares=1000, daily_volume_shares=1000, config=cfg)
assert actual == 50.0 # 1000 * 0.05 = 50
def test_volume_constraint_target_below_cap():
"""目标低于上限 → 不变。"""
cfg = ExecutionConfig(max_volume_pct=0.05)
actual = apply_volume_constraint(target_shares=10, daily_volume_shares=1000, config=cfg)
assert actual == 10.0
def test_volume_constraint_zero_volume():
"""当日成交量为 0 → 不能成交。"""
cfg = ExecutionConfig(max_volume_pct=0.05)
actual = apply_volume_constraint(target_shares=100, daily_volume_shares=0, config=cfg)
assert actual == 0.0
def test_volume_constraint_invalid_config_raises():
"""max_volume_pct=0 应报错。"""
with pytest.raises(ValueError, match="max_volume_pct"):
ExecutionConfig(max_volume_pct=0.0)
# ── apply_bid_ask_spread ────────────────────────────────────────
def test_bid_ask_spread_zero_default():
"""默认 spread=0 → 无影响。"""
cfg = ExecutionConfig()
assert apply_bid_ask_spread(1000.0, is_buy=True, config=cfg) == 1000.0
assert apply_bid_ask_spread(1000.0, is_buy=False, config=cfg) == 1000.0
def test_bid_ask_spread_buy():
"""买入:实际支付 = target + spread。"""
cfg = ExecutionConfig(bid_ask_spread_bps=10.0) # 10 bps = 0.1%
actual = apply_bid_ask_spread(1000.0, is_buy=True, config=cfg)
assert actual == pytest.approx(1001.0)
def test_bid_ask_spread_sell():
"""卖出:实际获得 = target - spread。"""
cfg = ExecutionConfig(bid_ask_spread_bps=10.0)
actual = apply_bid_ask_spread(1000.0, is_buy=False, config=cfg)
assert actual == pytest.approx(999.0)
def test_bid_ask_spread_negative_raises():
"""bid_ask_spread_bps < 0 应报错。"""
with pytest.raises(ValueError, match="bid_ask_spread"):
ExecutionConfig(bid_ask_spread_bps=-1.0)
# ── ExecutionConfig 新字段校验 ────────────────────────────────
def test_execution_config_t1_default_false():
"""默认 T+1 关闭。"""
cfg = ExecutionConfig()
assert cfg.enable_t1_settlement is False
def test_execution_config_price_limit_default_10pct():
"""默认涨跌停 ±10%(主板)。"""
cfg = ExecutionConfig()
assert cfg.price_limit_up_pct == 0.10
assert cfg.price_limit_down_pct == 0.10
def test_execution_config_max_volume_default_100pct():
"""默认 max_volume_pct=1.0(不限)。"""
cfg = ExecutionConfig()
assert cfg.max_volume_pct == 1.0
def test_execution_config_invalid_price_limit_raises():
"""price_limit_up_pct > 1 应报错。"""
with pytest.raises(ValueError, match="price_limit_up_pct"):
ExecutionConfig(price_limit_up_pct=1.5)
def test_execution_result_partial_fill_default():
"""ExecutionResult 默认 partial_fill_pct=1.0(全部成交)。"""
r = ExecutionResult(
stock_code="A",
target_value=1000.0,
executed_value=1000.0,
commission=3.0,
stamp_tax=0.0,
slippage_cost=0.0,
total_cost=3.0,
net_cash_flow=-1003.0,
)
assert r.partial_fill_pct == 1.0
assert r.blocked_reason == ""
# ── v1.2.0 O: simulate_with_daily_data + compute_realized_pnl ─────
def test_simulate_with_daily_data_basic():
"""simulate_with_daily_data 应返回 DailyPosition 列表。"""
dates = pd.date_range("2024-01-01", periods=10)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(10) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
assert len(positions) == 10
def test_simulate_with_daily_data_default_equal_weight():
"""默认 target_weights=None → 等权。"""
dates = pd.date_range("2024-01-01", periods=5)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(5) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=300_000.0)
# 第一天 100k / 3 ≈ 33333 per stock
assert positions[1].cash < 300_000.0 # 第一天后 cash 减少
def test_simulate_with_daily_data_custom_weights():
"""自定义 target_weights_history 应生效。"""
dates = pd.date_range("2024-01-01", periods=5)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(5) * 0.1 for i in range(3)},
index=dates,
)
# 第一天全仓 S0,之后空仓
targets = [{"S0": 1.0, "S1": 0.0, "S2": 0.0}] + [{"S0": 0.0}] * 4
positions = simulate_with_daily_data(
prices,
initial_cash=1_000_000.0,
target_weights_history=targets,
)
assert len(positions) == 5
def test_simulate_with_daily_data_length_mismatch_raises():
"""target_weights_history 长度不一致应报错。"""
dates = pd.date_range("2024-01-01", periods=5)
prices = pd.DataFrame({"S0": [10.0] * 5}, index=dates)
with pytest.raises(ValueError, match="length"):
simulate_with_daily_data(
prices,
target_weights_history=[{"S0": 1.0}],
initial_cash=1_000_000,
)
def test_simulate_with_daily_data_empty():
"""空输入 → 空输出。"""
prices = pd.DataFrame()
positions = simulate_with_daily_data(prices)
assert positions == []
def test_compute_realized_pnl_basic():
"""compute_realized_pnl 应返回 DailyPnL 列表(与 positions 同长度)。"""
dates = pd.date_range("2024-01-01", periods=10)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(10) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
pnl_list = compute_realized_pnl(positions)
assert len(pnl_list) == 10
def test_compute_realized_pnl_empty():
"""空 positions → 空 pnl 列表。"""
pnl_list = compute_realized_pnl([])
assert pnl_list == []
def test_compute_realized_pnl_first_day_zero():
"""第一天没有前值,realized 应为 0(pnl=0)。"""
dates = pd.date_range("2024-01-01", periods=3)
prices = pd.DataFrame({"S0": [10.0, 11.0, 12.0]}, index=dates)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
pnl_list = compute_realized_pnl(positions)
# 第一天 realized = 0(因为 prev_value = cur_value 时 nav_change = 0)
assert pnl_list[0].total_pnl == 0.0
def test_compute_realized_pnl_sell_realizes():
"""卖出后 realized_pnl 应反映已实现部分。"""
dates = pd.date_range("2024-01-01", periods=3)
# 价格大涨 100%,第二天全卖
prices = pd.DataFrame({"S0": [10.0, 20.0, 20.0]}, index=dates)
targets = [
{"S0": 1.0}, # 第一天买入
{"S0": 0.0}, # 第二天全卖
{"S0": 0.0}, # 第三天空仓
]
positions = simulate_with_daily_data(
prices,
initial_cash=1_000_000.0,
target_weights_history=targets,
)
pnl_list = compute_realized_pnl(positions)
# 第二天日末快照已包含当日卖出,现金流入应在当天反映。
assert pnl_list[1].realized_pnl > 0
# ── O3: end-to-end 端到端测试(集成多个函数) ──────────────
def test_execution_end_to_end_bull_market():
"""上涨行情:NAV 应明显增长。"""
dates = pd.date_range("2024-01-01", periods=30)
# 持续上涨 50%
base = 10 + np.arange(30) * 0.5
prices = pd.DataFrame(
{f"S{i}": base + np.random.rand(30) * 0.2 for i in range(5)},
index=dates,
)
# 等权持有
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
# NAV 应增长 ~50%
final_value = positions[-1].portfolio_value
assert final_value > 1_100_000 # 至少 +10%
def test_execution_end_to_end_with_pnl_breakdown():
"""完整 end-to-end:prices → positions → pnl。"""
dates = pd.date_range("2024-01-01", periods=20)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(20) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
pnl_list = compute_realized_pnl(positions)
# 累计 PnL 应等于 final_value - initial_cash(理论上)
total_pnl_sum = sum(p.total_pnl for p in pnl_list)
nav_change = positions[-1].portfolio_value - positions[0].portfolio_value
# 允许微小差异(因为是 NAV 变化减 realized 的累计)
assert abs(total_pnl_sum - nav_change) < 1.0