feat: add dynamic advisor asset allocation

This commit is contained in:
Windows
2026-09-11 14:44:29 +08:00
parent 8ffd08b4ce
commit ff71a1a724
15 changed files with 652 additions and 22 deletions
+160
View File
@@ -0,0 +1,160 @@
"""Explainable constraint-first allocation optimizer."""
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
HUNDRED = Decimal("100")
ASSET_CLASSES = ("cash_management_etf", "bond_etf", "equity_etf")
@dataclass(frozen=True)
class AssetClassMarketMetric:
asset_class: str
trailing_120d_return_pct: Decimal
max_drawdown_pct: Decimal
average_daily_turnover_amount: Decimal
product_count: int
@dataclass(frozen=True)
class DynamicAllocationResult:
weights: dict[str, int]
dynamic: bool
metric_coverage_pct: Decimal
factors: list[dict[str, object]]
class DynamicAllocationOptimizer:
@classmethod
def optimize(
cls,
strategic_weights: dict[str, int],
metrics: list[AssetClassMarketMetric],
*,
return_target_lower_pct: Decimal,
max_drawdown_pct: Decimal,
liquidity_requirement: str,
) -> DynamicAllocationResult:
available = {
item.asset_class: item for item in metrics if item.asset_class in ASSET_CLASSES
}
coverage = Decimal(len(available)) / Decimal(len(ASSET_CLASSES)) * HUNDRED
if len(available) < 2:
return DynamicAllocationResult(
dict(strategic_weights), False, coverage, cls._factors(metrics, {})
)
scores = {
key: cls._score(
value, metrics, return_target_lower_pct, max_drawdown_pct, liquidity_requirement
)
for key, value in available.items()
}
weights = {key: Decimal(value) for key, value in strategic_weights.items()}
mean = sum(scores.values(), Decimal()) / Decimal(len(scores))
winners = {key: score - mean for key, score in scores.items() if score > mean}
losers = {key: mean - score for key, score in scores.items() if score < mean}
if winners and losers:
tilt = min(Decimal("15"), sum(weights.get(key, Decimal()) for key in losers))
for key, value in winners.items():
weights[key] = weights.get(key, Decimal()) + tilt * value / sum(winners.values())
for key, value in losers.items():
weights[key] = max(
Decimal(), weights.get(key, Decimal()) - tilt * value / sum(losers.values())
)
equity_cap = (
Decimal("20")
if max_drawdown_pct <= 10
else Decimal("40")
if max_drawdown_pct <= 20
else Decimal("85")
)
excess = max(Decimal(), weights["equity_etf"] - equity_cap)
weights["equity_etf"] -= excess
weights["bond_etf"] += excess
cash_min = {
"daily": Decimal("25"),
"within_7_days": Decimal("15"),
"within_30_days": Decimal("5"),
"over_30_days": Decimal(),
}[liquidity_requirement]
shortfall = max(Decimal(), cash_min - weights["cash_management_etf"])
for key in ("bond_etf", "equity_etf"):
moved = min(shortfall, weights[key])
weights[key] -= moved
weights["cash_management_etf"] += moved
shortfall -= moved
rounded = {
key: int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
for key, value in weights.items()
}
largest = max(rounded, key=lambda key: rounded[key])
rounded[largest] += 100 - sum(rounded.values())
return DynamicAllocationResult(rounded, True, coverage, cls._factors(metrics, scores))
@staticmethod
def _score(
metric: AssetClassMarketMetric,
peers: list[AssetClassMarketMetric],
target: Decimal,
drawdown_limit: Decimal,
liquidity_requirement: str,
) -> Decimal:
returns = [item.trailing_120d_return_pct for item in peers]
liquidities = [item.average_daily_turnover_amount for item in peers]
return_fit = (
min(
Decimal("1"),
max(Decimal(), metric.trailing_120d_return_pct * Decimal("2.1") / target),
)
if target > 0
else Decimal("0.5")
)
return_rank = DynamicAllocationOptimizer._rank(metric.trailing_120d_return_pct, returns)
drawdown = abs(metric.max_drawdown_pct)
drawdown_fit = (
min(Decimal("1"), drawdown_limit / drawdown) if drawdown > 0 else Decimal("1")
)
liquidity_floor = {
"daily": Decimal("10000000"),
"within_7_days": Decimal("3000000"),
"within_30_days": Decimal("500000"),
"over_30_days": Decimal(),
}[liquidity_requirement]
liquidity_fit = (
min(Decimal("1"), metric.average_daily_turnover_amount / liquidity_floor)
if liquidity_floor
else Decimal("0.5")
)
liquidity_rank = DynamicAllocationOptimizer._rank(
metric.average_daily_turnover_amount, liquidities
)
return (
Decimal("0.45") * (return_fit + return_rank) / 2
+ Decimal("0.35") * drawdown_fit
+ Decimal("0.20") * (liquidity_fit + liquidity_rank) / 2
)
@staticmethod
def _rank(value: Decimal, peers: list[Decimal]) -> Decimal:
low, high = min(peers), max(peers)
return Decimal("0.5") if low == high else (value - low) / (high - low)
@staticmethod
def _factors(
metrics: list[AssetClassMarketMetric], scores: dict[str, Decimal]
) -> list[dict[str, object]]:
return [
{
"asset_class": item.asset_class,
"trailing_120d_return_pct": str(item.trailing_120d_return_pct),
"max_drawdown_pct": str(item.max_drawdown_pct),
"average_daily_turnover_amount": str(item.average_daily_turnover_amount),
"product_count": item.product_count,
"composite_score": (
str(scores[item.asset_class].quantize(Decimal("0.0001")))
if item.asset_class in scores
else None
),
}
for item in sorted(metrics, key=lambda value: value.asset_class)
]