From 5a91be2efd43357a445c01a13b4054a47dae4985 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Wed, 19 Aug 2026 22:22:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(strategy):=20A1=20=E5=8D=96=E5=87=BA?= =?UTF-8?q?=E5=8F=AA=E5=8D=96=E8=87=AA=E5=B7=B1=E2=80=94=E2=80=94=E5=85=B1?= =?UTF-8?q?=E4=BA=ABQMT=E8=B4=A6=E6=88=B7=E4=BA=92=E5=8D=96=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB,=E6=8C=81=E4=BB=93=E8=AF=BB=E5=8F=96=E4=BC=98?= =?UTF-8?q?=E5=85=88=E5=AE=9E=E4=BE=8B=E8=B4=A6=E6=9C=AC=E8=A7=86=E5=9B=BE?= =?UTF-8?q?(spec=20=C2=A7A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- sanguo_portfolio/strategies/all_weather.py | 69 ++++- sanguo_portfolio/strategies/all_weather_ex.py | 69 ++++- sanguo_portfolio/strategies/channel_test.py | 18 +- .../strategies/momentum_timing.py | 6 +- .../strategies/momentum_timing_ex.py | 6 +- sanguo_portfolio/strategies/small_cap.py | 12 +- sanguo_portfolio/strategies/small_cap_ex.py | 12 +- .../strategies/value_selection.py | 6 +- .../strategies/value_selection_ex.py | 6 +- .../portfolio/test_instance_view_isolation.py | 258 ++++++++++++++++++ 10 files changed, 416 insertions(+), 46 deletions(-) create mode 100644 tests/portfolio/test_instance_view_isolation.py diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index 40cd2c7..ab8c294 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -135,7 +135,7 @@ class AllWeatherStrategy: # =================== prepare_stock_list =================== def prepare_stock_list(self, context: Any) -> None: """刷新 g.hold_list + g.yesterday_HL_list(持仓中昨日涨停的)。""" - positions = _get_positions(context) + positions = _get_positions(context, self.broker) self.hold_list = list(positions.keys()) self.yesterday_hl_list = [] @@ -161,7 +161,7 @@ class AllWeatherStrategy: # =================== stop_loss =================== def stop_loss(self, context: Any) -> None: """昨日涨停打开则卖出;持仓 price < avg_cost*0.92 止损;补仓最多 N 只。""" - positions = _get_positions(context) + positions = _get_positions(context, self.broker) num_sold = 0 now_time = _current_dt(context) @@ -242,7 +242,7 @@ class AllWeatherStrategy: # 4) 过滤涨停/跌停/停牌(P1.3 批量预取:一次查三 filter 共享,原逐 filter # 不传 status_map/date → 全部走"无数据保留"死分支,照买涨停照交易停牌) - pos_keys = list(_get_positions(context).keys()) + pos_keys = list(_get_positions(context, self.broker).keys()) status_map = self._get_limit_status(target, current_dt) target = filters.filter_limitup_stock( target, self.provider, positions=pos_keys, status_map=status_map @@ -256,7 +256,7 @@ class AllWeatherStrategy: target = _dedup(target) # 5) 调仓:先清掉不在 target 的(除昨日涨停) - positions = _get_positions(context) + positions = _get_positions(context, self.broker) for stock in list(positions.keys()): if stock in target or stock in self.yesterday_hl_list: continue @@ -264,7 +264,7 @@ class AllWeatherStrategy: # 卖出后重取:聚宽原版卖出后持仓同步更新,若沿用卖出前快照, # 持仓数≥目标数的换仓月 target_num>position_count 恒 False → 一股不买, # 空仓躺到下月(2025-09-01/12-01 NAS 实测) - positions = _get_positions(context) + positions = _get_positions(context, self.broker) # 6) 买入 target 里的新股 position_count = len(positions) @@ -275,7 +275,7 @@ class AllWeatherStrategy: if stock in positions: continue if self._open_position(stock, cash): - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break logger.info("monthly_adjustment 结束: target=%s", target) @@ -501,7 +501,62 @@ class AllWeatherStrategy: # ======================== context 适配 ======================== -def _get_positions(context: Any) -> Dict[str, Any]: +class _InstancePosition: + """实例账本视图标的(轻量 jq Position)。 + + 数量/成本来自实例台账(LiveInstanceLedger.positions_view);现价/市值从 + 共享组合视图**同名标的**补——行情是市场数据不是所有权,别家持仓不含在内。 + 共享视图缺该标的(快照缺口)→ price None / value 0.0,调用方自行降级。 + """ + + __slots__ = ("security", "total_amount", "amount", "closeable_amount", + "avg_cost", "price", "last_sale_price", "value") + + def __init__(self, symbol: str, info: Dict[str, Any], real: Any) -> None: + self.security = symbol + self.total_amount = self.amount = int(info.get("amount", 0)) + self.closeable_amount = int(info.get("closeable_amount", 0)) + self.avg_cost = float(info.get("avg_cost", 0.0)) + self.price = _pos_price(real) if real is not None else None + self.last_sale_price = self.price + self.value = _pos_value(real) or 0.0 + + +def _pos_value(pos: Any) -> Optional[float]: + """从 position 对象取市值,兼容多种属性名;取不到返回 None。""" + for attr in ("value", "market_value", "total_value"): + v = getattr(pos, attr, None) + if v is not None: + return float(v) + return None + + +def _get_positions(context: Any, broker: Any = None) -> Dict[str, Any]: + """策略持仓视图(卖出/持仓判断的唯一入口,magic number 铁律)。 + + - broker 带 ``get_instance_positions``(实盘 runner_live 注入实例账本, + dae56e2)→ 只返回**本实例**的持仓:共享 QMT 账户下别家策略/手动持仓 + 绝不进卖出名单(2026-08-19 互卖事故根治,spec §A1)。视图空 = 本实例 + 无持仓 → 轮换退化为"只买不卖"(正确行为,不是 bug)。 + - 否则(回测/影子/单测)→ 原 context.portfolio.positions 逻辑不变。 + """ + shared = _shared_positions(context) + fn = getattr(broker, "get_instance_positions", None) if broker is not None else None + if fn is None: + return shared + view = fn() + if not isinstance(view, dict): + raise TypeError( + f"get_instance_positions 返回非 dict: {type(view).__name__}(通道损坏," + f"fail-fast 不回退共享视图——回退=互卖)" + ) + return { + sym: _InstancePosition(sym, info, shared.get(sym)) + for sym, info in view.items() + } + + +def _shared_positions(context: Any) -> Dict[str, Any]: """聚宽 context.portfolio.positions,兼容 dict / list / 对象。""" if context is None: return {} diff --git a/sanguo_portfolio/strategies/all_weather_ex.py b/sanguo_portfolio/strategies/all_weather_ex.py index 5f74b9e..0a80670 100644 --- a/sanguo_portfolio/strategies/all_weather_ex.py +++ b/sanguo_portfolio/strategies/all_weather_ex.py @@ -53,6 +53,10 @@ class BrokerFacade: set_order_cost: Callable[[Any, str], None] = lambda cost, type: None run_daily: Callable[..., None] = lambda *a, **kw: None run_monthly: Callable[..., None] = lambda *a, **kw: None + # 实例持仓通道(2026-08-19 共享QMT账户互卖根治,spec §A1):live 时 + # runner_live 注入 ledger.positions_view;回测/无台账为 None → 回退 + # context.portfolio。与 all_weather.BrokerFacade 保持同构(副本契约)。 + get_instance_positions: Optional[Callable[[], Dict[str, Dict[str, Any]]]] = None # ------------------------ 策略 ------------------------ @@ -134,7 +138,7 @@ class AllWeatherExStrategy: # =================== prepare_stock_list =================== def prepare_stock_list(self, context: Any) -> None: """刷新 g.hold_list + g.yesterday_HL_list(持仓中昨日涨停的)。""" - positions = _get_positions(context) + positions = _get_positions(context, self.broker) self.hold_list = list(positions.keys()) self.yesterday_hl_list = [] @@ -160,7 +164,7 @@ class AllWeatherExStrategy: # =================== stop_loss =================== def stop_loss(self, context: Any) -> None: """昨日涨停打开则卖出;持仓 price < avg_cost*0.92 止损;补仓最多 N 只。""" - positions = _get_positions(context) + positions = _get_positions(context, self.broker) num_sold = 0 now_time = _current_dt(context) @@ -241,7 +245,7 @@ class AllWeatherExStrategy: # 4) 过滤涨停/跌停/停牌(P1.3 批量预取:一次查三 filter 共享,原逐 filter # 不传 status_map/date → 全部走"无数据保留"死分支,照买涨停照交易停牌) - pos_keys = list(_get_positions(context).keys()) + pos_keys = list(_get_positions(context, self.broker).keys()) status_map = self._get_limit_status(target, current_dt) target = filters.filter_limitup_stock( target, self.provider, positions=pos_keys, status_map=status_map @@ -255,7 +259,7 @@ class AllWeatherExStrategy: target = _dedup(target) # 5) 调仓:先清掉不在 target 的(除昨日涨停) - positions = _get_positions(context) + positions = _get_positions(context, self.broker) for stock in list(positions.keys()): if stock in target or stock in self.yesterday_hl_list: continue @@ -263,7 +267,7 @@ class AllWeatherExStrategy: # 卖出后重取:聚宽原版卖出后持仓同步更新,若沿用卖出前快照, # 持仓数≥目标数的换仓月 target_num>position_count 恒 False → 一股不买, # 空仓躺到下月(2025-09-01/12-01 NAS 实测) - positions = _get_positions(context) + positions = _get_positions(context, self.broker) # 6) 买入 target 里的新股 position_count = len(positions) @@ -274,7 +278,7 @@ class AllWeatherExStrategy: if stock in positions: continue if self._open_position(stock, cash): - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break logger.info("monthly_adjustment 结束: target=%s", target) @@ -500,7 +504,58 @@ class AllWeatherExStrategy: # ======================== context 适配 ======================== -def _get_positions(context: Any) -> Dict[str, Any]: +class _InstancePosition: + """实例账本视图标的(轻量 jq Position),与 all_weather 同构(副本契约)。 + + 数量/成本来自实例台账;现价/市值从共享组合视图同名标的补(市场数据 + 非所有权);共享视图缺该标的 → price None / value 0.0,调用方自行降级。 + """ + + __slots__ = ("security", "total_amount", "amount", "closeable_amount", + "avg_cost", "price", "last_sale_price", "value") + + def __init__(self, symbol: str, info: Dict[str, Any], real: Any) -> None: + self.security = symbol + self.total_amount = self.amount = int(info.get("amount", 0)) + self.closeable_amount = int(info.get("closeable_amount", 0)) + self.avg_cost = float(info.get("avg_cost", 0.0)) + self.price = _pos_price(real) if real is not None else None + self.last_sale_price = self.price + self.value = _pos_value(real) or 0.0 + + +def _pos_value(pos: Any) -> Optional[float]: + """从 position 对象取市值,兼容多种属性名;取不到返回 None。""" + for attr in ("value", "market_value", "total_value"): + v = getattr(pos, attr, None) + if v is not None: + return float(v) + return None + + +def _get_positions(context: Any, broker: Any = None) -> Dict[str, Any]: + """策略持仓视图(卖出/持仓判断唯一入口,spec §A1 卖出只卖自己)。 + + broker 带 get_instance_positions(实盘注入)→ 只返回本实例持仓; + 否则(回测/影子/单测)→ 原 context.portfolio.positions 逻辑不变。 + """ + shared = _shared_positions(context) + fn = getattr(broker, "get_instance_positions", None) if broker is not None else None + if fn is None: + return shared + view = fn() + if not isinstance(view, dict): + raise TypeError( + f"get_instance_positions 返回非 dict: {type(view).__name__}(通道损坏," + f"fail-fast 不回退共享视图——回退=互卖)" + ) + return { + sym: _InstancePosition(sym, info, shared.get(sym)) + for sym, info in view.items() + } + + +def _shared_positions(context: Any) -> Dict[str, Any]: """聚宽 context.portfolio.positions,兼容 dict / list / 对象。""" if context is None: return {} diff --git a/sanguo_portfolio/strategies/channel_test.py b/sanguo_portfolio/strategies/channel_test.py index 9fe3a2e..0976802 100644 --- a/sanguo_portfolio/strategies/channel_test.py +++ b/sanguo_portfolio/strategies/channel_test.py @@ -89,7 +89,7 @@ class ChannelTestStrategy: self._day += 1 if (self._day - 1) % max(1, self.config.period) != 0: return - positions = _get_positions(context) + positions = _get_positions(context, self.broker) target = self._target_set() target_set = set(target) logger.info("[channel_test] day=%d 主调仓 target=%s holding=%s", @@ -102,17 +102,19 @@ class ChannelTestStrategy: # ---------------- 场景2:部分加减仓(非清仓) ---------------- def partial_adjust(self, context: Any) -> None: - positions = _get_positions(context) + positions = _get_positions(context, self.broker) if not positions: return total = _available_cash(context) + sum(_safe_value(p) for p in positions.values()) codes = sorted(positions.keys()) half = max(1, (len(codes) + 1) // 2) for code in codes[:half]: - # 加仓 10%(走"对已持仓追加买入") + # 加仓 10%(走"对已持仓追加买入");市值未知(实例视图缺现价)→ 跳过, + # 绝不 order_target_value(code, 0) 把"加仓"变成清仓 pos_val = _safe_value(positions[code]) - self.broker.order_target_value(code, pos_val * 1.1) - logger.info("[channel_test] 加仓 %s → +10%%", code) + if pos_val > 0: + self.broker.order_target_value(code, pos_val * 1.1) + logger.info("[channel_test] 加仓 %s → +10%%", code) for code in codes[half:half + 1]: # 减仓到一半(走"部分卖出",非清仓) pos_val = _safe_value(positions[code]) @@ -123,7 +125,7 @@ class ChannelTestStrategy: # ---------------- 场景3:卖后买(当日资金复用) ---------------- def swap_one(self, context: Any) -> None: - positions = _get_positions(context) + positions = _get_positions(context, self.broker) if not positions: return # 清掉第一只(昨日买的,T+1 可卖) → 换买 universe 里下一只未持有的 @@ -141,7 +143,7 @@ class ChannelTestStrategy: # ---------------- 场景4:T+1 拒单探针 ---------------- def t1_probe(self, context: Any) -> None: """当日(含今早刚买)持仓立即试卖 → 两端都应被 T+1 拒,验证拒单通路一致。""" - positions = _get_positions(context) + positions = _get_positions(context, self.broker) # 优先挑"今天买的":无买入时间信息就取第一只(今早主调仓必然买过新仓) if not positions: return @@ -154,7 +156,7 @@ class ChannelTestStrategy: # ---------------- 买入工具 ---------------- def _buy_equal_weight(self, context: Any, target: List[str]) -> None: - positions = _get_positions(context) + positions = _get_positions(context, self.broker) cash = _available_cash(context) total = cash + sum(_safe_value(p) for p in positions.values()) per = total / max(1, len(target)) diff --git a/sanguo_portfolio/strategies/momentum_timing.py b/sanguo_portfolio/strategies/momentum_timing.py index 1cd8eb4..83db53f 100644 --- a/sanguo_portfolio/strategies/momentum_timing.py +++ b/sanguo_portfolio/strategies/momentum_timing.py @@ -155,7 +155,7 @@ class MomentumTimingStrategy: return logger.info("[%s] buy_sign=%s", cur_date, buy_sign) - positions = _get_positions(context) + positions = _get_positions(context, self.broker) if not buy_sign: # 熊市:全部清仓(原策略语义,只对"用真实数据算出的熊市") @@ -212,7 +212,7 @@ class MomentumTimingStrategy: self._close_position(stock) # 7) 等额买入 stocks 里的新股(原策略 cash/countStocks 语义) - positions = _get_positions(context) # 卖出后刷新 + positions = _get_positions(context, self.broker) # 卖出后刷新 target_num = len(stocks) if target_num == 0: return @@ -224,7 +224,7 @@ class MomentumTimingStrategy: if stock in positions: continue if self._open_position(stock, per_value): - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break logger.info("[%s] 牛市调仓结束: target=%s", cur_date, stocks) diff --git a/sanguo_portfolio/strategies/momentum_timing_ex.py b/sanguo_portfolio/strategies/momentum_timing_ex.py index a70ef92..40b1da1 100644 --- a/sanguo_portfolio/strategies/momentum_timing_ex.py +++ b/sanguo_portfolio/strategies/momentum_timing_ex.py @@ -151,7 +151,7 @@ class MomentumTimingExStrategy: return logger.info("[%s] buy_sign=%s", cur_date, buy_sign) - positions = _get_positions(context) + positions = _get_positions(context, self.broker) if not buy_sign: # 熊市:全部清仓(原策略语义,只对"用真实数据算出的熊市") @@ -202,7 +202,7 @@ class MomentumTimingExStrategy: self._close_position(stock) # 7) 等额买入 stocks 里的新股(原策略 cash/countStocks 语义) - positions = _get_positions(context) # 卖出后刷新 + positions = _get_positions(context, self.broker) # 卖出后刷新 target_num = len(stocks) if target_num == 0: return @@ -214,7 +214,7 @@ class MomentumTimingExStrategy: if stock in positions: continue if self._open_position(stock, per_value): - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break logger.info("[%s] 牛市调仓结束: target=%s", cur_date, stocks) diff --git a/sanguo_portfolio/strategies/small_cap.py b/sanguo_portfolio/strategies/small_cap.py index ad3cdac..30b9389 100644 --- a/sanguo_portfolio/strategies/small_cap.py +++ b/sanguo_portfolio/strategies/small_cap.py @@ -254,11 +254,11 @@ class SmallCapStrategy: ) top_candidates = filters.filter_limitup_stock( top_candidates, self.provider, - positions=list(_get_positions(context).keys()), status_map=status_map, + positions=list(_get_positions(context, self.broker).keys()), status_map=status_map, ) top_candidates = filters.filter_limitdown_stock( top_candidates, self.provider, - positions=list(_get_positions(context).keys()), status_map=status_map, + positions=list(_get_positions(context, self.broker).keys()), status_map=status_map, ) top_candidates = _dedup(top_candidates) @@ -357,11 +357,11 @@ class SmallCapStrategy: target_stocks = list(self.in_position_stocks) if not target_stocks: # 名单空 → 全清(防御性,正常不会到这里) - for code in list(_get_positions(context).keys()): + for code in list(_get_positions(context, self.broker).keys()): self._close_position(code) return - positions = _get_positions(context) + positions = _get_positions(context, self.broker) # 1) 卖出不在 target 的(原策略 order_target(stock, 0, pindex=0)) for code in list(positions.keys()): @@ -370,7 +370,7 @@ class SmallCapStrategy: self._close_position(code) # 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len) - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 target_num = len(target_stocks) cash = _available_cash(context) if cash <= 0 or target_num == 0: @@ -380,7 +380,7 @@ class SmallCapStrategy: if code in positions: continue if self._open_position(code, per_value): - positions = _get_positions(context) + positions = _get_positions(context, self.broker) if len(positions) >= target_num: break logger.info( diff --git a/sanguo_portfolio/strategies/small_cap_ex.py b/sanguo_portfolio/strategies/small_cap_ex.py index b8a982b..60ee4a2 100644 --- a/sanguo_portfolio/strategies/small_cap_ex.py +++ b/sanguo_portfolio/strategies/small_cap_ex.py @@ -257,11 +257,11 @@ class SmallCapExStrategy: ) top_candidates = filters.filter_limitup_stock( top_candidates, self.provider, - positions=list(_get_positions(context).keys()), status_map=status_map, + positions=list(_get_positions(context, self.broker).keys()), status_map=status_map, ) top_candidates = filters.filter_limitdown_stock( top_candidates, self.provider, - positions=list(_get_positions(context).keys()), status_map=status_map, + positions=list(_get_positions(context, self.broker).keys()), status_map=status_map, ) top_candidates = _dedup(top_candidates) @@ -360,11 +360,11 @@ class SmallCapExStrategy: target_stocks = list(self.in_position_stocks) if not target_stocks: # 名单空 → 全清(防御性,正常不会到这里) - for code in list(_get_positions(context).keys()): + for code in list(_get_positions(context, self.broker).keys()): self._close_position(code) return - positions = _get_positions(context) + positions = _get_positions(context, self.broker) # 1) 卖出不在 target 的(原策略 order_target(stock, 0, pindex=0)) for code in list(positions.keys()): @@ -373,7 +373,7 @@ class SmallCapExStrategy: self._close_position(code) # 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len) - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 target_num = len(target_stocks) cash = _available_cash(context) if cash <= 0 or target_num == 0: @@ -383,7 +383,7 @@ class SmallCapExStrategy: if code in positions: continue if self._open_position(code, per_value): - positions = _get_positions(context) + positions = _get_positions(context, self.broker) if len(positions) >= target_num: break logger.info( diff --git a/sanguo_portfolio/strategies/value_selection.py b/sanguo_portfolio/strategies/value_selection.py index af71006..0cf42fd 100644 --- a/sanguo_portfolio/strategies/value_selection.py +++ b/sanguo_portfolio/strategies/value_selection.py @@ -171,7 +171,7 @@ class ValueSelectionStrategy: # 3) 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters) # 批量预取当日涨跌停/停牌状态(数据 session 判断好),三个 filter 共享一次查询 - positions = _get_positions(context) + positions = _get_positions(context, self.broker) status_map = self._get_limit_status(buy_list, previous_date) buy_list = filters.filter_limitup_stock( buy_list, self.provider, @@ -193,7 +193,7 @@ class ValueSelectionStrategy: self._close_position(stock) # 5) 等额买入 buy_list 里的新股(原策略 buy 函数, cash/countStocks 语义) - positions = _get_positions(context) # 卖出后刷新 + positions = _get_positions(context, self.broker) # 卖出后刷新 target_num = len(buy_list) if target_num == 0: return @@ -205,7 +205,7 @@ class ValueSelectionStrategy: if stock in positions: continue if self._open_position(stock, per_value): - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break logger.info("[%s] 月度调仓结束: target=%s", previous_date, buy_list) diff --git a/sanguo_portfolio/strategies/value_selection_ex.py b/sanguo_portfolio/strategies/value_selection_ex.py index 888f11e..42fea2a 100644 --- a/sanguo_portfolio/strategies/value_selection_ex.py +++ b/sanguo_portfolio/strategies/value_selection_ex.py @@ -174,7 +174,7 @@ class ValueSelectionExStrategy: # 3) 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters) # 批量预取当日涨跌停/停牌状态(数据 session 判断好),三个 filter 共享一次查询 - positions = _get_positions(context) + positions = _get_positions(context, self.broker) status_map = self._get_limit_status(buy_list, previous_date) buy_list = filters.filter_limitup_stock( buy_list, self.provider, @@ -196,7 +196,7 @@ class ValueSelectionExStrategy: self._close_position(stock) # 5) 等额买入 buy_list 里的新股(原策略 buy 函数, cash/countStocks 语义) - positions = _get_positions(context) # 卖出后刷新 + positions = _get_positions(context, self.broker) # 卖出后刷新 target_num = len(buy_list) if target_num == 0: return @@ -208,7 +208,7 @@ class ValueSelectionExStrategy: if stock in positions: continue if self._open_position(stock, per_value): - positions = _get_positions(context) # 刷新 + positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break logger.info("[%s] 月度调仓结束: target=%s", previous_date, buy_list) diff --git a/tests/portfolio/test_instance_view_isolation.py b/tests/portfolio/test_instance_view_isolation.py new file mode 100644 index 0000000..cc336f2 --- /dev/null +++ b/tests/portfolio/test_instance_view_isolation.py @@ -0,0 +1,258 @@ +"""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"}