452 lines
19 KiB
Python
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
|
|
)
|