From 61cc01efbed4462b4b86646dec025f2597ef0e23 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Fri, 14 Aug 2026 23:04:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20P1.3=E6=B6=A8=E8=B7=8C?= =?UTF-8?q?=E5=81=9C/=E5=81=9C=E7=89=8C=E8=BF=87=E6=BB=A4=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=8C=96=E5=8F=8C=E7=AB=AF=E2=80=94=E2=91=A0all=5Fwea?= =?UTF-8?q?ther=20monthly=5Fadjustment=E4=B8=89filter=E6=8E=A5=E7=BA=BF=5F?= =?UTF-8?q?get=5Flimit=5Fstatus=E6=89=B9=E9=87=8F=E9=A2=84=E5=8F=96(?= =?UTF-8?q?=E5=8E=9F=E4=B8=8D=E4=BC=A0status=5Fmap/date=E8=B5=B0=E6=97=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BF=9D=E7=95=99=E6=AD=BB=E5=88=86=E6=94=AF?= =?UTF-8?q?,=E7=85=A7=E4=B9=B0=E6=B6=A8=E5=81=9C=E7=85=A7=E4=BA=A4?= =?UTF-8?q?=E6=98=93=E5=81=9C=E7=89=8C;=E5=90=8Csmall=5Fcap=E7=AD=89?= =?UTF-8?q?=E4=B8=89=E7=AD=96=E7=95=A5)=20=E2=91=A1ShadowBroker=E6=B6=A8?= =?UTF-8?q?=E8=B7=8C=E5=81=9C=E6=8B=92=E5=8D=95(=E4=B9=B0=E6=92=9E?= =?UTF-8?q?=E6=B6=A8=E5=81=9C/=E5=8D=96=E6=92=9E=E8=B7=8C=E5=81=9C/?= =?UTF-8?q?=E5=81=9C=E7=89=8C=E5=8F=8C=E5=90=91=E6=8B=92;limit=5Fgetter?= =?UTF-8?q?=E6=B3=A8=E5=85=A5,=E6=9C=AA=E6=B3=A8=E5=85=A5/=E5=BC=82?= =?UTF-8?q?=E5=B8=B8/=E6=97=A0=E7=8A=B6=E6=80=81=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E7=AD=89=E4=BB=B7=E6=97=A7=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?)=20=E2=91=A2runner=20build=5Flimit=5Fgetter(=E4=BC=98=E5=85=88?= =?UTF-8?q?miniQMT=E5=AE=9E=E6=97=B6tick=20lastPrice=20vs=20UpStop/DownSto?= =?UTF-8?q?p=E4=B8=8E=E5=AE=9E=E7=9B=98=E5=90=8C=E6=BA=90,=E5=9B=9E?= =?UTF-8?q?=E9=80=80get=5Flimit=5Fstatus=5Fbatch=E6=97=A5=E7=BA=BF?= =?UTF-8?q?=E5=8F=A3=E5=BE=84);9=E6=96=B0=E6=B5=8B=E8=AF=95=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_portfolio/strategies/all_weather.py | 33 ++++++--- sanguo_trader/shadow/broker.py | 31 ++++++++ sanguo_trader/shadow/runner.py | 39 ++++++++++ tests/portfolio/test_all_weather.py | 51 +++++++++++++ tests/trader/test_shadow_broker.py | 86 ++++++++++++++++++++++ 5 files changed, 231 insertions(+), 9 deletions(-) diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index 0cadc85..8d66f56 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -244,14 +244,19 @@ class AllWeatherStrategy: logger.info("开外盘(海外 ETF)") target = list(cfg.foreign_etf) - # 4) 过滤涨停/跌停/停牌 + # 4) 过滤涨停/跌停/停牌(P1.3 批量预取:一次查三 filter 共享,原逐 filter + # 不传 status_map/date → 全部走"无数据保留"死分支,照买涨停照交易停牌) + pos_keys = list(_get_positions(context).keys()) + status_map = self._get_limit_status(target, current_dt) target = filters.filter_limitup_stock( - target, self.provider, positions=list(_get_positions(context).keys()) + target, self.provider, positions=pos_keys, status_map=status_map ) target = filters.filter_limitdown_stock( - target, self.provider, positions=list(_get_positions(context).keys()) + target, self.provider, positions=pos_keys, status_map=status_map + ) + target = filters.filter_paused_stock( + target, self.provider, status_map=status_map ) - target = filters.filter_paused_stock(target, self.provider) target = _dedup(target) # 5) 调仓:先清掉不在 target 的(除昨日涨停) @@ -460,15 +465,25 @@ class AllWeatherStrategy: 幅度精确算涨跌停价,与 filters/其他策略同源)。provider 未实现/异常 → None(跳过该股,等价原失效行为)。 """ + return self._get_limit_status([stock], now_time).get(stock) + + def _get_limit_status(self, stocks: List[str], date: Any) -> Dict[str, Any]: + """批量预取涨跌停/停牌状态(P1.3:monthly_adjustment 三 filter 共享一次查询)。 + + 与 small_cap/momentum_timing/value_selection 同款:provider 未实现 + get_limit_status_batch / 异常 → 返空 dict(filter 见 None 走"无数据 + 保留所有"分支,等价原失效行为)。 + """ + if not stocks: + return {} fn = getattr(self.provider, "get_limit_status_batch", None) if fn is None: - return None + return {} try: - result = fn([stock], now_time) or {} + return fn(stocks, date) or {} except Exception as exc: - logger.warning("get_limit_status_batch 失败 %s: %s", stock, exc) - return None - return result.get(stock) + logger.warning("get_limit_status_batch 失败: %s", exc) + return {} # ======================== context 适配 ======================== diff --git a/sanguo_trader/shadow/broker.py b/sanguo_trader/shadow/broker.py index 77ca58c..d051c0b 100644 --- a/sanguo_trader/shadow/broker.py +++ b/sanguo_trader/shadow/broker.py @@ -34,6 +34,7 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du min_commission: float = 5.0, slippage: float = 0.0, price_getter: Optional[Callable[[str], Optional[float]]] = None, + limit_getter: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, on_trade: Optional[Callable[[Dict[str, Any]], None]] = None, now_provider: Optional[Callable[[], datetime]] = None, ) -> None: @@ -44,6 +45,7 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du self.min_commission = float(min_commission) self.slippage = float(slippage) self.price_getter = price_getter + self.limit_getter = limit_getter self.on_trade = on_trade self._now = now_provider or datetime.now self._connected = True # 本地柜台永远"在线" @@ -104,6 +106,29 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du ref = None return ref + def _limit_blocked(self, security: str, side: str) -> Optional[str]: + """涨跌停/停牌拒单原因(P1.3,双轨对账与实盘约束对齐)。 + + buy 撞涨停买不进、sell 撞跌停卖不出、停牌双向拒。 + limit_getter 未注入/异常/无状态 → None(放行,等价旧行为)。 + """ + if self.limit_getter is None: + return None + try: + status = self.limit_getter(security) + except Exception as exc: # noqa: BLE001 - 状态查询失败降级放行 + logger.warning("[shadow] 涨跌停状态查询失败 %s: %s", security, exc) + return None + if not isinstance(status, dict): + return None + if status.get("is_paused") is True: + return "停牌不可交易" + if side == "buy" and status.get("is_limit_up") is True: + return "涨停拒买" + if side == "sell" and status.get("is_limit_down") is True: + return "跌停拒卖" + return None + # ===== 下单(即时全额成交) ===== async def buy(self, security: str, amount: int, price: Optional[float] = None, wait_timeout: Optional[float] = None, remark: Optional[str] = None, @@ -112,6 +137,9 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du ref = self._ref_price(security, price) if ref is None or ref <= 0: return self._reject(order_id, "无参考价") + blocked = self._limit_blocked(security, "buy") + if blocked: + return self._reject(order_id, blocked) amount = int(amount) if amount <= 0: return self._reject(order_id, "数量非法") @@ -139,6 +167,9 @@ class ShadowBroker: # noqa: R0903 - 仅实现 BrokerBase 协议(bullet_trade du ref = self._ref_price(security, price) if ref is None or ref <= 0: return self._reject(order_id, "无参考价") + blocked = self._limit_blocked(security, "sell") + if blocked: + return self._reject(order_id, blocked) amount = int(amount) pos = self.positions.get(security) held = int(pos["amount"]) if pos else 0 diff --git a/sanguo_trader/shadow/runner.py b/sanguo_trader/shadow/runner.py index cfc1032..e33e72e 100644 --- a/sanguo_trader/shadow/runner.py +++ b/sanguo_trader/shadow/runner.py @@ -73,6 +73,44 @@ def build_price_getter(provider: Any) -> Any: return get_price +def build_limit_getter(provider: Any) -> Any: + """从数据 provider 取标的实时涨跌停/停牌状态(P1.3,ShadowBroker 拒单用)。 + + 优先 ``get_live_current``(miniQMT 实时 tick:lastPrice vs UpStop/DownStop, + 与实盘同源同刻);无实时能力(回测 provider)→ 回退 ``get_limit_status_batch`` + (日线 prev_close×幅度口径)。查不到 → None(ShadowBroker 放行,等价旧行为)。 + """ + from datetime import datetime + from typing import Optional as _Opt + + def get_limit(security: str) -> _Opt[dict]: + live_fn = getattr(provider, "get_live_current", None) + if live_fn is not None: + try: + cur = live_fn(security) or {} + last = cur.get("last_price") + high = cur.get("high_limit") or 0.0 + low = cur.get("low_limit") or 0.0 + if last: + return { + "is_limit_up": bool(high and float(last) >= float(high)), + "is_limit_down": bool(low and float(last) <= float(low)), + "is_paused": bool(cur.get("paused")), + } + except Exception: # noqa: BLE001 - 实时源失败试批量源 + pass + batch_fn = getattr(provider, "get_limit_status_batch", None) + if batch_fn is not None: + try: + return (batch_fn([security], datetime.now().strftime("%Y-%m-%d")) + or {}).get(security) + except Exception: # noqa: BLE001 + return None + return None + + return get_limit + + def _paper_on_trade(db: str, account_id: int, strategy_id: str): """成交回调:落 paper_trades(与组合实走 EOD 同表,前端模拟盘页直接可见)。""" from sanguo_trader.persistence import save_trade @@ -161,6 +199,7 @@ def run_shadow(provider_config: Optional[Dict[str, Any]] = None) -> None: min_commission=float(cfg["min_comm"]), slippage=float(cfg["slippage"]), price_getter=build_price_getter(provider), + limit_getter=build_limit_getter(provider), on_trade=_paper_on_trade(cfg["db"], int(cfg["account_id"]), le["strategy"]) if cfg["db"] and cfg["account_id"] else None, ) diff --git a/tests/portfolio/test_all_weather.py b/tests/portfolio/test_all_weather.py index 4742932..09d5f16 100644 --- a/tests/portfolio/test_all_weather.py +++ b/tests/portfolio/test_all_weather.py @@ -270,6 +270,57 @@ class TestMonthlyAdjustmentDecision: for etf in s.config.foreign_etf: assert etf in called_codes, f"未触发海外 ETF 下单: {etf}" + def test_foreign_etf_branch_skips_limitup_and_paused(self): + """P1.3:涨停(未持有)与停牌的 ETF 不买入——filter 批量预取接线。""" + s = make_strategy( + index_stocks_map={ + "000300.XSHG": ["600519.XSHG"], + "399101.XSHE": ["000001.XSHE"], + }, + price_df_map={ + ("['600519.XSHG']", ("close",), 10): pd.DataFrame({ + "time": pd.to_datetime(["2024-09-20", "2024-09-30"]), + "code": ["600519.XSHG"] * 2, + "close": [15.0, 10.0], # 跌 + }), + ("['000001.XSHE']", ("close",), 10): pd.DataFrame({ + "time": pd.to_datetime(["2024-09-20", "2024-09-30"]), + "code": ["000001.XSHE"] * 2, + "close": [15.0, 10.0], # 跌 + }), + }, + ) + s.provider.get_fundamentals_df.return_value = make_fund_df([ + {"code": "600519.XSHG", "circulating_market_cap": 20000, "market_cap": 20000}, + {"code": "000001.XSHE", "circulating_market_cap": 500, "market_cap": 500}, + ]) + # 518880 涨停(未持有不买)、513030 停牌(不交易),其余正常 + def _glbs(codes, date): + out = {} + for c in codes: + if c == "518880.XSHG": + out[c] = {"is_limit_up": True, "is_limit_down": False, "is_paused": False} + elif c == "513030.XSHG": + out[c] = {"is_limit_up": False, "is_limit_down": False, "is_paused": True} + else: + out[c] = {"is_limit_up": False, "is_limit_down": False, "is_paused": False} + return out + s.provider.get_limit_status_batch.side_effect = _glbs + + ctx = MagicMock() + ctx.current_dt = datetime(2024, 10, 8, 9, 30) + ctx.previous_date = "2024-09-30" + ctx.portfolio.positions = {} + ctx.portfolio.available_cash = 1_000_000 + + s.monthly_adjustment(ctx) + + called_codes = [c.args[0] for c in s.broker.order_target_value.call_args_list] + assert "518880.XSHG" not in called_codes, "涨停 ETF 不应买入" + assert "513030.XSHG" not in called_codes, "停牌 ETF 不应交易" + for etf in ("513100.XSHG", "164824.XSHE", "159866.XSHE"): + assert etf in called_codes, f"正常 ETF 应下单: {etf}" + def test_big_market_branch_when_b_trend_dominant(self): """b_mean > s_mean 且 b_mean > 0 → 开大(选 B_stocks)。""" s = make_strategy( diff --git a/tests/trader/test_shadow_broker.py b/tests/trader/test_shadow_broker.py index 00c0af7..c49ab10 100644 --- a/tests/trader/test_shadow_broker.py +++ b/tests/trader/test_shadow_broker.py @@ -97,6 +97,92 @@ def test_no_price_rejects(): assert "无参考价" in b.orders[oid]["reject_reason"] +# ---- P1.3 涨跌停/停牌拒单(双轨对账:与实盘 QMT 约束对齐,减少对账噪音) ---- + +def _limit_map_getter(status: dict): + return lambda sec: status.get(sec) + + +def test_buy_rejected_when_limit_up(): + b = _mk_broker(limit_getter=_limit_map_getter({ + "600519.SH": {"is_limit_up": True, "is_limit_down": False, "is_paused": False}, + })) + oid = _buy(b, "600519.SH", 100) + assert b.orders[oid]["status"] == "rejected" + assert "涨停" in b.orders[oid]["reject_reason"] + assert "600519.SH" not in b.positions + + +def test_sell_rejected_when_limit_down(): + b = _mk_broker(limit_getter=_limit_map_getter({ + "600519.SH": {"is_limit_up": False, "is_limit_down": True, "is_paused": False}, + })) + _buy(b, "600519.SH", 100, px=100.0) # 买入时非跌停 + oid = _sell(b, "600519.SH", 100) + assert b.orders[oid]["status"] == "rejected" + assert "跌停" in b.orders[oid]["reject_reason"] + assert b.positions["600519.SH"]["amount"] == 100 + + +def test_buy_sell_rejected_when_paused(): + b = _mk_broker(limit_getter=_limit_map_getter({ + "600519.SH": {"is_limit_up": False, "is_limit_down": False, "is_paused": True}, + })) + oid = _buy(b, "600519.SH", 100) + assert b.orders[oid]["status"] == "rejected" + assert "停牌" in b.orders[oid]["reject_reason"] + + +def test_limit_getter_failure_degrades_to_fill(): + """limit_getter 抛异常 → 降级放行(等价无涨跌停数据的旧行为),不崩柜台。""" + def boom(sec): + raise RuntimeError("boom") + b = _mk_broker(limit_getter=boom) + oid = _buy(b, "600519.SH", 100) + assert b.orders[oid]["status"] == "filled" + + +def test_no_limit_getter_keeps_old_behavior(): + b = _mk_broker() # 不注入 limit_getter + oid = _buy(b, "600519.SH", 100) + assert b.orders[oid]["status"] == "filled" + + +# ---- build_limit_getter:provider 状态 → broker 语义映射 ---- + +def test_limit_getter_maps_live_current_limit_up(): + from sanguo_trader.shadow.runner import build_limit_getter + + class P: + def get_live_current(self, sec): + # last_price == high_limit → 涨停 + return {"last_price": 11.0, "high_limit": 11.0, + "low_limit": 9.0, "paused": False} + + g = build_limit_getter(P()) + assert g("600519.SH") == {"is_limit_up": True, "is_limit_down": False, + "is_paused": False} + + +def test_limit_getter_falls_back_to_batch_when_no_live_current(): + from sanguo_trader.shadow.runner import build_limit_getter + + class P: + def get_limit_status_batch(self, codes, date): + return {c: {"is_limit_up": False, "is_limit_down": True, + "is_paused": False} for c in codes} + + g = build_limit_getter(P()) + assert g("600519.SH")["is_limit_down"] is True + + +def test_limit_getter_returns_none_without_any_source(): + from sanguo_trader.shadow.runner import build_limit_getter + + g = build_limit_getter(object()) # 两接口都没有 + assert g("600519.SH") is None + + def test_on_trade_callback_receives_fills(): seen: list[dict] = [] b = ShadowBroker(