353 lines
15 KiB
Python
353 lines
15 KiB
Python
"""Constraint-first recommendations for the exchange-traded simulation domain."""
|
|
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.contracts import RequestContext
|
|
from app.core.errors import GenericResourceNotFoundError, InvalidStateError
|
|
from app.core.product_recommendation_contracts import ProductRecommendationQuery
|
|
from app.infrastructure.db import SessionFactory
|
|
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
|
from app.model.audit import InteractionAudit
|
|
from app.model.investment_goal import ClientFacingContent
|
|
from app.repository.advisor_product_repository import (
|
|
AdvisorProductRepository,
|
|
AuthoritativeProductCandidate,
|
|
)
|
|
from app.service.api_transaction_service import ApiTransactionService
|
|
from app.service.authorization_service import AuthorizationService
|
|
from app.service.investment_goal_service import InvestmentGoalService
|
|
from app.service.product_governance_monitor_service import SALES_INSTITUTION
|
|
from app.service.profile_governance_service import ProfileGovernanceService
|
|
from app.service.relationship_service import RelationshipService
|
|
from app.service.suitability_service import SuitabilityService
|
|
|
|
|
|
class ProductRecommendationService:
|
|
CONTENT_TYPE = "advisor_recommendation_plan"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session_factory: Callable[[], Any] = SessionFactory,
|
|
relationship_service: RelationshipService | None = None,
|
|
enforce_profile_governance: bool = False,
|
|
) -> None:
|
|
self.session_factory = session_factory
|
|
self.relationship_service = relationship_service or RelationshipService(
|
|
Neo4jGraphDriver(get_settings())
|
|
)
|
|
self.enforce_profile_governance = enforce_profile_governance
|
|
|
|
async def generate(
|
|
self, payload: ProductRecommendationQuery, context: RequestContext, key: str | None
|
|
) -> dict[str, object]:
|
|
await AuthorizationService.require(context, "product-recommendation:generate:self")
|
|
if self.enforce_profile_governance:
|
|
await ProfileGovernanceService().require_operable(int(context.user_id))
|
|
authority = await SuitabilityService().authority_for_customer(int(context.user_id))
|
|
if authority.customer_risk_level is None:
|
|
return {"status": "profile_required"}
|
|
goal = await InvestmentGoalService().current_for_agent(context)
|
|
if goal is None:
|
|
return {"status": "investment_goal_required"}
|
|
candidates, excluded = await self._candidates(
|
|
authority.customer_risk_level, str(goal["liquidity_requirement"])
|
|
)
|
|
horizon = goal.get("investment_horizon_months")
|
|
if not isinstance(horizon, int):
|
|
return {"status": "recommendation_input_invalid"}
|
|
ranked = self._rank(
|
|
candidates, authority.customer_risk_level, horizon
|
|
)
|
|
selected = ranked[: payload.limit]
|
|
excluded.extend(self._ranking_exclusions(ranked[payload.limit :], payload.limit))
|
|
graph_context = await self._graph_context(context)
|
|
products = [
|
|
self._view(item, index, goal, graph_context)
|
|
for index, item in enumerate(selected, start=1)
|
|
]
|
|
plan = {
|
|
"document_type": "advisor_recommendation_plan",
|
|
"document_version": "1.0",
|
|
"products": products,
|
|
"excluded_candidates": excluded,
|
|
"selection_summary": {
|
|
"candidate_count": len(candidates) + len(excluded),
|
|
"selected_count": len(products),
|
|
"excluded_count": len(excluded),
|
|
},
|
|
"graph_context": graph_context,
|
|
"disclosures": [
|
|
"推荐结果仅供场内基金模拟交易分析,不构成交易指令。",
|
|
"历史数据和风险等级不代表未来收益,收益目标不构成承诺。",
|
|
"推荐方案须经审核发布后方可对客户展示。",
|
|
],
|
|
}
|
|
if key is None:
|
|
return {"status": "ready", **plan, "analysis_only": True}
|
|
|
|
async def operation(session: Any) -> dict[str, object]:
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|
content = ClientFacingContent(
|
|
customer_id=int(context.user_id),
|
|
content_type=self.CONTENT_TYPE,
|
|
draft_content=plan,
|
|
generated_by_portal=context.portal,
|
|
review_status="pending_review",
|
|
reviewer_user_id=None,
|
|
reviewed_at=None,
|
|
published_at=None,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(content)
|
|
session.add(
|
|
InteractionAudit(
|
|
actor_type="user",
|
|
actor_id=int(context.user_id),
|
|
target_customer_id=int(context.user_id),
|
|
portal=context.portal,
|
|
action_type="advisor.recommendation_created",
|
|
detail={
|
|
"content_type": self.CONTENT_TYPE,
|
|
"status": "pending_review",
|
|
"trace_id": context.trace_id,
|
|
},
|
|
created_at=now,
|
|
)
|
|
)
|
|
await session.flush()
|
|
return {
|
|
"data": {
|
|
"content_id": str(content.id),
|
|
"status": content.review_status,
|
|
"plan": plan,
|
|
},
|
|
"meta": {"trace_id": context.trace_id},
|
|
}
|
|
|
|
return await ApiTransactionService().execute(
|
|
context,
|
|
f"advisor:recommendations:{context.user_id}",
|
|
key,
|
|
payload.model_dump(mode="json"),
|
|
operation,
|
|
)
|
|
|
|
async def _candidates(
|
|
self, customer_risk_level: int, liquidity_requirement: str
|
|
) -> tuple[list[AuthoritativeProductCandidate], list[dict[str, object]]]:
|
|
async with self.session_factory() as session:
|
|
candidates = await AdvisorProductRepository(session).authoritative_tradable_products(
|
|
datetime.now(UTC).replace(tzinfo=None),
|
|
sales_institution=SALES_INSTITUTION,
|
|
liquidity_requirement=liquidity_requirement,
|
|
limit=50,
|
|
)
|
|
return AdvisorProductRepository.hard_suitability_filter(candidates, customer_risk_level)
|
|
|
|
@staticmethod
|
|
def _rank(
|
|
candidates: list[AuthoritativeProductCandidate], risk: int, horizon: int
|
|
) -> list[tuple[AuthoritativeProductCandidate, float]]:
|
|
def score(candidate: AuthoritativeProductCandidate) -> float:
|
|
level = int(candidate.suitability.risk_level.removeprefix("R"))
|
|
risk_score = 1 - abs(risk - level) / 4
|
|
liquidity = candidate.liquidity
|
|
if liquidity is None or liquidity.average_daily_turnover_amount is None:
|
|
liquidity_score = 0.5
|
|
else:
|
|
liquidity_score = min(
|
|
1.0, float(liquidity.average_daily_turnover_amount / 10_000_000)
|
|
)
|
|
term_score = (
|
|
0.8
|
|
if horizon >= 36 and candidate.product.product_category in {"ETF", "LOF"}
|
|
else 0.6
|
|
)
|
|
return 0.55 * risk_score + 0.25 * liquidity_score + 0.20 * term_score
|
|
|
|
return sorted(
|
|
((candidate, score(candidate)) for candidate in candidates),
|
|
key=lambda item: (-item[1], item[0].product.product_code),
|
|
)
|
|
|
|
@staticmethod
|
|
def _ranking_exclusions(
|
|
ranked: list[tuple[AuthoritativeProductCandidate, float]], limit: int
|
|
) -> list[dict[str, object]]:
|
|
return [
|
|
{
|
|
"product_code": candidate.product.product_code,
|
|
"product_name": candidate.product.product_name,
|
|
"stage": "ranking",
|
|
"reason_code": "RANKED_BELOW_SELECTION_LIMIT",
|
|
"reason": "产品通过硬性约束但排序低于本次选择数量。",
|
|
"ranking_score": round(score, 4),
|
|
"selection_limit": limit,
|
|
}
|
|
for candidate, score in ranked
|
|
]
|
|
|
|
@staticmethod
|
|
def _view(
|
|
item: tuple[AuthoritativeProductCandidate, float],
|
|
rank: int,
|
|
goal: dict[str, object],
|
|
graph_context: dict[str, object],
|
|
) -> dict[str, object]:
|
|
candidate, score = item
|
|
product = candidate.product
|
|
contract = candidate.contract
|
|
return {
|
|
"rank": rank,
|
|
"product_code": product.product_code,
|
|
"product_name": product.product_name,
|
|
"product_category": product.product_category,
|
|
"reason": "该产品已通过场内可交易、权威适当性和合同证据校验,"
|
|
"并与已确认投资目标的期限和流动性要求相匹配。",
|
|
"score": round(score, 4),
|
|
"recommendation_evidence_card": {
|
|
"card_version": "1.0",
|
|
"hard_constraints": [
|
|
"exchange_traded",
|
|
"suitability_verified",
|
|
"contract_verified",
|
|
],
|
|
"suitability": {
|
|
"risk_level": candidate.suitability.risk_level,
|
|
"source_url": candidate.suitability.source_url,
|
|
"document_title": candidate.suitability.document_title,
|
|
},
|
|
"contract": {
|
|
"fund_type": contract.fund_type,
|
|
"source_url": contract.source_url,
|
|
"document_title": contract.document_title,
|
|
},
|
|
"liquidity": {
|
|
"status": candidate.liquidity.status if candidate.liquidity else "unknown",
|
|
"average_daily_turnover_amount": str(
|
|
candidate.liquidity.average_daily_turnover_amount
|
|
)
|
|
if candidate.liquidity
|
|
and candidate.liquidity.average_daily_turnover_amount is not None
|
|
else None,
|
|
},
|
|
"goal_constraints": {
|
|
"liquidity_requirement": goal["liquidity_requirement"],
|
|
"investment_horizon_months": goal["investment_horizon_months"],
|
|
},
|
|
"graph_status": "degraded" if graph_context.get("degraded") else "available",
|
|
},
|
|
}
|
|
|
|
async def _graph_context(self, context: RequestContext) -> dict[str, object]:
|
|
if self.relationship_service is None:
|
|
return {"degraded": True, "reason": "graph_not_configured"}
|
|
return await self.relationship_service.portfolio_industry_context(int(context.user_id))
|
|
|
|
async def review(
|
|
self,
|
|
content_id: int,
|
|
decision: str,
|
|
comment: str,
|
|
context: RequestContext,
|
|
key: str | None,
|
|
) -> dict[str, object]:
|
|
await AuthorizationService.require(context, "product-recommendation:review", admin=True)
|
|
|
|
async def operation(session: Any) -> dict[str, object]:
|
|
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
|
if content is None or content.content_type != self.CONTENT_TYPE:
|
|
raise GenericResourceNotFoundError("推荐方案不存在")
|
|
if content.review_status != "pending_review":
|
|
raise InvalidStateError("推荐方案当前不能审核")
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|
content.review_status = "approved" if decision == "approved" else "rejected"
|
|
content.reviewer_user_id = int(context.user_id)
|
|
content.reviewed_at = now
|
|
content.updated_at = now
|
|
content.draft_content = {**content.draft_content, "review_comment": comment}
|
|
await session.flush()
|
|
return {
|
|
"data": {"content_id": str(content.id), "status": content.review_status},
|
|
"meta": {"trace_id": context.trace_id},
|
|
}
|
|
|
|
return await ApiTransactionService().execute(
|
|
context,
|
|
f"advisor:recommendations:{content_id}:review",
|
|
key,
|
|
{"decision": decision, "comment": comment},
|
|
operation,
|
|
)
|
|
|
|
async def publish(
|
|
self, content_id: int, context: RequestContext, key: str | None
|
|
) -> dict[str, object]:
|
|
await AuthorizationService.require(context, "product-recommendation:publish", admin=True)
|
|
|
|
async def operation(session: Any) -> dict[str, object]:
|
|
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
|
if content is None or content.content_type != self.CONTENT_TYPE:
|
|
raise GenericResourceNotFoundError("推荐方案不存在")
|
|
if content.review_status != "approved":
|
|
raise InvalidStateError("推荐方案审核通过后才能发布")
|
|
content.review_status = "approved"
|
|
content.published_at = datetime.now(UTC).replace(tzinfo=None)
|
|
content.updated_at = content.published_at
|
|
await session.flush()
|
|
return {
|
|
"data": {"content_id": str(content.id), "status": "published"},
|
|
"meta": {"trace_id": context.trace_id},
|
|
}
|
|
|
|
return await ApiTransactionService().execute(
|
|
context,
|
|
f"advisor:recommendations:{content_id}:publish",
|
|
key,
|
|
{"publish": True},
|
|
operation,
|
|
)
|
|
|
|
async def published(self, context: RequestContext) -> dict[str, object]:
|
|
await AuthorizationService.require(context, "product-recommendation:read:self")
|
|
async with self.session_factory() as session:
|
|
rows = list(
|
|
await session.scalars(
|
|
select(ClientFacingContent)
|
|
.where(
|
|
ClientFacingContent.customer_id == int(context.user_id),
|
|
ClientFacingContent.content_type == self.CONTENT_TYPE,
|
|
ClientFacingContent.review_status == "approved",
|
|
ClientFacingContent.published_at.is_not(None),
|
|
)
|
|
.order_by(ClientFacingContent.published_at.desc())
|
|
.limit(20)
|
|
)
|
|
)
|
|
return {
|
|
"data": [
|
|
{
|
|
"content_id": str(row.id),
|
|
"plan": row.draft_content,
|
|
"published_at": row.published_at.isoformat() if row.published_at else None,
|
|
}
|
|
for row in rows
|
|
],
|
|
"meta": {"trace_id": context.trace_id},
|
|
}
|
|
|
|
|
|
async def product_recommendation_tool(
|
|
arguments: ProductRecommendationQuery, context: RequestContext
|
|
) -> dict[str, object]:
|
|
return await ProductRecommendationService(enforce_profile_governance=True).generate(
|
|
arguments, context, None
|
|
)
|