fix(strategy): A1 卖出只卖自己——共享QMT账户互卖根治,持仓读取优先实例账本视图(spec §A1)
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:
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user