"""A1 卖出只卖自己(spec docs/design/multi-strategy-instance-budget-spec.md §A1)。 共享 QMT 账户互卖事故(2026-08-19 盘后实锤:518880/600036 双实例各卖): context.portfolio 是**全账户**视图 → "卖掉所有不在目标里的持仓"会卖掉别家 策略的持仓。修法:策略持仓读取优先 broker.get_instance_positions() (实盘 runner_live 注入的实例账本视图),回测/无台账回退 context.portfolio。 验收(spec 原文):单测覆盖"实例视图为空 → 零卖出";实例视图只含自己的标的 → 只卖自己的。模拟盘/回测路径(context.portfolio)行为不变。 """ from __future__ import annotations from datetime import datetime from unittest.mock import MagicMock import pytest from tests.portfolio.conftest import FakeContext, FakePosition from sanguo_portfolio.strategies import ( ChannelTestConfig, ChannelTestStrategy, SmallCapStrategy, ) from sanguo_portfolio.strategies.all_weather import ( BrokerFacade, _get_positions, ) # =================== 公共装配 =================== def _shared_ctx() -> FakeContext: """共享账户视图:自己的 518880 + 别家的 600036(互卖事故当日的真实组合)。""" return FakeContext( current_dt=datetime(2024, 10, 8, 9, 35), positions={ "518880.XSHG": FakePosition("518880.XSHG", avg_cost=7.0, price=7.5), "600036.XSHG": FakePosition("600036.XSHG", avg_cost=35.0, price=36.0), }, ) def _attach_ledger(strategy, view: dict) -> None: """模拟 live_strategy._setup 的通道注入(dae56e2):账本视图挂到 broker。""" strategy.broker.get_instance_positions = lambda: view OWN_VIEW = {"518880.XSHG": {"amount": 1000, "closeable_amount": 1000, "avg_cost": 7.0}} # =================== helper 层 =================== class TestGetPositionsInstanceView: def test_no_channel_falls_back_to_shared(self): """无通道(回测/影子/单测)→ 原 context.portfolio 逻辑,对象原样。""" ctx = _shared_ctx() out = _get_positions(ctx, BrokerFacade()) assert set(out.keys()) == {"518880.XSHG", "600036.XSHG"} def test_channel_empty_view_returns_empty(self): """台账空(本实例无持仓)→ 空 dict,即使共享账户有别的策略持仓。""" ctx = _shared_ctx() broker = BrokerFacade() broker.get_instance_positions = lambda: {} assert _get_positions(ctx, broker) == {} def test_channel_view_keys_and_attrs(self): """有通道 → 键=实例视图;数量/成本取台账,现价从共享视图同名标的补。""" ctx = _shared_ctx() broker = BrokerFacade() broker.get_instance_positions = lambda: OWN_VIEW out = _get_positions(ctx, broker) assert set(out.keys()) == {"518880.XSHG"} pos = out["518880.XSHG"] assert pos.total_amount == 1000 assert pos.closeable_amount == 1000 assert pos.avg_cost == 7.0 assert pos.price == 7.5 # 行情从共享视图补(市场数据非所有权) assert pos.security == "518880.XSHG" def test_channel_symbol_missing_in_shared_price_none(self): """台账有、共享视图无(极端:快照缺口)→ price None,数量/成本仍可用。""" ctx = _shared_ctx() broker = BrokerFacade() broker.get_instance_positions = lambda: { "300059.XSHE": {"amount": 500, "closeable_amount": 500, "avg_cost": 10.0}, } pos = _get_positions(ctx, broker)["300059.XSHE"] assert pos.price is None assert pos.total_amount == 500 def test_no_broker_arg_backward_compatible(self): """单参调用(旧签名)→ 共享视图(既有测试/调用点不受影响)。""" out = _get_positions(_shared_ctx()) assert set(out.keys()) == {"518880.XSHG", "600036.XSHG"} # =================== channel_test(互卖事故现场) =================== class _RecordingBroker(BrokerFacade): def __init__(self) -> None: self.calls: list = [] super().__init__() self.order_target_value = self._rec self.order_value = self._rec def _rec(self, code, value): self.calls.append((code, value)) return None def _channel_strategy(**kw) -> ChannelTestStrategy: broker = _RecordingBroker() cfg = ChannelTestConfig(hold_n=2, period=1) for k, v in kw.items(): setattr(cfg, k, v) return ChannelTestStrategy(provider=None, broker=broker, config=cfg) class TestChannelTestIsolation: def test_rotate_empty_view_zero_sells(self): """spec 验收核心:实例视图空 → 主调仓零卖出(只买不卖,正确行为)。""" s = _channel_strategy() _attach_ledger(s, {}) s.rotate(_shared_ctx()) sells = [c for c, v in s.broker.calls if v == 0] assert sells == [] def test_rotate_sells_only_instance_positions(self): """目标不含 518880 → 只卖台账里的 518880,不碰别家的 600036。""" s = _channel_strategy() _attach_ledger(s, OWN_VIEW) s.rotate(_shared_ctx()) sells = [c for c, v in s.broker.calls if v == 0] assert sells == ["518880.XSHG"] def test_swap_one_uses_instance_view(self): """13:45 换仓卖出腿只从实例视图取标的(当日 518880 互卖事故路径)。""" s = _channel_strategy(intraday_swap=True) s._day = 1 _attach_ledger(s, OWN_VIEW) s.swap_one(_shared_ctx()) sells = [c for c, v in s.broker.calls if v == 0] assert sells == ["518880.XSHG"] def test_t1_probe_empty_view_no_order(self): """T+1 探针在实例视图空时不下单(不探别家的仓)。""" s = _channel_strategy(probe_t1=True) _attach_ledger(s, {}) s.t1_probe(_shared_ctx()) assert s.broker.calls == [] def test_partial_adjust_skips_unknown_value(self): """加减仓腿:取不到市值的标的跳过,绝不 order_target_value(code,0) 误清仓。""" s = _channel_strategy(intraday_partial=True) _attach_ledger(s, { "300059.XSHE": {"amount": 500, "closeable_amount": 500, "avg_cost": 10.0}, }) # 共享视图无 300059 → value 未知 → 加仓/减仓腿都应跳过 s.partial_adjust(_shared_ctx()) assert s.broker.calls == [] # =================== momentum_timing(熊市清仓段) =================== class TestMomentumBearClearIsolation: def _bear_strategy(self, view): from sanguo_portfolio.strategies import MomentumTimingConfig, MomentumTimingStrategy provider = MagicMock(name="provider") broker = BrokerFacade() broker.order_target_value = MagicMock(return_value=MagicMock(filled=100)) s = MomentumTimingStrategy( provider=provider, broker=broker, config=MomentumTimingConfig(index_list=["IDX.XSHG"]), ) if view is not None: _attach_ledger(s, view) return s def _force_bear(self, s): import pandas as pd s.provider.get_closes_panel.side_effect = None s.provider.get_closes_panel.return_value = pd.DataFrame( {"IDX.XSHG": [40.0 - i for i in range(30)]}, index=pd.DatetimeIndex( pd.date_range(end="2024-10-08", periods=30) ), ) def test_bear_clear_only_instance_positions(self): """真实熊市清仓也只清自己:台账 518880,别家 600036 不动。""" s = self._bear_strategy(OWN_VIEW) self._force_bear(s) s.handle_data(FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))) sell_codes = { c.args[0] for c in s.broker.order_target_value.call_args_list if c.args[1] == 0 } assert sell_codes == {"518880.XSHG"} def test_bear_clear_empty_view_zero_sells(self): """实例视图空 + 熊市 → 零卖出(别家持仓绝不动)。""" s = self._bear_strategy({}) self._force_bear(s) s.handle_data(FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))) sells = [c for c in s.broker.order_target_value.call_args_list if c.args[1] == 0] assert sells == [] # =================== small_cap(_rebalance 清仓段) =================== class TestSmallCapRebalanceIsolation: def test_defensive_clear_only_instance_positions(self): """名单空 → 全清分支只清实例视图内的标的。""" from sanguo_portfolio.strategies import SmallCapConfig provider = MagicMock(name="provider") broker = BrokerFacade() broker.order_target_value = MagicMock(return_value=MagicMock(filled=100)) s = SmallCapStrategy(provider=provider, broker=broker, config=SmallCapConfig()) _attach_ledger(s, OWN_VIEW) s.in_position_stocks = [] s._rebalance(FakeContext(current_dt=datetime(2024, 10, 8, 10, 0))) sell_codes = { c.args[0] for c in s.broker.order_target_value.call_args_list if c.args[1] == 0 } assert sell_codes == {"518880.XSHG"} # =================== all_weather(stop_loss 用 price/avg_cost) =================== class TestAllWeatherStopLossIsolation: def _make(self, view): from sanguo_portfolio.strategies import AllWeatherStrategy provider = MagicMock(name="provider") provider.get_limit_status_batch.side_effect = lambda codes, date=None: { c: {"is_limit_up": False, "is_limit_down": False, "is_paused": False} for c in codes } broker = BrokerFacade() broker.order_target_value = MagicMock(return_value=MagicMock(filled=100)) broker.order_value = MagicMock(return_value=MagicMock(filled=100)) s = AllWeatherStrategy(provider=provider, broker=broker) if view is not None: _attach_ledger(s, view) return s def test_stop_loss_sells_only_instance_positions(self): """两只都跌破 -8%:只卖台账里的 518880(止损价从共享视图补,链路仍通)。""" ctx = FakeContext( current_dt=datetime(2024, 10, 8, 14, 0), positions={ "518880.XSHG": FakePosition("518880.XSHG", avg_cost=10.0, price=8.0), "600036.XSHG": FakePosition("600036.XSHG", avg_cost=40.0, price=30.0), }, ) view = {"518880.XSHG": {"amount": 1000, "closeable_amount": 1000, "avg_cost": 10.0}} s = self._make(view) s.stop_loss(ctx) sell_codes = { c.args[0] for c in s.broker.order_target_value.call_args_list if c.args[1] == 0 } assert sell_codes == {"518880.XSHG"} # =================== A3 预检:B2 定寸虚拟化 × A1 通道 三明治(=VPS 实盘真实状态) =================== # live_strategy._setup 的接线:A1 broker 通道注入 + B2 wrap_scheduler 代理 context # 同时生效。本组用真实 LiveInstanceLedger + make_proxy_context 驱动真策略, # 验证 spec §A3 验收口径(定寸=预算/N,卖出只卖自己)在部署前就成立。 from sanguo_portfolio.live_instance_ledger import LiveInstanceLedger # noqa: E402 from sanguo_portfolio.live_portfolio_proxy import make_proxy_context # noqa: E402 from sanguo_portfolio.strategies.all_weather import _available_cash # noqa: E402 def _own_ledger() -> LiveInstanceLedger: """预算 100 万的实例账本,买过 518880×1000@7.0(现金≈99.3万)。""" led = LiveInstanceLedger(initial_cash=1_000_000.0) led.apply_trade(is_buy=True, symbol="518880.XSHG", price=7.0, volume=1000, trade_id="T-BUY-1", trade_date="2024-10-08") return led def _full_account_ctx() -> FakeContext: """共享 QMT 全账户(前后端 session 8-19 实锤口径):现金 396 万+ 本实例 518880 + 别家 600036,总账户≈995 万量级。""" return FakeContext( current_dt=datetime(2024, 10, 8, 9, 35), cash=3_961_768.0, positions={ "518880.XSHG": FakePosition("518880.XSHG", avg_cost=7.0, price=7.5), "600036.XSHG": FakePosition("600036.XSHG", avg_cost=35.0, price=36.0), }, ) class TestB2SizingSandwich: """B2(4a9208d)+A1(5a91be2) 同开:定寸走账本现金,卖出走账本标的。""" def _sandwich(self, strategy, ledger): """复刻 live_strategy._setup 两层接线(A1 通道 + B2 代理 context)。""" strategy.broker.get_instance_positions = ledger.positions_view return make_proxy_context(_full_account_ctx(), ledger) def test_available_cash_is_ledger_cash_not_account(self): """定寸读数=账本现金(≈99.3万),不是全账户 396 万——168万/只污染根治点。""" led = _own_ledger() ctx = make_proxy_context(_full_account_ctx(), led) cash = _available_cash(ctx) assert cash == led.cash assert 990_000 < cash < 1_000_000 # 预算口径 assert cash < 3_000_000 # 绝不是全账户 def test_channel_test_buy_sized_by_budget(self): """spec §A3 验收:channel_test 等权买入 per=(账本现金+Σ自己市值)/N, ≈50万/只(hold_n=2),不再是 168万(=995万/6)量级。""" led = _own_ledger() broker = _RecordingBroker() s = ChannelTestStrategy( provider=None, broker=broker, config=ChannelTestConfig(hold_n=2, period=1, probe_t1=False, intraday_partial=False, intraday_swap=False), ) ctx = self._sandwich(s, led) s.rotate(ctx) buys = [(c, v) for c, v in broker.calls if v > 0] assert len(buys) == 2 own_mv = 7.5 * 1000 # 518880 现价×量(现价从真 portfolio 透传) expect_per = (led.cash + own_mv) / 2 for _code, v in buys: assert v == pytest.approx(expect_per, rel=1e-6) assert expect_per < 600_000 # 预算/2 量级,非全账户/2 def test_sandwich_sells_only_own(self): """三明治下轮换只卖台账里的 518880,别家 600036 不动。""" led = _own_ledger() broker = _RecordingBroker() s = ChannelTestStrategy( provider=None, broker=broker, config=ChannelTestConfig(hold_n=2, period=1, probe_t1=False, intraday_partial=False, intraday_swap=False), ) ctx = self._sandwich(s, led) s.rotate(ctx) sells = [c for c, v in broker.calls if v == 0] assert sells == ["518880.XSHG"] def test_stop_loss_price_flows_through_both_layers(self): """止损链路三层通:账本 avg_cost(7.0) + 真账户现价透传(6.0) → 触发; 别家 600036 同跌不动。""" led = _own_ledger() from sanguo_portfolio.strategies import AllWeatherStrategy provider = MagicMock(name="provider") provider.get_limit_status_batch.side_effect = lambda codes, date=None: { c: {"is_limit_up": False, "is_limit_down": False, "is_paused": False} for c in codes } broker = BrokerFacade() broker.order_target_value = MagicMock(return_value=MagicMock(filled=100)) s = AllWeatherStrategy(provider=provider, broker=broker) real_ctx = FakeContext( current_dt=datetime(2024, 10, 8, 14, 0), positions={ "518880.XSHG": FakePosition("518880.XSHG", avg_cost=7.0, price=6.0), "600036.XSHG": FakePosition("600036.XSHG", avg_cost=40.0, price=30.0), }, ) s.broker.get_instance_positions = led.positions_view s.stop_loss(make_proxy_context(real_ctx, led)) sell_codes = { c.args[0] for c in broker.order_target_value.call_args_list if c.args[1] == 0 } assert sell_codes == {"518880.XSHG"} # 6.0 < 7.0*0.92=6.44 触发,只卖自己 def test_no_ledger_proxy_is_identity(self): """无账本(回测/影子/模拟盘)→ make_proxy_context 原样返回,零改动铁律。""" ctx = _full_account_ctx() assert make_proxy_context(ctx, None) is ctx