From 97403c2d9eaa2475b147e7682077021074744ec9 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Wed, 26 Aug 2026 20:38:03 +0800 Subject: [PATCH] =?UTF-8?q?refactor(strategy):=20=E5=AE=9A=E5=AF=B8?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=85=A8=E9=87=8F=E7=BB=9F=E4=B8=80=5Fex?= =?UTF-8?q?=E6=80=BB=E8=B5=84=E4=BA=A7=E8=AF=AD=E4=B9=89(=E7=94=A8?= =?UTF-8?q?=E6=88=B708-26=E6=8B=8D=E6=9D=BF=E3=80=8C=E9=83=BD=E8=A6=81?= =?UTF-8?q?=E7=BB=9F=E4=B8=80ex=E7=9A=84=E3=80=8D)=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E4=BE=A7=E5=9B=9B=E5=8E=9F=E7=89=88per=5Fval?= =?UTF-8?q?ue=E7=8E=B0=E9=87=91=E2=86=92=5Ftotal=5Fvalue(momentum/value/sm?= =?UTF-8?q?all=5Fcap=E5=90=8C=E6=AC=BEtotal<=3D0=E5=AE=88=E5=8D=AB+all=5Fw?= =?UTF-8?q?eather=E4=B8=A4=E5=A4=84:=E8=A1=A5=E8=B7=8C=E5=8A=A0=E4=BB=93n?= =?UTF-8?q?=5Fpick/=E6=9C=88=E5=BA=A6=E9=93=BA=E4=BD=99=E4=BD=8D=E7=A9=BA?= =?UTF-8?q?=E6=A7=BD=E6=95=B0),all=5Fweather=5Fex=E5=90=8C=E6=AD=A5(08-25?= =?UTF-8?q?=20A=E4=BF=AE=E6=B3=95=E6=97=B6=E8=A2=AB=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E5=A4=84=E8=A1=A5=E9=BD=90,=E7=A7=BB?= =?UTF-8?q?=E4=BA=A4=E6=96=87=E6=A1=A3=E3=80=8C=5Fex=E5=B7=B2=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E7=94=A8=E3=80=8D=E4=B8=8E=E5=AE=9E=E9=99=85=E4=B8=8D?= =?UTF-8?q?=E7=AC=A6);value=5Fselection=5Fex=E4=BA=A4=E9=9B=86=E5=AE=9A?= =?UTF-8?q?=E5=BA=8Flist=E2=86=92sorted=E5=AF=B9=E9=BD=90fa04475(=E5=8E=9F?= =?UTF-8?q?=E7=89=88=E5=B7=B2sorted,=5Fex=E5=89=AF=E6=9C=AC=E6=BC=8F?= =?UTF-8?q?=E5=B8=A6=3D=E5=8E=9F=E7=89=88=E2=86=94=5Fex=E5=90=8C=E5=8F=82?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E7=9A=84=E6=9C=80=E5=90=8E=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E5=B7=AE);8=E7=AD=96=E7=95=A5=E7=B1=BBinspect=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E9=9B=B6=E6=AE=8B=E7=95=99=E7=8E=B0=E9=87=91=E5=8F=A3?= =?UTF-8?q?=E5=BE=84;=E6=B5=8B=E8=AF=95=E5=8F=82=E6=95=B0=E5=8C=96?= =?UTF-8?q?=E5=8E=9F=E7=89=88+=5Fex=E5=8F=8C=E8=B7=91(12=E7=BB=BF=3D?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=AA=8C=E6=94=B6)+aw=E4=B8=A4=E7=8E=B0?= =?UTF-8?q?=E5=9C=BA=E6=96=B0=E5=A2=9E;=E9=A1=BA=E4=BF=AEtest=5Flive=5Frec?= =?UTF-8?q?oncile=E5=86=99=E6=AD=BB=E6=97=A5=E6=9C=9F=E8=B7=A8=E5=8D=88?= =?UTF-8?q?=E5=A4=9C=E7=BF=BB=E7=BA=A2(08-26=E5=AE=9E=E9=94=A4,=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E4=BB=8A=E5=A4=A9);524=E5=85=A8=E7=BB=BF;?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E5=8E=86=E5=8F=B2=E7=BB=93=E6=9E=9C=E5=B0=86?= =?UTF-8?q?=E5=8F=98(=E7=9B=AE=E7=9A=84=3D=E5=9B=9E=E6=B5=8B=E2=86=94?= =?UTF-8?q?=E5=AE=9E=E7=9B=98=E5=AF=B9=E9=BD=90)=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_portfolio/strategies/all_weather.py | 10 +- sanguo_portfolio/strategies/all_weather_ex.py | 11 +- .../strategies/momentum_timing.py | 8 +- sanguo_portfolio/strategies/small_cap.py | 7 +- .../strategies/value_selection.py | 7 +- .../strategies/value_selection_ex.py | 2 +- tests/portfolio/test_live_reconcile.py | 13 +- tests/portfolio/test_sizing_total_value.py | 137 +++++++++++++++--- 8 files changed, 154 insertions(+), 41 deletions(-) diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index 59b2b81..13a8e0f 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -201,7 +201,10 @@ class AllWeatherStrategy: # 跌幅最负的 n_pick 只(即"补跌最多") idx_sorted = np.argsort(drops)[:n_pick] picked = [remaining[i] for i in idx_sorted] - cash = _available_cash(context) / n_pick + total = _total_value(context) + if total <= 0: + return + cash = total / n_pick for code in picked: self.broker.order_value(code, cash) logger.debug("补跌最多的N支 Order %s", code) @@ -270,7 +273,10 @@ class AllWeatherStrategy: position_count = len(positions) target_num = len(target) if target_num > position_count: - cash = _available_cash(context) / (target_num - position_count) + total = _total_value(context) + if total <= 0: + return + cash = total / (target_num - position_count) for stock in target: if stock in positions: continue diff --git a/sanguo_portfolio/strategies/all_weather_ex.py b/sanguo_portfolio/strategies/all_weather_ex.py index 0a80670..faaeb12 100644 --- a/sanguo_portfolio/strategies/all_weather_ex.py +++ b/sanguo_portfolio/strategies/all_weather_ex.py @@ -29,6 +29,7 @@ import numpy as np import pandas as pd from .. import factors, filters +from .all_weather import _total_value # 2026-08-26 统一 _ex 定寸口径 logger = logging.getLogger(__name__) @@ -204,7 +205,10 @@ class AllWeatherExStrategy: # 跌幅最负的 n_pick 只(即"补跌最多") idx_sorted = np.argsort(drops)[:n_pick] picked = [remaining[i] for i in idx_sorted] - cash = _available_cash(context) / n_pick + total = _total_value(context) + if total <= 0: + return + cash = total / n_pick for code in picked: self.broker.order_value(code, cash) logger.debug("补跌最多的N支 Order %s", code) @@ -273,7 +277,10 @@ class AllWeatherExStrategy: position_count = len(positions) target_num = len(target) if target_num > position_count: - cash = _available_cash(context) / (target_num - position_count) + total = _total_value(context) + if total <= 0: + return + cash = total / (target_num - position_count) for stock in target: if stock in positions: continue diff --git a/sanguo_portfolio/strategies/momentum_timing.py b/sanguo_portfolio/strategies/momentum_timing.py index 0f2405f..f2ef9a8 100644 --- a/sanguo_portfolio/strategies/momentum_timing.py +++ b/sanguo_portfolio/strategies/momentum_timing.py @@ -34,10 +34,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__) @@ -226,10 +226,10 @@ class MomentumTimingStrategy: 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 diff --git a/sanguo_portfolio/strategies/small_cap.py b/sanguo_portfolio/strategies/small_cap.py index 475aabb..830edec 100644 --- a/sanguo_portfolio/strategies/small_cap.py +++ b/sanguo_portfolio/strategies/small_cap.py @@ -44,6 +44,7 @@ from .all_weather import ( _current_dt, _dedup, _get_positions, + _total_value, _previous_date_str, ) @@ -382,10 +383,10 @@ class SmallCapStrategy: # 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len) 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.py b/sanguo_portfolio/strategies/value_selection.py index 0cf42fd..7362a34 100644 --- a/sanguo_portfolio/strategies/value_selection.py +++ b/sanguo_portfolio/strategies/value_selection.py @@ -45,6 +45,7 @@ from .all_weather import ( _current_dt, _dedup, _get_positions, + _total_value, _previous_date_str, ) @@ -197,10 +198,10 @@ class ValueSelectionStrategy: 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/sanguo_portfolio/strategies/value_selection_ex.py b/sanguo_portfolio/strategies/value_selection_ex.py index b4366ba..661a367 100644 --- a/sanguo_portfolio/strategies/value_selection_ex.py +++ b/sanguo_portfolio/strategies/value_selection_ex.py @@ -285,7 +285,7 @@ class ValueSelectionExStrategy: cfg.earnings_growth_low, cfg.earnings_growth_high, ) - out = list(l1 & l2 & l3 & l4 & l5 & l6) + out = sorted(l1 & l2 & l3 & l4 & l5 & l6) logger.info( "[%s] L1=%d L2=%d L3=%d L4=%d L5=%d L6=%d → final=%d", date_str, len(l1), len(l2), len(l3), len(l4), len(l5), len(l6), diff --git a/tests/portfolio/test_live_reconcile.py b/tests/portfolio/test_live_reconcile.py index 54a383f..c5c413f 100644 --- a/tests/portfolio/test_live_reconcile.py +++ b/tests/portfolio/test_live_reconcile.py @@ -33,8 +33,13 @@ def _order(oid="o1", broker_oid="1001", security="000049.XSHE", is_buy=True, ) +_TODAY = datetime.now().strftime("%Y-%m-%d") + + def _qmt_trade(order_id="1001", security="000049.XSHE", amount=500, price=15.0, - trade_id="90001", time="2026-08-25 09:36:24", commission=0.0, tax=0.0): + trade_id="90001", time=None, commission=0.0, tax=0.0): + """time 缺省=动态今天(EOD 对账按当日过滤,写死日期跨日必翻红,08-26 实锤)。""" + time = time or f"{_TODAY} 09:36:24" return { "trade_id": trade_id, "order_id": order_id, "security": security, "amount": amount, "price": price, "time": time, @@ -199,7 +204,7 @@ class TestEodReconcile: save_trade(db, 20, { "strategy_name": "s", "symbol": "000049.XSHE", "direction": "buy", "offset": "open", "price": 15.0, - "volume": 500, "traded_at": "2026-08-25 09:36:24", + "volume": 500, "traded_at": f"{_TODAY} 09:36:24", "vt_tradeid": "90001"}) led = LiveInstanceLedger(initial_cash=100_000) led.apply_trade(True, "000049.XSHE", 15.0, 500, "90001", "2026-08-25") @@ -215,7 +220,7 @@ class TestEodReconcile: save_trade(db, 20, { "strategy_name": "s", "symbol": "000049.XSHE", "direction": "buy", "offset": "open", "price": 15.0, - "volume": 500, "traded_at": "2026-08-25 09:36:24", + "volume": 500, "traded_at": f"{_TODAY} 09:36:24", "vt_tradeid": ""}) led = LiveInstanceLedger(initial_cash=100_000) eng = _engine([_order(status="filled", filled=500)], [_qmt_trade()]) @@ -285,7 +290,7 @@ class TestIncidentReplay: # 09:36:24 迟到 fill:只在 QMT 原始行里(engine.get_trades 见不到) eng = _engine( [_order(status="filled", filled=500)], - [_qmt_trade(time="2026-08-25 09:36:24", amount=500, price=15.0)]) + [_qmt_trade(amount=500, price=15.0)]) assert reconcile_pending(eng, led, db, 19, "momentum_timing") == 1 # 名单出清 + EOD 复核:无缺口、无重复 summary = eod_reconcile(eng, led, db, 19, "momentum_timing") diff --git a/tests/portfolio/test_sizing_total_value.py b/tests/portfolio/test_sizing_total_value.py index 74a3f22..ae666a8 100644 --- a/tests/portfolio/test_sizing_total_value.py +++ b/tests/portfolio/test_sizing_total_value.py @@ -1,14 +1,16 @@ -"""定寸总资产口径(A 修法,前后端 session 2026-08-25 移交)。 +"""定寸总资产口径(A 修法 2026-08-25 → 全量统一 2026-08-26)。 08-25 9:30 事故:small_cap 同轮「全卖19只→马上全买20只」,台账现金只被 runner_live 的 60s 归因轮询更新(唯一 cash 更新入口)→ 买入定寸在两轮轮询 之间评估 → 卖出回款不可见 → 20 笔买入全部目标 0,全天空仓。B 修法 -(191270c,下单返回即时归因入账)已做;本组测试钉 **A 修法=防御纵深第二层**: -定寸口径回总资产 per_value = 总资产/N(现金+持仓市值,卖出前后不变), -即使台账归因滞后/异常,定寸也不再依赖未入账的现金。 +(191270c,下单返回即时归因入账)已做;A 修法=防御纵深第二层:定寸口径回 +总资产 per_value = 总资产/N(现金+持仓市值,卖出前后不变),即使台账归因 +滞后/异常,定寸也不再依赖未入账的现金。 -原 JQ 语义 ``per_value = stock_value/len`` 本就是总资产口径——cash 口径是 -移植时引入的失真,本修法=回原语义。守卫同步 cash<=0 → total<=0。 +**2026-08-26 用户拍板「都要统一ex的」**:原版(回测)四策略定寸同步换 +_total_value——原 JQ 语义 ``per_value = stock_value/len`` 本就是总资产口径, +cash 口径是移植时引入的失真;统一后回测↔实盘对齐,原版与 _ex 副本同参 +选股+定寸一致(本文件参数化跑 原版+_ex 两类即验收)。守卫 cash<=0 → total<=0。 """ from __future__ import annotations @@ -20,25 +22,35 @@ from unittest.mock import MagicMock from tests.portfolio.conftest import FakeContext, FakePosition from sanguo_portfolio.strategies import ( + AllWeatherExStrategy, + AllWeatherStrategy, MomentumTimingExStrategy, + MomentumTimingStrategy, SmallCapExStrategy, + SmallCapStrategy, ValueSelectionExStrategy, + ValueSelectionStrategy, ) from sanguo_portfolio.strategies.all_weather import _total_value # =================== 公共装配 =================== class _RecordingBroker: - """记录 order_target_value 委托;**不动 context**——精确模拟「卖出已成交、 - 台账归因未跑」的现金窗口(事故形态)。""" + """记录 order_target_value / order_value 委托;**不动 context**——精确模拟 + 「卖出已成交、台账归因未跑」的现金窗口(事故形态)。""" def __init__(self) -> None: self.orders: List[Tuple[str, float]] = [] + self.values: List[Tuple[str, float]] = [] def order_target_value(self, code: str, value: float) -> Any: self.orders.append((code, float(value))) return object() # 非 None = 下单成功 + def order_value(self, code: str, value: float) -> Any: + self.values.append((code, float(value))) + return object() + def _stale_cash_ctx(total: float = 10500.0, stale_cash: float = 1000.0) -> FakeContext: """旧持仓 OLD_A(市值9000)/OLD_B(市值500);现金=回款未入账的旧值 1000。 @@ -60,6 +72,11 @@ def _stale_cash_ctx(total: float = 10500.0, stale_cash: float = 1000.0) -> FakeC return ctx +def _falsy_status(stocks): + return {s: {"is_limit_up": False, "is_limit_down": False, "is_paused": False} + for s in stocks} + + def _assert_sized_by_total(broker: _RecordingBroker) -> None: """卖出旧仓两只 + 新仓三只各按 总资产/3=3500 定寸(绝非现金/N≈333)。 @@ -91,33 +108,109 @@ class TestTotalValueHelper: assert _total_value(ctx) == pytest.approx(1900.0) -# =================== 策略层:卖出未入账时定寸不缩水 =================== -def test_momentum_rotate_sizes_by_total_value(): - """momentum _rotate_positions:总资产定寸,对未入账卖出免疫。""" +# =================== 轮动定寸:原版+_ex 同参一致(2026-08-26 统一验收) =================== +def _run_momentum_rotate(strategy, ctx) -> None: + """统一入口:_ex 直接打 _rotate_positions;原版定寸内联 handle_data, + 数据层全打桩走通到调仓尾。""" + stocks = ["NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"] + if hasattr(strategy, "_rotate_positions"): + strategy._rotate_positions(ctx, stocks) + return + strategy._cal_buy_sign = lambda *a, **k: True + strategy._stock_pool_cached = lambda idx, d: ["I1.XSHG"] + strategy._ensure_day_panel = lambda *a, **k: None + strategy._find_stock_pool = lambda *a, **k: list(stocks) + strategy._select_stocks = lambda *a, **k: list(stocks) + strategy._get_limit_status = lambda ss, d: _falsy_status(ss) + strategy.handle_data(ctx) + + +@pytest.mark.parametrize("cls", [MomentumTimingStrategy, MomentumTimingExStrategy], + ids=["原版", "ex"]) +def test_momentum_rotate_sizes_by_total_value(cls): + """momentum 调仓:总资产定寸,对未入账卖出免疫(原版与 _ex 同断言)。""" broker = _RecordingBroker() - strat = MomentumTimingExStrategy(provider=MagicMock(), broker=broker) - strat._rotate_positions(_stale_cash_ctx(), ["NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"]) + strat = cls(provider=MagicMock(), broker=broker) + _run_momentum_rotate(strat, _stale_cash_ctx()) _assert_sized_by_total(broker) -def test_small_cap_rebalance_sizes_by_total_value(): - """small_cap _rebalance:同型回归。""" +@pytest.mark.parametrize("cls", [SmallCapStrategy, SmallCapExStrategy], + ids=["原版", "ex"]) +def test_small_cap_rebalance_sizes_by_total_value(cls): + """small_cap _rebalance:同型回归(原版与 _ex 同断言)。""" broker = _RecordingBroker() - strat = SmallCapExStrategy(provider=MagicMock(), broker=broker) + strat = cls(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(): +@pytest.mark.parametrize("cls", [ValueSelectionStrategy, ValueSelectionExStrategy], + ids=["原版", "ex"]) +def test_value_monthly_adjustment_sizes_by_total_value(cls): """value monthly_adjustment:同型回归(选股段打桩,只验定寸腿)。""" broker = _RecordingBroker() - strat = ValueSelectionExStrategy(provider=MagicMock(), broker=broker) + strat = cls(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._get_limit_status = lambda stocks, date: _falsy_status(stocks) strat.monthly_adjustment(_stale_cash_ctx()) _assert_sized_by_total(broker) + + +# =================== all_weather 两现场(2026-08-26 统一补齐) =================== +@pytest.mark.parametrize("cls", [AllWeatherStrategy, AllWeatherExStrategy], + ids=["原版", "ex"]) +def test_all_weather_stop_loss_adds_by_total_value(cls): + """stop_loss 补跌加仓:order_value = 总资产/n_pick(非 现金/n_pick)。 + + 形态:STOP 触发 -8% 止损(num_sold=1)→ HOLD 补跌加仓 1 只; + n_pick=1 → 加仓额=总资产 10500,现金口径只会下 1000。 + """ + broker = _RecordingBroker() + strat = cls(provider=MagicMock(), broker=broker) + strat.yesterday_hl_list = [] + strat.config.stop_loss_pct = 0.92 + strat.config.stock_num = 5 + ctx = FakeContext( + current_dt=datetime(2026, 8, 26, 14, 0), + positions={ + "STOP.XSHG": FakePosition("STOP.XSHG", avg_cost=10.0, price=8.9, total=100), + "HOLD.XSHG": FakePosition("HOLD.XSHG", avg_cost=10.0, price=9.7, total=100), + }, + cash=1000.0, + ) + ctx.portfolio.total_value = 10500.0 + strat.stop_loss(ctx) + assert broker.orders == [("STOP.XSHG", 0)] # 止损卖出 + assert broker.values == [("HOLD.XSHG", pytest.approx(10500.0))] + assert broker.values[0][1] > 1000.0 # 现金口径回归红线 + + +@pytest.mark.parametrize("cls", [AllWeatherStrategy, AllWeatherExStrategy], + ids=["原版", "ex"]) +def test_all_weather_monthly_spreads_total_over_empty_slots(cls): + """monthly_adjustment 买入腿:每股 = 总资产/空余槽位(非 现金/空余槽位)。 + + 旧仓 2 只(卖出后视图滞后仍计 2)+ 目标 3 只 → 空余 1 槽 → 每只 10500, + 现金口径只会下 1000。 + """ + broker = _RecordingBroker() + strat = cls(provider=MagicMock(), broker=broker) + strat.yesterday_hl_list = [] + strat.config.trend_threshold = 0.01 + strat._stock_pool = lambda idx, d: ["B1.XSHG"] if "000300" in idx else ["S1.XSHG"] + strat._market_cap_top = lambda stocks, d, top, n: list(stocks) + strat._trend_mean = lambda lst, d, w: 1.0 if lst and "B1" in lst[0] else 0.5 + strat._pick_big_universe = lambda b, cur, prev: [ + "NEW_1.XSHG", "NEW_2.XSHG", "NEW_3.XSHG"] + strat._get_limit_status = lambda stocks, date: _falsy_status(stocks) + strat.monthly_adjustment(_stale_cash_ctx()) + sells = {c for c, v in broker.orders if v == 0} + buys = [(c, v) for c, v in broker.orders if v > 0] + assert 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-2 空余槽) + assert v > 1000.0