Files
group_fqcd_jr/app/service/portfolio_analysis_service.py
T

452 lines
19 KiB
Python

"""Authoritative, read-only portfolio concentration and risk analysis."""
import re
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
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.product import (
FinancialHolding,
FinancialProduct,
ProductIndustryExposure,
ProductMetricSnapshot,
)
from app.repository.portfolio_analysis_repository import PortfolioAnalysisRepository
from app.service.authorization_service import AuthorizationService
from app.service.customer_profile_service import CustomerProfileService
from app.service.relationship_service import RelationshipService
from app.service.runtime_config_service import load_portfolio_analysis_config
_RISK_LEVEL = re.compile(r"R([1-5])$")
_RISK_VALUES = {f"C{level}": level for level in range(1, 6)}
_HUNDRED = Decimal("100")
@dataclass(frozen=True)
class PortfolioAnalysisThresholds:
single_product_concentration_pct: Decimal = Decimal("30")
single_industry_concentration_pct: Decimal = Decimal("40")
min_industry_coverage_pct: Decimal = Decimal("80")
@classmethod
def from_mapping(cls, value: object) -> "PortfolioAnalysisThresholds":
if not isinstance(value, dict):
return cls()
def threshold(key: str, default: Decimal) -> Decimal:
raw = value.get(key)
return Decimal(raw) if type(raw) is int and 1 <= raw <= 100 else default
return cls(
single_product_concentration_pct=threshold(
"single_product_concentration_pct", Decimal("30")
),
single_industry_concentration_pct=threshold(
"single_industry_concentration_pct", Decimal("40")
),
min_industry_coverage_pct=threshold("min_industry_coverage_pct", Decimal("80")),
)
class PortfolioAnalysisService:
"""Calculates portfolio findings from MySQL facts; it never issues trade instructions."""
def __init__(
self,
*,
session_factory: Callable[[], Any] = SessionFactory,
thresholds: PortfolioAnalysisThresholds | None = None,
graph_service: RelationshipService | None = None,
) -> None:
self.session_factory = session_factory
self.thresholds = thresholds or PortfolioAnalysisThresholds()
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")
profile = await CustomerProfileService().current_for_agent(context)
if profile is None:
return {"status": "profile_required"}
risk_level = profile.get("risk_level")
if risk_level not in _RISK_VALUES:
return {"status": "profile_invalid"}
async with self.session_factory() as session:
repository = PortfolioAnalysisRepository(session)
positions = await repository.positions(int(context.user_id))
exposures = await repository.latest_industry_exposures(
tuple(holding.product_id for holding, _product in positions), date.today()
)
metrics = await repository.latest_metrics(
tuple(holding.product_id for holding, _product in positions), date.today()
)
if not positions:
return {"status": "no_positions"}
result = self._analyze(
positions, exposures, metrics, _RISK_VALUES[risk_level], self.thresholds
)
if result["status"] == "ready":
result["graph_context"] = await self._graph_context(int(context.user_id))
return result
async def _graph_context(self, customer_id: int) -> dict[str, object]:
"""Attach relationship evidence only; MySQL remains the numerical source of truth."""
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_overlaps = context.get("overlaps")
if not isinstance(raw_overlaps, list):
return {"degraded": True, "reason": "neo4j_invalid_result"}
overlaps: list[dict[str, object]] = []
for item in raw_overlaps:
if not isinstance(item, dict):
continue
name, count = item.get("industry_name"), item.get("product_count")
if isinstance(name, str) and isinstance(count, int) and count >= 2:
overlaps.append({"industry_name": name, "product_count": count})
return {"degraded": False, "overlaps": overlaps[:5]}
@classmethod
def _analyze(
cls,
positions: list[tuple[FinancialHolding, FinancialProduct]],
exposures_by_product: dict[int, list[ProductIndustryExposure]],
metrics_by_product: dict[int, ProductMetricSnapshot],
customer_risk_level: int,
thresholds: PortfolioAnalysisThresholds,
) -> dict[str, object]:
valued = [
(holding, product, holding.market_value)
for holding, product in positions
if holding.market_value is not None and holding.market_value > 0
]
total_market_value = sum((value for _holding, _product, value in valued), Decimal())
if total_market_value <= 0:
return {
"status": "valuation_required",
"position_count": len(positions),
"warnings": [{
"code": "MARKET_VALUE_UNAVAILABLE",
"severity": "high",
"message": "当前持仓缺少可用市值,暂不能计算集中度。",
}],
}
product_rows = cls._product_rows(valued, total_market_value, metrics_by_product)
industry_amounts, industry_coverage, invalid_exposure_products = cls._industry_amounts(
valued, exposures_by_product
)
coverage_pct = industry_coverage / total_market_value * _HUNDRED
warnings = cls._warnings(
product_rows,
positions,
customer_risk_level,
industry_amounts,
total_market_value,
coverage_pct,
len(positions) - len(valued),
invalid_exposure_products,
sum(
1
for holding, _product, _value in valued
if holding.product_id not in metrics_by_product
),
thresholds,
)
industry_rows = cls._industry_rows(industry_amounts, total_market_value)
cost_summary = cls._cost_summary(valued)
return {
"status": "ready",
"summary": {
"position_count": len(positions),
"valued_position_count": len(valued),
"total_market_value": str(total_market_value),
**cost_summary,
"product_hhi": cls._hhi([item["share_pct"] for item in product_rows]),
"industry_hhi": cls._industry_hhi(
industry_rows,
coverage_pct,
total_market_value - industry_coverage,
thresholds,
),
"industry_coverage_pct": str(coverage_pct.quantize(Decimal("0.01"))),
},
"top_positions": product_rows[:5],
"top_industries": industry_rows[:5],
"warnings": warnings,
"analysis_only": True,
}
@staticmethod
def _product_rows(
valued: list[tuple[FinancialHolding, FinancialProduct, Decimal]],
total: Decimal,
metrics_by_product: dict[int, ProductMetricSnapshot],
) -> list[dict[str, object]]:
rows: list[dict[str, object]] = [
{
"product_code": product.product_code,
"product_name": product.product_name,
"product_category": product.product_category,
"market_value": str(value),
"share_pct": (value / total * _HUNDRED).quantize(Decimal("0.01")),
"unrealized_profit_loss": PortfolioAnalysisService._unrealized_profit_loss(
holding, value
),
"unrealized_profit_loss_ratio_pct": PortfolioAnalysisService._unrealized_ratio(
holding, value
),
"historical_metrics": PortfolioAnalysisService._metric_view(
metrics_by_product.get(holding.product_id)
),
}
for holding, product, value in valued
]
return sorted(
rows,
key=lambda item: (-Decimal(str(item["share_pct"])), str(item["product_code"])),
)
@staticmethod
def _cost_summary(
valued: list[tuple[FinancialHolding, FinancialProduct, Decimal]]
) -> dict[str, object]:
costed = [
(holding.cost_amount, value)
for holding, _product, value in valued
if holding.cost_amount is not None and holding.cost_amount > 0
]
covered_market_value = sum((value for _cost, value in costed), Decimal())
total_market_value = sum((value for _holding, _product, value in valued), Decimal())
coverage_pct = covered_market_value / total_market_value * _HUNDRED
if covered_market_value != total_market_value:
return {
"cost_coverage_pct": str(coverage_pct.quantize(Decimal("0.01"))),
"total_cost_amount": None,
"unrealized_profit_loss": None,
"unrealized_profit_loss_ratio_pct": None,
}
total_cost = sum((cost for cost, _value in costed if cost is not None), Decimal())
profit_loss = total_market_value - total_cost
return {
"cost_coverage_pct": "100.00",
"total_cost_amount": str(total_cost),
"unrealized_profit_loss": str(profit_loss),
"unrealized_profit_loss_ratio_pct": (
str((profit_loss / total_cost * _HUNDRED).quantize(Decimal("0.01")))
if total_cost > 0 else None
),
}
@staticmethod
def _unrealized_profit_loss(holding: FinancialHolding, market_value: Decimal) -> str | None:
if holding.profit_loss is not None:
return str(holding.profit_loss)
if holding.cost_amount is not None:
return str(market_value - holding.cost_amount)
return None
@staticmethod
def _unrealized_ratio(holding: FinancialHolding, market_value: Decimal) -> str | None:
if holding.profit_loss_ratio is not None:
return str(holding.profit_loss_ratio)
if holding.cost_amount is not None and holding.cost_amount > 0:
return str(
((market_value - holding.cost_amount) / holding.cost_amount * _HUNDRED).quantize(
Decimal("0.01")
)
)
return None
@staticmethod
def _metric_view(metric: ProductMetricSnapshot | None) -> dict[str, object] | None:
if metric is None:
return None
return {
"as_of_date": metric.as_of_date.isoformat(),
"trailing_20d_return_pct": (
str(metric.trailing_20d_return_pct)
if metric.trailing_20d_return_pct is not None else None
),
"trailing_120d_return_pct": (
str(metric.trailing_120d_return_pct)
if metric.trailing_120d_return_pct is not None else None
),
"annualized_volatility_pct": (
str(metric.annualized_volatility_pct)
if metric.annualized_volatility_pct is not None else None
),
"max_drawdown_pct": (
str(metric.max_drawdown_pct) if metric.max_drawdown_pct is not None else None
),
}
@staticmethod
def _industry_amounts(
valued: list[tuple[FinancialHolding, FinancialProduct, Decimal]],
exposures_by_product: dict[int, list[ProductIndustryExposure]],
) -> tuple[dict[str, Decimal], Decimal, set[int]]:
amounts: dict[str, Decimal] = defaultdict(Decimal)
covered = Decimal()
invalid_products: set[int] = set()
for holding, _product, value in valued:
exposures = exposures_by_product.get(holding.product_id, [])
total_weight = sum((item.exposure_weight_pct for item in exposures), Decimal())
if total_weight <= 0:
continue
if total_weight > _HUNDRED:
invalid_products.add(holding.product_id)
continue
covered += value * total_weight / _HUNDRED
for exposure in exposures:
amounts[exposure.industry_name] += value * exposure.exposure_weight_pct / _HUNDRED
return dict(amounts), covered, invalid_products
@staticmethod
def _industry_rows(amounts: dict[str, Decimal], total: Decimal) -> list[dict[str, object]]:
rows = [
{
"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(
rows,
key=lambda item: (-Decimal(str(item["share_pct"])), str(item["industry_name"])),
)
@classmethod
def _warnings(
cls,
product_rows: list[dict[str, object]],
positions: list[tuple[FinancialHolding, FinancialProduct]],
customer_risk_level: int,
industry_amounts: dict[str, Decimal],
total_market_value: Decimal,
industry_coverage_pct: Decimal,
unvalued_count: int,
invalid_exposure_products: set[int],
metrics_missing_count: int,
thresholds: PortfolioAnalysisThresholds,
) -> list[dict[str, str]]:
warnings: list[dict[str, str]] = []
top_product_share = Decimal(str(product_rows[0]["share_pct"])) if product_rows else None
if (
top_product_share is not None
and top_product_share > thresholds.single_product_concentration_pct
):
warnings.append({
"code": "SINGLE_PRODUCT_CONCENTRATION",
"severity": "high",
"message": "单一产品持仓占比较高,存在集中度风险。",
})
if industry_coverage_pct >= thresholds.min_industry_coverage_pct and industry_amounts:
top_amount = max(industry_amounts.values())
if (
top_amount / total_market_value * _HUNDRED
> thresholds.single_industry_concentration_pct
):
warnings.append({
"code": "SINGLE_INDUSTRY_CONCENTRATION",
"severity": "high",
"message": "单一行业穿透占比较高,存在行业集中度风险。",
})
for _holding, product in positions:
product_risk = cls._product_risk(product.risk_level)
if product_risk is not None and product_risk > customer_risk_level:
warnings.append({
"code": "HOLDING_SUITABILITY_REVIEW",
"severity": "medium",
"message": "部分持仓与当前风险测评结果存在待复核的适当性差异。",
})
break
if any(product.status != "上市" for _holding, product in positions):
warnings.append({
"code": "PRODUCT_STATUS_ATTENTION",
"severity": "medium",
"message": "部分持仓产品状态需关注,请核对可交易状态和风险提示。",
})
if industry_coverage_pct < thresholds.min_industry_coverage_pct:
warnings.append({
"code": "INDUSTRY_COVERAGE_INCOMPLETE",
"severity": "medium",
"message": "行业穿透参考数据覆盖不足,暂不输出行业集中度结论。",
})
if unvalued_count:
warnings.append({
"code": "MARKET_VALUE_PARTIAL",
"severity": "medium",
"message": "部分持仓缺少可用市值,本次集中度基于已估值持仓计算。",
})
if invalid_exposure_products:
warnings.append({
"code": "INDUSTRY_EXPOSURE_INVALID",
"severity": "medium",
"message": "部分产品的行业暴露参考数据异常,未纳入行业穿透计算。",
})
if metrics_missing_count:
warnings.append({
"code": "HISTORICAL_METRICS_INCOMPLETE",
"severity": "medium",
"message": (
"Historical product-risk metrics are incomplete for part of the portfolio."
),
})
return warnings
@staticmethod
def _product_risk(value: str) -> int | None:
match = _RISK_LEVEL.fullmatch(value)
return int(match.group(1)) if match else None
@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")))
@classmethod
def _industry_hhi(
cls,
industry_rows: list[dict[str, object]],
coverage_pct: Decimal,
unclassified: Decimal,
thresholds: PortfolioAnalysisThresholds,
) -> str | None:
if coverage_pct < thresholds.min_industry_coverage_pct:
return None
shares = [item["share_pct"] for item in industry_rows]
if unclassified > 0:
total = sum((Decimal(str(item["market_value"])) for item in industry_rows), Decimal())
if total > 0:
shares.append(Decimal("100") - total / (total + unclassified) * _HUNDRED)
return cls._hhi(shares)
async def portfolio_analysis_tool(
arguments: PortfolioAnalysisQuery, context: RequestContext
) -> dict[str, object]:
"""ToolExecutor entry point for analysis only; it cannot create sell or buy orders."""
try:
config = await load_portfolio_analysis_config()
except Exception:
config = {}
thresholds = PortfolioAnalysisThresholds.from_mapping(config)
return await PortfolioAnalysisService(thresholds=thresholds).analyze_for_agent(
arguments, context
)