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"