From 67a2e49f21c0d01b483e166bfd7ebdafba2319e7 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 6 Sep 2026 10:53:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(live):=20=E6=88=90=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=8A=A5=E5=9B=9E=E6=89=A7=E2=91=A1=E5=9B=9E=E6=89=A7=E2=86=94?= =?UTF-8?q?=E8=B4=A6=E6=9C=AC=E9=97=AD=E7=8E=AF=E6=AF=94=E5=AF=B9=E5=99=A8?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=94=A8=E6=88=B7=E9=92=A6=E7=82=B9=E3=80=8C?= =?UTF-8?q?=E5=86=8D=E5=87=BA=E7=8E=B0=E6=97=B6=E6=A0=B9=E5=9B=A0=E5=8F=AF?= =?UTF-8?q?=E6=9F=A5=E3=80=8D:09-04=E5=AE=9E=E8=AF=81=E6=9F=9C=E5=8F=B0?= =?UTF-8?q?=E6=9C=89=E6=88=90=E4=BA=A4/=E5=8F=B0=E8=B4=A6=E6=97=A0?= =?UTF-8?q?=E8=A1=8C/=E5=BC=95=E6=93=8E=E4=BC=9A=E8=AF=9D=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E6=97=A0=E8=A1=8C=E4=B8=94=E6=97=B6=E9=97=B4=E9=80=80?= =?UTF-8?q?=E5=8C=96,=E3=80=8C=E6=8E=A8=E9=80=81=E5=88=B0=E8=BE=BE?= =?UTF-8?q?=E2=86=92=E5=9B=9E=E8=B0=83=E5=A4=84=E7=90=86=E2=86=92=E8=90=BD?= =?UTF-8?q?=E5=BA=93=E3=80=8D=E4=B8=89=E8=B7=B3=E5=85=A8=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=AE=9A=E4=BD=8D=E6=95=85=E9=9A=9C=E7=82=B9?= =?UTF-8?q?;=E8=A7=82=E6=B5=8B=E5=A5=91=E7=BA=A6=E4=B8=89=E5=B1=82=3D?= =?UTF-8?q?=E2=91=A0=E5=BC=95=E6=93=8E=E4=BE=A7trade-receipt=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E8=A1=8C(=E7=AD=96=E7=95=A5session=E5=AE=9E=E7=8E=B0,?= =?UTF-8?q?tag/=E5=AD=97=E6=AE=B5=E5=90=8D=E4=BB=A5=E6=9C=AC=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=A7=A3=E6=9E=90=E5=99=A8=E4=B8=BA=E5=87=86)+?= =?UTF-8?q?=E2=91=A1=E6=9C=AC=E6=A8=A1=E5=9D=97parse+compare:=E6=9C=89?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E6=97=A0=E8=B4=A6=E8=A1=8C=3D=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=A4=84=E7=90=86=E4=B8=A2(=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=AF=81=E6=8D=AE),=E6=9F=9C=E5=8F=B0=E6=9C=89=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E6=97=A0=E5=9B=9E=E6=89=A7=3D=E6=9F=9C=E5=8F=B0?= =?UTF-8?q?=E6=B2=A1=E6=8E=A8(EOD=E6=81=92=E7=AD=89=E5=BC=8F=E5=B1=82),?= =?UTF-8?q?=E2=91=A2=E8=B4=A6=E6=9C=AC=E8=A1=8C=E5=B8=A6=E6=9F=9C=E5=8F=B0?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E5=8F=B7=E7=A1=AC=E9=94=AE(=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E4=BC=9A=E9=80=80=E5=8C=96=E7=9A=84=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E4=BA=94=E5=85=83=E7=BB=84);=E5=8E=9F=E5=88=99=E5=AE=81?= =?UTF-8?q?=E7=BC=BA=E5=8B=BF=E9=94=99:tid=3DNONE=E4=B8=8D=E5=81=9A?= =?UTF-8?q?=E6=A8=A1=E7=B3=8A=E5=8C=B9=E9=85=8D=E7=A1=AC=E5=87=91,?= =?UTF-8?q?=E5=A6=82=E5=AE=9E=E5=88=86=E5=88=97;ledger=5Fonly=E4=B8=BA?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E9=A1=B9(=E5=9B=9E=E6=89=A7=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E5=89=8D=E6=97=A7=E8=A1=8C/repair=E8=A1=8C)=E4=B8=8D?= =?UTF-8?q?=E5=88=A4=E8=84=8F;TDD=208=E6=B5=8B=E5=85=88=E7=BA=A2(=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=B8=8D=E5=AD=98=E5=9C=A8)=E5=90=8E=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_trader/shadow/trade_receipt.py | 101 +++++++++++++++++++++++ tests/trader/test_trade_receipt.py | 112 ++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 sanguo_trader/shadow/trade_receipt.py create mode 100644 tests/trader/test_trade_receipt.py diff --git a/sanguo_trader/shadow/trade_receipt.py b/sanguo_trader/shadow/trade_receipt.py new file mode 100644 index 0000000..6b5b260 --- /dev/null +++ b/sanguo_trader/shadow/trade_receipt.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""成交回报回执:②解析 [trade-receipt] 指纹行 + 回执↔账本闭环比对。 + +背景(2026-09-04 实证):柜台有成交、台账无行、引擎会话成交视图也无行且 +时间字段退化——「推送到达→回调处理→落库」三跳全静默,故障点无法定位。 +观测契约: + ① 引擎侧(策略 session 实现):成交回调任何处理之前打一行指纹,永不抛: + [trade-receipt] tid={柜台成交号} order={订单号} acc={账号} sym={代码} + vol={量} px={价} t_raw={回报自带时间,无则NONE} recv={本地时刻} + ② 本模块:解析该行,与 live_trades 当日行闭环—— + 有回执无账行=引擎处理丢(②③之间);柜台有成交且无回据=柜台没推(由 + EOD 恒等式/守恒缺口负责,不在此层)。 + ③ 账本行带柜台成交号(硬键),判重/补插不再依赖会退化的时间字段。 + +设计原则:宁缺勿错——tid=NONE 的行不做模糊匹配硬凑,如实分列。 +""" + +import re +from typing import Any, Dict, List + +# 回执行锚:tag 全文匹配,字段名与①契约严格一致(勿改拼写) +_RECEIPT_RE = re.compile( + r"\[trade-receipt\]\s+tid=(\S+)\s+order=(\S+)\s+acc=(\S+)\s+sym=(\S+)\s+" + r"vol=(\S+)\s+px=(\S+)\s+t_raw=(\S+)\s+recv=(\S+)" +) + +_NONE = {"NONE", "None", "none", ""} + + +def _or_none(v: str) -> Any: + return None if v in _NONE else v + + +def _to_float(v: str) -> Any: + try: + return float(v) + except (TypeError, ValueError): + return None + + +def parse_trade_receipts(log_text: str) -> List[Dict[str, Any]]: + """从引擎日志文本解析全部回执行;同 tid 重复回执去重并计 dup_count。""" + seen: Dict[str, Dict[str, Any]] = {} + order_keys: List[str] = [] + for m in _RECEIPT_RE.finditer(log_text): + tid = _or_none(m.group(1)) + key = tid if tid is not None else "_orphan_%d" % len(order_keys) + if key in seen: + seen[key]["dup_count"] += 1 + continue + row = { + "tid": tid, + "order": _or_none(m.group(2)), + "acc": _or_none(m.group(3)), + "sym": _or_none(m.group(4)), + "vol": _to_float(m.group(5)), + "px": _to_float(m.group(6)), + "t_raw": _or_none(m.group(7)), + "recv": _or_none(m.group(8)), + "dup_count": 1, + } + seen[key] = row + order_keys.append(key) + return [seen[k] for k in order_keys] + + +def compare_receipts_with_ledger(receipts: List[Dict[str, Any]], + ledger: List[Dict[str, Any]]) -> Dict[str, Any]: + """回执(①的产物)与账本行(live_trades 映射,带 tid)闭环比对。 + + ledger 行键约定:tid/symbol/volume/price(由调用方从 DB 行映射)。 + matched 只认 tid 硬键;tid 缺失双方不猜,各自入 receipt_only/ledger_only。 + ledger_only 为信息项(回执上线前的旧数据/repair 行),不判脏; + receipt_only 才是警报——「到了没记」即引擎处理跳丢失的直接证据。 + """ + ledger_by_tid: Dict[str, Dict[str, Any]] = { + r["tid"]: r for r in ledger if r.get("tid") is not None + } + receipt_only: List[Dict[str, Any]] = [] + matched = 0 + for r in receipts: + tid = r.get("tid") + if tid is not None and tid in ledger_by_tid: + matched += 1 + else: + receipt_only.append(r) + matched_tids = set() + for r in receipts: + if r.get("tid") is not None and r.get("tid") in ledger_by_tid: + matched_tids.add(r["tid"]) + ledger_only = [r for r in ledger + if r.get("tid") is not None and r["tid"] not in matched_tids] + ledger_only += [r for r in ledger if r.get("tid") is None] + return { + "receipts_n": len(receipts), + "ledger_n": len(ledger), + "matched_n": matched, + "receipt_only": receipt_only, + "ledger_only": ledger_only, + "clean": not receipt_only, + } diff --git a/tests/trader/test_trade_receipt.py b/tests/trader/test_trade_receipt.py new file mode 100644 index 0000000..838edcc --- /dev/null +++ b/tests/trader/test_trade_receipt.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""trade_receipt:成交回报回执解析+回执↔账本闭环比对。 + +背景(2026-09-04 实证):柜台有成交、台账无行、引擎会话视图也无行—— +故障点在「推送到达→回调处理→落库」三跳上且全静默。①(引擎侧)每笔回调 +到达即打 [trade-receipt] 指纹行;本模块②解析该行并与 live_trades 闭环: +有回执无账行=引擎处理丢,柜台有成交无回执=柜台没推——一分为二定位。 +""" + +import pytest + +from sanguo_trader.shadow.trade_receipt import ( + compare_receipts_with_ledger, + parse_trade_receipts, +) + + +class TestParseTradeReceipts: + def test_parse_full_line(self): + text = ("2026-09-04 09:34:23,101 INFO __main__: [trade-receipt] " + "tid=b9765f order=1098910971 acc=20 sym=002633.XSHE " + "vol=100 px=14.84 t_raw=09:34:23 recv=09:34:23.101") + rs = parse_trade_receipts(text) + assert len(rs) == 1 + r = rs[0] + assert r["tid"] == "b9765f" + assert r["order"] == "1098910971" + assert r["acc"] == "20" + assert r["sym"] == "002633.XSHE" + assert r["vol"] == 100 + assert r["px"] == 14.84 + assert r["t_raw"] == "09:34:23" + + def test_parse_none_t_raw_still_counted(self): + """时间字段退化(2026-09-04 实证形态)也必须留痕可解析。""" + text = ("2026-09-04 13:37:38,975 INFO __main__: [trade-receipt] " + "tid=5d9496 order=1098910973 acc=20 sym=002633.XSHE " + "vol=400 px=14.84 t_raw=NONE recv=13:37:38.975") + rs = parse_trade_receipts(text) + assert len(rs) == 1 + assert rs[0]["t_raw"] is None + assert rs[0]["vol"] == 400 + + def test_parse_ignores_noise_and_duplicates(self): + """噪线不误吞;同 tid 重复回执去重计数(重复本身值得知道)。""" + text = "\n".join([ + "2026-09-04 09:30:00 INFO jq_strategy: 开盘调度 start", + ("2026-09-04 09:34:23 INFO __main__: [trade-receipt] tid=aaa " + "order=1 acc=20 sym=600036.XSHG vol=100 px=41.4 t_raw=09:34:23 recv=09:34:23.5"), + "2026-09-04 09:34:24 INFO __main__: [instance-ledger] 恢复 1 笔", + ("2026-09-04 09:34:25 INFO __main__: [trade-receipt] tid=aaa " + "order=1 acc=20 sym=600036.XSHG vol=100 px=41.4 t_raw=09:34:23 recv=09:34:25.0"), + ]) + rs = parse_trade_receipts(text) + assert len(rs) == 1 + assert rs[0]["dup_count"] == 2 + + def test_parse_tid_missing_kept_with_none(self): + """tid 缺失(异常形态)不丢弃行——留痕优先。""" + text = ("2026-09-04 09:34:23 INFO __main__: [trade-receipt] " + "tid=NONE order=2 acc=20 sym=510500.XSHG vol=100 px=7.85 t_raw=NONE recv=x") + rs = parse_trade_receipts(text) + assert len(rs) == 1 + assert rs[0]["tid"] is None + + +class TestCompareReceiptsWithLedger: + def test_all_matched_clean(self): + receipts = [ + {"tid": "a", "order": "1", "sym": "002633.XSHE", "vol": 100, "px": 14.84}, + {"tid": "b", "order": "1", "sym": "002633.XSHE", "vol": 400, "px": 14.84}, + ] + ledger = [ + {"tid": "a", "symbol": "002633.XSHE", "volume": 100, "price": 14.84}, + {"tid": "b", "symbol": "002633.XSHE", "volume": 400, "price": 14.84}, + ] + rep = compare_receipts_with_ledger(receipts, ledger) + assert rep["receipts_n"] == 2 + assert rep["ledger_n"] == 2 + assert rep["matched_n"] == 2 + assert rep["receipt_only"] == [] + assert rep["ledger_only"] == [] + assert rep["clean"] is True + + def test_receipt_only_is_the_smoking_gun(self): + """有回执无账行=引擎处理丢(②③之间)——2026-09-04 的未定位跳。""" + receipts = [ + {"tid": "a", "order": "1", "sym": "002633.XSHE", "vol": 100, "px": 14.84}, + {"tid": "b", "order": "1", "sym": "002633.XSHE", "vol": 100, "px": 14.84}, + ] + ledger = [{"tid": "a", "symbol": "002633.XSHE", "volume": 100, "price": 14.84}] + rep = compare_receipts_with_ledger(receipts, ledger) + assert rep["clean"] is False + assert [r["tid"] for r in rep["receipt_only"]] == ["b"] + + def test_ledger_only_informational(self): + """有账行无回执=回执日志后上线前的旧行/估算行,信息项不算脏。""" + receipts = [] + ledger = [{"tid": "repair:x", "symbol": "002633.XSHE", "volume": 100, "price": 14.84}] + rep = compare_receipts_with_ledger(receipts, ledger) + assert rep["ledger_only"] == ledger + assert rep["clean"] is True # ledger_only 不判脏:只盯「到了没记」 + + def test_tid_none_rows_never_match_by_guess(self): + """tid=NONE 的行不做模糊匹配硬凑——宁缺勿错。""" + receipts = [{"tid": None, "order": "1", "sym": "002633.XSHE", "vol": 100, "px": 14.84}] + ledger = [{"tid": None, "symbol": "002633.XSHE", "volume": 100, "price": 14.84}] + rep = compare_receipts_with_ledger(receipts, ledger) + # tid 缺失双方都不猜:receipt_only 与 ledger_only 各自如实列出 + assert rep["matched_n"] == 0 + assert len(rep["receipt_only"]) == 1 + assert len(rep["ledger_only"]) == 1