Files
group_xinghuo_jinrong/app/service/convert/nav.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
2026-09-10 14:45:55 +08:00

75 lines
3.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""净值口径判定(架构 §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"]