diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index ab8c294..59b2b81 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -584,6 +584,33 @@ def _available_cash(context: Any) -> float: return float(cash or 0.0) +def _total_value(context: Any) -> float: + """组合总资产(现金+持仓市值)——轮动定寸用,卖出回款入账前后不变。 + + - B2 实盘代理:InstancePortfolio.total_value = 台账 equity(实时价,缺价 + 回退成本价);回测/影子:jq portfolio 原生 total_value。 + - 缺属性(兜底):现金 + Σ持仓市值(同语义)。 + + 2026-08-25 卖后买现金窗口事故 A 修法(前后端 session 移交,B 修法=191270c + 即时归因):台账现金只被 60s 归因轮询更新,「全卖→马上全买」的买入定寸落在 + 两轮之间 → 回款不可见 → 现金口径定寸≈0。总资产口径对未入账卖出天然免疫 + (少记的回款与多记的持仓市值相消)。原 JQ 语义 per_value=stock_value/len + 本就是总资产口径——cash 口径是移植失真,本 helper=回原语义。 + """ + portfolio = getattr(context, "portfolio", None) + if portfolio is not None: + tv = getattr(portfolio, "total_value", None) + if tv is not None: + return float(tv) + cash = _available_cash(context) + mv = 0.0 + for pos in _shared_positions(context).values(): + v = _pos_value(pos) + if v is not None: + mv += v + return cash + mv + + def _current_dt(context: Any) -> Any: return getattr(context, "current_dt", None) or (context.get("current_dt") if isinstance(context, dict) else None) diff --git a/sanguo_portfolio/strategies/momentum_timing_ex.py b/sanguo_portfolio/strategies/momentum_timing_ex.py index 86308fe..7665c03 100644 --- a/sanguo_portfolio/strategies/momentum_timing_ex.py +++ b/sanguo_portfolio/strategies/momentum_timing_ex.py @@ -37,10 +37,10 @@ import pandas as pd from .. import filters from .all_weather import ( BrokerFacade, - _available_cash, _current_dt, _dedup, _get_positions, + _total_value, ) logger = logging.getLogger(__name__) @@ -202,21 +202,35 @@ class MomentumTimingExStrategy: ) return - # 6) 调仓:先清掉不在 stocks 的 + # 6~7) 调仓:卖出旧仓+等额买入新股(总资产口径,独立方法便于回归钉死) + self._rotate_positions(context, stocks) + logger.info("[%s] 牛市调仓结束: target=%s", cur_date, stocks) + + def _rotate_positions(self, context: Any, stocks: List[str]) -> None: + """调仓:卖出不在 stocks 的旧仓 → 等额买入新股(原策略 sell+buy 语义)。 + + 定寸=总资产/N(2026-08-25 A 修法,前后端 session 移交):原 JQ 语义 + ``per_value = stock_value/len``;现金口径在「卖出已成交、台账归因未跑」 + 窗口(60s 轮询间隙)会把现金看成旧值 → 定寸缩水/归零(08-25 small_cap + 20 笔买入全 0 事故)。总资产=现金+持仓市值,卖出前后不变,对未入账 + 卖出天然免疫。守卫同步 total<=0。 + """ + positions = _get_positions(context, self.broker) + # 1) 卖出不在 stocks 的 for stock in list(positions.keys()): if stock in stocks: continue self._close_position(stock) - # 7) 等额买入 stocks 里的新股(原策略 cash/countStocks 语义) + # 2) 等额买入 stocks 里的新股(总资产口径) positions = _get_positions(context, self.broker) # 卖出后刷新 target_num = len(stocks) if target_num == 0: return - cash = _available_cash(context) - if cash <= 0: + total = _total_value(context) + if total <= 0: return - per_value = cash / target_num + per_value = total / target_num for stock in stocks: if stock in positions: continue @@ -224,7 +238,6 @@ class MomentumTimingExStrategy: positions = _get_positions(context, self.broker) # 刷新 if len(positions) >= target_num: break - logger.info("[%s] 牛市调仓结束: target=%s", cur_date, stocks) # =================== calRPS (修复:取 preDate~curDate 区间) =================== def _cal_rps( diff --git a/sanguo_portfolio/strategies/small_cap_ex.py b/sanguo_portfolio/strategies/small_cap_ex.py index a76d704..982c1d2 100644 --- a/sanguo_portfolio/strategies/small_cap_ex.py +++ b/sanguo_portfolio/strategies/small_cap_ex.py @@ -43,11 +43,11 @@ import pandas as pd from .. import filters from .all_weather import ( BrokerFacade, - _available_cash, _current_dt, _dedup, _get_positions, _previous_date_str, + _total_value, ) logger = logging.getLogger(__name__) @@ -382,13 +382,15 @@ class SmallCapExStrategy: continue self._close_position(code) - # 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len) + # 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len—— + # 总资产口径,2026-08-25 A 修法:对「卖出已成交、台账归因未跑」窗口 + # 免疫,现金口径当轮全买 0 的事故形态不再复发;守卫同步 total<=0) positions = _get_positions(context, self.broker) # 刷新 target_num = len(target_stocks) - cash = _available_cash(context) - if cash <= 0 or target_num == 0: + total = _total_value(context) + if total <= 0 or target_num == 0: return - per_value = cash / target_num + per_value = total / target_num for code in target_stocks: if code in positions: continue diff --git a/sanguo_portfolio/strategies/value_selection_ex.py b/sanguo_portfolio/strategies/value_selection_ex.py index 42fea2a..b4366ba 100644 --- a/sanguo_portfolio/strategies/value_selection_ex.py +++ b/sanguo_portfolio/strategies/value_selection_ex.py @@ -44,11 +44,11 @@ import pandas as pd from .. import filters from .all_weather import ( BrokerFacade, - _available_cash, _current_dt, _dedup, _get_positions, _previous_date_str, + _total_value, ) logger = logging.getLogger(__name__) @@ -195,15 +195,17 @@ class ValueSelectionExStrategy: continue self._close_position(stock) - # 5) 等额买入 buy_list 里的新股(原策略 buy 函数, cash/countStocks 语义) + # 5) 等额买入 buy_list 里的新股(原策略 buy 函数;总资产/N 口径—— + # 2026-08-25 A 修法:对「卖出已成交、台账归因未跑」窗口免疫, + # 守卫同步 total<=0) positions = _get_positions(context, self.broker) # 卖出后刷新 target_num = len(buy_list) if target_num == 0: return - cash = _available_cash(context) - if cash <= 0: + total = _total_value(context) + if total <= 0: return - per_value = cash / target_num + per_value = total / target_num for stock in buy_list: if stock in positions: continue diff --git a/tests/portfolio/test_sizing_total_value.py b/tests/portfolio/test_sizing_total_value.py new file mode 100644 index 0000000..74a3f22 --- /dev/null +++ b/tests/portfolio/test_sizing_total_value.py @@ -0,0 +1,123 @@ +"""定寸总资产口径(A 修法,前后端 session 2026-08-25 移交)。 + +08-25 9:30 事故:small_cap 同轮「全卖19只→马上全买20只」,台账现金只被 +runner_live 的 60s 归因轮询更新(唯一 cash 更新入口)→ 买入定寸在两轮轮询 +之间评估 → 卖出回款不可见 → 20 笔买入全部目标 0,全天空仓。B 修法 +(191270c,下单返回即时归因入账)已做;本组测试钉 **A 修法=防御纵深第二层**: +定寸口径回总资产 per_value = 总资产/N(现金+持仓市值,卖出前后不变), +即使台账归因滞后/异常,定寸也不再依赖未入账的现金。 + +原 JQ 语义 ``per_value = stock_value/len`` 本就是总资产口径——cash 口径是 +移植时引入的失真,本修法=回原语义。守卫同步 cash<=0 → total<=0。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Tuple + +import pytest +from unittest.mock import MagicMock + +from tests.portfolio.conftest import FakeContext, FakePosition +from sanguo_portfolio.strategies import ( + MomentumTimingExStrategy, + SmallCapExStrategy, + ValueSelectionExStrategy, +) +from sanguo_portfolio.strategies.all_weather import _total_value + + +# =================== 公共装配 =================== +class _RecordingBroker: + """记录 order_target_value 委托;**不动 context**——精确模拟「卖出已成交、 + 台账归因未跑」的现金窗口(事故形态)。""" + + def __init__(self) -> None: + self.orders: List[Tuple[str, float]] = [] + + def order_target_value(self, code: str, value: float) -> Any: + self.orders.append((code, float(value))) + return object() # 非 None = 下单成功 + + +def _stale_cash_ctx(total: float = 10500.0, stale_cash: float = 1000.0) -> FakeContext: + """旧持仓 OLD_A(市值9000)/OLD_B(市值500);现金=回款未入账的旧值 1000。 + + ``portfolio.total_value`` 模拟 jq/B2 语义(引擎现算 现金+Σ市值,与归因无关): + - B2 实盘 InstancePortfolio.total_value = ledger equity(现价,缺价回退成本) + - 回测 jq portfolio 原生 total_value + 现金口径会把定寸算成 1000/2=500;总资产口径 10500/2=5250——断言锚点。 + """ + ctx = FakeContext( + current_dt=datetime(2026, 8, 25, 9, 30), + positions={ + "OLD_A.XSHG": FakePosition("OLD_A.XSHG", avg_cost=9.0, price=9.0, total=1000), + "OLD_B.XSHG": FakePosition("OLD_B.XSHG", avg_cost=5.0, price=5.0, total=100), + }, + cash=stale_cash, + ) + ctx.portfolio.total_value = total + return ctx + + +def _assert_sized_by_total(broker: _RecordingBroker) -> None: + """卖出旧仓两只 + 新仓三只各按 总资产/3=3500 定寸(绝非现金/N≈333)。 + + 目标 3 只 > 旧仓 2 只 = 事故真实形状(19 旧仓/20 目标):买入守卫 + ``len(positions) >= target_num`` 因持仓视图滞后不提前 break,全部买入。 + """ + sells = [(c, v) for c, v in broker.orders if v == 0] + buys = [(c, v) for c, v in broker.orders if v > 0] + assert {c for c, _ in sells} == {"OLD_A.XSHG", "OLD_B.XSHG"} + assert [c for c, _ in buys] == ["NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"] + for _, v in buys: + assert v == pytest.approx(10500.0 / 3) + assert v > 1000.0 # 现金口径(1000/3≈333)必缩水——回归红线 + + +# =================== helper 层 =================== +class TestTotalValueHelper: + def test_reads_portfolio_total_value(self): + """portfolio 带 total_value(jq 原生/B2)→ 直接读。""" + ctx = FakeContext(cash=123.0) + ctx.portfolio.total_value = 4567.0 + assert _total_value(ctx) == pytest.approx(4567.0) + + def test_fallback_cash_plus_positions_when_attr_missing(self): + """缺 total_value 属性 → 现金 + Σ持仓市值(同语义兜底)。""" + pos = FakePosition("A.XSHG", avg_cost=1.0, price=2.0, total=100) + pos.value = 900.0 + ctx = FakeContext(positions={"A.XSHG": pos}, cash=1000.0) + assert _total_value(ctx) == pytest.approx(1900.0) + + +# =================== 策略层:卖出未入账时定寸不缩水 =================== +def test_momentum_rotate_sizes_by_total_value(): + """momentum _rotate_positions:总资产定寸,对未入账卖出免疫。""" + broker = _RecordingBroker() + strat = MomentumTimingExStrategy(provider=MagicMock(), broker=broker) + strat._rotate_positions(_stale_cash_ctx(), ["NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"]) + _assert_sized_by_total(broker) + + +def test_small_cap_rebalance_sizes_by_total_value(): + """small_cap _rebalance:同型回归。""" + broker = _RecordingBroker() + strat = SmallCapExStrategy(provider=MagicMock(), broker=broker) + strat.in_position_stocks = ["NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"] + strat._rebalance(_stale_cash_ctx()) + _assert_sized_by_total(broker) + + +def test_value_monthly_adjustment_sizes_by_total_value(): + """value monthly_adjustment:同型回归(选股段打桩,只验定寸腿)。""" + broker = _RecordingBroker() + strat = ValueSelectionExStrategy(provider=MagicMock(), broker=broker) + strat._stock_pool = lambda *a, **k: ["C1.XSHG", "C2.XSHG"] + strat._get_stock_list = lambda *a, **k: ["NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"] + strat._get_limit_status = lambda stocks, date: { + s: {"is_limit_up": False, "is_limit_down": False, "is_paused": False} + for s in stocks + } + strat.monthly_adjustment(_stale_cash_ctx()) + _assert_sized_by_total(broker)