"""客户持仓记忆:fin_holdings 聚合摘要,供最终画像整合使用。 只输出聚合结果(总市值、产品类型分布、盈亏概览、前三大持仓), 不向 LLM 或接口消费方暴露原始持仓行,控制 token 并避免明细泄露。 """ from __future__ import annotations from decimal import Decimal from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from repositories.fin_holdings import FinHoldingsRepo class CustomerHoldingsMemory: """读取并聚合客户当前持仓,输出画像整合所需的持仓摘要。""" TOP_HOLDINGS_LIMIT = 3 def __init__(self, *, repository_factory=FinHoldingsRepo): self.repository_factory = repository_factory async def summary( self, db: AsyncSession, customer_id: int ) -> tuple[dict[str, Any] | None, list[str]]: """聚合持有中持仓;无持仓返回 None,异常返回降级 warnings。""" try: rows = await self.repository_factory(db).list_with_products( customer_id, include_closed=False ) except Exception as exc: # noqa: BLE001 沿用记忆模块降级约定 return None, [f"holdings_recall_failed:{type(exc).__name__}"] if not rows: return None, [] total_value = Decimal("0") total_cost = Decimal("0") total_pnl = Decimal("0") win_count = 0 lose_count = 0 mix: dict[str, Decimal] = {} ranked: list[tuple[Decimal, str]] = [] for holding, product in rows: value = self._to_decimal(holding.current_value) total_value += value total_cost += self._to_decimal(holding.cost_amount) total_pnl += self._to_decimal(holding.profit_loss) ratio = holding.profit_ratio if ratio is not None: if ratio > 0: win_count += 1 elif ratio < 0: lose_count += 1 product_type = (product.product_type if product else None) or "未知" mix[product_type] = mix.get(product_type, Decimal("0")) + value name = product.product_name if product else f"产品{holding.product_id}" ranked.append((value, name)) payload: dict[str, Any] = { "holding_count": len(rows), "total_market_value": float(total_value), "total_cost": float(total_cost), "product_type_mix": self._ratio_map(mix, total_value), "profit_summary": { "total_pnl": float(total_pnl), "total_pnl_ratio": ( round(float(total_pnl / total_cost), 4) if total_cost > 0 else None ), "win_count": win_count, "lose_count": lose_count, }, "top_holdings": self._top_holdings(ranked, total_value), } return payload, [] @staticmethod def _to_decimal(value: Any) -> Decimal: """把 ORM Decimal / float / None 统一转换为 Decimal,防御脏数据类型。""" if value is None: return Decimal("0") try: return value if isinstance(value, Decimal) else Decimal(str(value)) except Exception: # noqa: BLE001 return Decimal("0") @staticmethod def _ratio_map(values: dict[str, Decimal], total: Decimal) -> dict[str, float]: """把各维度市值转换为相对总市值的占比,总量为 0 时返回空表。""" if total <= 0: return {} return { key: round(float(value / total), 4) for key, value in sorted(values.items(), key=lambda item: -item[1]) } def _top_holdings( self, ranked: list[tuple[Decimal, str]], total: Decimal ) -> list[dict[str, Any]]: """按市值取前三大持仓,返回名称与占比。""" if total <= 0: return [] ranked.sort(key=lambda item: -item[0]) return [ {"name": name, "weight": round(float(value / total), 4)} for value, name in ranked[: self.TOP_HOLDINGS_LIMIT] ] __all__ = ["CustomerHoldingsMemory"]