fix: enforce cash-backed long-only rebalancing
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user