diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index 4acdfa1..9bbd3ca 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -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) diff --git a/tests/portfolio/test_all_weather.py b/tests/portfolio/test_all_weather.py index 0f77489..c5bacb8 100644 --- a/tests/portfolio/test_all_weather.py +++ b/tests/portfolio/test_all_weather.py @@ -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(