Compare commits

...
6 Commits
3 changed files with 410 additions and 153 deletions
+18 -2
View File
@@ -19,7 +19,7 @@
## 模块
- `alpha_factors` — 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158)
- `execution` — 执行仿真(成本/滑点/T+1/涨跌停/部分成交/价差)+ 多日 NAV + PnL 拆解(借鉴 hikyuu 部件化思想)
- `execution` — A 股长仓执行仿真(成本/滑点/现金约束)+ 逐日成交/拒绝/持仓/NAV 审计;T+1、涨跌停、成交量与价差提供独立约束函数
- `indicators` — 50+ 技术指标(MACD / KDJ / 布林 / ATR / ADX / 等)
- `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理)
- `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark)
@@ -59,7 +59,7 @@ ruff check src/ tests/ # lint
```python
from quant_engine.alpha_factors import alpha_001, alpha_005, ALPHA158_REGISTRY
from quant_engine.execution import (
ExecutionConfig, simulate_with_daily_data, compute_realized_pnl,
ExecutionConfig, simulate_multi_day_with_audit, simulate_with_daily_data,
)
from quant_engine.backtest import run_weight_backtest
from quant_engine.portfolio_construction import scores_to_weight_table
@@ -76,6 +76,22 @@ df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True)
prices, volumes = prepare_execution_inputs(df)
result = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
# 已正确滞后的目标权重 → 现金约束执行 → 唯一来源的成交/拒绝/日末持仓/NAV
execution = simulate_multi_day_with_audit(
target_weights_history=[
("2024-01-02", {"000001.SZ": 1.0}),
("2024-01-03", {"000001.SZ": 1.0}),
],
price_history=[
("2024-01-02", {"000001.SZ": 10.0}),
("2024-01-03", {"000001.SZ": 10.5}),
],
initial_cash=1_000_000.0,
config=ExecutionConfig(),
)
print(execution.nav_series)
print(execution.daily_executions)
# 多期因子分数 → Top-K 等权组合 → 稳定回测结果
rebalance_weights = scores_to_weight_table(
factor_scores,
+256 -137
View File
@@ -10,7 +10,8 @@
借鉴 hikyuu SG/MM/CN/PG 部件化思想(不引入 hikyuu 框架):
- ExecutionConfig:佣金 + 印花税 + 滑点 + 最小交易额 + 止损/止盈阈值
- simulate_execution():从目标权重 → 实际成交金额(应用成本/滑点)
- simulate_multi_day():多日组合仿真(NAV 序列 + 调仓记录)
- simulate_multi_day_with_audit():目标权重差额调仓(成交/拒绝/持仓/NAV)
- simulate_multi_day():兼容的多日日末持仓快照入口
- check_stop_loss_take_profit():止损/止盈触发判定
- run_end_to_end_poc():signal → 调仓 → 执行 → NAV 端到端 POC
@@ -19,6 +20,7 @@
from __future__ import annotations
import math
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
@@ -344,19 +346,133 @@ class DailyExecution:
"""单日执行记录。"""
date: str
executions: list[ExecutionResult]
executions: tuple[ExecutionResult, ...]
nav_before: float
nav_after: float
rebalance_triggered: bool
def simulate_multi_day(
@dataclass(frozen=True)
class ExecutionSimulationResult:
"""单次多日仿真的持仓与执行审计结果。"""
initial_cash: float
positions: tuple[DailyPosition, ...]
daily_executions: tuple[DailyExecution, ...]
@property
def nav_series(self) -> pd.Series:
"""返回按日期索引的日末 NAV 副本。"""
return pd.Series(
[position.portfolio_value for position in self.positions],
index=[position.date for position in self.positions],
dtype=float,
)
@property
def total_costs(self) -> float:
"""汇总实际成交产生的成本。"""
return sum(
execution.total_cost
for daily in self.daily_executions
for execution in daily.executions
)
@property
def total_turnover(self) -> float:
"""汇总实际成交金额。"""
return sum(
execution.executed_value
for daily in self.daily_executions
for execution in daily.executions
)
@property
def total_rebalances(self) -> int:
"""返回至少有一笔实际成交的调仓日数量。"""
return sum(daily.rebalance_triggered for daily in self.daily_executions)
@property
def final_portfolio_value(self) -> float:
"""返回最后一个日末 NAV;空输入时返回初始资金。"""
if not self.positions:
return self.initial_cash
return self.positions[-1].portfolio_value
@property
def return_pct(self) -> float:
"""返回相对初始资金的百分比收益。"""
if self.initial_cash == 0:
return 0.0
return (self.final_portfolio_value / self.initial_cash - 1.0) * 100.0
def _blocked_execution(stock_code: str, target_value: float, reason: str) -> ExecutionResult:
"""构造未成交但可审计的执行记录。"""
return ExecutionResult(
stock_code=stock_code,
target_value=target_value,
executed_value=0.0,
commission=0.0,
stamp_tax=0.0,
slippage_cost=0.0,
total_cost=0.0,
net_cash_flow=0.0,
partial_fill_pct=0.0,
blocked_reason=reason,
)
def _validate_target_weights(date: str, targets: Mapping[str, float]) -> dict[str, float]:
"""校验并复制单日长仓目标权重。"""
normalized: dict[str, float] = {}
for stock_code, raw_weight in targets.items():
try:
weight = float(raw_weight)
except (TypeError, ValueError) as error:
raise ValueError(f"target weights on {date!r} must be numeric") from error
if not math.isfinite(weight) or weight < 0:
raise ValueError(f"target weights on {date!r} must be finite and non-negative")
normalized[stock_code] = weight
if sum(normalized.values()) > 1.0 + 1e-12:
raise ValueError(f"target weights on {date!r} must sum to at most 1.0")
return normalized
def _partially_fill_buy(
desired: ExecutionResult,
fill_pct: float,
config: ExecutionConfig,
) -> ExecutionResult:
"""按同一比例缩放买入,保留原始目标金额供审计。"""
actual_target_value = desired.target_value * fill_pct
executed_value, commission, stamp_tax, slippage_cost = _apply_costs(
actual_target_value,
True,
config,
)
total_cost = commission + stamp_tax + slippage_cost
return ExecutionResult(
stock_code=desired.stock_code,
target_value=desired.target_value,
executed_value=executed_value,
commission=commission,
stamp_tax=stamp_tax,
slippage_cost=slippage_cost,
total_cost=total_cost,
net_cash_flow=-(executed_value + commission + stamp_tax),
partial_fill_pct=fill_pct,
blocked_reason="insufficient_cash_partial_fill",
)
def simulate_multi_day_with_audit(
target_weights_history: list[tuple[str, dict[str, float]]],
price_history: list[tuple[str, dict[str, float]]],
initial_cash: float,
config: ExecutionConfig | None = None,
) -> list[DailyPosition]:
"""多日组合仿真(NAV 序列)。
) -> ExecutionSimulationResult:
"""按目标权重差额推进组合,并返回唯一事实来源的审计结果。
Args:
target_weights_history: [(date, {stock_code: target_weight})]
@@ -365,97 +481,146 @@ def simulate_multi_day(
config: 执行配置
Returns:
DailyPosition 列表(每日 NAV 快照)。
日末持仓快照与逐日成交记录组成的结构化审计结果。
Note:
- 调仓频率 = target_weights_history 的频率(每日 / 每周 / 每月都行)
- 每日先按当日 close 估值,再按当日 target 调仓(下一交易日生效)
- 此处简化:调仓使用当日 close 价格
- 每日先按当日 close 估值,再交易“目标市值 - 当前市值”的差额
- 此处简化为当日 close 成交;调用方必须传入已正确滞后的目标权重
"""
if config is None:
config = ExecutionConfig()
if not math.isfinite(initial_cash) or initial_cash < 0:
raise ValueError(f"initial_cash must be finite and non-negative, got {initial_cash}")
if len(target_weights_history) != len(price_history):
raise ValueError("target_weights_history and price_history must have same length")
if not target_weights_history:
return []
return ExecutionSimulationResult(initial_cash, (), ())
cash = initial_cash
holdings: dict[str, float] = {}
positions: list[DailyPosition] = []
for (date, targets), (_, prices) in zip(target_weights_history, price_history, strict=True):
# 1) 先按当日收盘价估值
portfolio_value = cash + sum(
shares * prices.get(code, 0.0) for code, shares in holdings.items()
daily_executions: list[DailyExecution] = []
for (date, targets), (price_date, prices) in zip(
target_weights_history, price_history, strict=True
):
if date != price_date:
raise ValueError(
f"target and price dates must match, got {date!r} and {price_date!r}"
)
normalized_targets = _validate_target_weights(date, targets)
for held_code in holdings:
held_price = prices.get(held_code)
if held_price is None or not math.isfinite(held_price) or held_price <= 0:
raise ValueError(f"missing price for held asset {held_code} on {date!r}")
nav_before = cash + sum(
shares * prices.get(stock_code, 0.0)
for stock_code, shares in holdings.items()
)
positions.append(
DailyPosition(
effective_targets = dict.fromkeys(holdings, 0.0)
effective_targets.update(normalized_targets)
buy_weights: dict[str, float] = {}
sell_weights: dict[str, float] = {}
rejected: list[ExecutionResult] = []
for stock_code, target_weight in effective_targets.items():
price = prices.get(stock_code)
target_value = float(target_weight) * nav_before
if price is None or not math.isfinite(price) or price <= 0:
if target_value != 0 or holdings.get(stock_code, 0.0) != 0:
rejected.append(_blocked_execution(stock_code, target_value, "missing_price"))
continue
current_value = holdings.get(stock_code, 0.0) * price
trade_value = target_value - current_value
if abs(trade_value) < config.min_trade_amount or math.isclose(
trade_value, 0.0, abs_tol=1e-12
):
continue
if nav_before == 0:
rejected.append(_blocked_execution(stock_code, trade_value, "zero_nav"))
continue
destination = buy_weights if trade_value > 0 else sell_weights
destination[stock_code] = trade_value / nav_before
sell_executions = simulate_execution(sell_weights, nav_before, config)
filled: list[ExecutionResult] = []
for execution in sell_executions:
price = prices[execution.stock_code]
share_change = abs(execution.target_value) / price
held = holdings.get(execution.stock_code, 0.0)
holdings[execution.stock_code] = max(0.0, held - share_change)
if holdings[execution.stock_code] < 1e-6:
del holdings[execution.stock_code]
cash += execution.net_cash_flow
filled.append(execution)
desired_buys = simulate_execution(buy_weights, nav_before, config)
required_cash = sum(-execution.net_cash_flow for execution in desired_buys)
buy_fill_pct = min(1.0, max(cash, 0.0) / required_cash) if required_cash > 0 else 1.0
for desired in desired_buys:
if buy_fill_pct == 0:
rejected.append(
_blocked_execution(desired.stock_code, desired.target_value, "insufficient_cash")
)
continue
execution = (
desired
if buy_fill_pct == 1.0
else _partially_fill_buy(desired, buy_fill_pct, config)
)
price = prices[execution.stock_code]
share_change = (
execution.target_value * execution.partial_fill_pct / price
)
holdings[execution.stock_code] = (
holdings.get(execution.stock_code, 0.0) + share_change
)
cash += execution.net_cash_flow
if math.isclose(cash, 0.0, abs_tol=1e-9):
cash = 0.0
filled.append(execution)
executions = (*filled, *rejected)
nav_after = cash + sum(
shares * prices.get(stock_code, 0.0)
for stock_code, shares in holdings.items()
)
positions.append(DailyPosition(date, cash, dict(holdings), nav_after))
daily_executions.append(
DailyExecution(
date=date,
cash=cash,
holdings=dict(holdings),
portfolio_value=portfolio_value,
executions=executions,
nav_before=nav_before,
nav_after=nav_after,
rebalance_triggered=bool(filled),
)
)
# 2) 计算 effective_targets(包含需要平仓的零权重)
effective_targets: dict[str, float] = dict(targets)
for held_code in holdings:
if held_code not in effective_targets:
effective_targets[held_code] = 0.0
# 3) 调仓(只对非零目标调用 simulate_execution)
non_zero_targets = {k: v for k, v in effective_targets.items() if v != 0}
results = simulate_execution(non_zero_targets, portfolio_value, config)
# 4) 处理零目标(平仓):构造 ExecutionResult,shares = held(全部卖出)
for stock_code, weight in effective_targets.items():
if weight == 0 and stock_code in holdings and holdings[stock_code] > 0:
price = prices.get(stock_code, 0.0)
if price > 0:
held = holdings[stock_code]
# 全部卖出:target_shares = held
# executed_value = held * price(考虑滑点)
slippage_factor = 1.0 - config.slippage_bps / 10000.0
target_value = -held * price
executed_value = target_value * slippage_factor
commission = abs(executed_value) * config.commission_bps / 10000.0
stamp_tax = abs(executed_value) * config.stamp_tax_bps / 10000.0
slippage_cost = abs(executed_value - target_value)
# 标记净卖出 shares = held
results.append(
ExecutionResult(
stock_code=stock_code,
target_value=target_value,
executed_value=executed_value,
commission=commission,
stamp_tax=stamp_tax,
slippage_cost=slippage_cost,
total_cost=commission + stamp_tax + slippage_cost,
net_cash_flow=executed_value - commission - stamp_tax,
)
)
# 5) 应用执行结果到持仓
for r in results:
cost = r.executed_value + r.commission + r.stamp_tax
proceeds = r.executed_value - r.commission - r.stamp_tax
price = prices.get(r.stock_code, 0.0)
if r.target_value > 0:
# 买入:shares = 正数 executed_value / price,cash 减少 cost
shares = r.executed_value / price if price > 0 else 0.0
holdings[r.stock_code] = holdings.get(r.stock_code, 0.0) + shares
cash -= cost
else:
# 卖出:cash 增加 proceeds 的绝对值(proceeds 本是负的)
held = holdings.get(r.stock_code, 0.0)
if held > 0:
# 如果是 zero-target 触发的全卖(target_value 与持仓市值近似),全部卖出
if abs(r.target_value) >= held * price * 0.95:
sell_shares = held
else:
target_shares = abs(r.executed_value) / price if price > 0 else held
sell_shares = min(held, target_shares)
holdings[r.stock_code] = held - sell_shares
if holdings[r.stock_code] < 1e-6:
del holdings[r.stock_code]
# proceeds 是负的(target_value 负),cash += proceeds 实际是减去
# 但卖出是现金流入,所以应该 cash += abs(proceeds)
cash += abs(proceeds)
return positions
return ExecutionSimulationResult(
initial_cash=initial_cash,
positions=tuple(positions),
daily_executions=tuple(daily_executions),
)
def simulate_multi_day(
target_weights_history: list[tuple[str, dict[str, float]]],
price_history: list[tuple[str, dict[str, float]]],
initial_cash: float,
config: ExecutionConfig | None = None,
) -> list[DailyPosition]:
"""兼容入口:返回多日仿真的日末持仓快照。"""
result = simulate_multi_day_with_audit(
target_weights_history,
price_history,
initial_cash,
config,
)
return list(result.positions)
def run_end_to_end_poc(
@@ -486,64 +651,16 @@ def run_end_to_end_poc(
config = ExecutionConfig()
if len(signals) != len(prices):
raise ValueError("signals and prices must have same length")
positions = simulate_multi_day(signals, prices, initial_cash, config)
nav_series = pd.Series(
[p.portfolio_value for p in positions], index=[p.date for p in positions]
)
# 计算 total_costs / total_turnover(重放所有执行)
total_cost_acc = 0.0
total_turnover_acc = 0.0
rebalance_count = 0
cash = initial_cash
holdings: dict[str, float] = {}
for (date, targets), (_, price_map) in zip(signals, prices, strict=True):
portfolio_value = cash + sum(
shares * price_map.get(code, 0.0) for code, shares in holdings.items()
)
if targets:
rebalance_count += 1
# 自动平仓:持仓但不在 target 中的股票
effective_targets: dict[str, float] = dict(targets)
for held_code in holdings:
if held_code not in effective_targets:
effective_targets[held_code] = 0.0
results = simulate_execution(effective_targets, portfolio_value, config)
total_cost_acc += total_costs(results)
total_turnover_acc += total_turnover(results)
for r in results:
cost = r.executed_value + r.commission + r.stamp_tax
proceeds = r.executed_value - r.commission - r.stamp_tax
if r.target_value > 0:
shares = (
r.executed_value / price_map[r.stock_code]
if price_map[r.stock_code] > 0
else 0.0
)
holdings[r.stock_code] = holdings.get(r.stock_code, 0.0) + shares
cash -= cost
else:
held = holdings.get(r.stock_code, 0.0)
if held > 0:
sell_shares = min(
held,
abs(r.executed_value / price_map[r.stock_code])
if price_map[r.stock_code] > 0
else held,
)
holdings[r.stock_code] = held - sell_shares
if holdings[r.stock_code] < 1e-6:
del holdings[r.stock_code]
cash += proceeds
audit = simulate_multi_day_with_audit(signals, prices, initial_cash, config)
return {
"positions": positions,
"nav_series": nav_series,
"total_costs": total_cost_acc,
"total_turnover": total_turnover_acc,
"total_rebalances": rebalance_count,
"final_portfolio_value": nav_series.iloc[-1] if len(nav_series) > 0 else initial_cash,
"return_pct": ((nav_series.iloc[-1] / initial_cash) - 1) * 100
if len(nav_series) > 0
else 0.0,
"positions": list(audit.positions),
"daily_executions": list(audit.daily_executions),
"nav_series": audit.nav_series,
"total_costs": audit.total_costs,
"total_turnover": audit.total_turnover,
"total_rebalances": audit.total_rebalances,
"final_portfolio_value": audit.final_portfolio_value,
"return_pct": audit.return_pct,
}
@@ -667,7 +784,9 @@ __all__ = [
"apply_bid_ask_spread",
"DailyPosition",
"DailyExecution",
"ExecutionSimulationResult",
"simulate_multi_day",
"simulate_multi_day_with_audit",
"run_end_to_end_poc",
"DailyPnL",
"simulate_with_daily_data",
+136 -14
View File
@@ -11,6 +11,7 @@ import pytest
from quant_engine.execution import (
ExecutionConfig,
ExecutionResult,
ExecutionSimulationResult,
apply_bid_ask_spread,
apply_volume_constraint,
check_price_limit,
@@ -20,6 +21,7 @@ from quant_engine.execution import (
run_end_to_end_poc,
simulate_execution,
simulate_multi_day,
simulate_multi_day_with_audit,
simulate_with_daily_data,
total_costs,
total_turnover,
@@ -327,24 +329,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 +356,118 @@ 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,
)
# ── v1.2.0 Phase 1:端到端 POC(run_end_to_end_poc) ─────
@@ -449,6 +562,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 +863,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 端到端测试(集成多个函数) ──────────────