"""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