108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
"""组合偏离度与调仓建议计算。"""
|
|
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,
|
|
}
|