5a91be2efd
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]
259 lines
10 KiB
Python
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"}
|