Explorar o código

fix#误报修复.

hefuzheng hai 1 semana
pai
achega
b2657c494d
Modificáronse 27 ficheiros con 732 adicións e 85 borrados
  1. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/columns.cpython-312.pyc
  2. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_otc003.cpython-312.pyc
  3. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_otc004.cpython-312.pyc
  4. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/note_chain.cpython-312.pyc
  5. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_rules.cpython-312.pyc
  6. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-312.pyc
  7. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sell_execution.cpython-312.pyc
  8. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-312.pyc
  9. BIN=BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_trade.cpython-312.pyc
  10. 18 1
      code/post_clear_check/src/post_clear_check/columns.py
  11. 0 13
      code/post_clear_check/src/post_clear_check/issue_otc003.py
  12. 129 2
      code/post_clear_check/src/post_clear_check/issue_otc004.py
  13. 58 13
      code/post_clear_check/src/post_clear_check/note_chain.py
  14. 83 10
      code/post_clear_check/src/post_clear_check/order_rules.py
  15. 54 0
      code/post_clear_check/src/post_clear_check/reader.py
  16. 1 0
      code/post_clear_check/src/post_clear_check/sell_execution.py
  17. 159 30
      code/post_clear_check/src/post_clear_check/trs_flow.py
  18. 3 0
      code/post_clear_check/src/post_clear_check/trs_trade.py
  19. BIN=BIN
      code/post_clear_check/tests/__pycache__/test_issue_reports.cpython-312-pytest-9.1.1.pyc
  20. BIN=BIN
      code/post_clear_check/tests/__pycache__/test_note_chain.cpython-312-pytest-9.1.1.pyc
  21. BIN=BIN
      code/post_clear_check/tests/__pycache__/test_order_rules.cpython-312-pytest-9.1.1.pyc
  22. BIN=BIN
      code/post_clear_check/tests/__pycache__/test_trs_reports.cpython-312-pytest-9.1.1.pyc
  23. 27 4
      code/post_clear_check/tests/test_issue_reports.py
  24. 43 2
      code/post_clear_check/tests/test_note_chain.py
  25. 46 7
      code/post_clear_check/tests/test_order_rules.py
  26. 108 0
      code/post_clear_check/tests/test_trs_reports.py
  27. 3 3
      doc/业务链路核对设计.md

BIN=BIN
code/post_clear_check/src/post_clear_check/__pycache__/columns.cpython-312.pyc


BIN=BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_otc003.cpython-312.pyc


BIN=BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_otc004.cpython-312.pyc


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


BIN=BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_rules.cpython-312.pyc


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


BIN=BIN
code/post_clear_check/src/post_clear_check/__pycache__/sell_execution.cpython-312.pyc


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


BIN=BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_trade.cpython-312.pyc


+ 18 - 1
code/post_clear_check/src/post_clear_check/columns.py

@@ -69,6 +69,8 @@ ORDER_SUB_COLUMNS = (
     "cum_amt_trade",
     "cum_amt_settle",
     "fx_settle",
+    # 订单名义本金是卖出执行名义本金的输入,不等于票据当前名义本金。
+    "notional_trade",
     "exec_notional_trade",
     "exec_notional_settle",
     "order_avg_px",
@@ -215,10 +217,13 @@ NOTE_SUB_COLUMNS = (
     "cur_px",
     "init_fx",
     "a_yield_ratio",
-    # 第 2 步新票据的期初用初始数量和初始价格,状态用来区分待生效。
+    # 第 2 步新票据的期初用初始数量和初始价格;买入完成后状态已是存续。
     "init_qty",
     "init_px",
     "note_status",
+    # 已卖出票据的最终估值、净值用合计卖出金额和合计卖出 A 类盈亏。
+    "sold_total_amt",
+    "sold_a_pnl",
 )
 
 # 增额、展期只读本批 input 冻结的事实,不读实时余额当起点。
@@ -245,6 +250,18 @@ EXTENSION_COLUMNS = (
     "approve_after_maturity",
     "approve_business_date",
 )
+# 到期日按事件主键重放:展期之后还可能有改到期日,不能只看最后一条展期任务。
+NOTE_EVENT_ARCHIVE_TABLE = "db_issue_clear.clr_note_event"
+NOTE_EVENT_COLUMNS = (
+    "id",
+    "note_id",
+    "sub_note_no",
+    "event_type",
+    "before_date",
+    "after_date",
+    "business_date",
+    "source_key",
+)
 # 当前投影和销售版本比对用实时票据,不拿它当 T 日结果。
 LIVE_NOTE_SUB_TABLE = "db_issue.note_sub_a"
 LIVE_NOTE_MASTER_TABLE = "db_issue.note_master"

+ 0 - 13
code/post_clear_check/src/post_clear_check/issue_otc003.py

@@ -49,7 +49,6 @@ _SELL_TOTAL = (
     "底层资产盈亏 × 收益比例,否则为 MAX(0, 面值 + 增额 + 底层资产盈亏)"
 )
 _WRITE_DEAL = "成交金额(结算币种) = 总金额(结算币种)"
-_DEAL_GAP = "说明中的成交金额(结算币种)与发行端写入不一致"
 
 # 子单列在结论文字里用中文,不把物理列名写进卡片。
 _SOURCE_CN = {
@@ -174,18 +173,6 @@ def _same_sell(lines: list[CheckLine], row: Mapping[str, Any], order_no: str | N
         order_no=order_no,
         scale=SCALE_AMT,
     )
-    present, number = present_number(row, "deal_amt_settle")
-    emit(
-        lines,
-        formula=_DEAL_GAP,
-        basis="发行端写入为成交金额(结算币种) = 总金额(结算币种),与说明不一致,不改写入等式",
-        calc_text="发行端写入为成交金额(结算币种) = 总金额(结算币种)",
-        calc_value=None,
-        check_text=_named(present, number, "成交金额(结算币种)", order_no, SCALE_AMT),
-        check_value=number if present else None,
-        verdict=VERDICT_SUSPECT,
-        scale=SCALE_AMT,
-    )
     _subtract(
         lines,
         formula="总金额(结算币种) = 成交金额(结算币种) − 发行费用(结算币种)",

+ 129 - 2
code/post_clear_check/src/post_clear_check/issue_otc004.py

@@ -48,10 +48,12 @@ _ORDER_STATUSES = frozenset({"completed", "settled"})
 _ISSUE_PRICE = "票据发行价(结算币种) = 当前票据面值(结算币种) + 发行费用(结算币种)"
 _FLOAT = "浮动盈亏(结算币种) = 票据估值(结算币种) − 当前票据面值(结算币种) − 增额金额(结算币种)"
 _NAV = "票据价格/净值 = 票据估值(结算币种) / (当前票据面值(结算币种) + 增额金额(结算币种)) × 100"
+_SOLD_NAV = "票据价格/净值 = 合计卖出金额 / (合计卖出金额 − 合计卖出A类盈亏) × 100"
 _VALUATION = (
     "票据估值(结算币种) = 底层资产浮动盈亏 > 0 时当前票据面值 + 增额金额 + "
     "底层资产浮动盈亏 × 收益比例,否则 MAX(0, 当前票据面值 + 增额金额 + 底层资产浮动盈亏)"
 )
+_SOLD_VALUATION = "票据估值(结算币种) = 合计卖出金额"
 _DIFF = "OTC005 共赢票据实时详情报表与当日 OTC004 共赢票据每日切片详情报表同列"
 
 # 副本列与报表列同名。标度按金额、股数、汇率、价格分开。
@@ -175,7 +177,7 @@ def _append_detail(
     sources: Mapping[str, str],
 ) -> None:
     _float_pnl(lines, report_name, row)
-    _nav(lines, report_name, row)
+    _nav(lines, report_name, row, note)
     _issue_price(lines, report_name, row, note_no, ctx)
     _copy_columns(lines, report_name, row, note, ambiguous, sources, ctx)
     _valuation(lines, report_name, row, note, ambiguous, sources, ctx)
@@ -224,7 +226,16 @@ def _float_pnl(lines: list[CheckLine], report_name: str, row: Mapping[str, Any])
     )
 
 
-def _nav(lines: list[CheckLine], report_name: str, row: Mapping[str, Any]) -> None:
+def _nav(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None = None,
+) -> None:
+    # 已卖出:净值 = 合计卖出金额 / (合计卖出金额 − 合计卖出 A 类盈亏) × 100。
+    if row.get("note_status") == "sold":
+        _sold_nav(lines, report_name, row, note)
+        return
     value_ok, valuation = present_number(row, "valuation")
     face_ok, face = present_number(row, "cur_face_value")
     add_ok, added = present_number(row, "add_amt")
@@ -393,6 +404,118 @@ def _copy_columns(
         )
 
 
+def _sold_valuation(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    sources: Mapping[str, str],
+    ctx: IssueContext,
+) -> None:
+    """已卖出票据估值等于合计卖出金额,与日切报表 calculate_final_summary 一致。"""
+
+    total = read_note_cell(note, "sold_total_amt", ctx.logs, sources)
+    if total.absent or total.number is None:
+        # 报表行上的合计卖出金额是写出结果,副本缺失时退回同行。
+        ok, number = present_number(row, "sold_total_amt")
+        calc = number if ok else None
+        text = (
+            "合计卖出金额缺失"
+            if calc is None
+            else f"合计卖出金额 = {format_decimal(calc, SCALE_AMT)}"
+        )
+        live = False
+    else:
+        calc = quantize(total.number, SCALE_AMT)
+        text = f"合计卖出金额 = {format_decimal(calc, SCALE_AMT)}"
+        live = total.live
+    emit_amount(
+        lines,
+        formula=_SOLD_VALUATION,
+        basis="已卖出票据估值等于合计卖出金额",
+        calc_text=text,
+        calc_value=calc,
+        report_name=report_name,
+        column="票据估值(结算币种)",
+        row=row,
+        field="valuation",
+        order_no=None,
+        scale=SCALE_AMT,
+        live=live,
+        force=VERDICT_SUSPECT if calc is None else None,
+    )
+
+
+def _sold_nav(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+) -> None:
+    """已卖出净值 = 合计卖出金额 / (合计卖出金额 − 合计卖出 A 类盈亏) × 100。"""
+
+    total_ok, total = present_number(row, "sold_total_amt")
+    pnl = None
+    if note is not None:
+        pnl = as_decimal(note.get("sold_a_pnl"))
+    if pnl is None:
+        # 日切把 sold_a_pnl 写进浮动盈亏列,副本没有时用同行。
+        _pnl_ok, pnl = present_number(row, "float_pnl")
+    if not total_ok or total is None or pnl is None:
+        emit_amount(
+            lines,
+            formula=_SOLD_NAV,
+            basis="已卖出票据净值按合计卖出金额与合计卖出A类盈亏",
+            calc_text="合计卖出金额或合计卖出A类盈亏缺失",
+            calc_value=None,
+            report_name=report_name,
+            column="票据价格/净值",
+            row=row,
+            field="nav",
+            order_no=None,
+            scale=SCALE_PCT,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    base = quantize(total - pnl, SCALE_AMT)
+    if base <= 0:
+        emit_amount(
+            lines,
+            formula=_SOLD_NAV,
+            basis="已卖出净值分母必须大于 0",
+            calc_text=(
+                f"合计卖出金额 − 合计卖出A类盈亏 = {format_decimal(base, SCALE_AMT)};分母不可用"
+            ),
+            calc_value=None,
+            report_name=report_name,
+            column="票据价格/净值",
+            row=row,
+            field="nav",
+            order_no=None,
+            scale=SCALE_PCT,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    calc = quantize(total, SCALE_AMT) / base * Decimal(100)
+    text = (
+        f"{format_decimal(quantize(total, SCALE_AMT), SCALE_AMT)} / "
+        f"{format_decimal(base, SCALE_AMT)} × 100 = {format_decimal(calc, SCALE_PCT)}"
+    )
+    emit_amount(
+        lines,
+        formula=_SOLD_NAV,
+        basis="已卖出票据净值按合计卖出金额与合计卖出A类盈亏",
+        calc_text=text,
+        calc_value=calc,
+        report_name=report_name,
+        column="票据价格/净值",
+        row=row,
+        field="nav",
+        order_no=None,
+        scale=SCALE_PCT,
+    )
+
+
 def _valuation(
     lines: list[CheckLine],
     report_name: str,
@@ -402,6 +525,10 @@ def _valuation(
     sources: Mapping[str, str],
     ctx: IssueContext,
 ) -> None:
+    # 已卖出后当前股数和面值被置 0,估值改为合计卖出金额,不再走存续浮动盈亏。
+    if row.get("note_status") == "sold":
+        _sold_valuation(lines, report_name, row, note, sources, ctx)
+        return
     calc, text, live = _underlying_pnl(ctx, row, note, ambiguous, sources)
     if calc is None:
         emit_amount(

+ 58 - 13
code/post_clear_check/src/post_clear_check/note_chain.py

@@ -66,8 +66,8 @@ FORMULA_TOPUP_SUM = (
 )
 FORMULA_EXTENSION_SET = f"{EXTENSION_SET} = input 中审批归属本营业日的已通过展期主键集合等于实时同日主键"
 FORMULA_EXTENSION_DATE = (
-    f"{EXTENSION_DATE} = work/3 到期日等于这些展期的审批后到期日;没有则保持 work/2;"
-    "work/4 与 cleared 保持该到期日"
+    f"{EXTENSION_DATE} = work/3 到期日等于本营业日展期与改到期日事件按主键重放后的结果;"
+    "没有这类事件则保持 work/2;work/4 与 cleared 保持该到期日"
 )
 
 _STEP_ISSUE = "研发设计 docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md 「5.1 发行端(6 步)」"
@@ -254,7 +254,10 @@ def _opening(ctx: Any, stages: Mapping[str, Any], ident: int, produced: list[tup
 
 
 def _new_opening(work: Mapping[str, Any], frozen: Mapping[str, Any], stages: Mapping[str, Any]) -> list[str]:
-    """新票据的 work/2 当前列来自 input 的初始列,增额和分红清零。"""
+    """新票据的 work/2 当前列来自 input 的初始列,增额和分红清零。
+
+    买入完成后清算装入工作态时已写成存续,不再要求待生效。
+    """
 
     problems: list[str] = []
     for current, initial, title, scale in _NEW_FROM_INIT:
@@ -263,13 +266,13 @@ def _new_opening(work: Mapping[str, Any], frozen: Mapping[str, Any], stages: Map
         number = as_decimal(work.get(field))
         if number is None or quantize(number, scale) != quantize(Decimal(0), scale):
             problems.append(f"{title} 应为 0")
-    if work.get("note_status") != "pending":
-        problems.append("新票据状态应为待生效")
+    if work.get("note_status") != "active":
+        problems.append("新票据状态应为存续")
     master = _master(_table(stages, SCOPE_WORK2, "note_master"), work)
     if master is None:
         problems.append("新票据主表缺失")
-    elif master.get("status") != "pending":
-        problems.append("新票据主表状态应为待生效")
+    elif master.get("status") != "active":
+        problems.append("新票据主表状态应为存续")
     return problems
 
 
@@ -451,16 +454,15 @@ def _extension_date(
     work2 = _master_by_note(_table(stages, SCOPE_WORK2, "note_master"), note_no)
     work3 = _master_by_note(_table(stages, SCOPE_WORK3, "note_master"), note_no)
     if work2 is None or work3 is None:
-        if frozen_day:
+        if frozen_day or _maturity_events(stages, note_no):
             _emit(produced, fund, FORMULA_EXTENSION_DATE, _STEP_ISSUE, ["work/2 或 work/3 没有这张主票据"], f"票据 {note_no} 到期日")
         return
-    if not frozen_day:
+    expected = _expected_maturity(stages, note_no, work2, frozen_day)
+    actual = as_date(work3.get("maturity_date"))
+    if expected is None:
         problems = _date_gap(work3, work2, "maturity_date", "到期日", "work/3", "work/2")
     else:
-        last = max(frozen_day, key=lambda row: _ident(row.get("id")) or 0)
-        expected = as_date(last.get("approve_after_maturity"))
-        actual = as_date(work3.get("maturity_date"))
-        problems = [] if expected is not None and actual == expected else [f"到期日应为 {expected}"]
+        problems = [] if actual == expected else [f"到期日应为 {expected}"]
     if not problems:
         # 交收步骤不改到期日。后面的快照必须带上第 3 步的结果。
         for scope, title in ((SCOPE_WORK4, "work/4"), (SCOPE_CLEARED, "cleared")):
@@ -470,6 +472,49 @@ def _extension_date(
     _emit(produced, fund, FORMULA_EXTENSION_DATE, _STEP_ISSUE, problems, f"票据 {note_no} 到期日")
 
 
+def _expected_maturity(
+    stages: Mapping[str, Any],
+    note_no: str,
+    work2: Mapping[str, Any],
+    frozen_day: Sequence[Mapping[str, Any]],
+) -> date | None:
+    """按事件主键重放本营业日的展期和改到期日;没有事件时退回最后一条展期任务。"""
+
+    events = _maturity_events(stages, note_no)
+    if events:
+        expected = as_date(work2.get("maturity_date"))
+        for event in events:
+            after = as_date(event.get("after_date"))
+            if after is not None:
+                expected = after
+        return expected
+    if not frozen_day:
+        return None
+    last = max(frozen_day, key=lambda row: _ident(row.get("id")) or 0)
+    return as_date(last.get("approve_after_maturity"))
+
+
+def _maturity_events(stages: Mapping[str, Any], note_no: str) -> list[Mapping[str, Any]]:
+    """本批 input 里影响这张票据到期日的事件,按主键升序。
+
+    clr_note_event 没有 note_no,用主表 id 回挂;子票据号以主票据号开头时也可对齐。
+    """
+
+    master = _master_by_note(_table(stages, SCOPE_WORK2, "note_master"), note_no)
+    note_id = None if master is None else _ident(master.get("id"))
+    found = [
+        row
+        for row in _table(stages, SCOPE_INPUT, "note_event")
+        if row.get("event_type") in {"extension", "change_maturity"}
+        and (
+            (note_id is not None and _ident(row.get("note_id")) == note_id)
+            or _text(row.get("sub_note_no")) == note_no
+            or str(row.get("sub_note_no") or "").startswith(f"{note_no}")
+        )
+    ]
+    return sorted(found, key=lambda row: _ident(row.get("id")) or 0)
+
+
 def _version_gaps(sales: Mapping[str, Any], live: Mapping[str, Any]) -> list[str]:
     """销售版本低于发行实时版本就必须已被覆盖;相等时当前列等于实时票据。"""
 

+ 83 - 10
code/post_clear_check/src/post_clear_check/order_rules.py

@@ -60,6 +60,7 @@ from post_clear_check.order_decimal import (
     quantize,
     quantize_optional,
     total_amount,
+    underlying_amount,
 )
 
 _ORDER_PREFIX = "ORD"
@@ -416,7 +417,7 @@ def _append_sell(
 ) -> None:
     label = row_label(sub)
     _exec_trade_line(lines, sub, logs, sources, label, sums, hedges)
-    _sell_qty_line(lines, sub, logs, sources, label)
+    _sell_qty_line(lines, sub, note, note_ambiguous, logs, sources, label)
     ratio, ratio_mode, ratio_live, ratio_error = _ratio(sub, note, note_ambiguous, logs, sources)
     face_calc, add_calc, dividend_calc = None, None, None
     face_live = add_live = dividend_live = ratio_live
@@ -457,7 +458,7 @@ def _append_sell(
     finish_number(
         lines,
         formula="底层资产盈亏 = 订单卖出股数 × 订单成交均价(结算汇率换到结算币种) − 订单卖出股数 × 票据期初价格(初始汇率换到结算币种) + 本次实现分红",
-        basis="两个汇率各自看交易币种金额、汇率、结算币种金额,不能共用一个方向",
+        basis="结算金额按交易金额乘汇率;两个汇率都用乘数方向",
         calc_text=pnl_text,
         calc_value=pnl_calc,
         cell=read_cell(sub, "underlying_pnl", "order_sub_a", logs, sources),
@@ -888,18 +889,35 @@ def _exec_trade_line(
             scale=SCALE_AMT,
         )
         return
-    # 订单名义本金(交易币种)只作为输入,不在这张主单的列上,不能用票据当前名义本金代替。
+    # 订单名义本金在子单 notional_trade,不能用票据当前名义本金代替。
+    order_notional = read_cell(sub, "notional_trade", "order_sub_a", logs, sources)
+    if order_notional.absent or order_notional.number is None:
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="订单名义本金(交易币种)缺失",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="exec_notional_trade",
+            scale=SCALE_AMT,
+            force=VERDICT_ERROR if check.absent else VERDICT_SUSPECT,
+        )
+        return
     finish_number(
         lines,
         formula=formula,
         basis=formula,
-        calc_text="订单名义本金(交易币种)不在主单上",
-        calc_value=None,
+        calc_text=(
+            f"订单名义本金(交易币种) = {format_decimal(order_notional.number, SCALE_AMT)}"
+        ),
+        calc_value=order_notional.number,
         cell=check,
         label=label,
         column="exec_notional_trade",
         scale=SCALE_AMT,
-        force=VERDICT_ERROR if check.absent else VERDICT_SUSPECT,
+        extra_live=order_notional.live,
     )
 
 
@@ -912,16 +930,53 @@ def _hedge_flat(hedges: Sequence[Mapping[str, Any]], sums: _Sums) -> bool:
 def _sell_qty_line(
     lines: list[CheckLine],
     sub: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    note_ambiguous: bool,
     logs: Sequence[tuple[str, str, Mapping[str, Any]]],
     sources: Mapping[str, str],
     label: str,
 ) -> None:
-    """最后一笔才改用剩余股数。一张主单看不出是不是最后一笔。"""
+    """整票一笔卖完时股数等于初始数量;多笔拆卖且看不出最后一笔时仍记可疑。"""
 
     check = read_cell(sub, "order_qty", "order_sub_a", logs, sources)
+    formula = "订单卖出股数 = 比例 × 当前票据股票数量"
+    if note_ambiguous or note is None:
+        finish_number(
+            lines,
+            formula=formula,
+            basis="最后一笔卖出用剩余股票数量;无法判断是不是最后一笔时,该条可疑",
+            calc_text="无法判断是不是最后一笔卖出",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="order_qty",
+            scale=SCALE_QTY,
+            force=VERDICT_SUSPECT if not check.absent else VERDICT_ERROR,
+        )
+        return
+    ratio, ratio_mode, ratio_live, _error = _ratio(sub, note, note_ambiguous, logs, sources)
+    # 执行名义本金等于初始名义本金且当前名义本金已扣成 0:这一笔就是整票卖完。
+    if ratio_mode == "full" and ratio is not None and quantize(ratio, SCALE_AMT) == quantize(Decimal(1), SCALE_AMT):
+        init_qty = read_note_cell(note, "init_qty", logs, sources)
+        if not init_qty.absent and init_qty.number is not None:
+            finish_number(
+                lines,
+                formula=formula,
+                basis="整票一笔卖完时订单卖出股数等于初始股票数量",
+                calc_text=(
+                    f"初始股票数量 = {format_decimal(init_qty.number, SCALE_QTY)}"
+                ),
+                calc_value=init_qty.number,
+                cell=check,
+                label=label,
+                column="order_qty",
+                scale=SCALE_QTY,
+                extra_live=ratio_live or init_qty.live,
+            )
+            return
     finish_number(
         lines,
-        formula="订单卖出股数 = 比例 × 当前票据股票数量",
+        formula=formula,
         basis="最后一笔卖出用剩余股票数量;无法判断是不是最后一笔时,该条可疑",
         calc_text="无法判断是不是最后一笔卖出",
         calc_value=None,
@@ -1065,8 +1120,26 @@ def _pnl_for_sub(
     needed = (qty, price, fx, init_px, init_fx)
     if dividend is None or any(cell.absent or cell.number is None for cell in needed):
         return None, "订单卖出股数、成交均价、汇率、期初价格或本次实现分红缺失", live
-    # 初始汇率没有交易币种金额和结算币种金额这一组三列,不能借用结算汇率的方向。
-    return None, "初始汇率无法从交易币种金额、初始汇率、结算币种金额确定方向", live
+    # 结算金额 = 交易金额 × 汇率,两个汇率都按乘数方向,与票据底层浮动盈亏同一口径。
+    calc = underlying_amount(
+        qty.number,
+        price.number,
+        fx.number,
+        "mul",
+        init_px.number,
+        init_fx.number,
+        "mul",
+        dividend,
+    )
+    if calc is None:
+        return None, "底层资产盈亏无法计算", live
+    text = (
+        f"{format_decimal(qty.number, SCALE_QTY)} × {format_decimal(price.number, SCALE_PX)} × "
+        f"{format_decimal(fx.number, SCALE_FX)} − {format_decimal(qty.number, SCALE_QTY)} × "
+        f"{format_decimal(init_px.number, SCALE_PX)} × {format_decimal(init_fx.number, SCALE_FX)} + "
+        f"{format_decimal(dividend, SCALE_AMT)} = {format_decimal(quantize(calc, SCALE_AMT), SCALE_AMT)}"
+    )
+    return calc, text, live
 
 
 def _total_line(

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

@@ -70,6 +70,8 @@ from post_clear_check.columns import (
     TOPUP_ARCHIVE_TABLE,
     TOPUP_COLUMNS,
     TOPUP_LIVE_TABLE,
+    NOTE_EVENT_ARCHIVE_TABLE,
+    NOTE_EVENT_COLUMNS,
     ORDER_MASTER_COLUMNS,
     ORDER_NO_LIKE,
     ORDER_STATUSES,
@@ -1041,6 +1043,7 @@ _CLR_ORDER_COLUMNS = (
     "src_id",
     "entrust_no",
     "user_id",
+    "sec_code",
     "trade_date",
     "direction",
     "fx_settle",
@@ -1053,6 +1056,8 @@ _CLR_ORDER_COLUMNS = (
     "price",
     "entrust_qty",
     "parent_order_id",
+    # 算法子单经 child 回挂母单号,成交金额按母单累加子单成交回报。
+    "algo_child_id",
 )
 _CLR_FILL_COLUMNS = (
     "src_id",
@@ -1066,6 +1071,7 @@ _CLR_FILL_COLUMNS = (
     "fill_qty",
     "fill_px",
     "fill_amount",
+    "realized_pnl_settle",
 )
 _CLR_FUND_COLUMNS = (
     "user_id",
@@ -1159,6 +1165,8 @@ async def _load_trs_steps(
         user_ids,
         missing,
     )
+    # 清算委托没有 algo_no 列,按 algo_child_id 回挂,供算法母单累加成交金额。
+    await _stamp_algo_no(gateway, orders, missing)
     funds = await _select_in(
         gateway,
         "db_trs.clr_fund_account",
@@ -1497,6 +1505,40 @@ def _order_view(row: Mapping[str, Any]) -> dict[str, Any]:
     return copied
 
 
+async def _stamp_algo_no(
+    gateway: QueryGateway,
+    orders: Sequence[Mapping[str, Any]],
+    missing: list[str],
+) -> None:
+    """给清算委托补上算法母单号。没有子单 id 的普通委托保持不动。"""
+
+    child_ids = _ids(row.get("algo_child_id") for row in orders)
+    if not child_ids:
+        return
+    children = await _select_in(
+        gateway,
+        TRADE_ALGO_CHILD_TABLE,
+        TRADE_ALGO_CHILD_COLUMNS,
+        "",
+        (),
+        "id",
+        child_ids,
+        missing,
+    )
+    algo_of_child = {
+        ident: algo_no
+        for row in children
+        if (ident := _int_id(row.get("id"))) is not None
+        and (algo_no := _text(row.get("algo_no"))) is not None
+    }
+    for row in orders:
+        if not isinstance(row, dict):
+            continue
+        algo_no = algo_of_child.get(_int_id(row.get("algo_child_id")) or -1)
+        if algo_no is not None:
+            row["algo_no"] = algo_no
+
+
 def _fill_view(row: Mapping[str, Any]) -> dict[str, Any]:
     """成交报表用的列名和镜像列名不同,读入时改成报表侧的名字。"""
 
@@ -1509,6 +1551,7 @@ def _fill_view(row: Mapping[str, Any]) -> dict[str, Any]:
         "filled_qty": row.get("fill_qty"),
         "filled_price": row.get("fill_px"),
         "filled_amount": row.get("fill_amount"),
+        "realized_pnl_settle": row.get("realized_pnl_settle"),
         "trade_date": row.get("trade_date"),
         "direction": row.get("direction"),
         "fx_settle": row.get("fx_settle"),
@@ -2286,10 +2329,21 @@ async def _load_note_chain_rows(
         input_args + ("approved", NOT_DELETED),
         missing,
     )
+    # 到期日按事件主键重放,展期后改到期日必须一起读进来。
+    events = await _select(
+        gateway,
+        NOTE_EVENT_ARCHIVE_TABLE,
+        NOTE_EVENT_COLUMNS,
+        f"{input_where} AND event_type IN (%s, %s)",
+        input_args + ("extension", "change_maturity"),
+        missing,
+    )
     if TOPUP_ARCHIVE_TABLE not in missing:
         _store_rows(issue_stages, scope_key("input", 0), "note_topup_log", topups)
     if EXTENSION_ARCHIVE_TABLE not in missing:
         _store_rows(issue_stages, scope_key("input", 0), "extension_task", extensions)
+    if NOTE_EVENT_ARCHIVE_TABLE not in missing:
+        _store_rows(issue_stages, scope_key("input", 0), "note_event", events)
     wanted = _staged_note_numbers(issue_stages) | _note_no_set(note_copies) | _note_no_set(topups) | _note_no_set(extensions)
     already: set[str] = set()
     for tables in issue_stages.values():

+ 1 - 0
code/post_clear_check/src/post_clear_check/sell_execution.py

@@ -21,6 +21,7 @@ from post_clear_check.order_cell import CheckLine
 from post_clear_check.order_decimal import SCALE_AMT, SCALE_FX, SCALE_PX, SCALE_QTY
 
 _FACT_COLUMNS = (
+    ("notional_trade", "订单名义本金", SCALE_AMT),
     ("exec_notional_trade", "成交名义本金", SCALE_AMT),
     ("order_avg_px", "订单成交均价", SCALE_PX),
     ("order_qty", "订单卖出股数", SCALE_QTY),

+ 159 - 30
code/post_clear_check/src/post_clear_check/trs_flow.py

@@ -14,11 +14,13 @@ from typing import Any
 from post_clear_check.issue_common import as_date, on_business_date, present_number
 from post_clear_check.order_cell import CheckLine, biz_text, finish
 from post_clear_check.order_decimal import (
+    SCALE_PX,
     VERDICT_CORRECT,
     VERDICT_ERROR,
     VERDICT_SUSPECT,
     as_decimal,
     format_decimal,
+    quantize,
 )
 from post_clear_check.trs_common import (
     SCALE_AMT,
@@ -413,38 +415,165 @@ def _fact_amount(
 
 
 def _realized(ctx: TrsContext, lines: list[CheckLine]) -> None:
-    for account, row in report_rows(ctx, T_TRS008):
-        user = ctx.users[account]
-        key, sources = rows_with_column(
-            user,
-            "realized_pnl_settle",
-            ("clr_trade_fill", "trade_fill", "clr_trade_order", "trade_order"),
-        )
-        sources = _align(row, sources, ("sec_code", "settle_ccy"))
-        order_no = linked_order(ctx, row) or _order_from_sources(ctx, sources)
-        where = where_text(T_TRS008, account, order_no)
-        if key is None or not sources:
-            calc_text, calc_value = "取数结果没有同号列 realized_pnl_settle", None
-        else:
-            calc_value = _sum(sources, "realized_pnl_settle")
-            calc_text = (
-                f"{key}.realized_pnl_settle 缺失"
-                if calc_value is None
-                else f"{key}.realized_pnl_settle = {format_decimal(calc_value, SCALE_AMT)}"
+    """TRS008 一行对应一笔叶子卖出子单的成交已实现盈亏之和,不是按证券加总。"""
+
+    grouped = _by_account(report_rows(ctx, T_TRS008))
+    for account in sorted(set(grouped) | set(ctx.users)):
+        user = ctx.users.get(account)
+        if user is None:
+            continue
+        reports = grouped.get(account, [])
+        facts = _realized_facts(user)
+        used: set[int] = set()
+        for row in reports:
+            fact = _match_realized(row, facts, used)
+            where = where_text(
+                T_TRS008,
+                account,
+                linked_order(ctx, row) or (_text(fact.get("entrust_no")) if fact else None),
             )
-        emit(
-            lines,
-            formula="已实现盈亏 = 平仓事实的同号列",
-            basis="平仓已实现盈亏等于平仓事实的盈亏",
-            calc_text=calc_text,
-            calc_value=calc_value,
-            report_name=TRS008_NAME,
-            column="实现盈亏",
-            row=row,
-            field="realized_pnl_settle",
-            where=where,
-            scale=SCALE_AMT,
+            if not facts and not user.has("clr_trade_fill") and not user.has("trade_fill"):
+                calc_text, calc_value = "取数结果没有同号列 realized_pnl_settle", None
+            elif fact is None:
+                calc_text, calc_value = "没有对上的平仓事实", None
+            else:
+                calc_value = as_decimal(fact.get("realized_pnl_settle"))
+                calc_text = (
+                    f"{fact.get('_source')}.realized_pnl_settle 缺失"
+                    if calc_value is None
+                    else (
+                        f"{fact.get('_source')}.realized_pnl_settle = "
+                        f"{format_decimal(calc_value, SCALE_AMT)}"
+                    )
+                )
+            emit(
+                lines,
+                formula="已实现盈亏 = 平仓事实的同号列",
+                basis="平仓已实现盈亏等于该叶子卖出子单成交回报的盈亏之和",
+                calc_text=calc_text,
+                calc_value=calc_value,
+                report_name=TRS008_NAME,
+                column="实现盈亏",
+                row=row,
+                field="realized_pnl_settle",
+                where=where,
+                scale=SCALE_AMT,
+            )
+
+
+def _realized_facts(user: UserView) -> list[dict[str, Any]]:
+    """按清算报表口径:叶子卖出子单聚合成交已实现盈亏。"""
+
+    order_key = "clr_trade_order" if user.has("clr_trade_order") else "trade_order"
+    fill_key = "clr_trade_fill" if user.has("clr_trade_fill") else "trade_fill"
+    if not user.has(order_key) or not user.has(fill_key):
+        return []
+    orders = user.rows(order_key)
+    fills = user.rows(fill_key)
+    parent_ids = {
+        int(order["parent_order_id"])
+        for order in orders
+        if order.get("parent_order_id") is not None
+    }
+    fills_by_entrust: dict[str, list[Mapping[str, Any]]] = {}
+    for fill in fills:
+        entrust = _text(fill.get("entrust_no"))
+        if entrust is not None:
+            fills_by_entrust.setdefault(entrust, []).append(fill)
+    facts: list[dict[str, Any]] = []
+    for order in orders:
+        # 方向枚举在库里是 sell;大小写兼容测试注入。
+        direction = _text(order.get("direction"))
+        if direction is None or direction.lower() != "sell":
+            continue
+        qty = as_decimal(order.get("filled_qty"))
+        if qty is None or qty <= 0:
+            continue
+        src_id = order.get("src_id", order.get("id"))
+        try:
+            if src_id is not None and int(src_id) in parent_ids:
+                continue
+        except (TypeError, ValueError):
+            pass
+        entrust = _text(order.get("entrust_no"))
+        if entrust is None:
+            continue
+        group = fills_by_entrust.get(entrust, [])
+        if not group:
+            continue
+        if any("realized_pnl_settle" not in fill for fill in group):
+            continue
+        pnl = _sum(group, "realized_pnl_settle")
+        facts.append(
+            {
+                "entrust_no": entrust,
+                "sec_code": order.get("sec_code"),
+                "filled_qty": qty,
+                "filled_price": order.get("filled_avg_px"),
+                "deal_date": order.get("trade_date"),
+                "realized_pnl_settle": pnl,
+                "_source": fill_key,
+            }
         )
+    return facts
+
+
+def _match_realized(
+    row: Mapping[str, Any],
+    facts: Sequence[Mapping[str, Any]],
+    used: set[int],
+) -> Mapping[str, Any] | None:
+    """报表行对叶子卖出事实。同证券同数量同均价可能多笔,按实现盈亏再收窄并各用一次。"""
+
+    candidates = [
+        fact
+        for fact in facts
+        if id(fact) not in used
+        and _same_token(fact.get("sec_code"), row.get("sec_code"))
+        and _same_qty(fact.get("filled_qty"), row.get("filled_qty"))
+        and _same_price(fact.get("filled_price"), row.get("filled_price"))
+        and _same_day(fact.get("deal_date"), row.get("deal_date"))
+    ]
+    if not candidates:
+        return None
+    report_pnl = as_decimal(row.get("realized_pnl_settle"))
+    if report_pnl is not None:
+        exact = [
+            fact
+            for fact in candidates
+            if as_decimal(fact.get("realized_pnl_settle")) is not None
+            and quantize(as_decimal(fact.get("realized_pnl_settle")), SCALE_AMT)
+            == quantize(report_pnl, SCALE_AMT)
+        ]
+        if exact:
+            candidates = exact
+    chosen = candidates[0]
+    used.add(id(chosen))
+    return chosen
+
+
+def _same_token(left: object, right: object) -> bool:
+    return _text(left) == _text(right) if _text(left) is not None and _text(right) is not None else left == right
+
+
+def _same_qty(left: object, right: object) -> bool:
+    a, b = as_decimal(left), as_decimal(right)
+    if a is None or b is None:
+        return left == right
+    return quantize(a, SCALE_QTY) == quantize(b, SCALE_QTY)
+
+
+def _same_price(left: object, right: object) -> bool:
+    a, b = as_decimal(left), as_decimal(right)
+    if a is None or b is None:
+        return left == right
+    return quantize(a, SCALE_PX) == quantize(b, SCALE_PX)
+
+
+def _same_day(left: object, right: object) -> bool:
+    if left is None or right is None:
+        return True
+    return as_date(left) == as_date(right)
 
 
 def _in_out(ctx: TrsContext, lines: list[CheckLine]) -> None:

+ 3 - 0
code/post_clear_check/src/post_clear_check/trs_trade.py

@@ -415,7 +415,10 @@ def _fill_amount(
     for fill in fills:
         if _text(fill.get("entrust_no")) not in entrusts:
             continue
+        # 读入时改成报表列名 filled_amount;原始表仍可能是 fill_amount。
         present, number = present_number(fill, "filled_amount")
+        if not present or number is None:
+            present, number = present_number(fill, "fill_amount")
         if not present or number is None:
             return None
         total += number

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


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


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


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


+ 27 - 4
code/post_clear_check/tests/test_issue_reports.py

@@ -112,8 +112,9 @@ def test_sell_write_total_can_be_correct_while_cross_source_is_error() -> None:
     assert cross.verdict == "error"
     assert cross.calc_value == Decimal("125.00")
     assert cross.check_value == Decimal("100.00")
-    gap = _exact(lines, "说明中的成交金额(结算币种)与发行端写入不一致")
-    assert gap.verdict == "suspect"
+    assert not any(
+        line.formula_text == "说明中的成交金额(结算币种)与发行端写入不一致" for line in lines
+    )
 
 
 def test_missing_otc003_row_is_error_only_for_population() -> None:
@@ -273,18 +274,40 @@ def test_two_sells_of_one_note_are_not_crossed() -> None:
 
 
 def test_sold_note_does_not_use_zeroed_face() -> None:
-    """已卖出后面值为 0。浮动盈亏和发行价不再拿这个 0 按存续公式去减。"""
+    """已卖出后面值为 0。浮动盈亏和发行价不再拿这个 0 按存续公式去减。
+
+    估值、净值改走合计卖出金额口径,不因当前面值为 0 报错。
+    """
 
     row = _detail_row(
         note_status="sold",
+        cur_qty=Decimal("0"),
         cur_face_value=Decimal("0"),
+        sold_total_amt=Decimal("1652315.30"),
         valuation=Decimal("1652315.30"),
         float_pnl=Decimal("-12501.70"),
+        nav=Decimal("99.25"),
         issue_price_settle=Decimal("1664867.00"),
     )
-    lines = check_issue_reports(_daily(row))
+    note = _note(
+        note_status="sold",
+        cur_qty=Decimal("0"),
+        cur_face_value=Decimal("0"),
+        sold_total_amt=Decimal("1652315.30"),
+        sold_a_pnl=Decimal("-12501.70"),
+    )
+    lines = check_issue_reports(_daily(row, note=note))
     assert not any(line.formula_text.startswith("浮动盈亏") for line in lines)
     assert not any(line.formula_text.startswith("票据发行价") for line in lines)
+    valuation = _exact(lines, "票据估值(结算币种) = 合计卖出金额")
+    assert valuation.verdict == "correct"
+    assert valuation.calc_value == Decimal("1652315.30")
+    nav = _exact(
+        lines,
+        "票据价格/净值 = 合计卖出金额 / (合计卖出金额 − 合计卖出A类盈亏) × 100",
+    )
+    assert nav.verdict == "correct"
+    assert nav.calc_value == Decimal("99.25")
 
 
 def test_sell_settlement_total_still_uses_sub_column() -> None:

+ 43 - 2
code/post_clear_check/tests/test_note_chain.py

@@ -28,7 +28,7 @@ def _sub(**overrides: object) -> dict[str, object]:
         "init_notional_trade": Decimal("100"),
         "init_qty": Decimal("10"),
         "init_face_value": Decimal("100"),
-        "note_status": "pending",
+        "note_status": "active",
     }
     row.update(overrides)
     return row
@@ -38,7 +38,7 @@ def _master(**overrides: object) -> dict[str, object]:
     row: dict[str, object] = {
         "id": 9,
         "note_no": "N1",
-        "status": "pending",
+        "status": "active",
         "maturity_date": MATURITY,
     }
     row.update(overrides)
@@ -246,6 +246,47 @@ def test_extension_moves_maturity_on_work_three() -> None:
     assert all(line.fund_account == "FA1" for line in lines)
 
 
+def test_change_maturity_after_extension_wins() -> None:
+    """展期之后还有改到期日时,按事件主键重放,不以最后一条展期任务为准。"""
+
+    final = date(2026, 11, 3)
+    stages = _stages(_sub(), _master())
+    stages["work/3"]["note_master"] = [_master(maturity_date=final)]
+    stages["work/4"]["note_master"] = [_master(maturity_date=final)]
+    stages["cleared/0"]["note_master"] = [_master(maturity_date=final)]
+    task = {
+        "id": 1,
+        "note_no": "N1",
+        "a_sub_note_no": "SN1",
+        "fund_account_ref": "FA1",
+        "status": "approved",
+        "approve_after_maturity": EXTENDED,
+        "approve_business_date": DAY,
+    }
+    stages["input/0"]["extension_task"] = [task]
+    stages["input/0"]["note_event"] = [
+        {
+            "id": 10,
+            "note_id": 9,
+            "sub_note_no": "SN1",
+            "event_type": "extension",
+            "after_date": EXTENDED,
+            "business_date": DAY,
+        },
+        {
+            "id": 11,
+            "note_id": 9,
+            "sub_note_no": "SN1",
+            "event_type": "change_maturity",
+            "after_date": final,
+            "business_date": DAY,
+        },
+    ]
+    lines = _lines(CHAIN_EXTENSION, "展期后到期日", issue_stages=stages, live_extensions=[task])
+    assert len(lines) == 1
+    assert lines[0].verdict == "correct"
+
+
 def test_yesterday_rolls_from_cleared_position() -> None:
     line = _lines(
         CHAIN_NOTE_HOLDING,

+ 46 - 7
code/post_clear_check/tests/test_order_rules.py

@@ -596,7 +596,7 @@ def test_flat_hedge_sets_exec_notional_to_zero() -> None:
     )
     other = _one(check_order(unfinished), "执行名义本金(交易币种)")
     assert other.verdict == "suspect"
-    assert "订单名义本金(交易币种)不在主单上" in other.calc_text
+    assert "订单名义本金(交易币种)缺失" in other.calc_text
 
 
 def test_sell_quantity_is_suspect_when_last_sale_is_unknown() -> None:
@@ -620,12 +620,51 @@ def test_sold_face_ratio_rounds_half_up() -> None:
     assert line.calc_value == Decimal("0.13")
 
 
-def test_underlying_pnl_does_not_share_fx_direction() -> None:
-    line = _one(check_order(_priced_sell()), "底层资产盈亏")
-    assert line.verdict == "suspect"
-    assert "初始汇率" in line.calc_text
-    total = _one(check_order(_priced_sell()), "总金额")
-    assert total.verdict == "suspect"
+def test_underlying_pnl_multiplies_both_fx() -> None:
+    """结算金额按交易金额乘汇率;两个汇率都用乘数方向。"""
+
+    order = _priced_sell()
+    # 4×2×3 − 4×2×1 + 2 = 18;面值 100、增额 5、收益 50% → 总金额 114
+    sub = order["order_sub_a"][0]
+    assert isinstance(sub, dict)
+    sub["underlying_pnl"] = Decimal("18.00")
+    sub["exec_total_amt_settle"] = Decimal("114.00")
+    sub["notional_trade"] = Decimal("50")
+    line = _one(check_order(order), "底层资产盈亏")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("18.00")
+    total = _one(check_order(order), "总金额")
+    assert total.verdict == "correct"
+    assert total.calc_value == Decimal("114.00")
+
+
+def test_exec_notional_reads_order_notional_trade() -> None:
+    order = _sell_order(exec_notional_trade=Decimal("50"))
+    sub = order["order_sub_a"][0]
+    assert isinstance(sub, dict)
+    sub["notional_trade"] = Decimal("50")
+    line = _one(check_order(order), "执行名义本金(交易币种)")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("50.00")
+
+
+def test_full_single_sale_qty_equals_init_qty() -> None:
+    order = _sell_order(
+        exec_notional_trade=Decimal("100"),
+        cur_notional_trade=Decimal("0"),
+        cur_face_value=Decimal("0"),
+    )
+    sub = order["order_sub_a"][0]
+    note = order["note_sub_a"][0]
+    assert isinstance(sub, dict) and isinstance(note, dict)
+    sub["notional_trade"] = Decimal("100")
+    sub["order_qty"] = Decimal("8")
+    note["init_notional_trade"] = Decimal("100")
+    note["init_qty"] = Decimal("8")
+    note["init_face_value"] = Decimal("200")
+    line = _one(check_order(order), "订单卖出股数")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("8.000000")
 
 
 def test_missing_sub_row_is_error() -> None:

+ 108 - 0
code/post_clear_check/tests/test_trs_reports.py

@@ -14,6 +14,7 @@ from post_clear_check.trs_common import (
     T_TRS004,
     T_TRS005,
     T_TRS006,
+    T_TRS008,
     T_TRS009,
     T_TRS010,
     T_TRS012,
@@ -342,6 +343,113 @@ def test_filled_amount_sums_fills_instead_of_multiplying_average() -> None:
     assert line.check_value == Decimal("100.00")
 
 
+def test_algo_master_filled_amount_sums_child_fills_not_master_column() -> None:
+    """算法母单成交金额按子单成交回报之和,不读母单 filled_amount 那一列。"""
+
+    data = _data(
+        hedges=[
+            {
+                "id": 7,
+                "src_order_no": "ORD1",
+                "hedge_account_no": "U1",
+                "trs_order_type": "algo",
+                "trs_algo_no": "ALGO1",
+            }
+        ]
+    )
+    data["trs_users"]["U1"]["trade_algo_master"] = [
+        {
+            "id": 14,
+            "algo_no": "ALGO1",
+            "ext_ref": "HR7",
+            "user_id": 1,
+            "filled_qty": Decimal("100"),
+            "filled_amount": Decimal("99.99"),
+        }
+    ]
+    data["trs_users"]["U1"]["clr_trade_order"] = [
+        {
+            "id": 3,
+            "src_id": 3,
+            "entrust_no": "E-1",
+            "algo_no": "ALGO1",
+            "algo_child_id": 1,
+            "user_id": 1,
+            "filled_qty": Decimal("100"),
+        }
+    ]
+    data["trs_users"]["U1"]["clr_trade_fill"] = [
+        {"id": 1, "entrust_no": "E-1", "filled_amount": Decimal("60.01")},
+        {"id": 2, "entrust_no": "E-1", "filled_amount": Decimal("39.99")},
+    ]
+    data["trs_users"]["U1"]["reports"][T_TRS002] = [
+        {
+            "id": 4,
+            "business_date": DAY,
+            "account": "U1",
+            "master_order_no": "ALGO1",
+            "filled_qty": Decimal("100"),
+            "filled_amount": Decimal("100.00"),
+            "entrust_qty": Decimal("100"),
+            "entrust_price": Decimal("1"),
+            "filled_price": Decimal("1"),
+            "occur_amount_trade": Decimal("100"),
+            "occur_amount_settle": Decimal("100"),
+        }
+    ]
+    line = _exact(check_trs_reports(data), "成交金额 = trade_order 或算法母单上同号的列")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("100.00")
+
+
+def test_realized_pnl_matches_leaf_sell_fill_sum() -> None:
+    """TRS008 一行对一笔叶子卖出子单,实现盈亏等于该子单成交回报之和。"""
+
+    data = _data()
+    data["trs_users"]["U1"]["clr_trade_order"] = [
+        {
+            "id": 3,
+            "src_id": 3,
+            "entrust_no": "E-SELL",
+            "user_id": 1,
+            "sec_code": "000001",
+            "direction": "sell",
+            "filled_qty": Decimal("100"),
+            "filled_avg_px": Decimal("11.48"),
+            "trade_date": DAY,
+        }
+    ]
+    data["trs_users"]["U1"]["clr_trade_fill"] = [
+        {
+            "id": 1,
+            "entrust_no": "E-SELL",
+            "filled_amount": Decimal("1148"),
+            "realized_pnl_settle": Decimal("-10.00"),
+        },
+        {
+            "id": 2,
+            "entrust_no": "E-SELL",
+            "filled_amount": Decimal("0"),
+            "realized_pnl_settle": Decimal("-7.79"),
+        },
+    ]
+    data["trs_users"]["U1"]["reports"][T_TRS008] = [
+        {
+            "id": 9,
+            "business_date": DAY,
+            "account": "U1",
+            "sec_code": "000001",
+            "deal_date": DAY,
+            "filled_qty": Decimal("100"),
+            "filled_price": Decimal("11.48"),
+            "realized_pnl_settle": Decimal("-17.79"),
+        }
+    ]
+    line = _exact(check_trs_reports(data), "已实现盈亏 = 平仓事实的同号列")
+    assert line.verdict == "correct"
+    assert line.calc_value == Decimal("-17.79")
+
+
 def test_available_change_matches_fund_ledger_by_id() -> None:
     """TRS006 的流水主键对上 fund_ledger.id,金额相等即正确。"""
 

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

@@ -224,10 +224,10 @@
 | 日切切片行 | 存续票据必须有 OTC004 行 | — | 产品原型 OTC004 |
 | 数量、面值、增额、分红 | 必须出现 | 等于本批 `cleared/0` 的当前值。没有这张票据的 `cleared` 行就是缺失,不改读实时表。最新价用本交易日 `cfg_daily_close_price` 收盘价,不用票据上的盘中最新价 | Excel「票据」第 6 行「票据股票数量」、第 9 行「票据面值」、第 14 行「增额金额」、第 15 行「分红金额」 |
 | 发行价 | 必须出现 | 按票据计算 | Excel「票据」第 10 行「票据发行价」 |
-| 底层浮动盈亏、估值、浮动盈亏、净值 | 必须出现 | 底层资产浮动盈亏 = 最新价 × 股票数量 × 账面汇率 − 期初价格 × 股票数量 × 初始汇率 + 分红金额。结算金额是交易金额乘汇率,不从名义本金和面值反推乘除。最新价用本交易日收盘价。估值按 YA 大于 0 时面值 + 增额 + YA × 收益比例,否则 MAX(0, 面值 + 增额 + YA)。报表浮动盈亏等于估值 − 面值 − 增额。已卖出的票据当前面值已被置 0,浮动盈亏和发行价不再按存续公式用当前面值。这些公式不读 `yesterday_*` | Excel「票据」第 21 行「底层资产浮动盈亏」、第 23 行「A类票据估值(结算币种)」、第 24 行「A类票据浮动盈亏」、第 26 行「A类票据净值」 |
+| 底层浮动盈亏、估值、浮动盈亏、净值 | 必须出现 | 底层资产浮动盈亏 = 最新价 × 股票数量 × 账面汇率 − 期初价格 × 股票数量 × 初始汇率 + 分红金额。结算金额是交易金额乘汇率,不从名义本金和面值反推乘除。最新价用本交易日收盘价。估值按 YA 大于 0 时面值 + 增额 + YA × 收益比例,否则 MAX(0, 面值 + 增额 + YA)。报表浮动盈亏等于估值 − 面值 − 增额。已卖出的票据当前面值已被置 0,浮动盈亏和发行价不再按存续公式用当前面值;估值改为合计卖出金额,净值改为合计卖出金额 / (合计卖出金额 − 合计卖出 A 类盈亏) × 100。这些公式不读 `yesterday_*` | Excel「票据」第 21 行「底层资产浮动盈亏」、第 23 行「A类票据估值(结算币种)」、第 24 行「A类票据浮动盈亏」、第 26 行「A类票据净值」 |
 | 已卖出合计 | 有卖出时必须出现 | 按卖出累计:该票据全部已完成或已结算的卖单,含往日卖单,交易日不晚于本轮交易日 | Excel「票据」第 27 行「已卖出票据面值」至第 30 行「合计卖出A类盈亏」 |
 | 实时详情 | 实时票据必须有 OTC005 行 | 没有后续事实时与当日 OTC004 同列一致。已有步骤快照时,差额改由当前投影节点解释,不再把后续增额、展期或卖出记成切片错误 | 产品原型 OTC005 |
-| 期初结转 | `work/2` 必须有结转票据和新票据 | 结转票据的当前数量、面值、增额、分红等于上一已发布 `cleared` 的同列。`input` 里的新票据,`work/2` 当前列等于初始列,增额和分红为 0,状态为待生效。首日开账不得出现结转票据。不读实时余额 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
+| 期初结转 | `work/2` 必须有结转票据和新票据 | 结转票据的当前数量、面值、增额、分红等于上一已发布 `cleared` 的同列。`input` 里的新票据,`work/2` 当前列等于初始列,增额和分红为 0,状态为存续(买入完成后清算装入工作态即写成 active)。首日开账不得出现结转票据。不读实时余额 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
 | T日票据结果 | `cleared/0` 与 `work/4` 都必须有该票据 | 数量、面值、增额、分红、到期日、状态一致。报表对 `cleared`,不对实时票据 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
 | 当前投影 | 实时票据必须存在 | 实时增额等于 `cleared` 增额加上归属日晚于本营业日的增额发生额;到期日等于更晚的已通过展期,没有则等于 `cleared`。没有交易日晚于本营业日的卖出时,数量和面值等于 `cleared`。有后续卖出时不在本链路重算卖出公式 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
 | 昨基线 | 本批 `cleared` 有对冲持仓时,实时持仓必须有同主键 | 实时 `yesterday_*` 等于本批 `cleared` 持仓的日终数量、成本和已实现分红。清算公式不读这几列 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
@@ -258,7 +258,7 @@ Excel 没有增额的计算公式(「票据」第 14 行「增额金额」只
 | 节点 | 应不应该 | 对不对 | 依据 |
 |---|---|---|---|
 | 展期冻结集合 | 审批归属本营业日的已通过展期必须同时出现在 `input/0` 和实时表 | 两边主键集合相同 | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
-| 展期后到期日 | 展期对应的主票据必须出现在 `work/2` 和 `work/3` | 有本营业日已通过展期时,`work/3` 到期日等于这些任务按主键顺序最后一条的 `approve_after_maturity`。没有时,`work/3` 到期日保持 `work/2`。`work/4` 与 `cleared/0` 的到期日等于 `work/3`。审批归属晚于本营业日的展期只进入当前投影,不改 `work/3` | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
+| 展期后到期日 | 展期对应的主票据必须出现在 `work/2` 和 `work/3` | 有本营业日展期或改到期日事件时,`work/3` 到期日等于这些事件按主键重放后的 `after_date`(与清算 `note_replay` 一致;展期之后仍可能被改到期日覆盖)。没有这类事件时,有已通过展期任务则取最后一条的 `approve_after_maturity`,否则 `work/3` 保持 `work/2`。`work/4` 与 `cleared/0` 的到期日等于 `work/3`。审批归属晚于本营业日的展期只进入当前投影,不改 `work/3` | 研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.1 发行端(6 步)」 |
 | OTC012 报表行 | 不支持 | — | 实时导出,没有落库结果,清算后无法复核。研发设计 `docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md`「5.4 仍然不进范围」 |
 
 ### 4.10 委托执行