diff --git a/config/data_platform.yaml b/config/data_platform.yaml index a31cab2..cd25e5c 100644 --- a/config/data_platform.yaml +++ b/config/data_platform.yaml @@ -46,3 +46,4 @@ live: enabled: false # 总开关(false=影子分支整个跳过,live_step 行为不变) bridge_url: https://bridge.mysanguo.top shadow: true # 模式A影子下单(模拟撮合为准,信号同步POST bridge影子) + mode_b: false # D-4c 模式B: bridge回报校正账本(默认关,切实盘再开) diff --git a/sanguo_trader/bridge_client.py b/sanguo_trader/bridge_client.py index 260d4a5..5ba91bb 100644 --- a/sanguo_trader/bridge_client.py +++ b/sanguo_trader/bridge_client.py @@ -41,6 +41,18 @@ def to_bridge_code(symbol: str) -> str: return f"sh{symbol}" +def from_bridge_code(code: str) -> str: + """bridge code(sh600000/sz000001)→ sanguo 纯数字码(to_bridge_code 逆函数)。 + + D-4c 模式 B reconcile:bridge /positions 返回 sh/sz 前缀码, + account.positions 以纯数字码为 key(与 PaperEngine trade.symbol 一致)。 + 已是纯数字则原样返回。 + """ + if code[:2].lower() in ("sh", "sz"): + return code[2:] + return code + + class BridgeClient: """QMT bridge HTTP 客户端(影子下单旁路)。 diff --git a/sanguo_trader/live_orchestrator.py b/sanguo_trader/live_orchestrator.py index 9136d27..c1618cb 100644 --- a/sanguo_trader/live_orchestrator.py +++ b/sanguo_trader/live_orchestrator.py @@ -23,6 +23,7 @@ from .models import AccountConfig, MatchSession, OrderSide, PaperOrder from .position_ledger import PositionLedger from .persistence import ( load_last_balance, load_positions, save_positions, + save_daily_balance, load_pending_orders, save_pending_orders, save_shadow_order, is_trade_shadowed, ) @@ -170,6 +171,10 @@ def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None # enabled=false 时 _shadow_trades_to_bridge 立即 return,live_step 行为完全不变 _shadow_trades_to_bridge(db_path, account_id, today, cfg) + # 8. 模式 B reconcile(D-4c,spec §5.3 模式 B):bridge 回报校正 account 账本 + # mode_b=false 时 reconcile_from_bridge 立即 return,live_step 行为不变 + reconcile_from_bridge(db_path, account_id, today, account, cfg) + def _shadow_trades_to_bridge(db_path: str, account_id: int, today: str, cfg) -> None: """当日成交影子下单到 bridge(D-3,spec §5 模式 A)。 @@ -228,6 +233,77 @@ def _shadow_trades_to_bridge(db_path: str, account_id: int, today: str, cfg) -> logger.warning("live_step %s: 影子下单异常(不阻断): %s", account_id, e) +def reconcile_from_bridge(db_path: str, account_id: int, today: str, + account: Account, cfg) -> None: + """模式 B: bridge 真实回报校正 account 账本(spec §5.2/§5.3 模式 B)。 + + bridge /account + /positions 为准,覆盖 account.cash/market_value/positions, + 纠模拟撮合漂移(实盘成交价/分红/拆股等导致的账本偏差)。 + 默认关闭(cfg.live.mode_b != True);任何失败仅记日志,不阻断 live_step。 + bridge 失败 → warning return,降级用模拟账本(live_step step 6 已存的 simulation 状态)。 + """ + try: + live_cfg = getattr(cfg, "live", None) or {} + if not live_cfg.get("enabled"): + return + if not live_cfg.get("mode_b"): + return + token = os.environ.get("BRIDGE_TOKEN") + if not token: + logger.warning("reconcile %s: mode_b 启用但 BRIDGE_TOKEN 未设,跳过", account_id) + return + url = live_cfg.get("bridge_url") + if not url: + logger.warning("reconcile %s: mode_b 启用但 bridge_url 未配,跳过", account_id) + return + + from .bridge_client import BridgeClient, from_bridge_code + + client = BridgeClient(url, token) + + # 1. 校正资金(bridge /account 为准) + acc_resp = client.get_account() + if acc_resp is None: + logger.warning("reconcile %s: get_account 失败,降级模拟账本", account_id) + return + account.cash = float(acc_resp.get("cash", account.cash)) + account.market_value = float(acc_resp.get("market_value", account.market_value)) + total = acc_resp.get("total") + if total is None: + total = account.equity + + # 2. 重建持仓(bridge /positions 为准;bridge code → 纯数字 key) + pos_resp = client.get_positions() + if pos_resp is None: + logger.warning("reconcile %s: get_positions 失败,降级模拟账本", account_id) + return + new_positions: dict[str, PositionLedger] = {} + for p in pos_resp: + code = from_bridge_code(p.get("code", "")) + vol = int(p.get("volume", 0)) + if vol <= 0: + continue + can_use = int(p.get("can_use", vol)) + new_positions[code] = PositionLedger( + code, volume=vol, + frozen=max(vol - can_use, 0), + avg_price=float(p.get("avg_price", 0.0)), + ) + account.positions = new_positions + + # 3. 持久化校正后账本(account scope + daily balance) + save_positions(db_path, account_id, "account", { + sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price} + for sym, p in account.positions.items()}, today) + save_daily_balance(db_path, account_id, today, + account.cash, account.market_value, total) + logger.info("reconcile %s @%s 完成: cash=%.2f mv=%.2f positions=%d", + account_id, today, account.cash, account.market_value, + len(account.positions)) + except Exception as e: # noqa: BLE001 reconcile 绝不阻断 live_step + logger.warning("reconcile %s: 异常(不阻断): %s", account_id, e) + + def list_live_accounts(db_path: str) -> list[int]: """所有 mode=live & status=running 的 account_id(live_runner 遍历用)。""" with sqlite3.connect(db_path) as conn: diff --git a/tests/trader/test_reconcile.py b/tests/trader/test_reconcile.py new file mode 100644 index 0000000..fc32dc4 --- /dev/null +++ b/tests/trader/test_reconcile.py @@ -0,0 +1,144 @@ +"""D-4c 模式 B reconcile 集成测试(bridge 回报校正 account 账本)。 + +不依赖 Windows——mock BridgeClient.get_account/get_positions,验证: +- mode_b=false / enabled=false → 跳过(不创建 BridgeClient) +- mode_b=true + bridge ok → cash/positions 校正 + code 转换 + 持久化 +- bridge 失败(get_account/get_positions 返回 None)→ 不阻断、降级(不持久化) +- 无 BRIDGE_TOKEN → 跳过 + +参考 tests/trader/test_shadow_orders.py 的 mock 风格。 +""" +from types import SimpleNamespace +from unittest.mock import patch + +from sanguo_trader.account import Account +from sanguo_trader.bridge_client import from_bridge_code +from sanguo_trader.live_orchestrator import reconcile_from_bridge +from sanguo_trader.persistence import ( + init_db, + load_last_balance, + load_positions, +) + +_DATE = "2026-07-11" + + +def _mk_db(tmp_path) -> str: + db = str(tmp_path / "t.db") + init_db(db) + return db + + +def _cfg(mode_b: bool, enabled: bool = True) -> SimpleNamespace: + return SimpleNamespace(live={ + "enabled": enabled, "bridge_url": "http://b.test", + "shadow": True, "mode_b": mode_b, + }) + + +class TestFromBridgeCode: + def test_sh_prefix(self): + assert from_bridge_code("sh600000") == "600000" + + def test_sz_prefix(self): + assert from_bridge_code("sz000001") == "000001" + + def test_already_plain(self): + assert from_bridge_code("600000") == "600000" + + +class TestReconcile: + def test_mode_b_false_skips(self, tmp_path): + """mode_b=false → 不创建 BridgeClient。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + with patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(False)) + mbc.assert_not_called() + + def test_enabled_false_skips(self, tmp_path): + """enabled=false → 跳过(即使 mode_b=true)。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + with patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(True, enabled=False)) + mbc.assert_not_called() + + def test_no_token_skips(self, tmp_path): + """mode_b=true 但无 BRIDGE_TOKEN → 跳过。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + with patch.dict("os.environ", {}, clear=True), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(True)) + mbc.assert_not_called() + + def test_reconciles_account_and_positions(self, tmp_path): + """mode_b=true + bridge ok → cash/positions 校正 + code 转换 + 持久化。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + acc.cash = 50000.0 # 模拟账本现金(将被 bridge 覆盖) + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.get_account.return_value = { + "ok": True, "cash": 80000.0, "frozen": 5000.0, + "market_value": 120000.0, "total": 200000.0, + } + mbc.return_value.get_positions.return_value = [ + {"code": "sh600000", "volume": 1000, "can_use": 800, "avg_price": 10.5}, + {"code": "sz000001", "volume": 500, "can_use": 500, "avg_price": 15.0}, + ] + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(True)) + + # account 资金校正(bridge 为准) + assert acc.cash == 80000.0 + assert acc.market_value == 120000.0 + # positions 重建(bridge sh/sz 前缀 → 纯数字 key) + assert set(acc.positions.keys()) == {"600000", "000001"} + assert acc.positions["600000"].volume == 1000 + assert acc.positions["600000"].frozen == 200 # volume - can_use = 1000 - 800 + assert acc.positions["600000"].avg_price == 10.5 + assert acc.positions["000001"].frozen == 0 # 500 - 500 + # 持久化(DB 反映 bridge 校正后状态) + pos = load_positions(db, 1, "account") + assert "600000" in pos and pos["600000"]["volume"] == 1000 + bal = load_last_balance(db, 1) + assert bal["cash"] == 80000.0 + assert bal["market_value"] == 120000.0 + assert bal["total_equity"] == 200000.0 + + def test_get_account_failure_degrades(self, tmp_path): + """get_account 返回 None → 不阻断、不覆盖、不持久化(降级模拟账本)。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + original_cash = acc.cash + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.get_account.return_value = None + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(True)) # 不抛 + assert acc.cash == original_cash # 未被覆盖 + assert load_last_balance(db, 1) is None # 未持久化 + + def test_get_positions_failure_degrades(self, tmp_path): + """get_positions 返回 None → 不阻断、降级(不持久化 reconcile)。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.get_account.return_value = { + "ok": True, "cash": 90000.0, "frozen": 0, + "market_value": 50000.0, "total": 140000.0, + } + mbc.return_value.get_positions.return_value = None + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(True)) # 不抛 + # 未持久化(降级,DB 保持 live_step step6 的 simulation 状态) + assert load_last_balance(db, 1) is None + + def test_exception_does_not_block(self, tmp_path): + """bridge 调用抛异常 → try/except 兜底,不阻断。""" + db = _mk_db(tmp_path) + acc = Account(100000.0) + with patch.dict("os.environ", {"BRIDGE_TOKEN": "tok"}), \ + patch("sanguo_trader.bridge_client.BridgeClient") as mbc: + mbc.return_value.get_account.side_effect = RuntimeError("boom") + reconcile_from_bridge(db, 1, _DATE, acc, _cfg(True)) # 不抛