Files
siloqy/prod/clean_arch/dita_v2/blue_parity.py
Codex f4ff1cd9b7 blue_parity: hyphen-tolerant price_of (first add of module)
PinkAssetPicker/PinkAlphaSizer (BLUE-parity IRP picker + sizer, 2026-06-10)
plus the 2026-06-11 fix: price_of falls back to the dehyphenated symbol —
reconcile/fill paths write venue format (FET-USDT) into the slot while the
scan universe keys are Binance-style (FETUSDT); without the fallback an
adopted slot had no price and TP/SL/MAX_HOLD never evaluated (the unmanaged
FET position incident).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:03:19 +02:00

255 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BLUE-parity alpha components for the PINK DITAv2 runtime.
PINK must trade the SAME universe with the SAME sizing curve as BLUE
(operator mandate 2026-06-10). This module wraps BLUE's exact production
kernels — no reimplementation, no drift:
- ``AlphaAssetSelector`` (nautilus_dolphin/nautilus/alpha_asset_selector.py)
IRP ranking over the scan universe (SYSTEM BIBLE §5). The scan payload
carries ``assets`` + ``asset_prices`` for the full ~50-asset universe on
every scan; PINK accumulates per-asset price history here and ranks at
signal time, exactly like BLUE's engine does.
- ``AlphaBetSizer`` (nautilus_dolphin/nautilus/alpha_bet_sizer.py)
Cubic-convex dynamic leverage + alpha-layer fraction (SYSTEM BIBLE §6).
Replaces the DITAv2 stub formula that saturated at max_leverage on every
entry (confidence = |vdiv/threshold| ≥ 1 whenever an ENTER is possible).
Both wrappers are observe()-driven and dedupe on scan_number so the 1 s poll
loop cannot poison histories with repeated scans.
DUAL-LEVERAGE INVARIANT (prod/docs/FRACTIONAL_LEVERAGE_TO_BINGX_FIX.md,
prod/bingx/leverage.py): the fractional leverage produced here is STRATEGY
conviction — it sizes the quantity. At-exchange leverage is derived from it
at the venue boundary via map_internal_conviction_to_exchange_leverage()
(linear [0.5, 9.0] → [1, cap], bankers rounding, security cap).
"""
from __future__ import annotations
import logging
import sys
from collections import deque
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
LOGGER = logging.getLogger("PinkBlueParity")
_PROJECT_ROOT = str(Path(__file__).resolve().parents[3])
def _import_blue_modules() -> Tuple[Any, Any]:
"""Import BLUE's selector/sizer modules, adding their root if needed.
The real package lives at <root>/nautilus_dolphin/nautilus_dolphin/ —
BLUE runs with <root>/nautilus_dolphin on sys.path (see
nautilus_event_trader.py's proxy_boost_engine import).
"""
try:
from nautilus_dolphin.nautilus import alpha_asset_selector, alpha_bet_sizer
except ImportError:
pkg_root = str(Path(_PROJECT_ROOT) / "nautilus_dolphin")
for p in (pkg_root, _PROJECT_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
# A namespace-package hit from <root>/nautilus_dolphin (no __init__)
# can shadow the real package — drop it so the retry resolves cleanly.
sys.modules.pop("nautilus_dolphin", None)
from nautilus_dolphin.nautilus import alpha_asset_selector, alpha_bet_sizer
return alpha_asset_selector, alpha_bet_sizer
class PinkAssetPicker:
"""Accumulate universe price history from scan payloads; rank via BLUE IRP.
BLUE semantics preserved:
- IRP lookback default 50 bars (alpha_asset_selector.IRP_LOOKBACK).
- Kernel gates (noise>500, latency>20, alignment<0.20) apply inside
rank_assets_irp_nb; min_irp_alignment=0.0 matches BLUE gold spec.
- No fallback asset when ranking returns no candidate (Bible §5.5:
"No match → return None").
"""
def __init__(self, lookback: int = 0, min_irp_alignment: float = 0.0):
selector_mod, _ = _import_blue_modules()
self._selector_mod = selector_mod
self.lookback = int(lookback) if lookback > 0 else int(selector_mod.IRP_LOOKBACK)
self.min_irp_alignment = float(min_irp_alignment)
self._selector = selector_mod.AlphaAssetSelector(lookback_horizon=self.lookback)
# Per-asset rolling price history. lookback+1 prices = lookback returns.
self._history: Dict[str, deque] = {}
self._last_price: Dict[str, float] = {}
self._last_scan_number: int = -1
self.scans_observed: int = 0
# ── feeding ──────────────────────────────────────────────────────────────
def observe(self, scan_payload: Optional[dict], scan_number: int) -> bool:
"""Append one bar per NEW scan. Returns True if the bar was accepted."""
payload = scan_payload or {}
sn = int(scan_number or 0)
if sn <= self._last_scan_number:
return False # duplicate / stale scan — poll loop runs faster than scans
assets = payload.get("assets") or []
prices = payload.get("asset_prices") or []
if not (isinstance(assets, list) and isinstance(prices, list) and assets):
return False
maxlen = self.lookback + 1
for asset, price in zip(assets, prices):
try:
px = float(price)
except (TypeError, ValueError):
continue
if px <= 0:
continue
sym = str(asset).upper()
hist = self._history.get(sym)
if hist is None:
hist = deque(maxlen=maxlen)
self._history[sym] = hist
hist.append(px)
self._last_price[sym] = px
self._last_scan_number = sn
self.scans_observed += 1
return True
# ── queries ──────────────────────────────────────────────────────────────
@property
def warm(self) -> bool:
"""True once at least one asset has a full lookback window."""
need = self.lookback + 1
return any(len(h) >= need for h in self._history.values())
def price_of(self, asset: str) -> Optional[float]:
key = str(asset).upper()
px = self._last_price.get(key)
if px is None:
# Venue-format tolerance: reconcile/fill paths write the BingX
# symbol ("FET-USDT") into the slot, while the scan universe keys
# are Binance-style ("FETUSDT"). Without this fallback an open
# slot adopted from the exchange has no price and every policy
# step degrades to HOLD — TP/SL/MAX_HOLD never fire (2026-06-11).
px = self._last_price.get(key.replace("-", ""))
return px
def pick(self, direction: int = -1) -> Optional[Tuple[str, float, float]]:
"""Rank the warm universe; return (asset, last_price, ars) or None.
``direction`` is the regime direction (-1 = short regime; PINK is
short-only). BLUE walks the ranked list applying the alignment and
direction gates; first survivor wins.
"""
need = self.lookback + 1
market_data: Dict[str, List[float]] = {
sym: list(h) for sym, h in self._history.items() if len(h) >= need
}
if not market_data:
return None
try:
rankings = self._selector.rank_assets(market_data, regime_direction=int(direction))
except Exception as exc:
LOGGER.warning("IRP ranking failed: %s — no asset picked", exc)
return None
expected_action = "SHORT" if int(direction) == -1 else "LONG"
for r in rankings:
if self.min_irp_alignment > 0 and r.metrics.alignment < self.min_irp_alignment:
continue
if r.action != expected_action:
continue
px = self._last_price.get(r.asset)
if px is None or px <= 0:
continue
return r.asset, px, float(r.ars_score)
return None
class PinkAlphaSizer:
"""BLUE's AlphaBetSizer with vel_div-trend tracking and trade feedback.
calculate_size() output is BLUE-identical:
strength_score = clamp((threshold vel_div)/(threshold extreme), 0, 1)
eff_leverage = min_lev + strength³ × (max_lev min_lev) (convexity 3)
eff_fraction = alpha layers (bucket boost, streak, trend, extreme)
vd_trend = vel_div[-1] vel_div[-10] over the last 10 SCANS (Bible §6.4),
maintained here via observe(); the decision layer only passes capital and
the current vel_div.
"""
def __init__(
self,
*,
base_fraction: float = 0.20,
min_leverage: float = 0.5,
max_leverage: float = 8.0,
leverage_convexity: float = 3.0,
vel_div_threshold: float = -0.02,
vel_div_extreme: float = -0.05,
use_dynamic_leverage: bool = True,
use_alpha_layers: bool = True,
):
_, sizer_mod = _import_blue_modules()
self._sizer = sizer_mod.AlphaBetSizer(
base_fraction=base_fraction,
min_leverage=min_leverage,
max_leverage=max_leverage,
leverage_convexity=leverage_convexity,
vel_div_threshold=vel_div_threshold,
vel_div_extreme=vel_div_extreme,
use_dynamic_leverage=use_dynamic_leverage,
use_alpha_layers=use_alpha_layers,
)
self.min_leverage = float(min_leverage)
self.max_leverage = float(max_leverage)
self._vd_history: deque = deque(maxlen=10)
self._last_scan_number: int = -1
# Bucket of the most recent calculate_size(); frozen by note_entry()
# so record_close() credits the right bucket even after later scans.
self._pending_bucket: int = 3
self._open_bucket: Optional[int] = None
def observe(self, vel_div: Any, scan_number: int) -> None:
sn = int(scan_number or 0)
if sn <= self._last_scan_number:
return
try:
vd = float(vel_div)
except (TypeError, ValueError):
return
self._vd_history.append(vd)
self._last_scan_number = sn
@property
def vd_trend(self) -> float:
if len(self._vd_history) >= 10:
return float(self._vd_history[-1] - self._vd_history[0])
return 0.0
def calculate_size(self, *, capital: float, vel_div: float) -> dict:
result = self._sizer.calculate_size(
capital=float(capital),
vel_div=float(vel_div),
vel_div_trend=self.vd_trend,
trade_direction=-1,
)
self._pending_bucket = int(result.get("bucket_idx", 3))
return result
# ── trade feedback (bucket boost / streak multiplier inputs) ─────────────
def note_entry(self) -> None:
"""An ENTER sized by the last calculate_size() was actually submitted."""
self._open_bucket = self._pending_bucket
def record_close(self, pnl: float) -> None:
"""Feed realized PnL of the closed trade back into the alpha layers."""
bucket = self._open_bucket
self._open_bucket = None
if bucket is None:
return
try:
self._sizer.record_trade(int(bucket), float(pnl))
except Exception as exc:
LOGGER.warning("sizer record_trade failed: %s", exc)