From 38f5635b59dbb58ad5e0f3ddd8faba15d1e60636 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 11 Jul 2026 05:35:20 +0800 Subject: [PATCH] =?UTF-8?q?test(live):=20D-3=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95(bridge=5Fclient+=E5=BD=B1=E5=AD=90=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E9=9B=86=E6=88=90,=20NAS=E7=8E=AF=E5=A2=8311=E4=BE=8B?= =?UTF-8?q?=E7=BB=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_bridge_client: to_bridge_code转换(sh/sz/前缀) + HTTP mock(成功/失败返回None不抛) - test_shadow_orders: 幂等persistence + enabled跳过 + 正向记录+symbol转换 + 幂等不重复 + bridge失败不阻断 - NAS容器真实环境(Python3.10/pytest) 11 passed - 补 D-3 测试空缺(Sub Agent临时自测未沉淀成持久测试) --- tests/trader/test_bridge_client.py | 63 ++++++++++++++++++ tests/trader/test_shadow_orders.py | 100 +++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/trader/test_bridge_client.py create mode 100644 tests/trader/test_shadow_orders.py diff --git a/tests/trader/test_bridge_client.py b/tests/trader/test_bridge_client.py new file mode 100644 index 0000000..99886c9 --- /dev/null +++ b/tests/trader/test_bridge_client.py @@ -0,0 +1,63 @@ +"""D-3 bridge_client 单元测试(symbol 转换 + HTTP mock)。 + +不依赖 Windows/miniQMT——bridge_client 是纯 HTTP 客户端,mock urllib 即可。 +覆盖:to_bridge_code 转换规则、place_order 成功/失败返回、失败不抛异常。 +""" +import urllib.error +from unittest.mock import MagicMock, patch + +from sanguo_trader.bridge_client import BridgeClient, to_bridge_code + + +class TestToBridgeCode: + """sanguo symbol(纯数字码)→ bridge code(sh/sz 前缀)转换。 + + 规则与 sanguo_data.datareader.guess_exchange 一致。 + """ + + def test_sh_codes(self): + """沪市:60/68/51/56/58 开头。""" + assert to_bridge_code("600000") == "sh600000" + assert to_bridge_code("688981") == "sh688981" + assert to_bridge_code("510300") == "sh510300" + + def test_sz_codes(self): + """深市:00/30/15 开头。""" + assert to_bridge_code("000001") == "sz000001" + assert to_bridge_code("300750") == "sz300750" + assert to_bridge_code("159915") == "sz159915" + + def test_already_prefixed(self): + """已含 sh/sz 前缀 → 小写直返。""" + assert to_bridge_code("sh600000") == "sh600000" + assert to_bridge_code("SZ000001") == "sz000001" + + +class TestBridgeClientHttp: + """BridgeClient HTTP 调用 mock(不真发请求)。""" + + def test_place_order_ok(self): + """成功返回 {ok, order_id}。""" + with patch("sanguo_trader.bridge_client.urllib.request.urlopen") as mu: + resp = MagicMock() + resp.read.return_value = b'{"ok": true, "order_id": 12345}' + mu.return_value.__enter__.return_value = resp + client = BridgeClient("http://b.test", "tok") + r = client.place_order("sh600000", "buy", 10.5, 100) + assert r == {"ok": True, "order_id": 12345} + mu.assert_called_once() # 确实发了请求 + + def test_place_order_network_failure_returns_none(self): + """网络失败 → 返回 None,不抛异常(影子下单旁路原则)。""" + with patch("sanguo_trader.bridge_client.urllib.request.urlopen") as mu: + mu.side_effect = urllib.error.URLError("conn refused") + client = BridgeClient("http://b.test", "tok") + r = client.place_order("sh600000", "buy", 10.5, 100) + assert r is None + + def test_get_account_failure_returns_none(self): + with patch("sanguo_trader.bridge_client.urllib.request.urlopen") as mu: + mu.side_effect = TimeoutError("slow") + client = BridgeClient("http://b.test", "tok") + r = client.get_account() + assert r is None diff --git a/tests/trader/test_shadow_orders.py b/tests/trader/test_shadow_orders.py new file mode 100644 index 0000000..5fc7bc8 --- /dev/null +++ b/tests/trader/test_shadow_orders.py @@ -0,0 +1,100 @@ +"""D-3 影子下单集成测试(persistence 幂等 + _shadow_trades_to_bridge 正向路径)。 + +不依赖 Windows——mock BridgeClient.place_order,验证: +- persistence 幂等(save_shadow_order / is_trade_shadowed) +- enabled=false 跳过(不下单、不查库) +- enabled=true + bridge ok → paper_shadow_orders 记录 + symbol 转换正确 +- 幂等:重跑同 trade_id 不重复下单 +- bridge 失败 → status=failed,不阻断 +""" +import sqlite3 +from types import SimpleNamespace +from unittest.mock import patch + +from sanguo_trader.live_orchestrator import _shadow_trades_to_bridge +from sanguo_trader.persistence import ( + init_db, + is_trade_shadowed, + save_shadow_order, + save_trade, +) + +_DATE = "2026-07-11" + + +def _mk_db(tmp_path) -> str: + db = str(tmp_path / "t.db") + init_db(db) + return db + + +def _cfg(enabled: bool) -> SimpleNamespace: + return SimpleNamespace(live={ + "enabled": enabled, "bridge_url": "http://b.test", "shadow": True, + }) + + +def _insert_trade(db, account_id=1, symbol="600000", direction="buy") -> int: + """插一条当日成交,返回 trade_id。""" + return save_trade(db, account_id, { + "symbol": symbol, "direction": direction, "price": 10.5, + "volume": 100, "bar_date": _DATE, "strategy_id": "s1", + }) + + +class TestShadowPersistence: + def test_idempotent_roundtrip(self, tmp_path): + db = _mk_db(tmp_path) + assert not is_trade_shadowed(db, 1, 100) + save_shadow_order(db, 1, 100, 555, "ok") + assert is_trade_shadowed(db, 1, 100) + + +class TestShadowTradesToBridge: + def test_disabled_skips(self, tmp_path): + """enabled=false → 不创建 BridgeClient、不下单。""" + db = _mk_db(tmp_path) + _insert_trade(db) + with patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + _shadow_trades_to_bridge(db, 1, _DATE, _cfg(False)) + mbc.assert_not_called() + + def test_enabled_records_and_symbol_converted(self, tmp_path): + """enabled=true + bridge ok → 记录 paper_shadow_orders + code 转换正确。""" + db = _mk_db(tmp_path) + trade_id = _insert_trade(db, symbol="600000", direction="buy") + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.place_order.return_value = {"ok": True, "order_id": 777} + _shadow_trades_to_bridge(db, 1, _DATE, _cfg(True)) + call = mbc.return_value.place_order.call_args + assert call[0][0] == "sh600000" # code 已转 sh 前缀 + assert call[0][1] == "buy" + assert is_trade_shadowed(db, 1, trade_id) + + def test_idempotent_no_duplicate(self, tmp_path): + """同 trade_id 重跑 → 不重复下单。""" + db = _mk_db(tmp_path) + _insert_trade(db) + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.place_order.return_value = {"ok": True, "order_id": 777} + _shadow_trades_to_bridge(db, 1, _DATE, _cfg(True)) + assert mbc.return_value.place_order.call_count == 1 + # 重跑:trade_id 已 shadowed,不再下单 + _shadow_trades_to_bridge(db, 1, _DATE, _cfg(True)) + assert mbc.return_value.place_order.call_count == 1 + + def test_bridge_failure_does_not_block(self, tmp_path): + """bridge 返回 None(失败)→ 记 status=failed,不抛异常。""" + db = _mk_db(tmp_path) + _insert_trade(db) + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.place_order.return_value = None + _shadow_trades_to_bridge(db, 1, _DATE, _cfg(True)) # 不抛 + with sqlite3.connect(db) as conn: + row = conn.execute( + "SELECT status FROM paper_shadow_orders WHERE account_id=1" + ).fetchone() + assert row and row[0] == "failed"