feat: quant_engine 独立量化引擎(v1.2.0 重构 bootstrap)

从 research_results 抽出纯回测核心能力,形成独立成员仓。

## 模块(10 个核心)

| 模块 | 内容 |
|---|---|
| alpha_factors | 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158)+ JSONB 工具 |
| execution | 执行仿真(成本/滑点/T+1/涨跌停/部分成交/价差)+ 多日 NAV + PnL 拆解 |
| indicators | 50+ 技术指标(MACD/KDJ/布林/ATR/ADX/等) |
| data_adapter | 桥接 qtdb_pro 长表与新模块(rename/long-wide/复权/vwap 代理) |
| backtest | weight-based 多日仿真 |
| metrics / perf_stats | 绩效指标 |
| factor_library | 通用方法(turnover/IC/winsorize/OLS) |
| portfolio_decomp / risk | 组合分解 + 风险指标 |
| logging | 统一 logger(标准库 + 可选 loguru) |

## 设计原则

- 零重型依赖(numpy/pandas/scipy)
- mypy strict 0 errors(12 source files)
- 394 tests passed(从 research_results 复制 + 适配)
- ruff clean

## 与 research_results 的关系

- research_results 通过 re-export wrapper 保持向后兼容(src.shared.X → quant_engine.X)
- 47 proj 的 import 路径暂时不变,后续逐步迁移
- 本仓角色:researchhub_workspace 引擎层

Co-Authored-By: Mavis <noreply@mavis.local>
This commit is contained in:
George Berkshire
2026-08-19 15:50:01 +08:00
co-authored by Mavis
commit c04acf0ab6
29 changed files with 9814 additions and 0 deletions
+780
View File
@@ -0,0 +1,780 @@
"""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,
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_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():
"""第一天 portfolio_value = initial_cash(无持仓)。"""
signals = [("d1", {"A": 1.0})]
prices = [("d1", {"A": 10.0})]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
# 第一天 NAV = 1_000_000(无持仓),第二天才是调仓后
assert positions[0].portfolio_value == 1_000_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
# ── 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)
# 第三天(卖出兑现)应有 realized 正利润(cash 从 -800 → 2M = +2M)
assert pnl_list[2].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