From da2ca51ff77281a83c41b49a6f7d63e18f1c4864 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:37:02 +0800 Subject: [PATCH] fix: enforce cash-backed long-only rebalancing --- src/quant_engine/execution.py | 102 +++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 14 deletions(-) diff --git a/src/quant_engine/execution.py b/src/quant_engine/execution.py index b26d4c2..0d137e5 100644 --- a/src/quant_engine/execution.py +++ b/src/quant_engine/execution.py @@ -422,6 +422,49 @@ def _blocked_execution(stock_code: str, target_value: float, reason: str) -> Exe ) +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]]], @@ -466,13 +509,20 @@ def simulate_multi_day_with_audit( 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() ) effective_targets = dict.fromkeys(holdings, 0.0) - effective_targets.update(targets) - trade_weights: dict[str, float] = {} + 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(): @@ -492,22 +542,46 @@ def simulate_multi_day_with_audit( if nav_before == 0: rejected.append(_blocked_execution(stock_code, trade_value, "zero_nav")) continue - trade_weights[stock_code] = trade_value / nav_before + destination = buy_weights if trade_value > 0 else sell_weights + destination[stock_code] = trade_value / nav_before - filled = simulate_execution(trade_weights, nav_before, config) - for execution in filled: + 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 - if execution.target_value > 0: - holdings[execution.stock_code] = ( - holdings.get(execution.stock_code, 0.0) + share_change - ) - else: - 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] + 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(