fix(strategy): A1 卖出只卖自己——共享QMT账户互卖根治,持仓读取优先实例账本视图(spec §A1)
CI/CD / test (push) Failing after 7s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

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]
This commit is contained in:
2026-08-19 22:22:42 +08:00
parent f9b6d77ebd
commit 5a91be2efd
10 changed files with 416 additions and 46 deletions
+62 -7
View File
@@ -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 {}
+62 -7
View File
@@ -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 {}
+10 -8
View File
@@ -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))
@@ -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)
@@ -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)
+6 -6
View File
@@ -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(
+6 -6
View File
@@ -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(
@@ -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)
@@ -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)
@@ -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"}