b86af798aa
复刻 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过)
376 lines
16 KiB
Python
376 lines
16 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
|
||
|
||
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
|