247 lines
11 KiB
Python
247 lines
11 KiB
Python
"""Constraint-first ETF allocation model for the investment-advisor Agent."""
|
|
|
|
from collections import defaultdict
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from app.core.advisor_allocation_contracts import AssetAllocationQuery
|
|
from app.core.config import get_settings
|
|
from app.core.contracts import RequestContext
|
|
from app.infrastructure.db import SessionFactory
|
|
from app.model.product import ProductMarketQuoteSnapshot
|
|
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
|
|
from app.repository.product_recommendation_repository import ProductRecommendationRepository
|
|
from app.service.authorization_service import AuthorizationService
|
|
from app.service.customer_profile_service import CustomerProfileService
|
|
from app.service.dynamic_allocation_optimizer import (
|
|
AssetClassMarketMetric,
|
|
DynamicAllocationOptimizer,
|
|
)
|
|
from app.service.investment_goal_service import InvestmentGoalService
|
|
|
|
_BASE_ALLOCATIONS: dict[str, dict[str, int]] = {
|
|
"C1": {"cash_management_etf": 50, "bond_etf": 40, "equity_etf": 10},
|
|
"C2": {"cash_management_etf": 30, "bond_etf": 50, "equity_etf": 20},
|
|
"C3": {"cash_management_etf": 15, "bond_etf": 45, "equity_etf": 40},
|
|
"C4": {"cash_management_etf": 10, "bond_etf": 25, "equity_etf": 65},
|
|
"C5": {"cash_management_etf": 5, "bond_etf": 15, "equity_etf": 80},
|
|
}
|
|
|
|
_ASSET_LABELS = {
|
|
"cash_management_etf": "现金管理类场内基金",
|
|
"bond_etf": "债券类场内基金",
|
|
"equity_etf": "权益类场内基金",
|
|
}
|
|
_ASSET_CLASSES = frozenset(_ASSET_LABELS)
|
|
_MIN_METRIC_OBSERVATIONS = 20
|
|
MarketMetricLoader = Callable[[str], Awaitable[list[AssetClassMarketMetric]]]
|
|
|
|
|
|
class AssetAllocationService:
|
|
"""Uses formal goals as constraints and historical product metrics as dynamic tilts."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session_factory: Callable[[], Any] = SessionFactory,
|
|
market_metric_loader: MarketMetricLoader | None = None,
|
|
) -> None:
|
|
self.session_factory = session_factory
|
|
self.market_metric_loader = market_metric_loader
|
|
|
|
async def generate_for_agent(self, context: RequestContext) -> dict[str, object]:
|
|
await AuthorizationService.require(context, "asset-allocation:generate:self")
|
|
profile = await CustomerProfileService().current_for_agent(context)
|
|
if profile is None:
|
|
return {"status": "profile_required"}
|
|
goal = await InvestmentGoalService().current_for_agent(context)
|
|
if goal is None:
|
|
return {"status": "investment_goal_required"}
|
|
if goal.get("status") != "confirmed":
|
|
return {"status": "investment_goal_confirmation_required"}
|
|
horizon_months = goal.get("investment_horizon_months")
|
|
if not isinstance(horizon_months, int):
|
|
return {"status": "investment_goal_invalid"}
|
|
|
|
strategic_weights = self._weights(
|
|
str(profile["risk_level"]),
|
|
horizon_months,
|
|
str(goal["liquidity_requirement"]),
|
|
Decimal(str(goal["max_drawdown_pct"])),
|
|
)
|
|
try:
|
|
metrics = await self._market_metrics(str(profile["risk_level"]))
|
|
except Exception:
|
|
metrics = []
|
|
optimized = DynamicAllocationOptimizer.optimize(
|
|
strategic_weights,
|
|
metrics,
|
|
return_target_lower_pct=Decimal(str(goal["annualized_return_lower_pct"])),
|
|
max_drawdown_pct=Decimal(str(goal["max_drawdown_pct"])),
|
|
liquidity_requirement=str(goal["liquidity_requirement"]),
|
|
)
|
|
return {
|
|
"status": "ready",
|
|
"allocation": [
|
|
{"asset_class": asset_class, "label": _ASSET_LABELS[asset_class], "target_pct": pct}
|
|
for asset_class, pct in optimized.weights.items()
|
|
],
|
|
"optimization": {
|
|
"method": "constrained_historical_multi_factor_v1",
|
|
"dynamic": optimized.dynamic,
|
|
"metric_coverage_pct": str(
|
|
optimized.metric_coverage_pct.quantize(Decimal("0.01"))
|
|
),
|
|
"strategic_allocation": strategic_weights,
|
|
"factor_evidence": optimized.factors,
|
|
},
|
|
"investment_horizon_months": horizon_months,
|
|
"liquidity_requirement": goal["liquidity_requirement"],
|
|
"benchmark_name": goal.get("benchmark_name"),
|
|
"analysis_only": True,
|
|
}
|
|
|
|
async def _market_metrics(self, customer_risk_level: str) -> list[AssetClassMarketMetric]:
|
|
if self.market_metric_loader is not None:
|
|
return await self.market_metric_loader(customer_risk_level)
|
|
sales_institution = get_settings().product_suitability_sales_institution.strip()
|
|
customer_risk = self._risk_value(customer_risk_level)
|
|
if not sales_institution or customer_risk is None:
|
|
return []
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|
async with self.session_factory() as session:
|
|
candidates = await ProductRecommendationRepository(
|
|
session
|
|
).authoritative_tradable_products(
|
|
now,
|
|
sales_institution=sales_institution,
|
|
limit=50,
|
|
)
|
|
eligible_ids = tuple(
|
|
candidate.product.id
|
|
for candidate in candidates
|
|
if (product_risk := self._risk_value(candidate.suitability.risk_level)) is not None
|
|
and product_risk <= customer_risk
|
|
)
|
|
repository = PortfolioAnalysisRepository(session)
|
|
classifications = await repository.latest_asset_classifications(
|
|
eligible_ids, date.today()
|
|
)
|
|
snapshots = await repository.latest_metrics(eligible_ids, date.today())
|
|
quality = await repository.latest_data_quality(eligible_ids, date.today())
|
|
quotes = await repository.latest_market_quotes(
|
|
eligible_ids, now - timedelta(days=3)
|
|
)
|
|
grouped: dict[str, list[AssetClassMarketMetric]] = defaultdict(list)
|
|
for product_id in eligible_ids:
|
|
classification = classifications.get(product_id)
|
|
metric = snapshots.get(product_id)
|
|
data_quality = quality.get(product_id)
|
|
if (
|
|
classification is None
|
|
or classification.asset_class not in _ASSET_CLASSES
|
|
or metric is None
|
|
or data_quality is None
|
|
or data_quality.status != "accepted"
|
|
or metric.observation_count < _MIN_METRIC_OBSERVATIONS
|
|
or metric.trailing_120d_return_pct is None
|
|
or metric.max_drawdown_pct is None
|
|
):
|
|
continue
|
|
liquidity, source = self._liquidity(
|
|
metric.average_daily_turnover_amount, quotes.get(product_id)
|
|
)
|
|
if liquidity is None:
|
|
continue
|
|
grouped[classification.asset_class].append(AssetClassMarketMetric(
|
|
asset_class=classification.asset_class,
|
|
trailing_120d_return_pct=metric.trailing_120d_return_pct,
|
|
max_drawdown_pct=metric.max_drawdown_pct,
|
|
average_daily_turnover_amount=liquidity,
|
|
product_count=1,
|
|
liquidity_source=source,
|
|
))
|
|
return [
|
|
AssetClassMarketMetric(
|
|
asset_class=asset_class,
|
|
trailing_120d_return_pct=sum(
|
|
(item.trailing_120d_return_pct for item in snapshots), Decimal()
|
|
) / len(snapshots),
|
|
max_drawdown_pct=sum(
|
|
(item.max_drawdown_pct for item in snapshots), Decimal()
|
|
) / len(snapshots),
|
|
average_daily_turnover_amount=sum(
|
|
(item.average_daily_turnover_amount for item in snapshots), Decimal()
|
|
) / len(snapshots),
|
|
product_count=sum(item.product_count for item in snapshots),
|
|
liquidity_source=(
|
|
snapshots[0].liquidity_source
|
|
if len({item.liquidity_source for item in snapshots}) == 1 else "mixed"
|
|
),
|
|
)
|
|
for asset_class, snapshots in grouped.items()
|
|
]
|
|
|
|
@staticmethod
|
|
def _risk_value(value: str) -> int | None:
|
|
if len(value) == 2 and value[0] in {"C", "R"} and value[1] in "12345":
|
|
return int(value[1])
|
|
return None
|
|
|
|
@staticmethod
|
|
def _liquidity(
|
|
historical_turnover: Decimal | None, quote: ProductMarketQuoteSnapshot | None
|
|
) -> tuple[Decimal | None, str]:
|
|
if historical_turnover is not None and historical_turnover > 0:
|
|
return historical_turnover, "historical_turnover"
|
|
if quote is None:
|
|
return None, "unavailable"
|
|
turnover = quote.turnover_amount
|
|
if turnover is not None and turnover > 0:
|
|
return turnover, "latest_quote_turnover"
|
|
price = quote.last_price
|
|
volume = quote.volume
|
|
if price is not None and price > 0 and volume is not None and volume > 0:
|
|
return price * volume * Decimal("100"), "estimated_from_quote_volume"
|
|
return None, "unavailable"
|
|
|
|
@staticmethod
|
|
def _weights(
|
|
risk_level: str, horizon_months: int, liquidity_requirement: str, max_drawdown_pct: Decimal
|
|
) -> dict[str, int]:
|
|
weights = dict(_BASE_ALLOCATIONS[risk_level])
|
|
if horizon_months <= 12:
|
|
AssetAllocationService._move(weights, "equity_etf", "cash_management_etf", 10)
|
|
elif horizon_months >= 60 and risk_level != "C1":
|
|
AssetAllocationService._move(weights, "bond_etf", "equity_etf", 5)
|
|
if liquidity_requirement == "daily":
|
|
AssetAllocationService._move(weights, "equity_etf", "cash_management_etf", 10)
|
|
AssetAllocationService._move(weights, "bond_etf", "cash_management_etf", 5)
|
|
if max_drawdown_pct <= Decimal("10"):
|
|
AssetAllocationService._cap_equity(weights, 20)
|
|
elif max_drawdown_pct <= Decimal("20"):
|
|
AssetAllocationService._cap_equity(weights, 40)
|
|
assert sum(weights.values()) == 100
|
|
return weights
|
|
|
|
@staticmethod
|
|
def _move(weights: dict[str, int], source: str, target: str, amount: int) -> None:
|
|
moved = min(weights[source], amount)
|
|
weights[source] -= moved
|
|
weights[target] += moved
|
|
|
|
@staticmethod
|
|
def _cap_equity(weights: dict[str, int], cap: int) -> None:
|
|
excess = max(0, weights["equity_etf"] - cap)
|
|
weights["equity_etf"] -= excess
|
|
weights["bond_etf"] += excess
|
|
|
|
|
|
async def asset_allocation_tool(
|
|
_arguments: AssetAllocationQuery, context: RequestContext
|
|
) -> dict[str, object]:
|
|
"""Tool entry point for analysis only; it cannot place orders."""
|
|
return await AssetAllocationService().generate_for_agent(context)
|