fix(portfolio): all_weather换仓月只卖不买根治——monthly_adjustment卖出旧仓后未重取持仓,陈旧快照len≥目标数致target_num>position_count恒False一股不买,空仓躺到下月(NAS实测2025-09-01/10-09/12-01三次调仓全中,持仓数<目标数的8-13/11-03正常买);卖出后_get_positions重取+动态持仓回归测试(复现卖5买0→修后卖5买5);老翻译bug非本轮5commit回归(VPS旧代码对照运行中) [vps]
CI/CD / test (push) Successful in 22s
CI/CD / nas-deploy (push) Successful in 42s
CI/CD / nas-verify (push) Successful in 25s

This commit is contained in:
2026-08-15 07:06:07 +08:00
parent b9315ef7e7
commit b1a43cba44
2 changed files with 70 additions and 0 deletions
@@ -257,6 +257,10 @@ class AllWeatherStrategy:
if stock in target or stock in self.yesterday_hl_list:
continue
self._close_position(stock)
# 卖出后重取:聚宽原版卖出后持仓同步更新,若沿用卖出前快照,
# 持仓数≥目标数的换仓月 target_num>position_count 恒 False → 一股不买,
# 空仓躺到下月(2025-09-01/12-01 NAS 实测)
positions = _get_positions(context)
# 6) 买入 target 里的新股
position_count = len(positions)
+66
View File
@@ -340,6 +340,72 @@ class TestMonthlyAdjustmentDecision:
for etf in s.config.foreign_etf:
assert etf in called_codes, f"未触发海外 ETF 下单: {etf}"
def test_rotation_buys_new_after_selling_old(self):
"""换仓月回归:卖出旧仓后须重取持仓再决定买入。
2025-09-01/12-01 实况:持仓数≥目标数时,step5 卖完全部旧仓,
step6 却用卖出【前】的陈旧持仓计数(len≥target)→ 一股不买 →
空仓躺到下月。聚宽原版卖出后持仓同步更新,翻译时快照没刷新。
"""
from tests.portfolio.conftest import FakePosition
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},
])
# 持仓 5 只(= foreign_etf 数量),卖出后动态清空(模拟引擎持仓属性)
held = {f"60000{i}.XSHG": FakePosition(f"60000{i}.XSHG", 10.0, 10.0)
for i in range(5)}
state = dict(held)
class DynPortfolio:
available_cash = 1_000_000
cash = 1_000_000
@property
def positions(self):
return dict(state)
ctx = MagicMock()
ctx.current_dt = datetime(2024, 10, 8, 9, 30)
ctx.previous_date = "2024-09-30"
ctx.portfolio = DynPortfolio()
def _order(code, value):
if value == 0:
state.pop(code, None) # 卖出→持仓减少(引擎语义)
return MagicMock(filled=100)
s.broker.order_target_value.side_effect = _order
s.monthly_adjustment(ctx)
# 旧仓 5 只全卖
sell_codes = [c.args[0] for c in s.broker.order_target_value.call_args_list
if c.args[1] == 0]
assert sorted(sell_codes) == sorted(held.keys())
# 新仓(5 只 ETF)要买进来——陈旧计数会让 5>5=False 一股不买
buy_codes = [c.args[0] for c in s.broker.order_target_value.call_args_list
if c.args[1] > 0]
assert sorted(buy_codes) == sorted(s.config.foreign_etf), \
f"换仓月未买入新目标: 实买={buy_codes}"
def test_foreign_etf_branch_skips_limitup_and_paused(self):
"""P1.3:涨停(未持有)与停牌的 ETF 不买入——filter 批量预取接线。"""
s = make_strategy(