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