500 lines
25 KiB
Python
500 lines
25 KiB
Python
"""场内基金模拟交易的投资顾问 Agent。"""
|
||
|
||
import re
|
||
from decimal import Decimal
|
||
from typing import Any, cast
|
||
|
||
from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
|
||
from app.service.agent.base import BaseAgent
|
||
|
||
|
||
class AdvisorAgent(BaseAgent):
|
||
"""提供只读行情、账户分析和适当性辅助,不执行任何交易。"""
|
||
|
||
definition = AgentDefinition(
|
||
agent_type="advisor",
|
||
version="1.5.0",
|
||
allowed_tools=(
|
||
"query_fund_quote",
|
||
"query_financial_data",
|
||
"check_suitability",
|
||
"query_investment_goal",
|
||
"query_customer_profile",
|
||
"generate_asset_allocation",
|
||
"recommend_products",
|
||
"analyze_portfolio",
|
||
"simulate_portfolio_rebalance",
|
||
),
|
||
allowed_roles=("customer", "advisor", "operator", "admin"),
|
||
allowed_portals=("api",),
|
||
supported_intents=(
|
||
"fund_quote",
|
||
"financial_query",
|
||
"suitability",
|
||
"investment_goal",
|
||
"product_recommend",
|
||
"portfolio_analysis",
|
||
"asset_allocation",
|
||
"comparison",
|
||
"general",
|
||
),
|
||
intent_descriptions={
|
||
"fund_quote": "查询指定六码场内基金的行情或净值",
|
||
"financial_query": "查询已授权的账户、交易、持仓或产品数据",
|
||
"suitability": "核验客户风险等级与产品风险等级是否适当",
|
||
"investment_goal": "采集或查看收益目标、回撤、流动性和投资期限",
|
||
"product_recommend": "用户要求推荐适合的场内基金或产品",
|
||
"portfolio_analysis": "用户要求分析持仓、集中度或行业分布",
|
||
"asset_allocation": "用户要求给出资产配置比例或资产建议",
|
||
"comparison": "用户要求比较多个产品、客户持仓或共同持仓",
|
||
"general": "其他场内基金模拟交易相关咨询",
|
||
},
|
||
)
|
||
|
||
_FUND_CODE = re.compile(r"(?<!\d)(\d{6})(?!\d)")
|
||
_TRADE_REQUEST = re.compile(
|
||
r"(?:请|帮我|替我|现在|立即|马上|直接).{0,8}(?:买入|卖出|下单|申购|赎回|撤单)"
|
||
)
|
||
_FINANCIAL_QUERY = (
|
||
"持仓", "交易记录", "资金流水", "现金余额", "账户余额", "客户画像", "风险测评",
|
||
"产品资料", "费率", "历史净值", "成交记录", "委托记录", "订单",
|
||
)
|
||
_GOAL_TERMS = ("投资目标", "收益目标", "最大回撤", "流动性", "投资期限", "目标书")
|
||
_PRODUCT_RECOMMEND_TERMS = ("推荐", "有什么适合", "给客户推荐")
|
||
_PORTFOLIO_ANALYSIS_TERMS = ("持仓分析", "集中", "分布在哪些行业", "风险预警")
|
||
_ASSET_ALLOCATION_TERMS = ("配置比例", "怎么分配", "资产建议")
|
||
_COMPARISON_TERMS = ("共同持仓", "有什么不同", "对比")
|
||
_ADVISOR_REVIEW_TERMS = ("完整投顾", "投顾全景", "投资复盘", "完整建议")
|
||
_DISCLAIMER = "基金投资有风险,过往业绩不预示未来表现。本回复仅供分析参考,不构成交易指令。"
|
||
|
||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||
message = request.message.strip()
|
||
if self._TRADE_REQUEST.search(message):
|
||
return CoreResult(
|
||
text=("我不能代您执行申购、赎回、买卖、下单或确认成交。"
|
||
"如需交易,请通过受监管的交易渠道完成确认。\n\n" + self._DISCLAIMER)
|
||
)
|
||
|
||
classified_intent = self._classified_intent.intent if self._classified_intent else None
|
||
if "调仓模拟" in message or "配置偏离" in message:
|
||
return await self._portfolio_rebalance(context)
|
||
if any(term in message for term in self._ADVISOR_REVIEW_TERMS):
|
||
return await self._advisor_review(context)
|
||
if classified_intent == "asset_allocation" or any(
|
||
term in message for term in self._ASSET_ALLOCATION_TERMS
|
||
):
|
||
return await self._asset_allocation(context)
|
||
if classified_intent == "portfolio_analysis" or any(
|
||
term in message for term in self._PORTFOLIO_ANALYSIS_TERMS
|
||
):
|
||
return await self._portfolio_analysis(message, context)
|
||
if classified_intent == "product_recommend" or any(
|
||
term in message for term in self._PRODUCT_RECOMMEND_TERMS
|
||
):
|
||
return await self._product_recommendation(context)
|
||
if classified_intent == "comparison" or any(
|
||
term in message for term in self._COMPARISON_TERMS
|
||
):
|
||
return self._comparison_unavailable()
|
||
|
||
if any(term in message for term in self._GOAL_TERMS):
|
||
return await self._investment_goal(context)
|
||
|
||
risks = self._risk_levels(message)
|
||
if risks is not None:
|
||
return await self._check_suitability(risks, context)
|
||
|
||
codes = tuple(dict.fromkeys(self._FUND_CODE.findall(message)))
|
||
if codes:
|
||
return await self._quote_funds(codes, context)
|
||
|
||
if any(term in message for term in self._FINANCIAL_QUERY):
|
||
return await self._query_financial_data(message, context)
|
||
|
||
return CoreResult(
|
||
text=("我可以协助解读场内基金行情、已授权范围内的持仓与交易数据,并进行适当性辅助判断。\n\n"
|
||
+ self._DISCLAIMER)
|
||
)
|
||
|
||
async def _advisor_review(self, context: RequestContext) -> CoreResult:
|
||
required = {
|
||
"asset-allocation:generate:self",
|
||
"customer-profile:read:self",
|
||
"investment-goal:read:self",
|
||
"portfolio-analysis:read:self",
|
||
"product-recommendation:read:self",
|
||
"suitability:read",
|
||
}
|
||
if not required.issubset(context.permissions):
|
||
return CoreResult(
|
||
text=(
|
||
"生成投顾全景复盘前,需要完成风险测评、确认投资目标并具备只读分析权限。\n\n"
|
||
+ self._DISCLAIMER
|
||
)
|
||
)
|
||
allocation = cast(dict[str, Any], await self.call_tool(
|
||
"generate_asset_allocation", {}, intent="asset_allocation", context=context
|
||
))
|
||
if allocation.get("status") != "ready":
|
||
return CoreResult(text="投资目标或风险测评尚未满足分析条件。\n\n" + self._DISCLAIMER)
|
||
portfolio = cast(dict[str, Any], await self.call_tool(
|
||
"analyze_portfolio", {}, intent="portfolio_analysis", context=context
|
||
))
|
||
rebalance = cast(dict[str, Any], await self.call_tool(
|
||
"simulate_portfolio_rebalance", {}, intent="asset_allocation", context=context
|
||
))
|
||
recommendation = cast(dict[str, Any], await self.call_tool(
|
||
"recommend_products", {"limit": 3}, intent="product_recommend", context=context
|
||
))
|
||
lines = ["投顾全景复盘(仅分析,不构成交易指令):", "目标约束下的动态配置:"]
|
||
lines.extend(
|
||
f"- {item['label']}:{item['target_pct']}%"
|
||
for item in cast(list[dict[str, Any]], allocation["allocation"])
|
||
)
|
||
optimization = allocation.get("optimization")
|
||
if isinstance(optimization, dict):
|
||
dynamic = "已启用" if optimization.get("dynamic") else "未启用"
|
||
coverage = optimization.get("metric_coverage_pct")
|
||
lines.append(f"- 动态优化:{dynamic};指标覆盖率 {coverage}%")
|
||
if portfolio.get("status") == "ready":
|
||
summary = cast(dict[str, Any], portfolio["summary"])
|
||
lines.append(
|
||
f"持仓概览:估值总市值 {summary['total_market_value']},"
|
||
f"产品集中度 HHI {summary['product_hhi']}。"
|
||
)
|
||
warnings = portfolio.get("warnings")
|
||
if isinstance(warnings, list) and warnings:
|
||
lines.append("持仓风险提示:")
|
||
lines.extend(f"- {item['message']}" for item in warnings if isinstance(item, dict))
|
||
else:
|
||
lines.append("持仓概览:当前没有可用于复盘的完整持仓估值。")
|
||
if rebalance.get("status") == "ready":
|
||
gaps = rebalance.get("allocation_gaps")
|
||
if isinstance(gaps, list):
|
||
lines.append("配置偏离:")
|
||
lines.extend(
|
||
f"- {item['asset_class']}:当前 {item['current_pct']}%,"
|
||
f"目标 {item['target_pct']}%,差异 {item['delta_pct']}%"
|
||
for item in gaps if isinstance(item, dict)
|
||
)
|
||
if recommendation.get("status") == "ready":
|
||
products = recommendation.get("products")
|
||
if isinstance(products, list):
|
||
lines.append("适当性过滤后的候选:")
|
||
lines.extend(
|
||
f"- {item['product_name']}({item['product_code']}):{item['reason']}"
|
||
for item in products if isinstance(item, dict)
|
||
)
|
||
lines.append("以上内容不生成买卖清单、委托、收益承诺或交易指令。")
|
||
lines.append(self._DISCLAIMER)
|
||
return CoreResult(text="\n".join(lines))
|
||
|
||
async def _asset_allocation(self, context: RequestContext) -> CoreResult:
|
||
required = {
|
||
"asset-allocation:generate:self",
|
||
"customer-profile:read:self",
|
||
"investment-goal:read:self",
|
||
}
|
||
if not required.issubset(context.permissions):
|
||
return CoreResult(
|
||
text=("生成资产配置前需要完成开户风险测评、确认投资目标,并具备相应的只读分析权限。"
|
||
"该流程不会执行交易。\n\n" + self._DISCLAIMER)
|
||
)
|
||
result = cast(dict[str, Any], await self.call_tool(
|
||
"generate_asset_allocation", {}, intent="asset_allocation", context=context
|
||
))
|
||
status = result.get("status")
|
||
if status == "profile_required":
|
||
return CoreResult(
|
||
text="请先完成开户风险测评后再生成资产配置建议。\n\n" + self._DISCLAIMER
|
||
)
|
||
if status == "investment_goal_required":
|
||
return CoreResult(
|
||
text=("请先采集收益目标、最大回撤、流动性和投资期限,再生成资产配置建议。\n\n"
|
||
+ self._DISCLAIMER)
|
||
)
|
||
if status == "investment_goal_confirmation_required":
|
||
return CoreResult(
|
||
text="投资目标尚待您确认,确认后才能用于生成资产配置建议。\n\n" + self._DISCLAIMER
|
||
)
|
||
if status != "ready":
|
||
return CoreResult(text="暂时无法生成资产配置建议,请稍后重试。\n\n" + self._DISCLAIMER)
|
||
allocation = cast(list[dict[str, Any]], result["allocation"])
|
||
lines = ["建议的场内基金模拟配置比例:"]
|
||
lines.extend(f"- {item['label']}:{item['target_pct']}%" for item in allocation)
|
||
lines.append("该比例基于已确认目标生成,仅用于分析参考,不会创建委托或执行交易。")
|
||
lines.append(self._DISCLAIMER)
|
||
return CoreResult(text="\n".join(lines))
|
||
|
||
async def _portfolio_analysis(self, message: str, context: RequestContext) -> CoreResult:
|
||
del message
|
||
required = {"portfolio-analysis:read:self", "customer-profile:read:self"}
|
||
if not required.issubset(context.permissions):
|
||
return CoreResult(
|
||
text=("进行持仓集中度分析前需要完成开户风险测评,并具备相应的只读分析权限。"
|
||
"该流程不会执行交易。\n\n" + self._DISCLAIMER)
|
||
)
|
||
result = cast(dict[str, Any], await self.call_tool(
|
||
"analyze_portfolio", {}, intent="portfolio_analysis", context=context
|
||
))
|
||
status = result.get("status")
|
||
if status == "profile_required":
|
||
return CoreResult(text="请先完成开户风险测评后再进行持仓分析。\n\n" + self._DISCLAIMER)
|
||
if status == "no_positions":
|
||
return CoreResult(text="当前没有可分析的场内基金持仓。\n\n" + self._DISCLAIMER)
|
||
if status == "valuation_required":
|
||
return CoreResult(
|
||
text=("当前持仓缺少可用市值,暂不能计算集中度,请等待行情数据更新。\n\n"
|
||
+ self._DISCLAIMER)
|
||
)
|
||
if status != "ready":
|
||
return CoreResult(text="暂时无法完成持仓分析,请稍后重试。\n\n" + self._DISCLAIMER)
|
||
summary = cast(dict[str, Any], result["summary"])
|
||
positions = cast(list[dict[str, Any]], result["top_positions"])
|
||
industries = cast(list[dict[str, Any]], result["top_industries"])
|
||
warnings = cast(list[dict[str, Any]], result["warnings"])
|
||
lines = [
|
||
f"已估值持仓总市值:{summary['total_market_value']},"
|
||
f"产品集中度 HHI:{summary['product_hhi']}。",
|
||
"主要持仓:",
|
||
]
|
||
lines.extend(
|
||
f"- {item['product_name']}({item['product_code']}):{item['share_pct']}%"
|
||
for item in positions
|
||
)
|
||
coverage = Decimal(str(summary["industry_coverage_pct"]))
|
||
if coverage >= Decimal("80") and industries:
|
||
lines.append("主要行业穿透:")
|
||
lines.extend(f"- {item['industry_name']}:{item['share_pct']}%" for item in industries)
|
||
else:
|
||
lines.append("行业穿透参考数据覆盖不足,暂不输出行业集中度结论。")
|
||
if warnings:
|
||
lines.append("风险提示:")
|
||
lines.extend(f"- {item['message']}" for item in warnings)
|
||
graph_context = result.get("graph_context")
|
||
if isinstance(graph_context, dict):
|
||
if graph_context.get("degraded"):
|
||
lines.append("图谱关系增强信息暂不可用;以上数值分析仍基于持仓和产品权威数据。")
|
||
else:
|
||
overlaps = graph_context.get("overlaps")
|
||
if isinstance(overlaps, list):
|
||
evidence = [
|
||
f"{item['industry_name']}:{item['product_count']}只产品"
|
||
for item in overlaps
|
||
if isinstance(item, dict)
|
||
and isinstance(item.get("industry_name"), str)
|
||
and isinstance(item.get("product_count"), int)
|
||
]
|
||
if evidence:
|
||
lines.append(
|
||
"图谱关系提示:多个持仓产品关联同一行业("
|
||
+ "、".join(evidence)
|
||
+ ")。该提示仅用于辅助识别关系重叠,不改变上述浓度计算。"
|
||
)
|
||
lines.append("以上为持仓结构分析,不构成买卖、调仓或交易指令。")
|
||
lines.append(self._DISCLAIMER)
|
||
return CoreResult(text="\n".join(lines))
|
||
|
||
async def _portfolio_rebalance(self, context: RequestContext) -> CoreResult:
|
||
required = {
|
||
"portfolio-analysis:read:self",
|
||
"asset-allocation:generate:self",
|
||
"customer-profile:read:self",
|
||
"investment-goal:read:self",
|
||
}
|
||
if not required.issubset(context.permissions):
|
||
return CoreResult(
|
||
text="请先完成风险测评、投资目标确认并获得只读分析权限。\n\n" + self._DISCLAIMER
|
||
)
|
||
result = cast(dict[str, Any], await self.call_tool(
|
||
"simulate_portfolio_rebalance", {}, intent="asset_allocation", context=context
|
||
))
|
||
if result.get("status") == "classification_required":
|
||
return CoreResult(
|
||
text=(
|
||
"持仓产品的资产类别参考数据覆盖不足,暂不输出调仓比例差。\n\n"
|
||
+ self._DISCLAIMER
|
||
)
|
||
)
|
||
if result.get("status") != "ready":
|
||
return CoreResult(text="暂时无法完成模拟调仓分析,请稍后重试。\n\n" + self._DISCLAIMER)
|
||
gaps = result.get("allocation_gaps")
|
||
if not isinstance(gaps, list):
|
||
return CoreResult(text="暂时无法完成模拟调仓分析,请稍后重试。\n\n" + self._DISCLAIMER)
|
||
lines = ["当前持仓与目标配置的模拟比例差:"]
|
||
for item in gaps:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
lines.append(
|
||
f"- {item.get('asset_class')}:当前 {item.get('current_pct')}%,"
|
||
f"目标 {item.get('target_pct')}%,差额 {item.get('delta_pct')}%"
|
||
)
|
||
lines.append("以上仅为配置偏离分析,不生成买卖清单、委托或交易指令。")
|
||
lines.append(self._DISCLAIMER)
|
||
return CoreResult(text="\n".join(lines))
|
||
|
||
async def _product_recommendation(self, context: RequestContext) -> CoreResult:
|
||
required = {
|
||
"product-recommendation:read:self",
|
||
"customer-profile:read:self",
|
||
"investment-goal:read:self",
|
||
"suitability:read",
|
||
}
|
||
if not required.issubset(context.permissions):
|
||
return CoreResult(
|
||
text=("生成产品推荐前需要完成开户风险测评、确认投资目标,并具备相应的只读分析权限。"
|
||
"该流程不会执行交易。\n\n" + self._DISCLAIMER)
|
||
)
|
||
result = cast(dict[str, Any], await self.call_tool(
|
||
"recommend_products", {"limit": 3}, intent="product_recommend", context=context
|
||
))
|
||
status = result.get("status")
|
||
if status == "profile_required":
|
||
return CoreResult(text="请先完成开户风险测评后再获取产品推荐。\n\n" + self._DISCLAIMER)
|
||
if status == "investment_goal_required":
|
||
return CoreResult(
|
||
text="请先采集收益目标、最大回撤、流动性和投资期限,再获取产品推荐。\n\n"
|
||
+ self._DISCLAIMER
|
||
)
|
||
if status == "investment_goal_confirmation_required":
|
||
return CoreResult(
|
||
text="投资目标尚待您确认,确认后才能用于产品推荐分析。\n\n" + self._DISCLAIMER
|
||
)
|
||
if status == "no_suitable_products":
|
||
return CoreResult(
|
||
text="当前没有同时满足在售状态和适当性要求的场内基金候选。\n\n" + self._DISCLAIMER
|
||
)
|
||
if status != "ready":
|
||
return CoreResult(text="暂时无法生成产品推荐,请稍后重试。\n\n" + self._DISCLAIMER)
|
||
products = cast(list[dict[str, Any]], result["products"])
|
||
lines = ["场内基金模拟分析候选:"]
|
||
for product in products:
|
||
lines.append(
|
||
f"- {product['product_name']}({product['product_code']}):{product['reason']}"
|
||
)
|
||
if result.get("graph_degraded"):
|
||
lines.append("图谱关联信息暂不可用,本次排序未使用该项增强信号。")
|
||
lines.append("以上仅供分析参考,不构成产品推介、收益承诺或交易指令。")
|
||
lines.append(self._DISCLAIMER)
|
||
return CoreResult(text="\n".join(lines))
|
||
|
||
def _comparison_unavailable(self) -> CoreResult:
|
||
return CoreResult(
|
||
text=("已识别为对比分析需求。多实体图谱查询和授权范围校验服务尚未接入,"
|
||
"当前不能生成跨客户或跨产品的对比报告。\n\n" + self._DISCLAIMER)
|
||
)
|
||
|
||
async def _investment_goal(self, context: RequestContext) -> CoreResult:
|
||
collection_prompt = (
|
||
"请先确认四项投资目标:期望年化收益区间、可承受的最大回撤、资金可使用时间,"
|
||
"以及投资期限(月)。收益目标将以业绩比较基准口径记录,不作为收益承诺。"
|
||
)
|
||
if "investment-goal:read:self" not in context.permissions:
|
||
return CoreResult(text=collection_prompt + "\n\n" + self._DISCLAIMER)
|
||
goal = cast(dict[str, Any] | None, await self.call_tool(
|
||
"query_investment_goal", {}, intent="investment_goal", context=context
|
||
))
|
||
if goal is None:
|
||
return CoreResult(text=collection_prompt + "\n\n" + self._DISCLAIMER)
|
||
return CoreResult(
|
||
text=(
|
||
"已记录您的投资目标:预期年化收益区间 "
|
||
f"{goal['annualized_return_lower_pct']}%-{goal['annualized_return_upper_pct']}%,"
|
||
f"最大回撤 {goal['max_drawdown_pct']}%,投资期限 "
|
||
f"{goal['investment_horizon_months']} 个月。"
|
||
"投资目标书仍须经过持证投顾审核,当前不构成投资建议或交易指令。\n\n"
|
||
+ self._DISCLAIMER
|
||
)
|
||
)
|
||
|
||
async def _quote_funds(self, codes: tuple[str, ...], context: RequestContext) -> CoreResult:
|
||
quotes = cast(list[dict[str, Any]], await self.call_tool(
|
||
"query_fund_quote",
|
||
{"fund_codes": list(codes), "limit": len(codes)},
|
||
intent="fund_quote",
|
||
context=context,
|
||
))
|
||
if not quotes:
|
||
return CoreResult(text="未获取到可用基金行情,请稍后重试。\n\n" + self._DISCLAIMER)
|
||
|
||
lines = ["基金行情:"]
|
||
for quote in quotes:
|
||
name = str(quote.get("fund_name") or "基金")
|
||
code = str(quote.get("fund_code") or "")
|
||
nav = quote.get("nav")
|
||
change = quote.get("daily_change")
|
||
nav_date = quote.get("nav_date") or "未知日期"
|
||
source = quote.get("quote_source") or "degraded"
|
||
nav_text = nav if nav is not None else "暂无"
|
||
change_text = change if change is not None else "暂无"
|
||
line = f"- {name}({code}):净值 {nav_text},日涨跌 {change_text},日期 {nav_date}。"
|
||
if source == "degraded" or quote.get("degraded"):
|
||
line += " 当前数据已降级,仅供参考。"
|
||
lines.append(line)
|
||
lines.extend(("行情用于分析,不代表委托、成交或收益保证。", self._DISCLAIMER))
|
||
return CoreResult(text="\n".join(lines))
|
||
|
||
async def _query_financial_data(
|
||
self, message: str, context: RequestContext
|
||
) -> CoreResult:
|
||
if "financial:nl2sql:read" not in context.permissions:
|
||
return CoreResult(
|
||
text=("当前身份没有查询金融明细数据的权限,无法展示持仓、交易或账户信息。\n\n"
|
||
+ self._DISCLAIMER)
|
||
)
|
||
result = cast(dict[str, Any], await self.call_tool(
|
||
"query_financial_data",
|
||
{"question": message, "dry_run": False, "limit": 50},
|
||
intent="financial_query",
|
||
context=context,
|
||
))
|
||
status = str(result.get("status", "unknown"))
|
||
message_text = str(result.get("message", "金融数据查询已完成。"))
|
||
if status == "need_confirmation":
|
||
return CoreResult(text=message_text + "\n\n" + self._DISCLAIMER)
|
||
if status != "success":
|
||
return CoreResult(text=f"未能完成金融数据查询:{message_text}\n\n{self._DISCLAIMER}")
|
||
data = result.get("data")
|
||
if isinstance(data, dict):
|
||
count = int(data.get("total", 0))
|
||
elif isinstance(data, list):
|
||
count = len(data)
|
||
else:
|
||
count = 0
|
||
return CoreResult(
|
||
text=(f"{message_text} 共返回 {count} 条记录。结果仅限授权数据范围内的分析,"
|
||
f"不构成交易指令。\n\n{self._DISCLAIMER}")
|
||
)
|
||
|
||
async def _check_suitability(
|
||
self, risks: tuple[int, int], context: RequestContext
|
||
) -> CoreResult:
|
||
customer_risk, product_risk = risks
|
||
result = cast(dict[str, Any], await self.call_tool(
|
||
"check_suitability",
|
||
{
|
||
"customer_risk_level": customer_risk,
|
||
"product_risk_level": product_risk,
|
||
"product_requires_disclosure": True,
|
||
},
|
||
intent="suitability",
|
||
context=context,
|
||
))
|
||
if not result.get("allowed"):
|
||
return CoreResult(
|
||
text=("适当性初步校验未通过,不能据此推荐该风险等级产品。原因:"
|
||
f"{result.get('reason_code', 'UNKNOWN')}。\n\n{self._DISCLAIMER}")
|
||
)
|
||
return CoreResult(
|
||
text=("适当性初步校验通过,但仍需核对风险测评有效期、产品风险评级和风险揭示确认。"
|
||
"该结论不构成产品推荐或交易确认。\n\n" + self._DISCLAIMER)
|
||
)
|
||
|
||
@staticmethod
|
||
def _risk_levels(message: str) -> tuple[int, int] | None:
|
||
customer = re.search(r"(?:客户|投资者)?\s*[Cc]\s*([1-5])", message)
|
||
product = re.search(r"(?:产品|基金)?\s*[Rr]\s*([1-5])", message)
|
||
if customer is None or product is None:
|
||
return None
|
||
return int(customer.group(1)), int(product.group(1))
|