Ver código fonte

fix#误报修复,增强TRS016报表检查核对.

hefuzheng 1 semana atrás
pai
commit
b9d7ae8f00
26 arquivos alterados com 324 adições e 43 exclusões
  1. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/chain.cpython-312.pyc
  2. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/note_chain.cpython-312.pyc
  3. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_decimal.cpython-312.pyc
  4. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-312.pyc
  5. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_asset.cpython-312.pyc
  6. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_account.cpython-312.pyc
  7. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_common.cpython-312.pyc
  8. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-312.pyc
  9. 5 0
      code/post_clear_check/src/post_clear_check/chain.py
  10. 36 9
      code/post_clear_check/src/post_clear_check/note_chain.py
  11. 6 0
      code/post_clear_check/src/post_clear_check/order_decimal.py
  12. 54 6
      code/post_clear_check/src/post_clear_check/reader.py
  13. 18 4
      code/post_clear_check/src/post_clear_check/sales_asset.py
  14. 30 7
      code/post_clear_check/src/post_clear_check/trs_account.py
  15. 2 0
      code/post_clear_check/src/post_clear_check/trs_common.py
  16. 9 1
      code/post_clear_check/src/post_clear_check/trs_flow.py
  17. BIN
      code/post_clear_check/tests/__pycache__/test_note_chain.cpython-312-pytest-9.1.1.pyc
  18. BIN
      code/post_clear_check/tests/__pycache__/test_order_rules.cpython-312-pytest-9.1.1.pyc
  19. BIN
      code/post_clear_check/tests/__pycache__/test_sales_reports.cpython-312-pytest-9.1.1.pyc
  20. BIN
      code/post_clear_check/tests/__pycache__/test_trs_reports.cpython-312-pytest-9.1.1.pyc
  21. 45 0
      code/post_clear_check/tests/test_note_chain.py
  22. 10 10
      code/post_clear_check/tests/test_order_rules.py
  23. 17 0
      code/post_clear_check/tests/test_sales_reports.py
  24. 89 3
      code/post_clear_check/tests/test_trs_reports.py
  25. 3 3
      doc/业务链路核对设计.md
  26. 0 0
      doc/使用说明.md

BIN
code/post_clear_check/src/post_clear_check/__pycache__/chain.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/note_chain.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_decimal.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_asset.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_account.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_common.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-312.pyc


+ 5 - 0
code/post_clear_check/src/post_clear_check/chain.py

@@ -417,6 +417,11 @@ _USER_ASSET_FORMULAS: Mapping[str, str] = {
     "总资产": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "总资产"),
     "净资产": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "净资产"),
     "当日计息金额": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "应付利息"),
+    # 与区间导出同一门槛:非零持仓成本必须有当月有效融资利率。
+    "有效月度融资利率": _prototype(
+        f"{_TRS_DAILY}/16 Holdings Amount Interest Report/16 Holdings Amount Interest Report.md",
+        "利率获取与硬性校验",
+    ),
     "杠杆倍数": _prototype(_TRS_FORMULA_MD, _TRS_OPERATION, "融资金额"),
     "封存资金": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
     "封存汇率": _design(_STEP_PLAN, "5.3 TRS(10 步)"),

+ 36 - 9
code/post_clear_check/src/post_clear_check/note_chain.py

@@ -61,7 +61,7 @@ FORMULA_SALES = (
 )
 FORMULA_TOPUP_SET = f"{TOPUP_SET} = input 中归属本营业日的增额主键集合等于实时同日增额主键"
 FORMULA_TOPUP_SUM = (
-    f"{TOPUP_SUM} = work/3 增额等于 work/2 增额加上 input 中本营业日增额发生额,"
+    f"{TOPUP_SUM} = work/3 增额等于 work/2 增额加上 input 中本营业日 top_up 与 sell_release 发生额,"
     "且 work/4 与 cleared 保持该增额"
 )
 FORMULA_EXTENSION_SET = f"{EXTENSION_SET} = input 中审批归属本营业日的已通过展期主键集合等于实时同日主键"
@@ -138,26 +138,47 @@ def check_note_holding(ctx: Any, lines: list[CheckLine]) -> None:
 
 
 def check_topup(ctx: Any, lines: list[CheckLine]) -> None:
-    """增额的冻结主键集合,以及发生额记入 work/3。"""
+    """增额的冻结主键集合,以及发生额记入 work/3。
+
+    主键集合只看 top_up。累计要把同日 sell_release 一并计入,否则当日卖出会把 work/3 增额清零,误报。
+    """
 
     stages = _stages(ctx)
     if not stages or not _facts_ready(stages, "note_topup_log"):
         return
     day = ctx.business_date
-    frozen = [row for row in _table(stages, SCOPE_INPUT, "note_topup_log") if _is_topup(row)]
+    archive = list(_table(stages, SCOPE_INPUT, "note_topup_log"))
+    frozen = [row for row in archive if _is_topup(row)]
     live = [row for row in _seq(getattr(ctx, "live_topups", ())) if _is_topup(row)]
     produced: list[tuple[str | None, CheckLine]] = []
-    note_nos = _note_numbers(frozen, live)
+    # 只有 sell_release、没有 top_up 的票据也要核累计(卖出释放同样改 work/3 增额)。
+    release_notes = {
+        note
+        for row in archive
+        if _is_sell_release(row)
+        and as_date(row.get("business_date")) == day
+        and (note := _text(row.get("note_no"))) is not None
+    }
+    note_nos = sorted(set(_note_numbers(frozen, live)) | release_notes)
     for note_no in note_nos:
         fund = _fund_for_note(stages, note_no)
         frozen_day = [row for row in frozen if row.get("note_no") == note_no and as_date(row.get("business_date")) == day]
         live_day = [row for row in live if row.get("note_no") == note_no and as_date(row.get("business_date")) == day]
         wrong_day = [row for row in frozen if row.get("note_no") == note_no and as_date(row.get("business_date")) != day]
-        problems = _id_gap("冻结", "实时", frozen_day, live_day)
-        for row in wrong_day:
-            problems.append(f"主键 {_ident(row.get('id'))} 的归属日不是本营业日")
-        _emit(produced, fund, FORMULA_TOPUP_SET, _STEP_ISSUE, problems, f"票据 {note_no} 增额主键")
-        _topup_sum(stages, note_no, frozen_day, fund, produced)
+        # 仅有卖出释放时不出冻结集合行,只核累计。
+        if frozen_day or live_day or wrong_day:
+            problems = _id_gap("冻结", "实时", frozen_day, live_day)
+            for row in wrong_day:
+                problems.append(f"主键 {_ident(row.get('id'))} 的归属日不是本营业日")
+            _emit(produced, fund, FORMULA_TOPUP_SET, _STEP_ISSUE, problems, f"票据 {note_no} 增额主键")
+        amount_day = [
+            row
+            for row in archive
+            if row.get("note_no") == note_no
+            and as_date(row.get("business_date")) == day
+            and (_is_topup(row) or _is_sell_release(row))
+        ]
+        _topup_sum(stages, note_no, amount_day, fund, produced)
     _flush(lines, produced)
 
 
@@ -842,6 +863,12 @@ def _is_topup(row: Mapping[str, Any]) -> bool:
     return row.get("change_type") == "top_up"
 
 
+def _is_sell_release(row: Mapping[str, Any]) -> bool:
+    """卖出释放会把增额清零,累计必须计入,但不进增额冻结主键集合。"""
+
+    return row.get("change_type") == "sell_release"
+
+
 def _is_approved(row: Mapping[str, Any]) -> bool:
     return row.get("status") == "approved"
 

+ 6 - 0
code/post_clear_check/src/post_clear_check/order_decimal.py

@@ -82,6 +82,9 @@ def judge_rate(
     if mul is None and div is None:
         return VERDICT_SUSPECT, None, f"{text};两个方向都无法算"
     if len(matches) >= 2:
+        # 汇率为 1 时乘除必然同时成立(同币种),不再记可疑。
+        if stored == quantize(Decimal(1), SCALE_FX):
+            return VERDICT_CORRECT, stored, f"{text};汇率为 1,乘除同值"
         return VERDICT_SUSPECT, stored, f"{text};两个方向都对得上"
     if len(matches) == 1:
         return VERDICT_CORRECT, matches[0], text
@@ -107,6 +110,9 @@ def judge_convert(
     mul_ok = mul_q == stored
     div_ok = div_q is not None and div_q == stored
     if mul_ok and div_ok:
+        # 汇率为 1 时乘除同值,同币种换算记正确。
+        if quantize(fx, SCALE_FX) == quantize(Decimal(1), SCALE_FX):
+            return VERDICT_CORRECT, mul_q, f"{mul_text};{div_text};汇率为 1,乘除同值"
         return VERDICT_SUSPECT, mul_q, f"{mul_text};{div_text};两个方向都对得上"
     if mul_ok:
         return VERDICT_CORRECT, mul_q, mul_text

+ 54 - 6
code/post_clear_check/src/post_clear_check/reader.py

@@ -332,6 +332,7 @@ async def read_rows(gateway: QueryGateway, request: ReadRequest) -> ReadResult:
     trs_dataset_id = await _trs_available_dataset_id(gateway, request, missing)
     await _load_user_ledgers(gateway, request, trs_users, accounts, user_ids, trs_dataset_id, missing)
     await _load_fund_in_out(gateway, request, trs_users, accounts, user_ids, missing)
+    await _load_interest_collect(gateway, request, trs_users, accounts, user_ids, missing)
     await _load_interest_payable(gateway, trs_users, accounts, user_ids, missing)
     await _load_cleared_positions(gateway, trs_users, accounts, user_ids, trs_dataset_id, missing)
     # 步骤节点另读 cleared 镜像和封存快照。TRS006 用的合并流水保持原样。
@@ -1704,8 +1705,18 @@ async def _load_user_ledgers(
         _add_unique(_ensure_trs(users, account).setdefault("fund_ledger", []), parsed)
 
 
-# TRS009 按主键或 source_no 对出入金。列与 db_trs.fund_in_out_record 一致。
-_IN_OUT_COLUMNS = ("id", "user_id", "source_no", "direction", "amount", "business_date")
+# TRS009:报表 ref 对入金/出金单 id(source_id),兼容流水主键与 source_no。
+_IN_OUT_COLUMNS = ("id", "user_id", "source_id", "source_no", "direction", "amount", "business_date")
+# TRS007:利息收取事实按用户挂桶,与报表 period_start/period_end/settle_ccy 对齐。
+_INTEREST_COLLECT_COLUMNS = (
+    "id",
+    "user_id",
+    "settle_ccy",
+    "period_start",
+    "period_end",
+    "amount",
+    "business_date",
+)
 
 
 async def _load_fund_in_out(
@@ -1742,6 +1753,40 @@ async def _load_fund_in_out(
         _ensure_trs(users, account).setdefault("fund_in_out_record", []).append(dict(row))
 
 
+async def _load_interest_collect(
+    gateway: QueryGateway,
+    request: ReadRequest,
+    users: dict[str, dict[str, Any]],
+    accounts: Mapping[int, str],
+    user_ids: Sequence[int],
+    missing: list[str],
+) -> None:
+    """按营业日挂利息收取事实,供 TRS007 与报表对齐。"""
+
+    if not user_ids:
+        return
+    rows = await _select_in(
+        gateway,
+        "db_trs.fund_interest_collect",
+        _INTEREST_COLLECT_COLUMNS,
+        "business_date = %s",
+        (request.business_date,),
+        "user_id",
+        user_ids,
+        missing,
+    )
+    if "db_trs.fund_interest_collect" in missing:
+        return
+    wanted = {accounts[ident] for ident in user_ids if ident in accounts}
+    for account in wanted:
+        _ensure_trs(users, account).setdefault("fund_interest_collect", [])
+    for row in rows:
+        account = accounts.get(_int_id(row.get("user_id")) or -1)
+        if account is None:
+            continue
+        _ensure_trs(users, account).setdefault("fund_interest_collect", []).append(dict(row))
+
+
 # TRS013 要知道未收取的应付利息有没有报表行。金额另按报表四列计算。
 _PAYABLE_COLUMNS = (
     "id",
@@ -2313,12 +2358,13 @@ async def _load_note_chain_rows(
     if dataset_id is None:
         return empty
     input_where, input_args = with_scope("", (), dataset_id, "input", 0)
+    # 增额累计要把同日 sell_release 计入;主键集合仍只看 top_up。
     topups = await _select(
         gateway,
         TOPUP_ARCHIVE_TABLE,
         TOPUP_COLUMNS,
-        f"{input_where} AND change_type = %s",
-        input_args + ("top_up",),
+        f"{input_where} AND change_type IN (%s, %s)",
+        input_args + ("top_up", "sell_release"),
         missing,
     )
     extensions = await _select(
@@ -2402,8 +2448,8 @@ async def _load_note_chain_rows(
         gateway,
         TOPUP_LIVE_TABLE,
         TOPUP_COLUMNS,
-        "change_type = %s AND business_date >= %s",
-        ("top_up", request.business_date),
+        "change_type IN (%s, %s) AND business_date >= %s",
+        ("top_up", "sell_release", request.business_date),
         "note_no",
         note_nos,
         missing,
@@ -3225,6 +3271,8 @@ def _freeze_user(bucket: Mapping[str, Any]) -> dict[str, Any]:
         frozen["clr_user_position"] = tuple(bucket["clr_user_position"])
     if "fund_in_out_record" in bucket:
         frozen["fund_in_out_record"] = tuple(dict(row) for row in bucket["fund_in_out_record"])
+    if "fund_interest_collect" in bucket:
+        frozen["fund_interest_collect"] = tuple(dict(row) for row in bucket["fund_interest_collect"])
     if "fund_interest_payable" in bucket:
         frozen["fund_interest_payable"] = tuple(dict(row) for row in bucket["fund_interest_payable"])
     for key in ("clr_trade_order", "clr_trade_fill", "clr_fund_account", "clr_fx_user"):

+ 18 - 4
code/post_clear_check/src/post_clear_check/sales_asset.py

@@ -354,9 +354,9 @@ def _hkd(lines: list[CheckLine], row: Any, where: str | None) -> None:
         )
         return
     kind = direction_of(net_asset, fx_book, stored, SCALE_AMT)
+    amounts = (available, frozen, valuation)
     # 汇率为 1 或金额为 0 时乘除都对上资产净值。三项换算也落到同一分位,才不用再猜方向。
     if kind == "both":
-        amounts = (available, frozen, valuation)
         mul_total = _fx_sum(amounts, fx_book, "mul")
         div_total = _fx_sum(amounts, fx_book, "div")
         if (
@@ -367,6 +367,19 @@ def _hkd(lines: list[CheckLine], row: Any, where: str | None) -> None:
             kind = "neither"
         else:
             kind = "mul"
+    # 整笔资产净值×汇率与分项换算可能差 1 分;整笔推不出方向时改用三项总和推断。
+    if kind not in {"mul", "div"}:
+        mul_total = _fx_sum(amounts, fx_book, "mul")
+        div_total = _fx_sum(amounts, fx_book, "div")
+        stored_q = quantize(stored, SCALE_AMT)
+        mul_ok = mul_total is not None and quantize(mul_total, SCALE_AMT) == stored_q
+        div_ok = div_total is not None and quantize(div_total, SCALE_AMT) == stored_q
+        if mul_ok and not div_ok:
+            kind = "mul"
+        elif div_ok and not mul_ok:
+            kind = "div"
+        elif mul_ok and div_ok:
+            kind = "mul"
     if kind not in {"mul", "div"}:
         emit_amount(
             lines,
@@ -402,7 +415,8 @@ def _hkd(lines: list[CheckLine], row: Any, where: str | None) -> None:
                 force=VERDICT_SUSPECT,
             )
             return
-        parts.append(converted)
+        # 与销售报表一致:每项先量化到分再相加,避免整笔乘汇率差 1 分。
+        parts.append(quantize(converted, SCALE_AMT))
     calc = sum(parts, Decimal(0))
     word = "乘" if kind == "mul" else "除"
     text = (
@@ -425,14 +439,14 @@ def _hkd(lines: list[CheckLine], row: Any, where: str | None) -> None:
 
 
 def _fx_sum(amounts: tuple[Decimal, ...], fx_book: Decimal, kind: str) -> Decimal | None:
-    """三项按同一方向换算。任何一项换不成就没有合计。"""
+    """三项按同一方向换算,每项先量化到分再相加。任何一项换不成就没有合计。"""
 
     total = Decimal(0)
     for amount in amounts:
         converted = apply_fx(amount, fx_book, kind)
         if converted is None:
             return None
-        total += converted
+        total += quantize(converted, SCALE_AMT)
     return total
 
 

+ 30 - 7
code/post_clear_check/src/post_clear_check/trs_account.py

@@ -3,7 +3,9 @@
 同行等式只做列注释里写明的那几条。授信和信用四列不单造公式。
 TRS001 的可用、冻结对清算资金账户,持仓市值对清算持仓市值合计。
 TRS010 现金结余用本行四列相加。TRS014 持仓市值用同一合计,不乘清算价。
-TRS016 持仓成本金额对清算持仓 cost_amount 合计。没有行,或融资利率为 NULL,都不产生计息那条。
+TRS016 持仓成本金额对清算持仓 cost_amount 合计。
+有非零持仓成本时,finance_rate 不得为 NULL(与区间导出要求一致);利率缺失记错误。
+持仓成本为 0 且利率为 NULL 时不产生计息行。
 """
 
 from __future__ import annotations
@@ -12,9 +14,9 @@ from collections.abc import Mapping
 from decimal import Decimal
 from typing import Any
 
-from post_clear_check.issue_common import present_number
+from post_clear_check.issue_common import as_date, present_number
 from post_clear_check.order_cell import CheckLine
-from post_clear_check.order_decimal import VERDICT_SUSPECT, as_decimal, format_decimal
+from post_clear_check.order_decimal import VERDICT_ERROR, VERDICT_SUSPECT, as_decimal, format_decimal
 from post_clear_check.trs_common import (
     F_BASE,
     F_CASH_ASSET,
@@ -22,6 +24,7 @@ from post_clear_check.trs_common import (
     F_INTEREST,
     F_LEVERAGE,
     F_LEVERAGE_CONFIG,
+    F_MONTHLY_RATE,
     F_NET,
     F_TOTAL,
     SCALE_AMT,
@@ -395,11 +398,31 @@ def _interest_line(
     row: Mapping[str, Any],
     where: str,
 ) -> None:
-    # NULL 表示还没维护利率,这条不产生。不把空利率当成 0。
-    if "finance_rate" in row and row.get("finance_rate") is None:
-        return
     cost = _cell(row, "holding_cost")
-    rate = _cell(row, "finance_rate")
+    rate_missing = "finance_rate" in row and row.get("finance_rate") is None
+    rate = None if rate_missing else _cell(row, "finance_rate")
+    # 零成本行导出时会跳过,不强制利率。非零成本缺利率会阻断 TRS016 区间导出。
+    if rate_missing and (cost is None or not is_zero(cost, SCALE_AMT)):
+        day = as_date(row.get("nature_date")) or ctx.business_date
+        ccy = _text_ccy(row) or "未知币种"
+        month_text = f"{day.year:04d}-{day.month:02d}"
+        emit(
+            lines,
+            formula=F_MONTHLY_RATE,
+            basis="有非零持仓成本时必须有有效月度融资利率,与 TRS016 区间导出一致",
+            calc_text=f"{month_text}缺少{ccy}有效月度融资利率,无法导出TRS016",
+            calc_value=None,
+            report_name=TRS016_NAME,
+            column="当月融资利率",
+            row=row,
+            field="finance_rate",
+            where=where,
+            scale=SCALE_RATE6,
+            force=VERDICT_ERROR,
+        )
+        return
+    if rate_missing:
+        return
     seen, year_days = lookup_year_days(ctx, ctx.users[account], row)
     if cost is None or rate is None or not seen or year_days is None or year_days == 0:
         missing = []

+ 2 - 0
code/post_clear_check/src/post_clear_check/trs_common.py

@@ -56,6 +56,8 @@ F_LEVERAGE_CONFIG = "杠杆倍数 = 资金账户配置杠杆"
 F_CASH_ASSET = "总资产 = 现金 + 持仓市值"
 F_NET = "净资产 = 总资产 − 融资及借贷 − 信用本金 − 应付利息"
 F_INTEREST = "当日计息金额 = 持仓成本金额 × 当月融资利率 / 年天数"
+# 与区间导出一致:有非零持仓成本时,当月结算币种必须有年化大于 0 的月度利率。
+F_MONTHLY_RATE = "有效月度融资利率 = 当月结算币种在 cfg_holdings_interest_rate 维护且年化大于 0"
 
 _BASIS_ROW = "报表列注释里的等式"
 _SKIP_KEYS = frozenset({"reports", "user_id", "year_days"})

+ 9 - 1
code/post_clear_check/src/post_clear_check/trs_flow.py

@@ -770,10 +770,18 @@ def _match_keys(row: Mapping[str, Any], facts: Sequence[Mapping[str, Any]], keys
 
 
 def _match_ref(row: Mapping[str, Any], records: Sequence[Mapping[str, Any]]) -> Mapping[str, Any] | None:
+    """TRS 报表 ref 写的是入金/出金单 id(流水 source_id),兼容流水主键与 source_no。"""
+
     ref = _text(row.get("ref"))
     if ref is None:
         return records[0] if len(records) == 1 else None
-    matched = [record for record in records if biz_text(record.get("id")) == ref or _text(record.get("source_no")) == ref]
+    matched = [
+        record
+        for record in records
+        if biz_text(record.get("source_id")) == ref
+        or biz_text(record.get("id")) == ref
+        or _text(record.get("source_no")) == ref
+    ]
     if len(matched) == 1:
         return matched[0]
     return None

BIN
code/post_clear_check/tests/__pycache__/test_note_chain.cpython-312-pytest-9.1.1.pyc


BIN
code/post_clear_check/tests/__pycache__/test_order_rules.cpython-312-pytest-9.1.1.pyc


BIN
code/post_clear_check/tests/__pycache__/test_sales_reports.cpython-312-pytest-9.1.1.pyc


BIN
code/post_clear_check/tests/__pycache__/test_trs_reports.cpython-312-pytest-9.1.1.pyc


+ 45 - 0
code/post_clear_check/tests/test_note_chain.py

@@ -207,6 +207,51 @@ def test_topup_primary_keys_and_work_three_sum() -> None:
     assert all(line.fund_account == "FA1" for line in lines)
 
 
+def test_topup_sum_includes_same_day_sell_release() -> None:
+    """同日 top_up 后再 sell_release,work/3 增额按两者合计,不误报。"""
+
+    stages = _stages(_sub(add_amt=Decimal("40000"), note_status="active"), _master(status="active"))
+    stages["work/3"]["note_sub_a"] = [_sub(add_amt=Decimal("0"), note_status="sold")]
+    stages["work/4"]["note_sub_a"] = [_sub(add_amt=Decimal("0"), note_status="sold")]
+    stages["cleared/0"]["note_sub_a"] = [_sub(add_amt=Decimal("0"), note_status="sold")]
+    stages["input/0"]["note_topup_log"] = [
+        {
+            "id": 9,
+            "note_no": "N1",
+            "a_sub_note_no": "SN1",
+            "change_type": "top_up",
+            "change_amt": Decimal("100000"),
+            "business_date": DAY,
+        },
+        {
+            "id": 10,
+            "note_no": "N1",
+            "a_sub_note_no": "SN1",
+            "change_type": "sell_release",
+            "change_amt": Decimal("-140000"),
+            "business_date": DAY,
+        },
+    ]
+    lines = _lines(
+        CHAIN_TOPUP,
+        "增额",
+        issue_stages=stages,
+        live_topups=[
+            {
+                "id": 9,
+                "note_no": "N1",
+                "a_sub_note_no": "SN1",
+                "change_type": "top_up",
+                "change_amt": Decimal("100000"),
+                "business_date": DAY,
+            }
+        ],
+    )
+    summed = [line for line in lines if line.formula_text.startswith("增额累计")]
+    assert len(summed) == 1
+    assert summed[0].verdict == "correct"
+
+
 def test_topup_missing_from_freeze_is_error() -> None:
     stages = _stages(_sub(), _master())
     stages["input/0"]["note_topup_log"] = []

+ 10 - 10
code/post_clear_check/tests/test_order_rules.py

@@ -54,18 +54,18 @@ def test_zero_average_price_makes_quantity_suspect() -> None:
     assert "订单成交均价为 0" in line.calc_text
 
 
-def test_fx_neither_direction_is_error_and_both_is_suspect() -> None:
-    """汇率两个方向都对不上为错误,都对得上为可疑。"""
+def test_fx_neither_direction_is_error_and_rate_one_is_correct() -> None:
+    """汇率两个方向都对不上为错误;汇率为 1 时乘除同值记正确。"""
 
     neither = _one(_fx_lines(Decimal("100"), Decimal("300"), Decimal("1.5")), "结算汇率")
     assert neither.verdict == "error"
     assert neither.calc_value is None
     assert "两个方向都对不上" in neither.calc_text
 
-    both = _one(_fx_lines(Decimal("100"), Decimal("100"), Decimal("1")), "结算汇率")
-    assert both.verdict == "suspect"
-    assert both.calc_value == Decimal("1.000000")
-    assert "两个方向都对得上" in both.calc_text
+    same = _one(_fx_lines(Decimal("100"), Decimal("100"), Decimal("1")), "结算汇率")
+    assert same.verdict == "correct"
+    assert same.calc_value == Decimal("1.000000")
+    assert "汇率为 1" in same.calc_text
 
     one = _one(_fx_lines(Decimal("100"), Decimal("300"), Decimal("3")), "结算汇率")
     assert one.verdict == "correct"
@@ -73,11 +73,11 @@ def test_fx_neither_direction_is_error_and_both_is_suspect() -> None:
 
 
 def test_conversion_uses_only_a_unique_direction() -> None:
-    """名义本金换算不默认乘或除。"""
+    """名义本金换算不默认乘或除;汇率为 1 时乘除同值记正确。"""
 
-    both = _one(_convert_lines(Decimal("100"), Decimal("1"), Decimal("100")), "执行订单名义本金(结算币种)")
-    assert both.verdict == "suspect"
-    assert "两个方向都对得上" in both.calc_text
+    same = _one(_convert_lines(Decimal("100"), Decimal("1"), Decimal("100")), "执行订单名义本金(结算币种)")
+    assert same.verdict == "correct"
+    assert "汇率为 1" in same.calc_text
 
     mul = _one(_convert_lines(Decimal("100"), Decimal("3"), Decimal("300")), "执行订单名义本金(结算币种)")
     assert mul.verdict == "correct"

+ 17 - 0
code/post_clear_check/tests/test_sales_reports.py

@@ -289,6 +289,23 @@ def test_hkd_same_either_direction_is_correct() -> None:
     assert line.check_value == Decimal("10.00")
 
 
+def test_hkd_infers_direction_from_component_sum_when_net_rounds_off() -> None:
+    """整笔资产净值×汇率差 1 分时,改用三项分项换算总和推断方向。"""
+
+    row = _asset(
+        cash_available=Decimal("9899500.00"),
+        frozen_amt=Decimal("0.00"),
+        note_valuation=Decimal("111887.46"),
+        net_asset=Decimal("10011387.46"),
+        fx_book=Decimal("1.186689"),
+        net_asset_hkd=Decimal("11880403.38"),
+    )
+    line = _exact(check_sales_reports(_asset_data(row)), "等值港币资产净值 = 现金可用、冻结金额、票据估值各自按账面汇率换算后相加")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("11880403.38")
+    assert line.check_value == Decimal("11880403.38")
+
+
 def test_hkd_stays_suspect_when_component_directions_differ() -> None:
     """资产净值为 0 时乘除都对上,但现金按两个方向换算并不相同。"""
 

+ 89 - 3
code/post_clear_check/tests/test_trs_reports.py

@@ -8,12 +8,14 @@ from post_clear_check.trs_common import (
     F_INTEREST,
     F_LEVERAGE,
     F_LEVERAGE_CONFIG,
+    F_MONTHLY_RATE,
     F_TOTAL,
     T_TRS001,
     T_TRS002,
     T_TRS004,
     T_TRS005,
     T_TRS006,
+    T_TRS007,
     T_TRS008,
     T_TRS009,
     T_TRS010,
@@ -78,6 +80,63 @@ def test_fund_in_out_matches_record_id() -> None:
     assert direction.verdict == "correct"
 
 
+def test_fund_in_out_matches_source_id() -> None:
+    """TRS 报表 ref 写的是入金/出金单 id,应对流水 source_id。"""
+
+    data = _data()
+    data["trs_users"]["U1"]["reports"][T_TRS009] = [
+        {"id": 5, "business_date": DAY, "client_code": "U1", "ref": "21", "amount": Decimal("999999.00"), "dw": "D"}
+    ]
+    data["trs_users"]["U1"]["fund_in_out_record"] = [
+        {
+            "id": 30,
+            "user_id": 1,
+            "source_id": 21,
+            "source_no": "DEP20261103090655179070",
+            "direction": "in",
+            "amount": Decimal("999999.00"),
+            "business_date": DAY,
+        }
+    ]
+    lines = check_trs_reports(data)
+    amount = _exact(lines, "金额 = 出入金流水的同号列")
+    direction = _exact(lines, "方向 = 出入金流水的方向")
+    assert amount.verdict == "correct"
+    assert amount.calc_value == Decimal("999999.00")
+    assert direction.verdict == "correct"
+
+
+def test_interest_collect_matches_period_keys() -> None:
+    """TRS007 利息收取金额对 fund_interest_collect 同区间同币种。"""
+
+    data = _data()
+    data["trs_users"]["U1"]["reports"][T_TRS007] = [
+        {
+            "id": 1,
+            "business_date": DAY,
+            "account": "U1",
+            "ccy": "HKD",
+            "period_start": date(2026, 10, 1),
+            "period_end": date(2026, 10, 31),
+            "period_interest": Decimal("891.56"),
+        }
+    ]
+    data["trs_users"]["U1"]["fund_interest_collect"] = [
+        {
+            "id": 1,
+            "user_id": 1,
+            "settle_ccy": "HKD",
+            "period_start": date(2026, 10, 1),
+            "period_end": date(2026, 10, 31),
+            "amount": Decimal("891.56"),
+            "business_date": DAY,
+        }
+    ]
+    line = _exact(check_trs_reports(data), "利息收取金额 = 利息收取事实")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("891.56")
+
+
 def test_profile_uses_cleared_fund_and_position_market_value() -> None:
     data = _data()
     data["trs_users"]["U1"]["clr_fund_account"] = [
@@ -226,21 +285,48 @@ def test_zero_principal_uses_account_leverage() -> None:
     assert line.check_value == Decimal("2.0000")
 
 
-def test_null_finance_rate_skips_interest_line() -> None:
+def test_null_finance_rate_with_cost_is_error() -> None:
+    """非零持仓成本且 finance_rate 为空时,按导出门槛记错误。"""
+
     data = _data()
     data["trs_users"]["U1"]["reports"][T_TRS016] = [
         {
             "id": 9,
             "nature_date": DAY,
             "account": "U1",
+            "settle_ccy": "HKD",
             "holding_cost": Decimal("365"),
             "finance_rate": None,
-            "interest": Decimal("1"),
+            "interest": None,
+        }
+    ]
+    lines = check_trs_reports(data)
+    formulas = [line.formula_text for line in lines]
+    assert F_INTEREST not in formulas
+    rate = _exact(lines, F_MONTHLY_RATE)
+    assert rate.verdict == "error"
+    assert "2026-10缺少HKD有效月度融资利率,无法导出TRS016" in rate.calc_text
+    assert all("TRS015" not in text for line in lines for text in (line.formula_text, line.check_text))
+
+
+def test_null_finance_rate_with_zero_cost_skips_interest_line() -> None:
+    """零成本行导出会跳过,利率为空不记错误。"""
+
+    data = _data()
+    data["trs_users"]["U1"]["reports"][T_TRS016] = [
+        {
+            "id": 9,
+            "nature_date": DAY,
+            "account": "U1",
+            "settle_ccy": "HKD",
+            "holding_cost": Decimal("0"),
+            "finance_rate": None,
+            "interest": None,
         }
     ]
     formulas = [line.formula_text for line in check_trs_reports(data)]
     assert F_INTEREST not in formulas
-    assert all("TRS015" not in text for line in check_trs_reports(data) for text in (line.formula_text, line.check_text))
+    assert F_MONTHLY_RATE not in formulas
 
 
 def test_interest_without_year_days_is_suspect() -> None:

+ 3 - 3
doc/业务链路核对设计.md

@@ -236,14 +236,14 @@
 ### 4.8 增额
 
 - 归属:资金账户。取增额所在子票据的 `fund_account_ref`。
-- 人口:本批发行 `input/0` 里 `change_type=top_up` 且归属本营业日的增额,以及实时表里同一归属日的增额。卖出释放(`sell_release`)是卖出订单的派生记录,不进这条链路。不读实时 `after_add_amt` 当起点。
+- 人口:本批发行 `input/0` 里 `change_type=top_up` 且归属本营业日的增额,以及实时表里同一归属日的增额。卖出释放(`sell_release`)是卖出订单的派生记录,**不进冻结主键集合**,但增额累计要把同日 `sell_release` 发生额计入。不读实时 `after_add_amt` 当起点。
 - 涉及:发行端清算步骤2 备份清算前数据;发行端清算步骤3 订单及票据处理。
 - 现有核对:`issue_event.check_topup`。不读 `report_topup_payment`,不出 OTC009 报表行。
 
 | 节点 | 应不应该 | 对不对 | 依据 |
 |---|---|---|---|
 | 增额冻结集合 | 归属本营业日的 `top_up` 必须同时出现在 `input/0` 和实时表 | 两边主键集合相同。多出的实时主键是冻结漏记,多出的冻结主键是实时已没有这条记录 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
-| 增额累计 | 增额对应的子票据必须出现在 `work/2` 和 `work/3` | `work/3` 增额 = `work/2` 增额 + 本批 `input` 里该子票据本营业日 `top_up` 发生额之和。`work/4` 与 `cleared/0` 的增额等于 `work/3`。不使用实时增额减去后续发生额 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
+| 增额累计 | 增额或同日卖出释放对应的子票据必须出现在 `work/2` 和 `work/3` | `work/3` 增额 = `work/2` 增额 + 本批 `input` 里该子票据本营业日 `top_up` 与 `sell_release` 发生额之和。`work/4` 与 `cleared/0` 的增额等于 `work/3`。不使用实时增额减去后续发生额 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
 | OTC009 报表行 | 不支持 | — | 实时导出,没有落库结果,清算后无法复核。研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.4 仍然不进范围」 |
 
 Excel 没有增额的计算公式(「票据」第 14 行「增额金额」只有说明),本条链路的依据是研发设计。
@@ -300,7 +300,7 @@ Excel 没有增额的计算公式(「票据」第 14 行「增额金额」只
 | 计息 | 有融资利率时必须出现 | 应付利息 = 计息金额 × 融资利率 ÷ 100 ÷ 365。报表年化是百分数,3.2542 表示 3.2542%。利率变动、利息收取与明细一致 | 同文件「重点字段与计算公式」应付利息;产品原型 TRS007、TRS012、TRS013、TRS016;研发设计 `docs/清算/三端清算最终实施方案.md`「4.3 用户汇率和应付利息」 |
 | T日债务 | 本批 `interest_input` 的 `debt` 行必须保留 T 日债务 | 等于计息日不晚于 T、且未收取或收取营业日晚于 T 的利息合计。T+1 已收取的明细仍算在这笔债务里,不能因为当前状态已是 collected 就冲掉 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.3 TRS(10 步)」 |
 | 汇率调整 | 差额不为 0 时本批快照必须有一条 `clearing_fund_ledger`;差额为 0 时必须没有 | 金额等于 T 日 cleared 成交现金减 T 日原始买卖凭证。成交现金按卖出加 `amount_settle`、买入减 `amount_settle`。原始凭证只取封存快照里的 `stock_buy`、`stock_sell`,不读实时 `fund_ledger`。后续成交两边都不进 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.3 TRS(10 步)」 |
-| 资金账户 | 报表有该币种时,cleared 必须有该用户该币种 | 本金、融资、可用、冻结是第 5 步写上的列。第 6 步不替换这张表。TRS001 可用金额等于 `clr_fund_account.available`,冻结金额等于 `frozen`。TRS001 与 TRS014 的持仓市值等于该用户该结算币种 `clr_user_position.market_value` 合计,不乘清算价。TRS016 持仓成本金额等于该用户该结算币种 `cost_amount` 合计。TRS010 现金结余等于本行承前现金、出入金额、实际盈亏、已付利息之和。TRS013 当日应付利息等于(融资金额 + 授信借贷 + 信用借贷)× 年化 / 100 / 365,年化按百分数;`status=uncollected` 且计息日为本营业日的应付利息必须有报表行 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.3 TRS(10 步)」;产品原型 TRS001、TRS010、TRS013、TRS014、TRS016 |
+| 资金账户 | 报表有该币种时,cleared 必须有该用户该币种 | 本金、融资、可用、冻结是第 5 步写上的列。第 6 步不替换这张表。TRS001 可用金额等于 `clr_fund_account.available`,冻结金额等于 `frozen`。TRS001 与 TRS014 的持仓市值等于该用户该结算币种 `clr_user_position.market_value` 合计,不乘清算价。TRS016 持仓成本金额等于该用户该结算币种 `cost_amount` 合计;有非零持仓成本时 `finance_rate` 不得为空(与区间导出「未维护月度利率则拒绝导出」一致,缺利率记错误)。TRS010 现金结余等于本行承前现金、出入金额、实际盈亏、已付利息之和。TRS013 当日应付利息等于(融资金额 + 授信借贷 + 信用借贷)× 年化 / 100 / 365,年化按百分数;`status=uncollected` 且计息日为本营业日的应付利息必须有报表行 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.3 TRS(10 步)」;产品原型 TRS001、TRS010、TRS013、TRS014、TRS016 |
 | 候选资金 | 封存的非交易事实必须出现 | 营业日不晚于 T。源表里营业日晚于 T 的非交易流水、持仓流水和基础调整不得出现在本批快照 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.3 TRS(10 步)」 |
 | 净资产 | 必须出现 | 总资产 − 融资金额 − 应付利息(授信、信用四项本期不做,不单造公式) | 同文件「重点字段与计算公式」净资产 |
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
doc/使用说明.md


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff