"""净值口径判定(架构 §7 · D10 · PRD §2.2/§8.3)。 **本模块不查库** —— 调用方先 `core_ro.get_nav_as_of(product_id, trade_date)` 取回 `nav_date <= 交易日` 的最近一行,再传入此处判定。取值本身(T 日未知价法、 降序取 1)由 SQL 保证(D10:**不改** `get_latest_nav`,它会取到未来日期的净值)。 两个判定必须分清——它们对应**完全不同的处置**: | 判定 | 含义 | 处置 | | --- | --- | --- | | `ensure_nav_ready()` | 该产品**无任何净值记录** | **503 `NAV_NOT_READY`**,不落占位(PRD §8.3) | | `is_stale()` | 有净值但距交易日过久 | **不阻断**,仅额外落一条 `nav_stale` 审计(PRD §2.2) | 把"净值过期"误当"无净值"会拦掉本可完成的转换;反之会让演示库的过期净值 静默按新鲜净值折算。故两者是**两个独立函数**,不合并成一个布尔返回。 """ from __future__ import annotations from datetime import date from app.service.convert.errors import NavNotReady from app.service.convert.types import to_date #: 净值过期阈值(天)—— 与 `settings.convert_nav_stale_days` 默认值一致。 #: 纯函数不读配置,故此处只是**默认参数**,运行时以调用方传入为准。 DEFAULT_STALE_DAYS = 3 def ensure_nav_ready(nav_date: date | None, *, product_id: str | None = None) -> None: """无净值记录 → `NavNotReady`(503);有则原样通过。""" if to_date(nav_date) is None: raise NavNotReady(f"基金 {product_id!r} 尚无可用净值(无任何净值记录)") def is_stale( nav_date: date | None, trade_date: date, stale_days: int = DEFAULT_STALE_DAYS, ) -> bool: """净值是否过期:`(交易日 − 净值日期).days > stale_days`。 注意是 **`>`**:恰好等于阈值不算过期(与 PRD §2.2「超过此值」一致)。 `nav_date` 为 `None` 时抛 `ValueError` 而**不是**返回 `True`—— "无净值"必须走 `ensure_nav_ready()` 得到 503,混进 `stale` 分支会 变成"不阻断 + 落审计",性质完全不同。调用顺序:先 `ensure_nav_ready` 再 `is_stale`。 """ d = to_date(nav_date) t = to_date(trade_date) if d is None: raise ValueError("nav_date 为 None(无净值记录),应先调用 ensure_nav_ready()") if t is None: raise ValueError("trade_date 不能为 None") return (t - d).days > stale_days def evaluate_nav( nav_date: date | None, trade_date: date, stale_days: int = DEFAULT_STALE_DAYS, *, product_id: str | None = None, ) -> bool: """便捷入口:先确保有净值(否则 503),再返回是否过期。 返回值即响应体 `nav_stale` 字段(PRD §5.3),也是 `risk_convert_detail.nav_stale` 的取值(PRD §4.1)。 """ ensure_nav_ready(nav_date, product_id=product_id) return is_stale(nav_date, trade_date, stale_days) __all__ = ["DEFAULT_STALE_DAYS", "ensure_nav_ready", "is_stale", "evaluate_nav"]