Files
group_fqcd_jr/app/service/portfolio_analysis_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

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
)