Files
sanguo_vnpy_v2/tests/portfolio/test_instance_view_isolation.py
T
claude_dev b86af798aa
CI/CD / test (push) Successful in 23s
CI/CD / nas-deploy (push) Successful in 32s
CI/CD / nas-verify (push) Successful in 18s
test(strategy): A3预检——B2定寸虚拟化×A1通道三明治集成测试5条(issue#29) [vps]
复刻 live_strategy._setup 实盘接线(A1 broker通道注入+B2 wrap_scheduler代理
context 同开=VPS重建后将运行的真实状态),真实 LiveInstanceLedger+真策略驱动:
①available_cash 过代理=账本现金(99.3万预算口径,非全账户396万)
②channel_test 等权买入 per=(账本现金+Σ自己市值)/N≈50万/只(hold_n=2),
  精确断言 spec §A3 验收口径(非168万=995万/6量级)
③三明治下轮换只卖台账标的(别家600036不动)
④止损链路三层通:账本avg_cost+真账户现价透传(6.0<7.0*0.92触发,只卖自己)
⑤无账本 make_proxy_context 原样返回(模拟盘零改动铁律)
19/19绿;portfolio 367绿(3失败=Mac缺bullet_trade环境性,NAS CI过)
2026-08-19 22:42:30 +08:00

376 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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