From 90b37ce27ff7768fc83078317a5f8b5107cba43b Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 6 Sep 2026 11:14:29 +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=E6=97=A5=E5=BF=97=E2=91=A0=E2=80=94?= =?UTF-8?q?=E2=80=9409-04=E5=AE=88=E6=81=92=E7=BC=BA=E5=8F=A3=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E4=BB=B6=20[vps]=E2=80=94=E2=80=94=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=E4=BB=B6:002633/002836=E7=BC=BA=E5=8F=A3=E5=A4=8D=E7=9B=98?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E5=BC=95=E6=93=8E=E4=BC=9A=E8=AF=9D=E5=BD=93?= =?UTF-8?q?=E6=97=A5=E6=88=90=E4=BA=A4=E8=A7=86=E5=9B=BE=E8=A1=8C=E7=BC=BA?= =?UTF-8?q?+=E6=97=B6=E9=97=B4=E5=AD=97=E6=AE=B5=E4=B8=AD=E9=80=94?= =?UTF-8?q?=E9=80=80=E5=8C=96None,QMT=E8=A1=8Cdump=E5=85=A8=E6=89=93?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=97=B6=E5=88=BB=E5=8D=B3=E9=93=81=E8=AF=81?= =?UTF-8?q?,=E4=BA=8B=E5=90=8E=E6=97=A0=E4=BB=8E=E5=AF=B9=E3=80=8C?= =?UTF-8?q?=E5=93=AA=E7=AC=94=E4=BB=80=E4=B9=88=E6=97=B6=E5=80=99=E4=BB=A5?= =?UTF-8?q?=E4=BB=80=E4=B9=88=E5=BD=A2=E6=80=81=E8=A2=AB=E7=9C=8B=E5=88=B0?= =?UTF-8?q?=E3=80=8D;=E7=94=A8=E6=88=B7=E9=92=A6=E7=82=B9=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=E6=9C=BA=E5=88=B6=E3=80=8C=E5=86=8D=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E6=97=B6=E6=A0=B9=E5=9B=A0=E5=8F=AF=E6=9F=A5=E3=80=8D,dev?= =?UTF-8?q?=E5=A5=91=E7=BA=A6:=E4=BB=BB=E4=BD=95=E5=A4=84=E7=90=86/?= =?UTF-8?q?=E5=8C=B9=E9=85=8D/=E5=8E=BB=E9=87=8D=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E6=89=93[trade-receipt]=20tid/order/acc/sym/vol/px/t=5Fraw/rec?= =?UTF-8?q?v=E5=85=AB=E5=AD=97=E6=AE=B5(=E2=91=A1=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E6=AD=A4=E6=A0=BC=E5=BC=8F,=E6=8B=BC=E5=86=99=E5=86=BB?= =?UTF-8?q?=E7=BB=93);=E5=AE=9E=E7=8E=B0=3Dwith=5Ftrade=5Freceipt=E5=8C=85?= =?UTF-8?q?=E8=A3=85QmtBroker.get=5Ftrades(bullet=5Ftrade=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E5=88=B6=E6=97=A0=E6=88=90=E4=BA=A4=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=9B=9E=E8=B0=83,=5FCallback=E4=BB=85=E6=A1=A5=E6=8E=A5?= =?UTF-8?q?=E6=96=AD=E8=BF=9E,broker.get=5Ftrades=E6=98=AF=E6=9F=9C?= =?UTF-8?q?=E5=8F=B0=E8=A1=8C=E8=BF=9B=E6=9C=AC=E8=BF=9B=E7=A8=8B=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E5=92=BD=E5=96=89,=E5=BC=95=E6=93=8E=E5=90=8C?= =?UTF-8?q?=E6=AD=A5/a=E7=AA=84=E4=BF=AE/b=E5=AE=BD=E4=BF=AE=E5=85=A8?= =?UTF-8?q?=E7=BB=8F=E5=AE=83),=E4=B8=8Ewith=5Fconnect=5Fretry=E5=90=8C?= =?UTF-8?q?=E6=AC=BE=E5=AE=9E=E4=BE=8B=E7=BA=A7=E5=8C=85=E8=A3=85=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E9=9B=B6=E6=94=B9=E5=8A=A8;=E9=93=81=E5=BE=8B=3Dt=5Fr?= =?UTF-8?q?aw=E5=8E=9F=E6=A0=B7=E7=85=A7=E8=AE=B0None=E4=B9=9F=E8=AE=B0/?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=B0=B8=E4=B8=8D=E6=8A=9B=E9=80=90=E8=A1=8C?= =?UTF-8?q?try-except=E5=8C=85=E6=AD=BB/get=5Ftrades=E5=8E=9F=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E7=85=A7=E6=8A=9B=E8=A1=8C=E4=B8=BA=E4=B8=8D=E5=8F=98?= =?UTF-8?q?;=E9=87=8F=E6=8E=A7=3D=E8=A7=82=E6=B5=8B=E5=B1=82=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E6=A3=80=E6=B5=8B=E9=9D=9E=E5=A4=84=E7=90=86=E5=B1=82?= =?UTF-8?q?=E5=8E=BB=E7=BB=84:=E5=90=8Ctid=E7=AD=BE=E5=90=8D(t=5Fraw+?= =?UTF-8?q?=E9=87=8F+=E4=BB=B7+=E5=8D=95=E5=8F=B7)=E4=B8=8D=E5=8F=98?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E6=89=93,=E9=80=80=E5=8C=96=E5=8D=B3?= =?UTF-8?q?=E9=87=8D=E6=89=93(=E6=8D=95=E8=8E=B709-04=E5=BD=A2=E6=80=81),?= =?UTF-8?q?=E8=A1=8C=E7=85=A7=E5=B8=B8=E6=B5=81=E5=90=91=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E6=96=B9;=E2=91=A2=E7=BB=93=E8=AE=BA=E5=85=8D=E6=94=B9:vt=5Ftr?= =?UTF-8?q?adeid=E4=B8=89=E6=9D=A1=E5=BD=92=E5=9B=A0=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=B7=B2=E6=89=BF=E8=BD=BD=E6=9F=9C=E5=8F=B0=E6=88=90=E4=BA=A4?= =?UTF-8?q?=E5=8F=B7(=E5=8D=B3=E6=97=B6=3D=E5=BC=95=E6=93=8Etrades?= =?UTF-8?q?=E9=94=AE/a=E7=AA=84=E4=BF=AE=3Dtrade=5Fid/b=E5=AE=BD=E4=BF=AE?= =?UTF-8?q?=3Deod:=E5=89=8D=E7=BC=80),=E6=97=A2=E6=9C=89=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=B7=B2=E9=92=89(test=5Flive=5Freconcile.py:123,206+test=5Fli?= =?UTF-8?q?ve=5Finstance=5Fledger.py:236),=E2=91=A1=E6=8C=89tid=E6=AF=94?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E2=86=94live=5Ftrades=E5=8D=B3=E9=97=AD?= =?UTF-8?q?=E7=8E=AF;TDD=207=E6=96=B0=E6=B5=8B=E5=85=88=E7=BA=A2=E5=90=8E?= =?UTF-8?q?=E7=BB=BF(caplog=E9=A1=BBset=5Flevel(INFO)=E5=9D=91)+598?= =?UTF-8?q?=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_portfolio/live_trade_receipt.py | 96 ++++++++++++++++++ sanguo_portfolio/runner_live.py | 6 ++ tests/portfolio/test_live_trade_receipt.py | 111 +++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 sanguo_portfolio/live_trade_receipt.py create mode 100644 tests/portfolio/test_live_trade_receipt.py diff --git a/sanguo_portfolio/live_trade_receipt.py b/sanguo_portfolio/live_trade_receipt.py new file mode 100644 index 0000000..5c4cf81 --- /dev/null +++ b/sanguo_portfolio/live_trade_receipt.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""成交回报回执日志(①,2026-09-04 守恒缺口观测件)。 + +挂点=包装 QmtBroker.get_trades:bullet_trade 是轮询制(无成交推送回调, +qmt._Callback 仅桥接断连),``broker.get_trades()`` 是柜台成交行进入本进程 +的唯一咽喉——引擎同步(_sync_trades_from_broker)、a窄修(reconcile_pending)、 +b宽修(eod_reconcile) 全部经它取行。回执在**任何处理/匹配/去重之前**打。 + +行格式(dev 2026-09-06 契约,② 解析——字段名与顺序不得改): + [trade-receipt] tid={柜台成交号} order={订单号} acc={账号} sym={代码} + vol={量} px={价} t_raw={回报自带时间,无则NONE} recv={本地时刻} + +铁律: +- t_raw 原样照记,None/退化也记——09-04 事故根因即时间字段中途退化, + 补插五元组无从对齐,回执即为此留证; +- 日志永不抛、永不影响交易路径(逐行 try/except 包死;get_trades 本身的 + 异常原样上抛,不改变消费方行为); +- 量控=观测层变化检测,非处理层去重:同 tid 状态签名(t_raw+量+价+单号) + 不变不重打,退化/漂移即重打;行照常流向所有消费方。签名表进程内 + 数百行量级(当日成交数),引擎轮换即清,无需淘汰。 + +③ 注(同批观测契约):live_trades.vt_tradeid 三条归因路径已承载柜台成交号 +(即时归因=引擎 trades 键、a窄修=trade_id、b宽修=eod: 前缀),测试已钉死, +无 schema 改动;② 用本回执与 live_trades 行按 tid 比对即闭环。 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# tid -> 状态签名(观测层变化检测;线程安全=py dict 原子操作,竞争最坏多打一行) +_RECEIPT_SEEN: Dict[str, str] = {} + + +def _first_present(row: Dict[str, Any], *names: str) -> Any: + for n in names: + v = row.get(n) + if v is not None: + return v + return None + + +def _t_raw_str(v: Any) -> str: + return "NONE" if v is None else str(v) + + +def log_trade_receipts(rows: Optional[List[Any]], account: str) -> None: + """逐行打回执(纯日志副作用,永不抛)。""" + if not rows: + return + recv = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + for r in rows: + try: + if not isinstance(r, dict): + continue + tid = str(_first_present(r, "trade_id") or "") + if not tid: + continue + t_raw = _first_present(r, "time", "trade_time") + vol = _first_present(r, "amount", "volume", "trade_volume") + px = _first_present(r, "price", "traded_price") + order = _first_present(r, "order_id", "entrust_id") + sig = "|".join((_t_raw_str(t_raw), str(vol), str(px), str(order))) + if _RECEIPT_SEEN.get(tid) == sig: + continue + _RECEIPT_SEEN[tid] = sig + logger.info( + "[trade-receipt] tid=%s order=%s acc=%s sym=%s vol=%s px=%s " + "t_raw=%s recv=%s", + tid, order, account, _first_present(r, "security"), + vol, px, _t_raw_str(t_raw), recv) + except Exception: # noqa: BLE001 - 回执绝不影响交易路径 + pass + + +def with_trade_receipt(broker: Any, account: str) -> Any: + """包一层 broker.get_trades:返回行交给消费方之前先打回执。 + + 与 ``with_connect_retry`` 同款实例级包装(bullet_trade 源码零改动); + get_trades 原异常照抛,回执打点失败吞掉不声张。 + """ + orig = broker.get_trades + + def _get_trades() -> Any: + rows = orig() + try: + log_trade_receipts(rows, account) + except Exception: # noqa: BLE001 - 双保险,契约=永不影响交易路径 + pass + return rows + + broker.get_trades = _get_trades # type: ignore[method-assign] + return broker diff --git a/sanguo_portfolio/runner_live.py b/sanguo_portfolio/runner_live.py index 7981861..8354904 100644 --- a/sanguo_portfolio/runner_live.py +++ b/sanguo_portfolio/runner_live.py @@ -30,6 +30,8 @@ import time from pathlib import Path from typing import Any, Dict +from .live_trade_receipt import with_trade_receipt + logger = logging.getLogger(__name__) ADAPTER_FILE = Path(__file__).resolve().parent / "live_strategy.py" @@ -297,6 +299,10 @@ def run_live(provider_config: Dict[str, Any] | None = None) -> None: broker = with_connect_retry( QmtBroker(account_id=cfg["account"], data_path=cfg["mini_path"])) + # ① 成交回报回执(2026-09-04 守恒缺口观测契约):柜台行进本进程的第一站 + # 留痕(tid/order/t_raw 原样,t_raw 退化形态可查);与 with_connect_retry + # 同款实例级包装,bullet_trade 源码零改动 + broker = with_trade_receipt(broker, cfg["account"]) logger.info("QmtBroker 装配 account=%s data_path=%s(connect 失败退避重试 %d×%.0fs)", cfg["account"], cfg["mini_path"], CONNECT_RETRY_ATTEMPTS, CONNECT_RETRY_WAIT_SEC) diff --git a/tests/portfolio/test_live_trade_receipt.py b/tests/portfolio/test_live_trade_receipt.py new file mode 100644 index 0000000..bb30b8b --- /dev/null +++ b/tests/portfolio/test_live_trade_receipt.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""成交回报回执日志单测(①,2026-09-04 守恒缺口观测件)。 + +契约(dev 2026-09-06 钦定,② 解析本格式——字段名与顺序不得改): + [trade-receipt] tid={柜台成交号} order={订单号} acc={账号} sym={代码} + vol={量} px={价} t_raw={回报自带时间,无则NONE} recv={本地时刻} + +铁律: +- 任何处理/匹配/去重**之前**打(挂点=broker.get_trades 包装,bullet_trade + 轮询制下它是柜台行进本进程的唯一咽喉); +- t_raw 原样照记,None/退化也记(09-04 事故根因=时间字段中途退化); +- 日志永不抛、永不影响交易路径(get_trades 原异常照抛,行为不变); +- 观测层变化检测:同 tid 状态签名不变不重打,退化/漂移即重打——行照常 + 流向消费方,这不是处理层去重。 +""" +import logging + +import pytest + +from sanguo_portfolio.live_trade_receipt import ( + log_trade_receipts, + with_trade_receipt, +) + +_ROW = { + "trade_id": "ecf0fbda55b9765f", "order_id": "1098910841", + "security": "002633.SZ", "amount": 100, "price": 14.84, + "time": "20260904133738", +} + + +@pytest.fixture(autouse=True) +def _clear_seen(caplog): + from sanguo_portfolio import live_trade_receipt + live_trade_receipt._RECEIPT_SEEN.clear() + caplog.set_level(logging.INFO, logger="sanguo_portfolio.live_trade_receipt") + yield + live_trade_receipt._RECEIPT_SEEN.clear() + + +def test_receipt_line_format_pins_contract(caplog): + """格式铁契:tag+八字段顺序,recv 为本地时刻。""" + log_trade_receipts([_ROW], "66639661") + lines = [r.getMessage() for r in caplog.records + if r.getMessage().startswith("[trade-receipt]")] + assert len(lines) == 1 + assert lines[0].startswith( + "[trade-receipt] tid=ecf0fbda55b9765f order=1098910841 " + "acc=66639661 sym=002633.SZ vol=100 px=14.84 " + "t_raw=20260904133738 recv=") + recv = lines[0].rsplit("recv=", 1)[1] + assert len(recv) == 19 and recv[4] == "-" and recv[13] == ":" # YYYY-MM-DD HH:MM:SS + + +def test_receipt_t_raw_none_logged_as_none(caplog): + """t_raw=None → 字面 NONE(09-04 退化形态照记)。""" + log_trade_receipts([dict(_ROW, time=None)], "a1") + assert "t_raw=NONE" in caplog.records[-1].getMessage() + + +def test_repeat_same_state_suppressed(caplog): + """同 tid 状态签名不变 → 只打一次(观测层量控)。""" + for _ in range(3): + log_trade_receipts([_ROW], "a1") + lines = [r.getMessage() for r in caplog.records + if r.getMessage().startswith("[trade-receipt]")] + assert len(lines) == 1 + + +def test_degraded_t_raw_relogged(caplog): + """t_raw 中途退化(有值→None)→ 签名变化即重打——捕获 09-04 形态。""" + log_trade_receipts([_ROW], "a1") + log_trade_receipts([dict(_ROW, time=None)], "a1") + lines = [r.getMessage() for r in caplog.records + if r.getMessage().startswith("[trade-receipt]")] + assert len(lines) == 2 + assert "t_raw=NONE" in lines[1] + + +def test_bad_rows_skipped_never_raise(caplog): + """非 dict 行 / 缺 tid 行:静默跳过,绝不抛。""" + log_trade_receipts([None, "junk", {"order_id": "x"}, 42], "a1") + assert not [r for r in caplog.records + if r.getMessage().startswith("[trade-receipt]")] + + +class _Broker: + def __init__(self, rows=None, exc=None): + self._rows, self._exc = rows or [], exc + + def get_trades(self): + if self._exc: + raise self._exc + return self._rows + + +def test_wrapper_returns_rows_unchanged(caplog): + """包装层透传:同一列表原样返回,回执即打。""" + rows = [dict(_ROW)] + b = with_trade_receipt(_Broker(rows), "66639661") + out = b.get_trades() + assert out is rows + assert any(r.getMessage().startswith("[trade-receipt]") + for r in caplog.records) + + +def test_wrapper_preserves_original_exception(): + """get_trades 原异常照抛(不吞不改消费方行为)。""" + b = with_trade_receipt(_Broker(exc=RuntimeError("qmt down")), "a1") + with pytest.raises(RuntimeError, match="qmt down"): + b.get_trades()