"""组合偏离度与调仓建议计算。""" from __future__ import annotations from collections import defaultdict from decimal import Decimal, ROUND_HALF_UP from typing import Iterable from common.common_const import ( CUSTOMER_REL_STATUS_SIGNED, ERR_CODE_NOT_SIGNED_REBALANCE, ) from common.suitability import check_suitability from utils.exceptions import ApiError _MONEY = Decimal("0.01") _PERCENT = Decimal("100") def _money(value: Decimal) -> Decimal: return value.quantize(_MONEY, rounding=ROUND_HALF_UP) def build_rebalance_plan( *, relation_status: str, customer_risk: str, holdings: Iterable[dict], target_allocation: dict[str, int | float | Decimal], threshold: Decimal, candidates: Iterable[dict], ) -> dict | None: if relation_status != CUSTOMER_REL_STATUS_SIGNED: raise ApiError(ERR_CODE_NOT_SIGNED_REBALANCE, "客户尚未签约,禁止生成调仓草稿") values: dict[str, Decimal] = defaultdict(Decimal) holdings_by_class: dict[str, list[dict]] = defaultdict(list) for holding in holdings: asset_class = str(holding.get("asset_class", "")) value = Decimal(str(holding.get("market_value", 0) or 0)) values[asset_class] += value holdings_by_class[asset_class].append(holding) total = sum(values.values(), Decimal("0")) if total <= 0: return None target = { asset_class: Decimal(str(weight)) for asset_class, weight in target_allocation.items() } deviation: dict[str, Decimal] = {} for asset_class in target: actual = values.get(asset_class, Decimal("0")) / total * _PERCENT deviation[asset_class] = (actual - target[asset_class]).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) if not any(abs(value) > threshold for value in deviation.values()): return None sell: list[dict] = [] buy: list[dict] = [] for asset_class, drift in deviation.items(): if drift > threshold: target_value = total * target[asset_class] / _PERCENT excess = _money(values.get(asset_class, Decimal("0")) - target_value) remaining = excess for holding in holdings_by_class.get(asset_class, []): amount = min( remaining, _money(Decimal(str(holding.get("market_value", 0) or 0))), ) if amount > 0: sell.append( { "product_code": holding.get("product_code"), "asset_class": asset_class, "amount": amount, } ) remaining -= amount if remaining <= 0: break elif drift < -threshold: target_value = total * target[asset_class] / _PERCENT amount = _money(target_value - values.get(asset_class, Decimal("0"))) for candidate in candidates: if candidate.get("asset_class") != asset_class: continue if not check_suitability( customer_risk, candidate.get("risk_level", "") ).ok: continue buy.append( { "product_code": candidate.get("product_code"), "asset_class": asset_class, "amount": amount, } ) break return { "deviation": deviation, "sell": sell, "buy": buy, }