test(live): D-3持久化测试(bridge_client+影子下单集成, NAS环境11例绿)

- 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临时自测未沉淀成持久测试)
This commit is contained in:
2026-07-11 05:35:20 +08:00
parent 379836d34e
commit 38f5635b59
2 changed files with 163 additions and 0 deletions
+63
View File
@@ -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 codesh/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
+100
View File
@@ -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"