Files
group_fqcd_jr/app/service/asset_allocation_service.py
T

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)