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(
|
def simulate_multi_day_with_audit(
|
||||||
target_weights_history: list[tuple[str, dict[str, float]]],
|
target_weights_history: list[tuple[str, dict[str, float]]],
|
||||||
price_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}"
|
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(
|
nav_before = cash + sum(
|
||||||
shares * prices.get(stock_code, 0.0)
|
shares * prices.get(stock_code, 0.0)
|
||||||
for stock_code, shares in holdings.items()
|
for stock_code, shares in holdings.items()
|
||||||
)
|
)
|
||||||
effective_targets = dict.fromkeys(holdings, 0.0)
|
effective_targets = dict.fromkeys(holdings, 0.0)
|
||||||
effective_targets.update(targets)
|
effective_targets.update(normalized_targets)
|
||||||
trade_weights: dict[str, float] = {}
|
buy_weights: dict[str, float] = {}
|
||||||
|
sell_weights: dict[str, float] = {}
|
||||||
rejected: list[ExecutionResult] = []
|
rejected: list[ExecutionResult] = []
|
||||||
|
|
||||||
for stock_code, target_weight in effective_targets.items():
|
for stock_code, target_weight in effective_targets.items():
|
||||||
@@ -492,22 +542,46 @@ def simulate_multi_day_with_audit(
|
|||||||
if nav_before == 0:
|
if nav_before == 0:
|
||||||
rejected.append(_blocked_execution(stock_code, trade_value, "zero_nav"))
|
rejected.append(_blocked_execution(stock_code, trade_value, "zero_nav"))
|
||||||
continue
|
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)
|
sell_executions = simulate_execution(sell_weights, nav_before, config)
|
||||||
for execution in filled:
|
filled: list[ExecutionResult] = []
|
||||||
|
for execution in sell_executions:
|
||||||
price = prices[execution.stock_code]
|
price = prices[execution.stock_code]
|
||||||
share_change = abs(execution.target_value) / price
|
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)
|
held = holdings.get(execution.stock_code, 0.0)
|
||||||
holdings[execution.stock_code] = max(0.0, held - share_change)
|
holdings[execution.stock_code] = max(0.0, held - share_change)
|
||||||
if holdings[execution.stock_code] < 1e-6:
|
if holdings[execution.stock_code] < 1e-6:
|
||||||
del holdings[execution.stock_code]
|
del holdings[execution.stock_code]
|
||||||
cash += execution.net_cash_flow
|
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)
|
executions = (*filled, *rejected)
|
||||||
nav_after = cash + sum(
|
nav_after = cash + sum(
|
||||||
|
|||||||
Reference in New Issue
Block a user