"""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_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 演化。 注意:positions[i] 是第 i 天 rebalance 之前的快照。 所以要看 d2 rebalance 后的 holdings,需要看 positions[2](d3 的快照)。 """ 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 ] 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) # d3 的 PRE-trade snapshot 应该只有 A(B 在 d2 被平仓) assert "B" not in positions[2].holdings 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_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, ) # ── 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 # ── 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