perf(portfolio): G5-P2收尾—all_weather三调用点向量化:①filter_roic逐只循环→一次批量+fields=['roic']短路(9×)②_trend_mean get_price长表+pivot→get_closes_panel宽表(340×,count=N→start-N*2自然日+.tail(N)同momentum口径,fq均raw)③prepare_stock_list get_price(close+high_limit)→get_limit_status_batch精确涨跌停口径;测试fixture补panel/limit_batch mock(原MagicMock碰巧truthy蒙混paused剔除语义,默认改全正常);+6回归测试,portfolio全套302绿 [vps]
CI/CD / test (push) Successful in 13s
CI/CD / nas-deploy (push) Successful in 39s
CI/CD / nas-verify (push) Successful in 14s

This commit is contained in:
2026-08-14 23:17:13 +08:00
parent e9aec041db
commit 2cd158286b
2 changed files with 144 additions and 52 deletions
+91 -9
View File
@@ -68,6 +68,30 @@ def make_strategy(
provider.get_price.side_effect = _get_price
# get_closes_panel:从 price_df_map 同源构造 close 宽表(忽略 count 差异)
def _get_closes_panel(symbols, start=None, end=None, interval="d", fq="raw"):
frames = [
df[["time", "code", "close"]]
for (sec, fields, _cnt), df in price_df_map.items()
if sec == str(symbols) and tuple(fields) == ("close",)
and df is not None and len(df)
]
if not frames:
return pd.DataFrame()
long_df = pd.concat(frames)
return long_df.pivot(index="time", columns="code", values="close")
provider.get_closes_panel.side_effect = _get_closes_panel
# 涨跌停/停牌批量:默认全正常(filter_paused 无bar=剔除语义,MagicMock/{} 会误杀)
def _get_limit_status_batch(codes, date=None):
return {
c: {"is_limit_up": False, "is_limit_down": False, "is_paused": False}
for c in codes
}
provider.get_limit_status_batch.side_effect = _get_limit_status_batch
broker = BrokerFacade()
broker.order_target_value = MagicMock(return_value=MagicMock(filled=100))
broker.order_value = MagicMock(return_value=MagicMock(filled=100))
@@ -136,23 +160,69 @@ class TestPrepareStockList:
def test_records_yesterday_limit_up(self):
s = make_strategy()
# 清掉 make_strategy 设置的 side_effect,直接用 return_value
s.provider.get_price.side_effect = None
pos = MagicMock(); pos.security = "600519.XSHG"
ctx = MagicMock()
ctx.portfolio.positions = {"600519.XSHG": pos}
ctx.previous_date = "2024-09-30"
# close == high_limit 视为涨停
s.provider.get_price.return_value = pd.DataFrame({
"code": ["600519.XSHG"],
"close": [10.0],
"high_limit": [10.0],
})
# get_limit_status_batch 返回昨日涨停(G5-P2:原 get_price close==high_limit 改批量口径)
s.provider.get_limit_status_batch.side_effect = \
lambda codes, date=None: {
c: {"is_limit_up": True, "is_limit_down": False, "is_paused": False}
for c in codes
}
s.prepare_stock_list(ctx)
assert "600519.XSHG" in s.yesterday_hl_list
def test_uses_batch_endpoint_once(self):
"""G5-P2:原 get_price(close+high_limit) → get_limit_status_batch 一次批量。"""
s = make_strategy()
pos = MagicMock(); pos.security = "600519.XSHG"
ctx = MagicMock()
ctx.portfolio.positions = {"600519.XSHG": pos, "000001.XSHE": MagicMock(security="000001.XSHE")}
ctx.previous_date = "2024-09-30"
s.prepare_stock_list(ctx)
assert s.provider.get_limit_status_batch.call_count == 1
args, _kwargs = s.provider.get_limit_status_batch.call_args
assert sorted(args[0]) == ["000001.XSHE", "600519.XSHG"]
s.provider.get_price.assert_not_called()
# =================== stop_loss ===================
# =================== trend_mean ===================
class TestTrendMean:
def _panel_seed(self):
return pd.DataFrame({
"time": pd.to_datetime(["2024-09-20", "2024-09-30"]),
"code": ["600519.XSHG"] * 2,
"close": [10.0, 15.0], # 涨 50%
})
def test_uses_closes_panel_not_get_price(self):
"""G5-P2:原 get_price 长表+pivot → get_closes_panel 宽表。"""
s = make_strategy(price_df_map={
("['600519.XSHG']", ("close",), 10): self._panel_seed(),
})
out = s._trend_mean(["600519.XSHG"], "2024-09-30", 10)
assert out == 50.0
s.provider.get_closes_panel.assert_called_once()
s.provider.get_price.assert_not_called()
def test_empty_or_short_panel_returns_zero(self):
"""panel 缺失/不足 2 行 → 0.0 不崩(降级同原 get_price 空df语义)。"""
s = make_strategy()
assert s._trend_mean([], "2024-09-30", 10) == 0.0
assert s._trend_mean(["600519.XSHG"], "2024-09-30", 10) == 0.0 # 无种子数据
def test_nan_only_change_not_propagated(self):
"""某列 NaN(close 缺失)→ nan_to_num 归 0,不产出 NaN。"""
wide = pd.DataFrame(
{"600519.XSHG": [np.nan, 15.0], "000001.XSHE": [10.0, 11.0]},
index=pd.to_datetime(["2024-09-20", "2024-09-30"]),
)
s = make_strategy()
s.provider.get_closes_panel.side_effect = None
s.provider.get_closes_panel.return_value = wide
# 600519 NaN→0%, 000001 +10% → 均值 5(浮点容差)
assert s._trend_mean(["600519.XSHG", "000001.XSHE"], "2024-09-30", 10) == pytest.approx(5.0)
class TestStopLoss:
def test_stop_loss_triggers_when_price_drops_8pct(self):
"""avg_cost=100, price=91 (< 100*0.92=92) → 止损。"""
@@ -467,3 +537,15 @@ class TestFilterRoic:
s = make_strategy()
out = s.filter_roic([], previous_date="2024-09-30")
assert out == []
def test_batch_single_call_with_fields_shortcut(self):
"""G5-P2:原逐只循环 → 一次批量 + fields=['roic'] 短路。"""
df = make_fund_df([{"code": "A.XSHG", "roic": 0.15}])
s = make_strategy()
s.provider.get_fundamentals_df.return_value = df
s.filter_roic(["A.XSHG", "B.XSHG"], previous_date="2024-09-30")
# 一次批量调用(非逐只 N 次),且带 fields 短路
assert s.provider.get_fundamentals_df.call_count == 1
args, kwargs = s.provider.get_fundamentals_df.call_args
assert list(args[0]) == ["A.XSHG", "B.XSHG"]
assert kwargs.get("fields") == ["roic"]