2393097074
- _MockBridge HTTP server模拟bridge(health/order/account/positions+鉴权) - bridge_client真发HTTP(urlopen+X-Bridge-Token)+响应解析+错token 401返回None - _shadow_trades_to_bridge真HTTP链路:注入成交→真POST→mock收到下单+symbol转sh600000→paper_shadow_orders记录 - NAS容器4 passed - D-4a sanguo半边端到端验证(真HTTP); miniQMT真报单另半边待Windows bridge实跑
153 lines
5.7 KiB
Python
153 lines
5.7 KiB
Python
"""D-4a 集成测试:sanguo → bridge 真实 HTTP round-trip(mock bridge server)。
|
||
|
||
不依赖 Windows——本地起 mock HTTP server 模拟 bridge(health/order/account/positions + 鉴权),
|
||
验证 BridgeClient 真发 HTTP(urlopen + X-Bridge-Token + JSON 解析)+ _shadow_trades_to_bridge
|
||
完整链路在真实 HTTP 环境跑通。
|
||
|
||
验证范围(D-4a sanguo 半边):
|
||
- bridge_client HTTP 层真发请求、带鉴权 header、解析响应
|
||
- _shadow_trades_to_bridge → bridge_client → 真 HTTP → mock bridge 收到下单 → paper_shadow_orders
|
||
|
||
未覆盖(D-4a 另一半,物理依赖 Windows):
|
||
- bridge → xtquant → miniQMT 真报单(miniQMT 只 Windows,需 Windows bridge 实跑)
|
||
"""
|
||
import json
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
from types import SimpleNamespace
|
||
from urllib.parse import urlparse
|
||
|
||
from sanguo_trader.bridge_client import BridgeClient
|
||
from sanguo_trader.live_orchestrator import _shadow_trades_to_bridge
|
||
from sanguo_trader.persistence import init_db, is_trade_shadowed, save_trade
|
||
|
||
_TOKEN = "integration-test-token"
|
||
|
||
|
||
class _MockBridgeHandler(BaseHTTPRequestHandler):
|
||
"""模拟 Windows bridge 的 4 接口 + token 鉴权(契约同 sanguo_qmt_bridge/bridge.py)。"""
|
||
|
||
def log_message(self, *args): # 静默测试日志
|
||
pass
|
||
|
||
def _has_token(self) -> bool:
|
||
return self.headers.get("X-Bridge-Token") == _TOKEN
|
||
|
||
def _respond(self, code: int, payload: dict) -> None:
|
||
body = json.dumps(payload).encode()
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_GET(self):
|
||
path = urlparse(self.path).path
|
||
if path == "/health":
|
||
self._respond(200, {"status": "ok", "miniqmt_connected": True})
|
||
elif path in ("/account", "/positions"):
|
||
if not self._has_token():
|
||
self._respond(401, {"detail": "token 无效"})
|
||
return
|
||
self._respond(200, {"ok": True, "cash": 1e7, "frozen": 0.0,
|
||
"market_value": 0.0, "total": 1e7, "positions": []})
|
||
else:
|
||
self._respond(404, {"detail": "not found"})
|
||
|
||
def do_POST(self):
|
||
path = urlparse(self.path).path
|
||
if path != "/order":
|
||
self._respond(404, {"detail": "not found"})
|
||
return
|
||
if not self._has_token():
|
||
self._respond(401, {"detail": "token 无效"})
|
||
return
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
req = json.loads(self.rfile.read(length))
|
||
self.server.orders.append(req) # 记录,供测试断言
|
||
self._respond(200, {"ok": True, "order_id": 99999})
|
||
|
||
|
||
class _MockBridge:
|
||
"""mock bridge HTTP server(后台线程)。"""
|
||
|
||
def __init__(self):
|
||
self.server = HTTPServer(("127.0.0.1", 0), _MockBridgeHandler)
|
||
self.server.orders = []
|
||
self.port = self.server.server_address[1]
|
||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||
|
||
def start(self) -> "_MockBridge":
|
||
self.thread.start()
|
||
return self
|
||
|
||
def stop(self) -> None:
|
||
self.server.shutdown()
|
||
|
||
@property
|
||
def url(self) -> str:
|
||
return f"http://127.0.0.1:{self.port}"
|
||
|
||
@property
|
||
def orders(self) -> list:
|
||
"""mock bridge 收到的下单请求(handler 写入 server.orders)。"""
|
||
return self.server.orders
|
||
|
||
|
||
class TestBridgeClientRealHttp:
|
||
"""BridgeClient 真发 HTTP round-trip(验证 HTTP 层 + 鉴权 + 解析)。"""
|
||
|
||
def test_get_account_with_token(self):
|
||
b = _MockBridge().start()
|
||
try:
|
||
r = BridgeClient(b.url, _TOKEN).get_account()
|
||
assert r is not None and r["ok"] is True and r["cash"] == 1e7
|
||
finally:
|
||
b.stop()
|
||
|
||
def test_order_real_http_payload(self):
|
||
b = _MockBridge().start()
|
||
try:
|
||
r = BridgeClient(b.url, _TOKEN).place_order("sh600000", "buy", 10.5, 100)
|
||
assert r == {"ok": True, "order_id": 99999}
|
||
assert b.orders == [{"code": "sh600000", "action": "buy", "price": 10.5,
|
||
"volume": 100, "price_type": "limit", "reason": ""}]
|
||
finally:
|
||
b.stop()
|
||
|
||
def test_wrong_token_returns_none_on_401(self):
|
||
b = _MockBridge().start()
|
||
try:
|
||
# 错 token → mock 返回 401 → urlopen 抛 HTTPError → bridge_client 返回 None
|
||
r = BridgeClient(b.url, "wrong-token").get_account()
|
||
assert r is None
|
||
finally:
|
||
b.stop()
|
||
|
||
|
||
def _cfg(url: str) -> SimpleNamespace:
|
||
return SimpleNamespace(live={"enabled": True, "bridge_url": url, "shadow": True})
|
||
|
||
|
||
class TestShadowTradesRealHttp:
|
||
"""_shadow_trades_to_bridge 真发 HTTP 到 mock bridge(D-4a sanguo 半边端到端)。"""
|
||
|
||
def test_full_shadow_roundtrip(self, tmp_path, monkeypatch):
|
||
db = str(tmp_path / "t.db")
|
||
init_db(db)
|
||
trade_id = save_trade(db, 1, {"symbol": "600000", "direction": "buy", "price": 10.5,
|
||
"volume": 100, "bar_date": "2026-07-11",
|
||
"strategy_id": "s1"})
|
||
monkeypatch.setenv("BRIDGE_TOKEN", _TOKEN)
|
||
b = _MockBridge().start()
|
||
try:
|
||
_shadow_trades_to_bridge(db, 1, "2026-07-11", _cfg(b.url))
|
||
# mock bridge 真收到下单(symbol 已转 sh600000)
|
||
assert len(b.orders) == 1
|
||
assert b.orders[0]["code"] == "sh600000"
|
||
assert b.orders[0]["action"] == "buy"
|
||
# paper_shadow_orders 真记录
|
||
assert is_trade_shadowed(db, 1, trade_id)
|
||
finally:
|
||
b.stop()
|