- Updated `RiskListAccess` and `ThresholdWriteAccess` to enforce access control in the risk repository and threshold repository, ensuring only authorized roles can perform sensitive operations. - Introduced new methods in `RiskRepository` for counting pending alerts and listing alerts with access checks, improving data security and compliance. - Enhanced the `chat.py` and `deps.py` files to integrate compliance roles into the risk management matrix, allowing for more granular access control. - Updated documentation to reflect the new testing baseline of 825 passed tests, indicating improved stability and functionality across the application. This update significantly strengthens the risk management capabilities, ensuring robust access control and compliance with organizational policies.
154 lines
4.8 KiB
Python
154 lines
4.8 KiB
Python
"""客户亏损阈值提醒(C-04)。
|
||
|
||
从 L1 槽位 threshold_pref_summary 解析百分比并写入 customer_threshold_config;
|
||
持仓查询时按组合浮动盈亏与配置比对,命中则追加提醒并写 customer_notify_log。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import json
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from app.repository.threshold_repository import ThresholdRepository
|
||
from app.repository.repo_access import ThresholdWriteAccess
|
||
|
||
_LOSS_PCT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%")
|
||
|
||
|
||
def parse_loss_threshold_pct(summary: str) -> Decimal | None:
|
||
"""从「亏10%提醒我」类摘要解析正数阈值(百分点)。"""
|
||
m = _LOSS_PCT_RE.search(summary or "")
|
||
if not m:
|
||
return None
|
||
pct = Decimal(m.group(1))
|
||
if pct <= 0 or pct > 100:
|
||
return None
|
||
return pct
|
||
|
||
|
||
def sync_threshold_from_summary(customer_id: str, summary: str) -> int | None:
|
||
"""画像摘要 → 组合级 customer_threshold_config(upsert)。"""
|
||
pct = parse_loss_threshold_pct(summary)
|
||
if pct is None:
|
||
return None
|
||
return ThresholdRepository().upsert_portfolio(
|
||
customer_id, pct, access=ThresholdWriteAccess.profile_sync()
|
||
)
|
||
|
||
|
||
def portfolio_pnl_pct(holdings: list[dict[str, Any]]) -> float | None:
|
||
"""按市值加权计算组合浮动盈亏率(%)。"""
|
||
if not holdings:
|
||
return None
|
||
total_mv = sum(float(r.get("market_value") or 0) for r in holdings)
|
||
if total_mv <= 0:
|
||
return None
|
||
weighted = sum(
|
||
float(r.get("market_value") or 0) * float(r.get("pnl_pct") or 0)
|
||
for r in holdings
|
||
)
|
||
return weighted / total_mv
|
||
|
||
|
||
def build_threshold_alert(
|
||
customer_id: str,
|
||
holdings: list[dict[str, Any]],
|
||
*,
|
||
trace_id: str = "",
|
||
) -> str | None:
|
||
"""若组合亏损达到配置阈值,返回提醒文案并留痕;否则 None。"""
|
||
repo = ThresholdRepository()
|
||
configs = repo.list_enabled(customer_id)
|
||
portfolio_cfg = next((c for c in configs if c.get("scope_type") == "portfolio"), None)
|
||
if not portfolio_cfg:
|
||
return None
|
||
|
||
pnl = portfolio_pnl_pct(holdings)
|
||
if pnl is None or pnl >= 0:
|
||
return None
|
||
|
||
threshold = float(portfolio_cfg["loss_threshold_pct"])
|
||
loss_pct = abs(pnl)
|
||
if loss_pct < threshold:
|
||
return None
|
||
|
||
alert = (
|
||
f"【阈值提醒】您的持仓组合浮动亏损约 {loss_pct:.2f}%,"
|
||
f"已达到您设置的 {threshold:.0f}% 提醒线。"
|
||
"以上为系统只读计算,不构成投资建议;如需调整提醒条件,可告诉我新的亏损提醒比例。"
|
||
)
|
||
repo.insert_notify_log(
|
||
customer_id=customer_id,
|
||
trace_id=trace_id,
|
||
threshold_config_id=int(portfolio_cfg["id"]),
|
||
payload={
|
||
"portfolio_pnl_pct": round(pnl, 4),
|
||
"threshold_pct": threshold,
|
||
"holding_count": len(holdings),
|
||
},
|
||
)
|
||
return alert
|
||
|
||
|
||
THRESHOLD_PUSH_CHANNEL = "customer:threshold:push"
|
||
|
||
|
||
def publish_threshold_push(customer_id: str, message: str) -> bool:
|
||
"""C-04 演示:Redis pub 主动推送(前端/脚本可订阅)。"""
|
||
if not message:
|
||
return False
|
||
try:
|
||
from app.config.database import get_redis_client
|
||
|
||
payload = json.dumps({"customer_id": customer_id, "message": message}, ensure_ascii=False)
|
||
get_redis_client().publish(THRESHOLD_PUSH_CHANNEL, payload)
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
|
||
def run_threshold_check(
|
||
customer_id: str,
|
||
holdings: list[dict[str, Any]],
|
||
*,
|
||
trace_id: str = "",
|
||
push: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""扫描持仓阈值;可选 Redis push + 留痕。"""
|
||
alert = build_threshold_alert(customer_id, holdings, trace_id=trace_id)
|
||
pushed = False
|
||
if alert and push:
|
||
pushed = publish_threshold_push(customer_id, alert)
|
||
if pushed:
|
||
ThresholdRepository().insert_notify_log(
|
||
customer_id=customer_id,
|
||
trace_id=trace_id or "threshold-push",
|
||
threshold_config_id=None,
|
||
payload={"alert": alert[:200], "channel": "redis_pub"},
|
||
channel="push",
|
||
send_status="sent",
|
||
)
|
||
return {"alert": alert, "pushed": pushed}
|
||
|
||
|
||
def append_threshold_to_tool_result(
|
||
customer_id: str,
|
||
tool_result: dict[str, Any],
|
||
*,
|
||
trace_id: str = "",
|
||
) -> dict[str, Any]:
|
||
"""持仓 tool 成功后追加阈值提醒到 fact_text。"""
|
||
if tool_result.get("tool") != "holding_query" or not tool_result.get("ok"):
|
||
return tool_result
|
||
facts = tool_result.get("facts")
|
||
if not isinstance(facts, list):
|
||
return tool_result
|
||
alert = build_threshold_alert(customer_id, facts, trace_id=trace_id)
|
||
if not alert:
|
||
return tool_result
|
||
out = dict(tool_result)
|
||
out["fact_text"] = f"{out.get('fact_text', '')}\n\n{alert}"
|
||
return out
|