"""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.relationship_service import RelationshipService HUNDRED = Decimal("100") class PortfolioAnalysisService: def __init__( self, *, session_factory: Callable[[], Any] = SessionFactory, graph_service: RelationshipService | None = None, ) -> None: self.session_factory = session_factory self.graph_service = graph_service or RelationshipService( Neo4jGraphDriver(get_settings()) ) async def analyze_for_agent( self, _arguments: PortfolioAnalysisQuery, context: RequestContext ) -> dict[str, object]: await AuthorizationService.require(context, "portfolio-analysis:read:self") async with self.session_factory() as session: repository = PortfolioAnalysisRepository(session) positions = await repository.positions(int(context.user_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(int(context.user_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().analyze_for_agent(arguments, context)