feat(portfolio): B2定寸虚拟化InstancePortfolio代理——策略决策层脱离全账户污染(spec§B2+A2清单issue#29)——①live_portfolio_proxy:InstanceContextProxy(portfolio→账本视图,其余属性透传真context,property+__getattr__实现)+InstancePortfolio(每次访问现算)②覆盖A2清单全集:available_cash/cash=账本现金(168万/只定寸污染根治点,.cash fallback同源);positions=账本jq对象(total_amount/amount/closeable_amount(T+1)/avg_cost(台账口径=成交价)/cost_basis/security)+price/last_sale_price从真portfolio同名标的透传(市场数据非所有权,缺→None止损跳过);value/market_value/total_value=现价×量(缺价→0偏保守不误卖);total_value/positions_value=账本equity(现价优先成本兜底);locked_cash=0③清单外属性AttributeError fail-fast(真值=全账户数字,静默透传=污染复发,宁可崩=A1同款house style)④wrap_scheduler包装facade的run_daily/run_monthly(发生在策略initialize注册任务之前,策略代码零改动);functools.wraps保任务名;无账本原样返回=回测/影子零改动⑤live_strategy._setup接线(_ledger None→wrap直通);引擎内部撮合/风控仍看真实账户;+13测试(定寸现金/T+1/现价透传/缺价保守/equity兜底/fail-fast/调度代理注入/无账本零改动/_setup接线两态);portfolio 373绿 [vps]
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""B2 定寸虚拟化:InstancePortfolio 代理(spec §multi-strategy-instance-budget §B2)。
|
||||
|
||||
共享 QMT 账户下,策略读 ``context.portfolio`` 会看到全账户(995万)→ 定寸被污染
|
||||
(channel_test 168万/只 vs 影子 16.8万)。本模块把**策略决策层**看到的 portfolio
|
||||
换成实例账本视图;引擎内部(撮合/风控/下单)仍看真实账户。
|
||||
|
||||
覆盖范围(A2 属性清单,策略 session issue#29 核对):
|
||||
- ``available_cash`` / ``cash``:实例账本现金(定寸污染点,核心)
|
||||
- ``positions``:账本视图的 jq 风格对象——``total_amount``/``amount``/
|
||||
``closeable_amount``(T+1)/``avg_cost``/``cost_basis``/``security`` +
|
||||
``price``/``last_sale_price``(现价从真 portfolio 同名标的透传=市场数据非所有权,
|
||||
缺则 None)/``value``/``market_value``/``total_value``(现价缺失时 0,策略当 0
|
||||
处理偏保守不误卖)
|
||||
- ``total_value``/``positions_value``:账本 equity(现金+Σ市值,现价缺则成本价)
|
||||
- ``locked_cash``:0(账本无锁现语义)
|
||||
|
||||
**未覆盖属性 fail-fast**(AttributeError):A2 确认策略不读;真值是全账户数字,
|
||||
静默透传=污染复发,宁可崩(house style 同 A1 的 TypeError fail-fast)。
|
||||
非 portfolio 属性(current_dt/previous_date 等)由 context 代理透传真 context。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstancePosition:
|
||||
"""实例持仓的 jq 风格对象(账本数量/成本 + 真实账户现价透传)。"""
|
||||
|
||||
def __init__(self, security: str, total_amount: int,
|
||||
closeable_amount: int, avg_cost: float,
|
||||
price: Optional[float]) -> None:
|
||||
self.security = security
|
||||
self.total_amount = int(total_amount)
|
||||
self.amount = self.total_amount
|
||||
self.closeable_amount = int(closeable_amount)
|
||||
self.avg_cost = float(avg_cost)
|
||||
self.cost_basis = self.avg_cost
|
||||
self.price = float(price) if price else None
|
||||
self.last_sale_price = self.price
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
"""现价缺失 → 0(channel_test _safe_value 语义:取不到当 0,偏保守)。"""
|
||||
return self.price * self.total_amount if self.price else 0.0
|
||||
|
||||
@property
|
||||
def market_value(self) -> float:
|
||||
return self.value
|
||||
|
||||
@property
|
||||
def total_value(self) -> float:
|
||||
return self.value
|
||||
|
||||
|
||||
class InstancePortfolio:
|
||||
"""每次访问现算的实例账本 portfolio 视图。"""
|
||||
|
||||
def __init__(self, ledger: Any,
|
||||
real_portfolio: Callable[[], Any]) -> None:
|
||||
self._ledger = ledger
|
||||
self._real_portfolio = real_portfolio
|
||||
|
||||
@property
|
||||
def available_cash(self) -> float:
|
||||
return float(self._ledger.cash)
|
||||
|
||||
@property
|
||||
def cash(self) -> float:
|
||||
# 策略族 _available_cash 先读 available_cash 再 fallback .cash——两口径同源
|
||||
return float(self._ledger.cash)
|
||||
|
||||
@property
|
||||
def positions(self) -> Dict[str, InstancePosition]:
|
||||
real = self._real_positions()
|
||||
real_by_sym = _index_real_positions(real)
|
||||
out: Dict[str, InstancePosition] = {}
|
||||
for sym, info in self._ledger.positions_view(_today()).items():
|
||||
rp = real_by_sym.get(sym)
|
||||
price = None
|
||||
if rp is not None:
|
||||
price = (getattr(rp, "price", None)
|
||||
or getattr(rp, "last_sale_price", None))
|
||||
out[sym] = InstancePosition(
|
||||
security=sym,
|
||||
total_amount=int(info.get("amount", 0)),
|
||||
closeable_amount=int(info.get("closeable_amount", 0)),
|
||||
avg_cost=float(info.get("avg_cost", 0.0)),
|
||||
price=price,
|
||||
)
|
||||
return out
|
||||
|
||||
@property
|
||||
def total_value(self) -> float:
|
||||
real_by_sym = _index_real_positions(self._real_positions())
|
||||
prices: Dict[str, float] = {}
|
||||
for sym, rp in real_by_sym.items():
|
||||
p = getattr(rp, "price", None) or getattr(rp, "last_sale_price", None)
|
||||
if p:
|
||||
prices[sym] = float(p)
|
||||
_cash, _mv, total = self._ledger.equity(prices)
|
||||
return float(total)
|
||||
|
||||
@property
|
||||
def positions_value(self) -> float:
|
||||
return self.total_value - float(self._ledger.cash)
|
||||
|
||||
@property
|
||||
def locked_cash(self) -> float:
|
||||
return 0.0 # 账本无锁现语义(A2:策略不读;给安全值)
|
||||
|
||||
# 其余属性(AttributeError fail-fast):真值是全账户数字,透传=污染复发
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
raise AttributeError(
|
||||
f"InstancePortfolio 未覆盖 portfolio.{name}(A2 清单外;"
|
||||
f"真值=全账户数字,拒绝静默透传——如策略确需,请扩本代理)")
|
||||
|
||||
def _real_positions(self) -> Any:
|
||||
try:
|
||||
rp = self._real_portfolio()
|
||||
except Exception: # noqa: BLE001 - 真 portfolio 拿不到 → 无现价可用
|
||||
return {}
|
||||
if rp is None:
|
||||
return {}
|
||||
try:
|
||||
return getattr(rp, "positions", None) or {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
|
||||
def _index_real_positions(real_positions: Any) -> Dict[str, Any]:
|
||||
"""真 portfolio.positions(dict 或 list)→ {symbol: position_obj}。"""
|
||||
out: Dict[str, Any] = {}
|
||||
if isinstance(real_positions, dict):
|
||||
for k, v in real_positions.items():
|
||||
out[str(k)] = v
|
||||
else:
|
||||
for p in real_positions or []:
|
||||
sec = getattr(p, "security", None) or getattr(p, "symbol", None)
|
||||
if sec:
|
||||
out[str(sec)] = p
|
||||
return out
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
from datetime import date as _date
|
||||
return _date.today().isoformat()
|
||||
|
||||
|
||||
class InstanceContextProxy:
|
||||
"""context 代理:``portfolio`` → InstancePortfolio,其余透传真 context。"""
|
||||
|
||||
def __init__(self, real_context: Any, ledger: Any) -> None:
|
||||
object.__setattr__(self, "_real", real_context)
|
||||
object.__setattr__(self, "_ledger", ledger)
|
||||
|
||||
@property
|
||||
def portfolio(self) -> InstancePortfolio:
|
||||
real = object.__getattribute__(self, "_real")
|
||||
ledger = object.__getattribute__(self, "_ledger")
|
||||
return InstancePortfolio(
|
||||
ledger, lambda: getattr(real, "portfolio", None))
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(object.__getattribute__(self, "_real"), name)
|
||||
|
||||
|
||||
def make_proxy_context(context: Any, ledger: Optional[Any]) -> Any:
|
||||
"""有账本 → 代理 context;无(回测/影子/测试) → 原 context 原样。"""
|
||||
if ledger is None:
|
||||
return context
|
||||
return InstanceContextProxy(context, ledger)
|
||||
|
||||
|
||||
def wrap_scheduler(bt_sched: Callable, ledger: Optional[Any]) -> Callable:
|
||||
"""包装 run_daily/run_monthly:经其注册的回调收到的 context 换成代理。
|
||||
|
||||
策略代码零改动——虚拟化发生在 bullet_trade 调度器 → 策略函数之间。
|
||||
"""
|
||||
if ledger is None:
|
||||
return bt_sched
|
||||
|
||||
def sched(func: Callable, *args: Any, **kw: Any) -> Any:
|
||||
@functools.wraps(func)
|
||||
def wrapped(context: Any, *cb_args: Any, **cb_kw: Any) -> Any:
|
||||
return func(make_proxy_context(context, ledger),
|
||||
*cb_args, **cb_kw)
|
||||
return bt_sched(wrapped, *args, **kw)
|
||||
logger.info("定寸虚拟化已启用: run_daily/run_monthly 回调注入实例账本视图")
|
||||
return sched
|
||||
@@ -122,6 +122,12 @@ def _setup(context):
|
||||
strategy.broker.get_instance_positions = _ledger.positions_view
|
||||
logger.info("instance 台账通道已注入: get_instance_positions (cash=%.2f 持仓 %d 只)",
|
||||
_ledger.cash, len(_ledger.positions))
|
||||
# B2 定寸虚拟化:经 facade 注册的定时回调收到代理 context(portfolio=实例
|
||||
# 账本视图),策略定寸/资金守卫不再被全账户污染;引擎内部仍看真实账户。
|
||||
# 无账本(回测/影子) → wrap_scheduler 原样返回,行为零改动。
|
||||
from .live_portfolio_proxy import wrap_scheduler as _wrap_sched
|
||||
strategy.broker.run_daily = _wrap_sched(bt_run_daily, _ledger)
|
||||
strategy.broker.run_monthly = _wrap_sched(bt_run_monthly, _ledger)
|
||||
# A 股费用 + 滑点(与回测默认一致)
|
||||
set_order_cost(
|
||||
OrderCost(
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Tests for B2 定寸虚拟化(spec §B2 + A2 属性清单 issue#29)。
|
||||
|
||||
核心断言:策略决策层看到的 portfolio = 实例账本视图(现金/持仓/定寸不再被
|
||||
全账户污染);现价从真 portfolio 透传(市场数据非所有权);未覆盖属性
|
||||
fail-fast;无账本(回测/影子)零改动。
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from sanguo_portfolio.live_instance_ledger import (
|
||||
LiveInstanceLedger, set_active,
|
||||
)
|
||||
from sanguo_portfolio.live_portfolio_proxy import (
|
||||
InstanceContextProxy, InstancePortfolio, make_proxy_context,
|
||||
wrap_scheduler,
|
||||
)
|
||||
|
||||
|
||||
class _RealPos:
|
||||
def __init__(self, security, price, amount=0):
|
||||
self.security = security
|
||||
self.price = price
|
||||
self.last_sale_price = price
|
||||
self.total_amount = amount
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ledger():
|
||||
led = LiveInstanceLedger(initial_cash=1_000_000)
|
||||
# 买入 510300@4.74×4200(费用=est) → cash 减,持仓进账本
|
||||
led.apply_trade(is_buy=True, symbol="510300.XSHG", price=4.74,
|
||||
volume=4200, trade_id="t1", trade_date="2026-08-19")
|
||||
return led
|
||||
|
||||
|
||||
def _real_portfolio(positions):
|
||||
return SimpleNamespace(positions=positions)
|
||||
|
||||
|
||||
def _portfolio(ledger, real_positions=None):
|
||||
return InstancePortfolio(
|
||||
ledger, lambda: _real_portfolio(real_positions or {}))
|
||||
|
||||
|
||||
# ---------------- 现金/定寸(A2 核心:污染点) ----------------
|
||||
|
||||
def test_available_cash_is_ledger_not_account(ledger):
|
||||
"""定寸读到的现金=账本现金,不是共享账户 995 万。"""
|
||||
p = _portfolio(ledger)
|
||||
# 账本现金 = 100万 − 4200×4.74 − 佣金 max(19908×0.0003,5)=5.9724 → 980086.0276
|
||||
assert p.available_cash == pytest.approx(
|
||||
1_000_000 - 19_908 - 5.9724, rel=1e-9)
|
||||
assert p.cash == p.available_cash # _available_cash 的 .cash fallback 同源
|
||||
|
||||
|
||||
def test_positions_from_ledger_with_t1(ledger):
|
||||
"""持仓=账本视图:当日买入 T+1 → closeable=0,amount=总量,成本=账本均价。"""
|
||||
p = _portfolio(ledger, real_positions={
|
||||
"510300.XSHG": _RealPos("510300.XSHG", 4.80)})
|
||||
pos = p.positions["510300.XSHG"]
|
||||
assert pos.security == "510300.XSHG"
|
||||
assert pos.total_amount == 4200
|
||||
assert pos.amount == 4200
|
||||
assert pos.closeable_amount == 0 # 今日买入,T+1 锁定
|
||||
assert pos.avg_cost == pytest.approx(4.74) # 台账口径=成交价(不含费)
|
||||
assert pos.cost_basis == pos.avg_cost
|
||||
# 现价从真 portfolio 透传(市场数据非所有权)
|
||||
assert pos.price == 4.80
|
||||
assert pos.last_sale_price == 4.80
|
||||
assert pos.value == pytest.approx(4.80 * 4200)
|
||||
|
||||
|
||||
def test_price_missing_value_zero(ledger):
|
||||
"""真 portfolio 无同名标的 → price None,value 0(策略当 0 处理,偏保守)。"""
|
||||
p = _portfolio(ledger, real_positions={})
|
||||
pos = p.positions["510300.XSHG"]
|
||||
assert pos.price is None
|
||||
assert pos.value == 0.0
|
||||
assert pos.market_value == 0.0
|
||||
|
||||
|
||||
def test_positions_empty_ledger(ledger):
|
||||
"""账本无持仓 → 空 dict(轮换退化为只买不卖=正确行为)。"""
|
||||
empty = LiveInstanceLedger(initial_cash=100_000)
|
||||
p = _portfolio(empty)
|
||||
assert p.positions == {}
|
||||
|
||||
|
||||
def test_real_positions_list_form_indexed(ledger):
|
||||
"""真 positions 为 list(bullet_trade 兼容路径)→ 按 .security 索引。"""
|
||||
p = _portfolio(ledger, real_positions=[
|
||||
_RealPos("600036.XSHG", 38.0), _RealPos("510300.XSHG", 4.75)])
|
||||
assert p.positions["510300.XSHG"].price == 4.75
|
||||
|
||||
|
||||
# ---------------- equity / 未覆盖属性 ----------------
|
||||
|
||||
def test_total_value_is_ledger_equity(ledger):
|
||||
"""total_value=账本 equity(现价优先,缺价成本兜底),非全账户。"""
|
||||
p = _portfolio(ledger, real_positions={
|
||||
"510300.XSHG": _RealPos("510300.XSHG", 4.80)})
|
||||
cash = ledger.cash
|
||||
assert p.total_value == pytest.approx(cash + 4.80 * 4200)
|
||||
assert p.positions_value == pytest.approx(4.80 * 4200)
|
||||
|
||||
|
||||
def test_total_value_cost_fallback(ledger):
|
||||
"""缺现价 → 账本 equity 的成本价兜底。"""
|
||||
p = _portfolio(ledger)
|
||||
_c, _mv, total = ledger.equity({})
|
||||
assert p.total_value == pytest.approx(total)
|
||||
|
||||
|
||||
def test_locked_cash_zero_and_uncovered_fail_fast(ledger):
|
||||
"""locked_cash 安全值 0;清单外属性 AttributeError(拒绝静默透传全账户数字)。"""
|
||||
p = _portfolio(ledger)
|
||||
assert p.locked_cash == 0.0
|
||||
with pytest.raises(AttributeError, match="inout"):
|
||||
_ = p.inout
|
||||
|
||||
|
||||
# ---------------- context 代理 ----------------
|
||||
|
||||
def test_context_proxy_passthrough_and_portfolio(ledger):
|
||||
real = SimpleNamespace(current_dt="2026-08-19 09:35:00",
|
||||
previous_date="2026-08-18",
|
||||
portfolio=_real_portfolio({}))
|
||||
proxy = InstanceContextProxy(real, ledger)
|
||||
assert proxy.current_dt == "2026-08-19 09:35:00"
|
||||
assert proxy.previous_date == "2026-08-18"
|
||||
assert isinstance(proxy.portfolio, InstancePortfolio)
|
||||
assert proxy.portfolio.cash == ledger.cash
|
||||
|
||||
|
||||
def test_make_proxy_context_none_ledger_identity():
|
||||
ctx = SimpleNamespace(x=1)
|
||||
assert make_proxy_context(ctx, None) is ctx
|
||||
|
||||
|
||||
# ---------------- 调度器包装 ----------------
|
||||
|
||||
def test_wrap_scheduler_callbacks_get_proxy(ledger):
|
||||
"""经包装注册的回调收到代理 context(portfolio=账本视图)。"""
|
||||
captured = {}
|
||||
|
||||
def bt_sched(func, time, **kw):
|
||||
captured["func"] = func
|
||||
captured["time"] = time
|
||||
|
||||
sched = wrap_scheduler(bt_sched, ledger)
|
||||
seen_ctx = {}
|
||||
|
||||
def strategy_task(context):
|
||||
seen_ctx["cash"] = context.portfolio.available_cash
|
||||
seen_ctx["dt"] = context.current_dt
|
||||
|
||||
strategy_task.__name__ = "strategy_task"
|
||||
sched(strategy_task, "09:35")
|
||||
# 调度器侧拿到的 wrapped 保留原函数名(任务命名不受影响)
|
||||
assert captured["func"].__name__ == "strategy_task"
|
||||
real_ctx = SimpleNamespace(
|
||||
current_dt="2026-08-19 09:35:00",
|
||||
portfolio=_real_portfolio(
|
||||
{"510300.XSHG": _RealPos("510300.XSHG", 4.80)}))
|
||||
captured["func"](real_ctx)
|
||||
assert seen_ctx["cash"] == pytest.approx(ledger.cash)
|
||||
assert seen_ctx["dt"] == "2026-08-19 09:35:00"
|
||||
|
||||
|
||||
def test_wrap_scheduler_none_ledger_passthrough():
|
||||
"""无账本 → 原调度器原样返回(回测/影子零改动)。"""
|
||||
def bt_sched(func, *a, **kw):
|
||||
return "registered"
|
||||
|
||||
assert wrap_scheduler(bt_sched, None) is bt_sched
|
||||
|
||||
|
||||
def test_setup_wraps_facade_schedulers(monkeypatch):
|
||||
"""_setup 接线:有账本 → facade.run_daily 被换包装;无账本 → 原样。"""
|
||||
from sanguo_portfolio import live_strategy
|
||||
|
||||
def _patch(monkeypatch):
|
||||
import bullet_trade.core as bt_core
|
||||
import bullet_trade.data.api as bt_data_api
|
||||
|
||||
def fake_run_daily(func, t, **kw):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(bt_core, "run_daily", fake_run_daily)
|
||||
monkeypatch.setattr(bt_core, "run_monthly",
|
||||
lambda f, d, t, **kw: None)
|
||||
monkeypatch.setattr(bt_data_api, "get_data_provider",
|
||||
lambda: SimpleNamespace())
|
||||
return bt_core
|
||||
|
||||
bt_core = _patch(monkeypatch)
|
||||
monkeypatch.setenv("SANGUO_LIVE_STRATEGY", "channel_test")
|
||||
try:
|
||||
live_strategy._STATE.update(strategy=None, wired=False)
|
||||
set_active(LiveInstanceLedger(initial_cash=100_000))
|
||||
live_strategy._setup(SimpleNamespace())
|
||||
assert live_strategy._STATE["strategy"].broker.run_daily \
|
||||
is not bt_core.run_daily # 已包装
|
||||
|
||||
live_strategy._STATE.update(strategy=None, wired=False)
|
||||
set_active(None)
|
||||
live_strategy._setup(SimpleNamespace())
|
||||
assert live_strategy._STATE["strategy"].broker.run_daily \
|
||||
is bt_core.run_daily # 原样
|
||||
finally:
|
||||
live_strategy._STATE.update(strategy=None, wired=False)
|
||||
set_active(None)
|
||||
Reference in New Issue
Block a user