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]
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user