Files

113 lines
4.2 KiB
Python

"""客户持仓记忆: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"]