后端(向后兼容,customer_id 缺省即原行为): - 三个请求契约新增可选 customer_id(推荐/资产配置/持仓诊断) - AuthorizationService 新增 require_customer_scope(权限码 + 数据范围) - 推荐/资产配置/持仓诊断服务支持指定被分析客户 - InvestmentGoalService 新增 current_for_customer 前端(employee-advisor/dashboard/index.html): - 删除「本人」虚拟条目,客户列表改为 4 位真实客户 - 动作与自然语言入口均按所选客户带 customer_id 调真实后端 - 新增风评超期熔断闸门(FM-03,流水线停在 ② 画像) - 统一对话入口从硬编码占位改为自然语言意图路由
228 lines
10 KiB
Python
228 lines
10 KiB
Python
"""Authoritative, read-only portfolio concentration and risk analysis."""
|
|
|
|
from collections import defaultdict
|
|
from collections.abc import Callable
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.contracts import RequestContext
|
|
from app.core.portfolio_analysis_contracts import PortfolioAnalysisQuery
|
|
from app.infrastructure.db import SessionFactory
|
|
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
|
from app.model.advisor_product import AdvisorProductIndustryExposure, AdvisorProductMetricSnapshot
|
|
from app.model.fund import FundHolding, FundProduct
|
|
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
|
|
from app.service.authorization_service import AuthorizationService
|
|
from app.service.profile_governance_service import ProfileGovernanceService
|
|
from app.service.relationship_service import RelationshipService
|
|
|
|
HUNDRED = Decimal("100")
|
|
|
|
|
|
class PortfolioAnalysisService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session_factory: Callable[[], Any] = SessionFactory,
|
|
graph_service: RelationshipService | None = None,
|
|
enforce_profile_governance: bool = False,
|
|
) -> None:
|
|
self.session_factory = session_factory
|
|
self.graph_service = graph_service or RelationshipService(
|
|
Neo4jGraphDriver(get_settings())
|
|
)
|
|
self.enforce_profile_governance = enforce_profile_governance
|
|
|
|
async def analyze_for_agent(
|
|
self, arguments: PortfolioAnalysisQuery, 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, "portfolio-analysis:read:self")
|
|
else:
|
|
await AuthorizationService.require_customer_scope(
|
|
context, "portfolio-analysis:read:customer", customer_id
|
|
)
|
|
if self.enforce_profile_governance:
|
|
await ProfileGovernanceService().require_operable(customer_id)
|
|
async with self.session_factory() as session:
|
|
repository = PortfolioAnalysisRepository(session)
|
|
positions = await repository.positions(customer_id)
|
|
ids = tuple(holding.product_id for holding, _product in positions)
|
|
exposures = await repository.latest_industry_exposures(ids, date.today())
|
|
metrics = await repository.latest_metrics(ids, date.today())
|
|
result = self._analyze(positions, exposures, metrics)
|
|
result["graph_context"] = await self._graph_context(customer_id)
|
|
return result
|
|
|
|
async def _graph_context(self, customer_id: int) -> dict[str, object]:
|
|
try:
|
|
context = await self.graph_service.portfolio_industry_context(customer_id)
|
|
except Exception as exc:
|
|
return {"degraded": True, "reason": f"neo4j_unavailable:{type(exc).__name__}"}
|
|
if bool(context.get("degraded")):
|
|
return {"degraded": True, "reason": str(context.get("reason", "neo4j_unavailable"))}
|
|
raw = context.get("overlaps")
|
|
if not isinstance(raw, list):
|
|
return {"degraded": True, "reason": "neo4j_invalid_result"}
|
|
overlaps = [
|
|
{"industry_name": item["industry_name"], "product_count": item["product_count"]}
|
|
for item in raw
|
|
if isinstance(item, dict)
|
|
and isinstance(item.get("industry_name"), str)
|
|
and isinstance(item.get("product_count"), int)
|
|
and item["product_count"] >= 2
|
|
]
|
|
return {"degraded": False, "overlaps": overlaps[:5]}
|
|
|
|
@classmethod
|
|
def _analyze(
|
|
cls,
|
|
positions: list[tuple[FundHolding, FundProduct]],
|
|
exposures: dict[int, list[AdvisorProductIndustryExposure]],
|
|
metrics: dict[int, AdvisorProductMetricSnapshot],
|
|
) -> dict[str, object]:
|
|
if not positions:
|
|
return {"status": "no_positions", "position_count": 0}
|
|
valued = [
|
|
(holding, product, holding.market_value)
|
|
for holding, product in positions
|
|
if holding.market_value is not None and holding.market_value > 0
|
|
]
|
|
total = sum((value for _holding, _product, value in valued), Decimal())
|
|
if total <= 0:
|
|
return {
|
|
"status": "valuation_required",
|
|
"position_count": len(positions),
|
|
"warnings": [{
|
|
"code": "MARKET_VALUE_UNAVAILABLE",
|
|
"severity": "high",
|
|
"message": "当前持仓缺少可用市值,暂不能计算集中度。",
|
|
}],
|
|
}
|
|
product_rows = cls._product_rows(valued, total)
|
|
industry_rows, coverage, invalid = cls._industry_rows(valued, exposures, total)
|
|
coverage_pct = (coverage / total * HUNDRED).quantize(Decimal("0.01"))
|
|
warnings: list[dict[str, str]] = []
|
|
if Decimal(str(product_rows[0]["share_pct"])) > Decimal("30"):
|
|
warnings.append({
|
|
"code": "SINGLE_PRODUCT_CONCENTRATION",
|
|
"severity": "high",
|
|
"message": "单一产品持仓占比较高,存在集中度风险。",
|
|
})
|
|
industry_hhi = None
|
|
if coverage_pct >= Decimal("80") and industry_rows:
|
|
industry_hhi = cls._hhi([row["share_pct"] for row in industry_rows])
|
|
if Decimal(str(industry_rows[0]["share_pct"])) > Decimal("40"):
|
|
warnings.append({
|
|
"code": "SINGLE_INDUSTRY_CONCENTRATION",
|
|
"severity": "high",
|
|
"message": "单一行业穿透占比较高,存在行业集中度风险。",
|
|
})
|
|
else:
|
|
warnings.append({
|
|
"code": "INDUSTRY_COVERAGE_INCOMPLETE",
|
|
"severity": "medium",
|
|
"message": "行业穿透参考数据覆盖不足,暂不输出确定性行业结论。",
|
|
})
|
|
if invalid:
|
|
warnings.append({
|
|
"code": "INDUSTRY_EXPOSURE_INVALID",
|
|
"severity": "medium",
|
|
"message": "部分产品行业暴露数据异常,未纳入行业穿透计算。",
|
|
})
|
|
missing_metrics = len(valued) - len(metrics)
|
|
if missing_metrics:
|
|
warnings.append({
|
|
"code": "HISTORICAL_METRICS_INCOMPLETE",
|
|
"severity": "medium",
|
|
"message": "部分持仓缺少历史行情指标,风险分析完整性受限。",
|
|
})
|
|
if len(valued) < len(positions):
|
|
warnings.append({
|
|
"code": "MARKET_VALUE_PARTIAL",
|
|
"severity": "medium",
|
|
"message": "部分持仓缺少可用市值,本次集中度基于已估值持仓计算。",
|
|
})
|
|
return {
|
|
"status": "ready",
|
|
"summary": {
|
|
"position_count": len(positions),
|
|
"valued_position_count": len(valued),
|
|
"total_market_value": str(total.quantize(Decimal("0.01"))),
|
|
"industry_coverage_pct": str(coverage_pct),
|
|
"metrics_coverage_pct": str(
|
|
(Decimal(len(metrics)) / Decimal(len(valued)) * HUNDRED).quantize(
|
|
Decimal("0.01")
|
|
)
|
|
),
|
|
},
|
|
"product_concentration": {
|
|
"hhi": cls._hhi([row["share_pct"] for row in product_rows]),
|
|
"rows": product_rows,
|
|
},
|
|
"industry_concentration": {
|
|
"hhi": industry_hhi,
|
|
"rows": industry_rows,
|
|
"conclusion_available": industry_hhi is not None,
|
|
},
|
|
"warnings": warnings,
|
|
"disclaimer": "分析结果仅供参考,不生成交易指令。",
|
|
}
|
|
|
|
@staticmethod
|
|
def _product_rows(
|
|
valued: list[tuple[FundHolding, FundProduct, Decimal]], total: Decimal
|
|
) -> list[dict[str, object]]:
|
|
rows = [{
|
|
"product_id": str(holding.product_id),
|
|
"product_code": product.product_code,
|
|
"product_name": product.product_name,
|
|
"market_value": str(value.quantize(Decimal("0.01"))),
|
|
"share_pct": (value / total * HUNDRED).quantize(Decimal("0.01")),
|
|
} for holding, product, value in valued]
|
|
return sorted(rows, key=lambda row: -Decimal(str(row["share_pct"])))
|
|
|
|
@staticmethod
|
|
def _industry_rows(
|
|
valued: list[tuple[FundHolding, FundProduct, Decimal]],
|
|
exposures: dict[int, list[AdvisorProductIndustryExposure]],
|
|
total: Decimal,
|
|
) -> tuple[list[dict[str, object]], Decimal, set[int]]:
|
|
amounts: dict[str, Decimal] = defaultdict(Decimal)
|
|
covered = Decimal()
|
|
invalid: set[int] = set()
|
|
for holding, _product, value in valued:
|
|
rows = exposures.get(holding.product_id, [])
|
|
weight = sum((row.exposure_weight_pct for row in rows), Decimal())
|
|
if weight <= 0:
|
|
continue
|
|
if weight > HUNDRED:
|
|
invalid.add(holding.product_id)
|
|
continue
|
|
covered += value * weight / HUNDRED
|
|
for row in rows:
|
|
amounts[row.industry_name] += value * row.exposure_weight_pct / HUNDRED
|
|
result = [{
|
|
"industry_name": name,
|
|
"market_value": str(value.quantize(Decimal("0.01"))),
|
|
"share_pct": (value / total * HUNDRED).quantize(Decimal("0.01")),
|
|
} for name, value in amounts.items()]
|
|
return sorted(result, key=lambda row: -Decimal(str(row["share_pct"]))), covered, invalid
|
|
|
|
@staticmethod
|
|
def _hhi(shares: list[object]) -> str:
|
|
value = sum((Decimal(str(share)) ** 2 for share in shares), Decimal())
|
|
return str(value.quantize(Decimal("0.01")))
|
|
|
|
|
|
async def portfolio_analysis_tool(
|
|
arguments: PortfolioAnalysisQuery, context: RequestContext
|
|
) -> dict[str, object]:
|
|
return await PortfolioAnalysisService(enforce_profile_governance=True).analyze_for_agent(
|
|
arguments, context
|
|
)
|