0761342baf
容器 docker run(非 compose)注入 BRIDGE_TOKEN env 需重建容器,风险大。改为 live_cfg.bridge_token 优先、fallback BRIDGE_TOKEN env。run_live_step 每次 load_config,改 config 免重启即生效。 - _shadow_trades_to_bridge + reconcile_from_bridge 两处 token 读取 - config: live.bridge_token 占位空值(真实值填 NAS gitignored config,不入库) - tests: +6 测试(config优先/env fallback/都无跳过),191 passed
144 lines
6.0 KiB
Python
144 lines
6.0 KiB
Python
"""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"
|
||
|
||
|
||
class TestShadowTokenSource:
|
||
"""bridge_token 优先级:config > env > skip(D-3 影子下单)。"""
|
||
|
||
def test_config_token_used(self, tmp_path):
|
||
"""(a) config 有 bridge_token → 用 config token(env 未设)。"""
|
||
db = _mk_db(tmp_path)
|
||
_insert_trade(db)
|
||
cfg = SimpleNamespace(live={
|
||
"enabled": True, "bridge_url": "http://b.test", "shadow": True,
|
||
"bridge_token": "cfg-token",
|
||
})
|
||
with patch.dict("os.environ", {}, clear=True), \
|
||
patch("sanguo_trader.bridge_client.BridgeClient") as mbc:
|
||
mbc.return_value.place_order.return_value = {"ok": True, "order_id": 1}
|
||
_shadow_trades_to_bridge(db, 1, _DATE, cfg)
|
||
assert mbc.call_args[0][1] == "cfg-token" # BridgeClient(url, token)
|
||
|
||
def test_env_fallback_when_config_missing(self, tmp_path):
|
||
"""(b) config 无 bridge_token → fallback 到 BRIDGE_TOKEN env。"""
|
||
db = _mk_db(tmp_path)
|
||
_insert_trade(db)
|
||
cfg = SimpleNamespace(live={
|
||
"enabled": True, "bridge_url": "http://b.test", "shadow": True,
|
||
})
|
||
with patch.dict("os.environ", {"BRIDGE_TOKEN": "env-token"}), \
|
||
patch("sanguo_trader.bridge_client.BridgeClient") as mbc:
|
||
mbc.return_value.place_order.return_value = {"ok": True, "order_id": 1}
|
||
_shadow_trades_to_bridge(db, 1, _DATE, cfg)
|
||
assert mbc.call_args[0][1] == "env-token"
|
||
|
||
def test_no_token_skips(self, tmp_path):
|
||
"""(c) config 和 env 都无 bridge_token → 跳过、warning,不下单。"""
|
||
db = _mk_db(tmp_path)
|
||
_insert_trade(db)
|
||
cfg = SimpleNamespace(live={
|
||
"enabled": True, "bridge_url": "http://b.test", "shadow": True,
|
||
})
|
||
with patch.dict("os.environ", {}, clear=True), \
|
||
patch("sanguo_trader.bridge_client.BridgeClient") as mbc:
|
||
_shadow_trades_to_bridge(db, 1, _DATE, cfg)
|
||
mbc.assert_not_called()
|