Files
group_fqcd_jr/app/service/offsite_fund_rules.py
T

153 lines
6.0 KiB
Python

"""场外基金申购赎回确定性规则。"""
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from app.core.offsite_fund_contracts import RuleResultStatus
HUNDRED = Decimal("100")
TEN_PERCENT = Decimal("0.10")
TWENTY_PERCENT = Decimal("0.20")
ONE_YUAN = Decimal("1")
@dataclass(frozen=True)
class RuleDecision:
rule_code: str
rule_name: str
result: RuleResultStatus
document_value: dict[str, object]
database_value: dict[str, object]
calculation: dict[str, object]
def decimal_from(value: object) -> Decimal | None:
if value is None or value == "":
return None
try:
return Decimal(str(value).replace(",", "").strip())
except (InvalidOperation, ValueError):
return None
def normalize_amount_yuan(raw_value: object, unit: object) -> Decimal | None:
amount = decimal_from(raw_value)
if amount is None:
return None
raw_unit = str(unit or "元").strip()
if raw_unit == "万元":
return amount * Decimal("10000")
if raw_unit in {"元", "人民币", "CNY"}:
return amount
return None
def parse_application_date(raw_value: object) -> date | None:
text = str(raw_value or "").strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y年%m月%d日"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
class OffsiteFundRuleEngine:
"""只做程序化计算,不读取数据库,也不调用模型。"""
def check_subscription(
self,
*,
amount_yuan: Decimal | None,
nav: Decimal | None,
total_fund_shares: Decimal | None,
before_holding_shares: Decimal | None,
) -> list[RuleDecision]:
decisions = [self._minimum_subscription(amount_yuan)]
if (amount_yuan is None or nav is None or nav <= 0
or total_fund_shares is None or total_fund_shares == 0):
return decisions + [
self._unknown("subscription_holding_ratio", "申购后单一投资者持有比例"),
self._unknown("subscription_single_share_limit", "申购单笔份额上限"),
]
current_shares = amount_yuan / nav
before = before_holding_shares or Decimal("0")
ratio = (before + current_shares) / total_fund_shares
decisions.append(RuleDecision(
rule_code="subscription_holding_ratio",
rule_name="申购后单一投资者持有比例",
result="异常" if ratio > TWENTY_PERCENT else "正常",
document_value={"申购金额元": str(amount_yuan)},
database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares),
"申请前持有份额": str(before)},
calculation={"本次申购份额": str(current_shares), "申购后持有比例": str(ratio)},
))
limit = total_fund_shares * TEN_PERCENT
decisions.append(RuleDecision(
rule_code="subscription_single_share_limit",
rule_name="申购单笔份额上限",
result="异常" if current_shares > limit else "正常",
document_value={"申购金额元": str(amount_yuan)},
database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares)},
calculation={"本次申购份额": str(current_shares), "份额上限": str(limit)},
))
return decisions
def check_redemption(
self,
*,
redemption_shares: Decimal | None,
total_fund_shares: Decimal | None,
available_quantity: Decimal | None,
) -> list[RuleDecision]:
if redemption_shares is None or total_fund_shares is None or total_fund_shares == 0:
ratio = self._unknown("redemption_large_ratio", "赎回巨额比例")
else:
value = redemption_shares / total_fund_shares
ratio = RuleDecision(
rule_code="redemption_large_ratio",
rule_name="赎回巨额比例",
result="异常" if value > TWENTY_PERCENT else "正常",
document_value={"赎回份额": str(redemption_shares)},
database_value={"产品最新总份额": str(total_fund_shares)},
calculation={"赎回比例": str(value)},
)
if redemption_shares is None or available_quantity is None:
available = self._unknown("redemption_available_quantity", "账户可用份额")
else:
available = RuleDecision(
rule_code="redemption_available_quantity",
rule_name="账户可用份额",
result="异常" if redemption_shares > available_quantity else "正常",
document_value={"赎回份额": str(redemption_shares)},
database_value={"当前最新可用份额": str(available_quantity)},
calculation={"是否超出可用份额": redemption_shares > available_quantity},
)
return [ratio, available]
@staticmethod
def _minimum_subscription(amount_yuan: Decimal | None) -> RuleDecision:
result: RuleResultStatus = "无法判断"
if amount_yuan is not None:
result = "异常" if amount_yuan <= ONE_YUAN else "正常"
return RuleDecision(
rule_code="subscription_minimum_amount",
rule_name="申购最低金额",
result=result,
document_value={"申购金额元": str(amount_yuan) if amount_yuan is not None else None},
database_value={},
calculation={"判断口径": "标准化申购金额 <= 1 元为异常"},
)
@staticmethod
def _unknown(rule_code: str, rule_name: str) -> RuleDecision:
return RuleDecision(
rule_code=rule_code,
rule_name=rule_name,
result="无法判断",
document_value={},
database_value={},
calculation={"原因": "必要识别字段或查询结果缺失"},
)