Files
group_fqcd_jr/app/service/asset_allocation_service.py
T
lzf_0626 857c106faf 投顾工作台:三接口支持按客户出方案 + 动作按所选客户分流
后端(向后兼容,customer_id 缺省即原行为):
- 三个请求契约新增可选 customer_id(推荐/资产配置/持仓诊断)
- AuthorizationService 新增 require_customer_scope(权限码 + 数据范围)
- 推荐/资产配置/持仓诊断服务支持指定被分析客户
- InvestmentGoalService 新增 current_for_customer

前端(employee-advisor/dashboard/index.html):
- 删除「本人」虚拟条目,客户列表改为 4 位真实客户
- 动作与自然语言入口均按所选客户带 customer_id 调真实后端
- 新增风评超期熔断闸门(FM-03,流水线停在 ② 画像)
- 统一对话入口从硬编码占位改为自然语言意图路由
2026-09-14 18:22:56 +08:00

202 lines
8.9 KiB
Python

"""Dynamic, analysis-only asset allocation from confirmed goals and history."""
from collections import defaultdict
from collections.abc import Callable
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from app.core.advisor_allocation_contracts import AssetAllocationQuery
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory
from app.repository.advisor_product_repository import AdvisorProductRepository
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
from app.service.authorization_service import AuthorizationService
from app.service.dynamic_allocation_optimizer import (
ASSET_CLASSES,
AssetClassMarketMetric,
DynamicAllocationOptimizer,
)
from app.service.investment_goal_service import InvestmentGoalService
from app.service.product_governance_monitor_service import SALES_INSTITUTION
from app.service.profile_governance_service import ProfileGovernanceService
from app.service.suitability_service import SuitabilityService
BASE_ALLOCATIONS = {
"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": "权益类场内基金",
}
class AssetAllocationService:
def __init__(
self,
*,
session_factory: Callable[[], Any] = SessionFactory,
enforce_profile_governance: bool = False,
) -> None:
self.session_factory = session_factory
self.enforce_profile_governance = enforce_profile_governance
async def generate_for_agent(
self, arguments: AssetAllocationQuery, context: RequestContext
) -> dict[str, object]:
# 代客:arguments.customer_id 指定客户;留空则以登录用户自身为对象(原行为)。
customer_id = arguments.customer_id or int(context.user_id)
if customer_id == int(context.user_id):
await AuthorizationService.require(context, "asset-allocation:generate:self")
else:
await AuthorizationService.require_customer_scope(
context, "asset-allocation:generate:customer", customer_id
)
if self.enforce_profile_governance:
await ProfileGovernanceService().require_operable(customer_id)
authority = await SuitabilityService().authority_for_customer(customer_id)
if authority.customer_risk_level is None:
return {"status": "profile_required"}
goal = await InvestmentGoalService().current_for_customer(customer_id, context)
if goal is None:
return {"status": "investment_goal_required"}
horizon = goal.get("investment_horizon_months")
if not isinstance(horizon, int):
return {"status": "investment_goal_invalid"}
risk = f"C{authority.customer_risk_level}"
liquidity = str(goal["liquidity_requirement"])
strategic = self._strategic_weights(
risk, horizon, liquidity, Decimal(str(goal["max_drawdown_pct"]))
)
metrics = await self._market_metrics(authority.customer_risk_level)
optimized = DynamicAllocationOptimizer.optimize(
strategic,
metrics,
return_target_lower_pct=Decimal(str(goal["annualized_return_lower_pct"])),
max_drawdown_pct=Decimal(str(goal["max_drawdown_pct"])),
liquidity_requirement=liquidity,
)
return {
"status": "ready",
"allocation": [
{"asset_class": key, "label": ASSET_LABELS[key], "target_pct": value}
for key, value 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,
"factor_evidence": optimized.factors,
},
"constraints": {
"annualized_return_lower_pct": goal["annualized_return_lower_pct"],
"max_drawdown_pct": goal["max_drawdown_pct"],
"liquidity_requirement": liquidity,
"investment_horizon_months": horizon,
},
"analysis_only": True,
}
async def _market_metrics(self, customer_risk_level: int) -> list[AssetClassMarketMetric]:
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session_factory() as session:
candidates = await AdvisorProductRepository(session).authoritative_tradable_products(
now, sales_institution=SALES_INSTITUTION, limit=50
)
candidates, _excluded = AdvisorProductRepository.hard_suitability_filter(
candidates, customer_risk_level
)
ids = tuple(item.product.id for item in candidates)
repository = PortfolioAnalysisRepository(session)
classifications = await repository.latest_asset_classifications(ids, date.today())
snapshots = await repository.latest_metrics(ids, date.today())
qualities = await repository.latest_quality(ids, date.today())
grouped: dict[str, list[AssetClassMarketMetric]] = defaultdict(list)
for product_id in ids:
classification = classifications.get(product_id)
metric = snapshots.get(product_id)
quality = qualities.get(product_id)
if (
classification is None
or classification.asset_class not in ASSET_CLASSES
or quality is None
or quality.status != "accepted"
or metric is None
or metric.trailing_120d_return_pct is None
or metric.max_drawdown_pct is None
or metric.average_daily_turnover_amount is None
or metric.observation_count < 20
):
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=metric.average_daily_turnover_amount,
product_count=1,
)
)
return [
AssetClassMarketMetric(
asset_class=key,
trailing_120d_return_pct=sum(
(item.trailing_120d_return_pct for item in rows), Decimal()
)
/ len(rows),
max_drawdown_pct=(
sum((item.max_drawdown_pct for item in rows), Decimal()) / len(rows)
),
average_daily_turnover_amount=sum(
(item.average_daily_turnover_amount for item in rows), Decimal()
)
/ len(rows),
product_count=len(rows),
)
for key, rows in grouped.items()
]
@staticmethod
def _strategic_weights(
risk: str, horizon: int, liquidity: str, max_drawdown: Decimal
) -> dict[str, int]:
weights = dict(BASE_ALLOCATIONS[risk])
if horizon <= 12:
AssetAllocationService._move(weights, "equity_etf", "cash_management_etf", 10)
elif horizon >= 60 and risk != "C1":
AssetAllocationService._move(weights, "bond_etf", "equity_etf", 5)
if liquidity == "daily":
AssetAllocationService._move(weights, "equity_etf", "cash_management_etf", 10)
AssetAllocationService._move(weights, "bond_etf", "cash_management_etf", 5)
if max_drawdown <= 10:
AssetAllocationService._cap_equity(weights, 20)
elif max_drawdown <= 20:
AssetAllocationService._cap_equity(weights, 40)
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]:
return await AssetAllocationService(enforce_profile_governance=True).generate_for_agent(
arguments, context
)