Files
Mutual_Fund/service/redeem.py

160 lines
5.6 KiB
Python

"""赎回服务:校验持仓 → 建单(待确认) → 风控检测 → 落账/挂起(单事务原子)。"""
from __future__ import annotations
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from model.fin_risk_alert import FinRiskAlert
from model.fin_transaction import FinTransaction
from model.sys_user import SysUser
from model.trade_order import TradeOrder
from repositories.fin_account import FinAccountRepo
from repositories.fin_holdings import FinHoldingsRepo
from repositories.fin_product import FinProductRepo
from repositories.trade_order import TradeOrderRepo
from schemas.holdings import HoldingResp
from schemas.redeem import RedeemResp
from service.memory.composed_profile import invalidate_composed_profile
from service.risk.engine import RiskEngine, summarize
from service.risk.settle import settle
from utils.exceptions import ForbiddenError, NotFoundError, ParamError
from utils.order_no import gen_order_no
_MONEY = Decimal("0.01")
_SHARES = Decimal("0.0001")
def _holding_resp(holding) -> HoldingResp:
return HoldingResp(
id=holding.id,
customer_id=holding.customer_id,
product_id=holding.product_id,
shares=holding.shares,
cost_amount=holding.cost_amount,
current_value=holding.current_value,
profit_loss=holding.profit_loss,
profit_ratio=holding.profit_ratio,
status=holding.status,
)
async def redeem(
db: AsyncSession, user: SysUser, product_id: int, shares: Decimal
) -> RedeemResp:
"""赎回基金:校验持仓 → 建单 → 风控检测 → 落账/挂起,全程单事务。
- 仅客户可赎回;
- 产品须存在且净值非空,持仓须足额;
- 未命中规则:减仓 + 入账 + 写流水,订单「已确认」;
- 命中规则:生成预警,订单「风控挂起」,账户与持仓不变。
"""
if user.user_type != "CUSTOMER":
raise ForbiddenError("仅客户账号可赎回")
shares = shares.quantize(_SHARES, rounding=ROUND_HALF_UP)
if shares <= 0:
raise ParamError("赎回份额须大于 0")
product = await FinProductRepo(db).get(product_id)
if product is None:
raise NotFoundError("产品不存在")
if product.nav is None:
raise ParamError("产品暂无净值,无法赎回")
holdings_repo = FinHoldingsRepo(db)
account_repo = FinAccountRepo(db)
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
if holding is None or holding.shares <= 0:
raise ParamError("无可赎回份额")
if holding.shares < shares:
raise ParamError("可赎回份额不足")
credited = (shares * product.nav).quantize(_MONEY, rounding=ROUND_HALF_UP)
if credited <= 0:
raise ParamError("赎回金额过低")
now = datetime.now()
order = TradeOrder(
order_no=gen_order_no("PO"),
customer_id=user.id,
product_id=product_id,
advisor_id=None,
order_type="赎回",
amount=credited,
shares=shares,
nav=product.nav,
fee=Decimal("0"),
status="待确认",
create_time=now,
)
db.add(order)
await db.flush() # 生成 order.id,供流水/预警关联
hits = await RiskEngine(db).detect(order, user)
try:
if not hits:
# 未命中:减仓 + 入账 + 写流水 + 订单确认
await settle(db, order)
db.add(
FinTransaction(
transaction_no=gen_order_no("TR"),
order_id=order.id,
customer_id=user.id,
product_id=product_id,
operator_id=None,
transaction_type="赎回",
amount=credited,
shares=shares,
nav=product.nav,
fee=Decimal("0"),
status="已确认",
create_time=now,
)
)
await TradeOrderRepo(db).update_status(
order.id, status="已确认", confirm_time=now
)
await db.commit()
# 持仓变更后失效最终画像缓存(失败静默,不影响交易结果)
await invalidate_composed_profile(user.id)
account = await account_repo.get_by_customer_id(user.id)
current_holding = await holdings_repo.get_by_customer_product(
user.id, product_id
)
return RedeemResp(
order_no=order.order_no,
status="已确认",
balance=account.balance,
holding=_holding_resp(current_holding),
)
# 命中:生成预警 + 订单挂起
summary = summarize(hits)
alert = FinRiskAlert(
customer_id=user.id,
order_id=order.id,
alert_type=summary.alert_type,
alert_level=summary.alert_level,
trigger_detail=summary.trigger_detail,
transaction_ids=summary.transaction_ids or None,
confidence=summary.confidence,
status="未处理",
create_time=now,
)
db.add(alert)
await db.flush() # 生成 alert.id,供订单回填 risk_alert_id
await TradeOrderRepo(db).update_status(
order.id, status="风控挂起", risk_alert_id=alert.id
)
await db.commit()
return RedeemResp(
order_no=order.order_no, status="风控挂起", alert_id=alert.id
)
except Exception:
await db.rollback()
raise