Files
sanguo_vnpy_v2/tests/portfolio/test_instance_view_isolation.py
T
claude_dev 5a91be2efd
CI/CD / test (push) Failing after 7s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
fix(strategy): A1 卖出只卖自己——共享QMT账户互卖根治,持仓读取优先实例账本视图(spec §A1)
2026-08-19 盘后实锤:8路实盘共享miniQMT账户66639661,context.portfolio是
全账户视图→'卖掉所有不在目标里的持仓'卖掉别家持仓(518880/600036双实例
各卖当日真实发生)。spec: docs/design/multi-strategy-instance-budget-spec.md

- _get_positions(context, broker=None)(all_weather共享helper+all_weather_ex
  同构副本):broker.get_instance_positions存在(runner_live注入台账,dae56e2)
  →只返回本实例持仓(_InstancePosition:数量/成本取台账,price/value从共享
  视图同名标的补=市场数据非所有权);否则回测/影子原context.portfolio不变,
  模拟盘零改动;通道返非dict→TypeError fail-fast(回退=互卖,宁可崩)
- 视图空dict→轮换退化'只买不卖'(spec:正确行为不是bug)
- 逐个卖出腿过一遍(spec清单):channel_test rotate/partial_adjust/swap_one/
  t1_probe、momentum/_ex熊市清仓+调仓+买入去重、small_cap/_ex _rebalance、
  value_selection/_ex月度卖出、all_weather/_ex prepare/stop_loss/monthly
- 附带修:channel_test加仓腿市值未知时跳过,绝不order_target_value(code,0)
  把加仓变清仓
- 测试:test_instance_view_isolation.py 14条(helper5+channel_test5+momentum
  熊市2+small_cap1+all_weather止损1),含'实例视图空→零卖出'×3策略;
  strategy 126全绿;A2属性清单已发issue#29给前后端(B2虚拟化输入)
- 注:工作树另有前后端session未提交WIP(routes_live.py B3预算),不混入本commit

[vps]
2026-08-19 22:22:42 +08:00

259 lines
10 KiB
Python

"""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
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"}