fix(live): 三策略定寸现金→总资产口径(卖后买现金窗口A修法,回原JQ语义) [vps]
08-25 9:30 事故(前后端session移交):small_cap同轮「全卖19只→马上全买20只」, 台账现金只被runner_live 60s归因轮询更新(唯一cash入口),买入定寸落在两轮之间 →卖出回款不可见→20笔买入全部目标0全天空仓;momentum同型撞运只入账首笔 79k/6≈13.2k/只(44%仓)。B修法(191270c,下单返回即时归因)已做;本A修法= 防御纵深第二层:定寸口径回总资产,归因异常/漏单时定寸也不再依赖未入账现金。 改动: - all_weather.py 共享helper _total_value:B2 InstancePortfolio.total_value (=台账equity,实时价缺价回退成本)/jq原生total_value直接读;缺属性回退 现金+Σ持仓市值(同语义) - momentum_timing_ex/small_cap_ex/value_selection_ex 三处轮动定寸 per_value=cash/N→total/N,守卫cash<=0→total<=0 - momentum 尾部卖出+买入抽独立方法 _rotate_positions(纯移动零逻辑变化, 便于回归直接钉死) - 原JQ语义per_value=stock_value/len本就是总资产口径,cash口径是移植失真; all_weather/channel_test的cash读取(递增建仓/现金铺余位语义)按移交指示不动 - 清三文件因此孤儿化的_available_cash import 测试:tests/portfolio/test_sizing_total_value.py 5条——helper 2(直读/回退) +每策略1条「卖出未入账时定寸不缩水」(fake现金旧值+持仓仍显示旧仓→per_value =总资产/3=3500而非现金/3≈333;目标3>旧仓2=事故真实形状,防买守卫提前break)。 portfolio+data_platform+api全量811绿。验收=08-26 9:30 momentum补仓满额 (~490k/6);与191270c同车推VPS(NAS恢复后)。
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user