feat(portfolio): P1.3涨跌停/停牌过滤批量化双端—①all_weather monthly_adjustment三filter接线_get_limit_status批量预取(原不传status_map/date走无数据保留死分支,照买涨停照交易停牌;同small_cap等三策略) ②ShadowBroker涨跌停拒单(买撞涨停/卖撞跌停/停牌双向拒;limit_getter注入,未注入/异常/无状态降级放行等价旧行为) ③runner build_limit_getter(优先miniQMT实时tick lastPrice vs UpStop/DownStop与实盘同源,回退get_limit_status_batch日线口径);9新测试 [vps]
CI/CD / test (push) Successful in 15s
CI/CD / nas-deploy (push) Successful in 40s
CI/CD / nas-verify (push) Successful in 13s

This commit is contained in:
2026-08-14 23:04:15 +08:00
parent fbd0c39c6c
commit 61cc01efbe
5 changed files with 231 additions and 9 deletions
+51
View File
@@ -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(
+86
View File
@@ -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(