VIOLET V3.4d: launcher OB-persistence + boost/beta prior; OB-feed/HZ-bridge/coordination doc

Launcher shadow path now keeps the persistent state BLUE keeps across scans (faithful, not
per-scan single-shot): ONE OBFeatureEngine wired lazily on first assets and kept for the
service lifetime, reading BLUE's EXTANT published OBF feed via HZOBProvider (read-only HZ
entry-listener cache — NO new OB storage; provider is the swap seam for a future direct
BingX/3rd-party OB stream); per-scan ob_bar_idx into step_live/get_market; and prior_boost_beta
carried across scans (stale exf keeps the prior). shadow_decision_step threads ob_engine +
bar_idx + prior into source_live_blue_sizing_factors; build_shadow_live_source seeds the state
+ a default ob_engine_factory (injectable for tests).

TODO_HZBRIDGE markers added at all 3 VIOLET->Hazelcast touch points: per operator the upcoming
dolphinng5_predict/hzbridge must become the sanctioned HZ connection (silent client-death /
lockup / dropout mitigation) — refactor ASAP once it ships.

Doc VIOLET_OB_FEED_AND_AGENT_COORDINATION.md: OB-feed sourcing doctrine, HZ-bridge TODO, and
the multi-agent worktree + doctrine/release/status scheme (prompted by the shared-index
incidents where agents' staged files cross-contaminate commits).

6 launcher tests (+wired-once/bar_idx-increments, +prior-carries). violet-only; partial commit
(only my 4 paths) to avoid sweeping a concurrent agent's staged files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-16 21:44:03 +02:00
parent 2f5ce55967
commit 0f3d7650c3
4 changed files with 233 additions and 4 deletions

View File

@@ -70,6 +70,9 @@ LOGGER = logging.getLogger("violet.live_blue_source")
# Hazelcast coordinates — MUST equal nautilus_event_trader.py:107-108 (BLUE's live # Hazelcast coordinates — MUST equal nautilus_event_trader.py:107-108 (BLUE's live
# cluster). HZOBProvider opens its own connection to these, exactly as BLUE's _wire_obf. # cluster). HZOBProvider opens its own connection to these, exactly as BLUE's _wire_obf.
# TODO_HZBRIDGE: all VIOLET HZ access (the caller's client + HZOBProvider) must move to
# dolphinng5_predict/hzbridge when it ships — raw HazelcastClient is the silent-death/lockup
# surface (see prod/docs/VIOLET_OB_FEED_AND_AGENT_COORDINATION.md + hz_client_death memory).
HZ_CLUSTER = _os.environ.get("HZ_CLUSTER", "dolphin") HZ_CLUSTER = _os.environ.get("HZ_CLUSTER", "dolphin")
HZ_HOST = _os.environ.get("HZ_HOST", "127.0.0.1:5701") HZ_HOST = _os.environ.get("HZ_HOST", "127.0.0.1:5701")

View File

@@ -2,6 +2,17 @@
These helpers stay separate from the launcher module so they can be unit-tested These helpers stay separate from the launcher module so they can be unit-tested
without importing the full launcher import chain. without importing the full launcher import chain.
They hold the PERSISTENT state BLUE keeps across scans so the shadow plane is faithful
to BLUE's live factor stream rather than a per-scan single-shot:
- ONE OBFeatureEngine, wired lazily on first assets and kept for the service lifetime
(OBFeatureEngine accumulates a lookback window; a fresh engine per scan has none).
It reads BLUE's EXTANT published OBF feed via HZOBProvider (a read-only HZ entry-
listener cache — NO new OB storage). The provider is the swap seam for a future
direct BingX / 3rd-party OB stream. See VIOLET_OB_FEED_AND_AGENT_COORDINATION.md.
- a per-scan-incrementing ``ob_bar_idx`` (BLUE steps one bar_idx into step_live).
- the last good ``prior_boost_beta`` so a stale-exf scan keeps the prior (BLUE keeps
_day_base_boost/_day_beta).
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,7 +20,25 @@ from __future__ import annotations
import logging import logging
import os import os
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger("violet.shadow_live_factors")
def _default_ob_engine_factory(assets):
"""Wire BLUE's EXTANT OBF feed: HZOBProvider (read-only listener) → OBFeatureEngine.
Exactly BLUE's _wire_obf (nautilus_event_trader.py:4967-4980). No OB storage, no new
exchange WS — it consumes the asset_*_ob shards BLUE already publishes to Hazelcast."""
from nautilus_dolphin.nautilus.ob_features import OBFeatureEngine
from nautilus_dolphin.nautilus.hz_ob_provider import HZOBProvider
from .live_blue_source import HZ_CLUSTER, HZ_HOST
# TODO_HZBRIDGE: HZOBProvider opens its OWN raw HazelcastClient. Once dolphinng5_predict/
# hzbridge ships, this connection MUST route through the bridge (silent HZ client death /
# lockup mitigation — see [[hz_client_death_investigation]]). One of 3 VIOLET HZ touch
# points to refactor (also build_shadow_live_source's client_factory + the live smoke).
provider = HZOBProvider(hz_cluster=HZ_CLUSTER, hz_host=HZ_HOST, assets=list(assets))
return OBFeatureEngine(provider)
def build_shadow_live_source( def build_shadow_live_source(
@@ -18,14 +47,18 @@ def build_shadow_live_source(
selector_factory=None, selector_factory=None,
source_factory=None, source_factory=None,
scan_history_factory=None, scan_history_factory=None,
ob_engine_factory=None,
): ):
"""Create the read-only BLUE live-factor mirror for the shadow path.""" """Create the read-only BLUE live-factor mirror for the shadow path, with the
persistent OB engine / bar_idx / boost-beta-prior state BLUE keeps across scans."""
if client_factory is None or selector_factory is None or source_factory is None or scan_history_factory is None: if client_factory is None or selector_factory is None or source_factory is None or scan_history_factory is None:
import hazelcast import hazelcast
from .alpha_wrappers import VioletAssetSelector from .alpha_wrappers import VioletAssetSelector
from .live_blue_source import LiveBlueScanHistory, source_live_blue_sizing_factors from .live_blue_source import LiveBlueScanHistory, source_live_blue_sizing_factors
# TODO_HZBRIDGE: raw HazelcastClient — route through dolphinng5_predict/hzbridge once
# it ships, to avoid the silent-death/lockup class. See [[hz_client_death_investigation]].
client_factory = client_factory or (lambda: hazelcast.HazelcastClient( client_factory = client_factory or (lambda: hazelcast.HazelcastClient(
cluster_name=os.environ.get("HZ_CLUSTER", "dolphin"), cluster_name=os.environ.get("HZ_CLUSTER", "dolphin"),
cluster_members=[os.environ.get("HZ_HOST", "localhost:5701")], cluster_members=[os.environ.get("HZ_HOST", "localhost:5701")],
@@ -40,9 +73,35 @@ def build_shadow_live_source(
"scan_history": scan_history_factory(), "scan_history": scan_history_factory(),
"selector": selector_factory(), "selector": selector_factory(),
"live_source": source_factory, "live_source": source_factory,
# persistent state (BLUE keeps these across scans)
"ob_engine": None,
"ob_engine_factory": ob_engine_factory or _default_ob_engine_factory,
"ob_bar_idx": 0,
"prior_boost_beta": None,
"last_live_source": None,
} }
def _ensure_ob_engine(shadow, payload):
"""Lazily wire ONE OB engine on the first scan that has an asset universe — like BLUE's
_wire_obf (`if not assets or self.ob_assets: return`). Kept for the service lifetime."""
if shadow.get("ob_engine") is not None:
return shadow["ob_engine"]
factory = shadow.get("ob_engine_factory")
if factory is None:
return None
from .live_blue_source import _scan_assets, _scan_view
assets = _scan_assets(_scan_view(payload))
if not assets:
return None
eng = factory(assets)
shadow["ob_engine"] = eng
shadow["ob_assets"] = assets
LOGGER.info("shadow OB wired to BLUE's extant feed for %d assets", len(assets))
return eng
def shadow_decision_step( def shadow_decision_step(
shadow: dict, shadow: dict,
payload: dict, payload: dict,
@@ -52,17 +111,28 @@ def shadow_decision_step(
vel_div: float, vel_div: float,
vol_ok: bool, vol_ok: bool,
) -> bool: ) -> bool:
"""Run one shadow decision against the live BLUE factor plane.""" """Run one muted shadow decision against the live BLUE factor plane, carrying the
persistent OB engine, bar_idx, and boost/beta prior across scans (BLUE-faithful)."""
shadow["engine"].observe(payload, scan_number) shadow["engine"].observe(payload, scan_number)
live_source = shadow.get("live_source") live_source = shadow.get("live_source")
factors = None factors = None
if live_source is not None: if live_source is not None:
ob_engine = _ensure_ob_engine(shadow, payload)
live_result = live_source( live_result = live_source(
shadow["client"], shadow["client"],
scan_history=shadow["scan_history"], scan_history=shadow["scan_history"],
selector=shadow["selector"], selector=shadow["selector"],
ob_engine=ob_engine,
bar_idx=shadow.get("ob_bar_idx", 0),
prior_boost_beta=shadow.get("prior_boost_beta"),
) )
shadow["last_live_source"] = live_result shadow["last_live_source"] = live_result
shadow["ob_bar_idx"] = shadow.get("ob_bar_idx", 0) + 1
# persist last good boost/beta as next scan's prior (BLUE keeps _day_base_boost/_day_beta).
ab = getattr(live_result, "acb_boost", None)
bb = getattr(live_result, "acb_beta", None)
if ab is not None and bb is not None:
shadow["prior_boost_beta"] = (ab, bb)
factors = live_result.factors factors = live_result.factors
if factors is None: if factors is None:
return False return False

View File

@@ -92,7 +92,8 @@ def test_shadow_decision_step_uses_live_factors_and_journals():
"client": object(), "client": object(),
"scan_history": object(), "scan_history": object(),
"selector": object(), "selector": object(),
"live_source": lambda client, scan_history, selector: SimpleNamespace( "live_source": lambda client, scan_history=None, selector=None, ob_engine=None,
bar_idx=0, prior_boost_beta=None: SimpleNamespace(
factors=SizingFactors( factors=SizingFactors(
boost=1.4, boost=1.4,
beta=0.2, beta=0.2,
@@ -104,6 +105,8 @@ def test_shadow_decision_step_uses_live_factors_and_journals():
posture="APEX", posture="APEX",
), ),
selected_asset="BTCUSDT", selected_asset="BTCUSDT",
acb_boost=1.4,
acb_beta=0.2,
), ),
"live_decisions": 0, "live_decisions": 0,
"last_live_source": None, "last_live_source": None,
@@ -153,3 +156,69 @@ def test_shadow_decision_step_skips_without_live_factor_plane():
vol_ok=True, vol_ok=True,
) )
assert ok is False assert ok is False
class _NoDecisionEngine:
def observe(self, payload, scan_number):
pass
def decide(self, **kwargs):
return None # step returns False after the live-source call (which is what we probe)
def test_shadow_step_lazily_wires_ob_engine_once_and_increments_bar_idx():
from prod.clean_arch.violet import shadow_live_factors as slf
built, seen = [], []
def ob_factory(assets):
eng = SimpleNamespace(assets=list(assets))
built.append(eng)
return eng
def live_source(client, *, scan_history, selector, ob_engine, bar_idx, prior_boost_beta):
seen.append((ob_engine, bar_idx))
return SimpleNamespace(factors=SizingFactors(posture="APEX"), selected_asset="BTCUSDT",
acb_boost=1.0, acb_beta=0.0)
shadow = slf.build_shadow_live_source(
client_factory=lambda: object(), selector_factory=lambda: object(),
source_factory=live_source, scan_history_factory=lambda: object(),
ob_engine_factory=ob_factory,
)
shadow["engine"] = _NoDecisionEngine()
shadow["capital"] = 1.0
payload = {"assets": ["BTCUSDT", "ETHUSDT"], "vel_div": -0.03}
for i in range(3):
slf.shadow_decision_step(shadow, payload, scan_number=i, now_ns=i, vel_div=-0.03, vol_ok=True)
assert len(built) == 1 # OB engine wired exactly ONCE
assert all(s[0] is built[0] for s in seen) # same persistent engine reused
assert [s[1] for s in seen] == [0, 1, 2] # bar_idx increments per scan
def test_shadow_step_carries_boost_beta_prior_across_scans():
from prod.clean_arch.violet import shadow_live_factors as slf
priors_seen, counter = [], {"n": 0}
def live_source(client, *, scan_history, selector, ob_engine, bar_idx, prior_boost_beta):
priors_seen.append(prior_boost_beta)
counter["n"] += 1
b = float(counter["n"])
return SimpleNamespace(factors=SizingFactors(posture="APEX"), selected_asset="X",
acb_boost=b, acb_beta=b / 10.0)
shadow = slf.build_shadow_live_source(
client_factory=lambda: object(), selector_factory=lambda: object(),
source_factory=live_source, scan_history_factory=lambda: object(),
ob_engine_factory=lambda assets: object(),
)
shadow["engine"] = _NoDecisionEngine()
shadow["capital"] = 1.0
payload = {"assets": ["X"], "vel_div": -0.03}
for i in range(3):
slf.shadow_decision_step(shadow, payload, scan_number=i, now_ns=i, vel_div=-0.03, vol_ok=True)
# scan0: no prior yet; scan1: prior = scan0's (1.0, 0.1); scan2: prior = scan1's (2.0, 0.2)
assert priors_seen == [None, (1.0, 0.1), (2.0, 0.2)]

View File

@@ -0,0 +1,87 @@
# VIOLET — OB feed sourcing, HZ Bridge, and multi-agent coordination
Date: 2026-06-16. Operator-driven decisions captured during the V3.4c/d review.
## 1. OB feed sourcing doctrine
**VIOLET consumes BLUE's EXTANT published OBF feed — it does NOT store OB data.**
- `live_blue_source._source_ob_market` and `shadow_live_factors._default_ob_engine_factory`
use BLUE's OWN `HZOBProvider` (`nautilus_dolphin/.../hz_ob_provider.py`) + `OBFeatureEngine`,
wired exactly as BLUE's `_wire_obf` (nautilus_event_trader.py:4967-4980).
- `HZOBProvider` is a **read-only Hazelcast entry-listener cache**: it subscribes to the
`asset_*_ob` shards BLUE already publishes and keeps only the latest snapshot per asset in
memory. Verified: **no writes, no persistence, no new exchange WS**. OB raw data is the
heaviest disk load in the system; VIOLET adds ZERO to it by reading the published shards.
- `OBFeatureEngine` keeps an in-memory lookback window only (no disk). The launcher holds
ONE engine for the service lifetime (`shadow["ob_engine"]`), stepping a per-scan `bar_idx`,
so OB accumulation matches BLUE across scans (a fresh per-scan engine has no history).
**Swap seam for future multi-exchange OB.** The `OBProvider` behind `OBFeatureEngine` is the
single swap point. To run a SEPARATE OB stream (e.g. BingX testnet / a third-party venue,
which has genuinely different OB than the Binance reference), replace `HZOBProvider` with a
direct-WS provider feeding the SAME `OBFeatureEngine` — no other code changes. This is
specced/reasonable and the design already accommodates it.
**Cadence compliance (VIOLET's whole point — faster-than-OBF, event-driven).** Two regimes:
- NOW (shadow / parity): match BLUE, which samples OB at the OBF cadence (~1s native). You
cannot be *more* faithful than BLUE's own OB sampling, so reading the published shards is
correct for parity. `HZOBProvider` is itself a PUSH entry-listener (not polling), which is
already event-driven and aligned with the reactor model.
- LATER (V5/V6 sub-second): when VIOLET wants OB faster than BLUE's OBF, it swaps in a direct
WS OB provider (the seam above) on the reactor clock. That is a DELIBERATE VIOLET feature /
divergence, gated separately — not a parity break of the current shadow stage.
## 2. HZ Bridge — TODO_HZBRIDGE (refactor ASAP when it ships)
An upcoming **Hazelcast Bridge** (`dolphinng5_predict/hzbridge`) will be the sanctioned way to
connect to Hazelcast, mitigating the silent client-death / lockup / dropout class (see the
`hz_client_death_investigation` memory + black-box dump work in BLUE). **All VIOLET raw
`HazelcastClient` / `HZOBProvider` connections must route through the bridge once available.**
In-code `TODO_HZBRIDGE` markers flag the three touch points:
1. `shadow_live_factors.build_shadow_live_source` — the launcher's `client_factory`.
2. `shadow_live_factors._default_ob_engine_factory``HZOBProvider`'s own connection.
3. `live_blue_source` HZ_CLUSTER/HZ_HOST + `_source_ob_market` provider construction; the
live-HZ smoke test.
Refactor priority: ASAP after the bridge lands (or accelerate the bridge). Until then, VIOLET
uses raw clients, accepting the known fragility (it is DARK, so a dropout loses shadow rows,
never orders).
## 3. Multi-agent coordination (worktrees + doctrine/status)
Agents on this box: **Claude, CommandCode, Codex, Crush.** The 2026-06-16 incident — one
agent's staged files swept into another's commit (a doc landed in the forbidden `dita_v2/`) —
was caused by **multiple agents sharing ONE working tree + ONE `.git/index`**. `index.lock`
serializes plumbing ops; it does NOT isolate logical work, and `git commit` commits the WHOLE
index regardless of which files you `git add`. Careful add does not protect you — only
isolation does.
**Target model (industry standard):**
- **One `git worktree` per agent** over the shared object DB: each gets its own working dir +
index + HEAD, so collisions are impossible.
```
git worktree add ../vp-claude -b agent/claude
git worktree add ../vp-commandcode -b agent/commandcode
git worktree add ../vp-codex -b agent/codex
git worktree add ../vp-crush -b agent/crush
```
Each agent works ONLY in its own tree. (This is what the Claude Code harness's
`isolation: "worktree"` already does for subagents.)
- **Branch-per-agent → integrate via Gitea PRs.** Push agent branches to the Gitea remote
(`hjnormey/siloqy`); merge to a canonical branch via review. `main`/`release` protected.
- **Doctrine / release = a protected canonical branch + tags.** The LIVE working tree (the one
BLUE/PINK actually run from) tracks the canonical branch only; tag doctrinal snapshots
(`git tag release-YYYYMMDD`). Anything not on the canonical branch is WIP by definition —
that answers "which files are doctrinal vs in-work".
- **Agent work-status board = Gitea branch + PR list.** Each open PR / agent branch (with its
ahead/behind + last-commit author) IS the status dashboard. Optionally a top-level
`AGENT_WORKLOG.md` or the existing `.beads/` tracker for human-readable status.
**Critical secondary point:** this working tree is ALSO the live deployment path (BLUE runs
`prod/nautilus_event_trader.py` from here). Agents editing it directly means WIP code sits in
the live path — a stray restart could load half-finished edits. Worktrees fix this too:
agents edit isolated trees; deployment becomes an explicit checkout/merge of the canonical
branch onto the live tree.
Setup is operator-gated (it reorganizes how all four agents work + touches the live tree), so
it is documented here for greenlight rather than executed unilaterally.