3 Commits

Author SHA1 Message Date
Adriano Dal Pastro 647e3e565f fix(safety): il boot-check dell'audit tollera il lag benigno dell'anchor
L'anchor dell'audit (system_state.last_audit_hash) e' persistito best-effort:
sotto contesa di lock SQLite il mirror puo' restare indietro rispetto al log,
che invece cresce in avanti integro. Il check di boot armava il kill-switch
su qualsiasi disuguaglianza anchor!=coda, scambiando questo lag per
manomissione.

- audit_log: nuova tail_continues_from() — True solo se l'anchor e' un
  antenato valido della coda (presente in catena + chain forward integra a EOF)
- orchestrator._verify_audit_anchor: se lag benigno -> re-sync anchor + warning;
  solo anchor assente o chain rotta (troncamento/tamper) -> critical -> arma

Verificato live: anchor in ritardo -> re-sync senza armare; anchor bogus -> arma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:19 +00:00
Adriano Dal Pastro a5a39a6d27 feat(entry): finestra evento macro configurabile, ridotta a 1g (config v1.6.0)
Il gate macro riusava structure.dte_target (18g) per decidere entro quanti
giorni un evento high-severity blocca l'entrata, accoppiando la protezione
di rischio-evento alla scelta delle scadenze opzioni. Disaccoppiato con un
parametro dedicato.

- schema: nuovo entry.exclude_macro_within_days (default 18 = comportamento storico)
- entry_validator: il gate macro usa il nuovo campo; rimosso structure_cfg inutilizzato
- entry_cycle: la fetch del calendario macro guarda avanti exclude_macro_within_days giorni
- strategy.yaml: exclude_macro_within_days=1, config_version 1.5.0->1.6.0, hash rigenerato

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:19 +00:00
Adriano Dal Pastro 839285d75b fix(mcp): allinea il client all'interfaccia unificata MCP V2
Cerbero MCP V2 ha spostato get_instruments/get_historical/get_indicators
sul router unificato /mcp (rimossi dagli endpoint per-exchange) e ha
rinominato get_technical_indicators in get_indicators. Il client chiamava
ancora questi tool su /mcp-deribit -> 404 -> kill-switch armato, raccolta
option-chain ferma e bias direzionale rotto.

- mcp_endpoints: nuovo endpoint `unified` (CERBERO_BITE_MCP_UNIFIED_URL,
  default http://cerbero-mcp:9000/mcp)
- deribit: i 3 tool dati passano dal client unificato con exchange="deribit",
  interval minuscolo (1D->1d) e parsing del nuovo shape (symbol + native.*);
  adx_14 usa get_indicators (indicators.adx.adx)
- dependencies/gui: iniettano il client unificato in DeribitClient
- docker-compose: default in-cluster per l'endpoint /mcp

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:55:04 +00:00
11 changed files with 188 additions and 45 deletions
+3
View File
@@ -42,6 +42,9 @@ x-bite-env: &bite-env
CERBERO_BITE_MCP_HYPERLIQUID_URL: ${CERBERO_BITE_MCP_HYPERLIQUID_URL:-http://cerbero-mcp:9000/mcp-hyperliquid}
CERBERO_BITE_MCP_MACRO_URL: ${CERBERO_BITE_MCP_MACRO_URL:-http://cerbero-mcp:9000/mcp-macro}
CERBERO_BITE_MCP_SENTIMENT_URL: ${CERBERO_BITE_MCP_SENTIMENT_URL:-http://cerbero-mcp:9000/mcp-sentiment}
# MCP V2 unified interface (/mcp): get_instruments, get_historical,
# get_indicators — removed from the per-exchange routers.
CERBERO_BITE_MCP_UNIFIED_URL: ${CERBERO_BITE_MCP_UNIFIED_URL:-http://cerbero-mcp:9000/mcp}
services:
cerbero-bite:
+59 -20
View File
@@ -120,12 +120,29 @@ def _to_decimal(value: Any) -> Decimal | None:
class DeribitClient:
SERVICE = "deribit"
def __init__(self, http: HttpToolClient) -> None:
def __init__(
self, http: HttpToolClient, unified: HttpToolClient | None = None
) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
# Cerbero MCP V2 moved the data tools (get_instruments,
# get_historical, get_indicators) to the unified ``/mcp`` router;
# they are no longer on ``/mcp-deribit``. This second client points
# at ``/mcp`` and is only needed by those three methods — diagnostic
# paths (e.g. ``environment_info`` for ping) leave it ``None``.
self._unified = unified
def _unified_client(self, tool: str) -> HttpToolClient:
if self._unified is None:
raise McpDataAnomalyError(
f"unified MCP client not configured; '{tool}' lives on /mcp",
service=self.SERVICE,
tool=tool,
)
return self._unified
# ------------------------------------------------------------------
# Environment / health
@@ -206,7 +223,16 @@ class DeribitClient:
limit: int = 500,
) -> list[InstrumentMeta]:
"""Return option instruments matching the filters as typed metadata."""
body: dict[str, Any] = {"currency": currency, "kind": "option", "limit": limit}
# MCP V2: get_instruments is unified-only (/mcp). The venue is
# selected via ``exchange`` and each row is normalized — the native
# symbol is ``symbol`` and Deribit-specific fields live under
# ``native``.
body: dict[str, Any] = {
"exchange": self.SERVICE,
"currency": currency,
"kind": "option",
"limit": limit,
}
if expiry_from is not None:
body["expiry_from"] = expiry_from.date().isoformat()
if expiry_to is not None:
@@ -214,28 +240,31 @@ class DeribitClient:
if min_open_interest is not None:
body["min_open_interest"] = min_open_interest
raw = await self._http.call("get_instruments", body)
raw = await self._unified_client("get_instruments").call(
"get_instruments", body
)
instruments = raw.get("instruments") or []
out: list[InstrumentMeta] = []
for entry in instruments:
if not isinstance(entry, dict):
continue
name = entry.get("name")
name = entry.get("symbol")
if not isinstance(name, str):
continue
try:
strike, expiry, option_type = _parse_instrument(name)
except McpDataAnomalyError:
continue
native = entry.get("native") if isinstance(entry.get("native"), dict) else {}
out.append(
InstrumentMeta(
name=name,
strike=strike,
expiry=expiry,
option_type=option_type,
open_interest=_to_decimal(entry.get("open_interest")),
open_interest=_to_decimal(native.get("open_interest")),
tick_size=_to_decimal(entry.get("tick_size")),
min_trade_amount=_to_decimal(entry.get("min_trade_amount")),
min_trade_amount=_to_decimal(native.get("min_trade_amount")),
)
)
return out
@@ -291,13 +320,17 @@ class DeribitClient:
in :func:`compute_bias`. Returns ``None`` when the chain has no
data in the window.
"""
raw = await self._http.call(
# MCP V2: get_historical is unified-only (/mcp); it takes
# ``exchange`` + lowercase ``interval`` (e.g. "1d", "1h") instead of
# the old per-exchange ``resolution``.
raw = await self._unified_client("get_historical").call(
"get_historical",
{
"exchange": self.SERVICE,
"instrument": instrument,
"start_date": start.date().isoformat(),
"end_date": end.date().isoformat(),
"resolution": resolution,
"interval": resolution.lower(),
},
)
candles = (raw or {}).get("candles") or []
@@ -422,28 +455,34 @@ class DeribitClient:
resolution: str = "1h",
) -> Decimal | None:
"""Return the most recent ADX(14) value, or ``None`` when missing."""
raw = await self._http.call(
"get_technical_indicators",
# MCP V2: indicators are computed by the unified ``get_indicators``
# tool (/mcp), which replaced the per-exchange
# ``get_technical_indicators``. Body takes ``exchange`` + lowercase
# ``interval``; the response nests each indicator under
# ``indicators`` (ADX as ``{"adx": <float>, "+di":…, "-di":…}``).
raw = await self._unified_client("get_indicators").call(
"get_indicators",
{
"exchange": self.SERVICE,
"instrument": instrument,
"indicators": ["adx"],
"start_date": start.date().isoformat(),
"end_date": end.date().isoformat(),
"resolution": resolution,
"interval": resolution.lower(),
},
)
if not isinstance(raw, dict):
return None
# The MCP server returns either a top-level dict with the
# indicator keyed by name, or a list of points. Be tolerant.
adx_payload = raw.get("adx") or raw.get("ADX") or raw.get("indicators", {})
if isinstance(adx_payload, list) and adx_payload:
tail = adx_payload[-1]
value = tail.get("value") if isinstance(tail, dict) else tail
return None if value is None else Decimal(str(value))
if isinstance(adx_payload, dict):
value = adx_payload.get("latest") or adx_payload.get("value")
indicators = raw.get("indicators")
if not isinstance(indicators, dict):
return None
adx_block = indicators.get("adx")
if isinstance(adx_block, dict):
value = adx_block.get("adx")
return None if value is None else Decimal(str(value))
# Tolerate a flattened scalar shape just in case.
if adx_block is not None and not isinstance(adx_block, list):
return Decimal(str(adx_block))
return None
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
+13
View File
@@ -62,6 +62,14 @@ DEFAULT_ENDPOINTS: dict[str, str] = {
}
# Cerbero MCP V2 unified interface (``/mcp``). The data tools
# ``get_instruments`` / ``get_historical`` / ``get_indicators`` live here
# ONLY — they were removed from the per-exchange routers in MCP V2. The
# caller passes ``exchange="deribit"`` in the body to target one venue.
_UNIFIED_ENV = "CERBERO_BITE_MCP_UNIFIED_URL"
_DEFAULT_UNIFIED_URL = "http://cerbero-mcp:9000/mcp"
@dataclass(frozen=True)
class McpEndpoints:
"""Resolved per-service URLs."""
@@ -70,6 +78,7 @@ class McpEndpoints:
hyperliquid: str
macro: str
sentiment: str
unified: str = _DEFAULT_UNIFIED_URL
def for_service(self, name: str) -> str:
try:
@@ -85,6 +94,10 @@ def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
for name, (host, port, env_var) in MCP_SERVICES.items():
override = e.get(env_var)
resolved[name] = override.rstrip("/") if override else _default_url(host, port)
unified_override = e.get(_UNIFIED_ENV)
resolved["unified"] = (
unified_override.rstrip("/") if unified_override else _DEFAULT_UNIFIED_URL
)
return McpEndpoints(**resolved)
+5
View File
@@ -59,6 +59,11 @@ class EntryConfig(BaseModel):
no_position_concurrent: bool = True
exclude_macro_severity: list[str] = Field(default_factory=lambda: ["high"])
exclude_macro_countries: list[str] = Field(default_factory=lambda: ["US", "EU"])
# Finestra (giorni) entro cui un evento macro high-severity blocca
# l'entry. Disaccoppiata da `structure.dte_target`: il filtro macro
# è una protezione di rischio evento, indipendente dalla scadenza
# scelta per le opzioni. Default 18 = comportamento storico.
exclude_macro_within_days: int = 18
# directional bias (§3.1)
trend_window_days: int = 30
+1 -2
View File
@@ -101,7 +101,6 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
"""
reasons: list[str] = []
entry_cfg = cfg.entry
structure_cfg = cfg.structure
if ctx.has_open_position:
reasons.append("open position already exists")
@@ -118,7 +117,7 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
if (
ctx.next_macro_event_in_days is not None
and ctx.next_macro_event_in_days <= structure_cfg.dte_target
and ctx.next_macro_event_in_days <= entry_cfg.exclude_macro_within_days
):
reasons.append(
f"macro event within DTE window ({ctx.next_macro_event_in_days} days)"
+1 -1
View File
@@ -191,7 +191,7 @@ async def _fetch_balances_async(*, timeout_s: float = 8.0) -> BalancesSnapshot:
client=http_client,
)
deribit = DeribitClient(_client("deribit"))
deribit = DeribitClient(_client("deribit"), _client("unified"))
hl = HyperliquidClient(_client("hyperliquid"))
macro = MacroClient(_client("macro"))
+1 -1
View File
@@ -158,7 +158,7 @@ def build_runtime(
telegram=telegram, audit_log=audit_log, kill_switch=kill_switch
)
deribit = DeribitClient(_client("deribit"))
deribit = DeribitClient(_client("deribit"), _client("unified"))
macro = MacroClient(_client("macro"))
sentiment = SentimentClient(_client("sentiment"))
hyperliquid = HyperliquidClient(_client("hyperliquid"))
+1 -1
View File
@@ -145,7 +145,7 @@ async def _gather_snapshot(
)
macro_t: asyncio.Task[int | None] = asyncio.create_task(
macro.next_high_severity_within(
days=cfg.structure.dte_target,
days=cfg.entry.exclude_macro_within_days,
countries=list(cfg.entry.exclude_macro_countries),
now=now,
)
+36 -11
View File
@@ -40,7 +40,9 @@ from cerbero_bite.runtime.option_chain_snapshot_cycle import (
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
from cerbero_bite.runtime.recovery import recover_state
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
from cerbero_bite.safety.audit_log import tail_continues_from
from cerbero_bite.state import connect as connect_state
from cerbero_bite.state import transaction
__all__ = ["Orchestrator"]
@@ -50,8 +52,8 @@ _log = logging.getLogger("cerbero_bite.runtime.orchestrator")
Environment = Literal["testnet", "mainnet"]
# Default cron schedule (matches docs/06-operational-flow.md table).
_CRON_ENTRY = "0 14 * * *" # crypto 24/7: candidatura giornaliera; i gate decidono se entrare
_CRON_MONITOR = "0 2,14 * * *"
_CRON_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare
_CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora
_CRON_HEALTH = "*/5 * * * *"
_CRON_BACKUP = "0 * * * *"
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
@@ -158,16 +160,39 @@ class Orchestrator:
if state is None or state.last_audit_hash is None:
return # first boot, nothing to compare against
actual_tail = self._ctx.audit_log.last_hash
if actual_tail != state.last_audit_hash:
await self._ctx.alert_manager.critical(
source="orchestrator.boot",
message=(
f"audit log anchor mismatch: anchor="
f"{state.last_audit_hash[:12]}…, file tail="
f"{actual_tail[:12]}… — possible tampering or truncation"
),
component="safety.audit_log",
if actual_tail == state.last_audit_hash:
return
# The anchor is persisted best-effort (see build_runtime): under
# SQLite write contention the mirror can fall behind the log while
# the log itself keeps growing forward, intact. Treat that benign
# lag — anchor is a valid ancestor of the current tail — as a
# re-sync, not tampering. Only a missing anchor or a broken
# post-anchor chain (truncation/tampering) arms the kill switch.
if tail_continues_from(self._ctx.audit_log.path, state.last_audit_hash):
conn = connect_state(self._ctx.db_path)
try:
with transaction(conn):
self._ctx.repository.set_last_audit_hash(
conn, hex_hash=actual_tail
)
finally:
conn.close()
_log.warning(
"audit anchor lagged behind tail; re-synced "
"(anchor=%s…, tail=%s…)",
state.last_audit_hash[:12],
actual_tail[:12],
)
return
await self._ctx.alert_manager.critical(
source="orchestrator.boot",
message=(
f"audit log anchor mismatch: anchor="
f"{state.last_audit_hash[:12]}…, file tail="
f"{actual_tail[:12]}… — possible tampering or truncation"
),
component="safety.audit_log",
)
async def run_entry(
self, *, now: datetime | None = None
+50
View File
@@ -28,6 +28,7 @@ __all__ = [
"AuditChainError",
"AuditEntry",
"AuditLog",
"tail_continues_from",
"verify_chain",
]
@@ -157,6 +158,55 @@ def verify_chain(path: str | Path) -> int:
return count
def tail_continues_from(path: str | Path, anchor_hash: str) -> bool:
"""Return ``True`` when *anchor_hash* is a genuine ancestor of the tail.
That is: *anchor_hash* is the ``hash`` of some line in the log AND every
line after it forms a valid forward chain to EOF. This distinguishes a
**benign anchor lag** — the best-effort anchor persisted in
:func:`cerbero_bite.runtime.dependencies.build_runtime` fell behind the
file under SQLite write contention, yet the log grew forward
legitimately — from real **truncation/tampering** (anchor absent, or the
post-anchor chain broken).
Returns ``False`` if the file is missing/empty, the anchor is not found,
or the chain from the anchor to the tail does not verify. The single-
writer invariant of :class:`AuditLog` still holds; this only makes the
boot-time anchor check tolerant of the durability gap it documents.
"""
p = Path(path)
if not p.exists() or p.stat().st_size == 0:
return False
seen = False
expected_prev = ""
with p.open("r", encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
try:
entry = _parse_line(line)
except AuditChainError:
if seen:
return False
continue
if seen:
if entry.prev_hash != expected_prev:
return False
recomputed = _compute_hash(
entry.timestamp.isoformat(),
entry.event,
_canonical_payload(entry.payload),
entry.prev_hash,
)
if recomputed != entry.hash:
return False
expected_prev = entry.hash
elif entry.hash == anchor_hash:
seen = True
expected_prev = entry.hash
return seen
def iter_entries(path: str | Path) -> Iterator[AuditEntry]:
"""Yield each :class:`AuditEntry` from *path* without verifying."""
p = Path(path)
+18 -9
View File
@@ -6,9 +6,9 @@
# config hash), and lands as a separate commit with the motivation in
# the commit message.
config_version: "1.4.0"
config_hash: "22182814216190331e0b69b3bc99493e6d69cc813f7ed937394986eecc1f5d11"
last_review: "2026-04-26"
config_version: "1.6.0"
config_hash: "67df85f84ce6148b88f7eb9d96346145c1b9892a7250f5fc42619da3178dac86"
last_review: "2026-05-29"
last_reviewer: "Adriano"
asset:
@@ -16,7 +16,7 @@ asset:
exchange: "deribit"
entry:
cron: "0 14 * * *"
cron: "0 */2 * * *"
skip_holidays_country: "IT"
capital_min_usd: "720"
@@ -27,12 +27,15 @@ entry:
no_position_concurrent: true
exclude_macro_severity: ["high"]
exclude_macro_countries: ["US", "EU"]
# Finestra evento macro ridotta a 1 giorno: blocca l'entry solo se un
# evento high-severity cade entro 24h, invece dei 18gg (dte_target).
exclude_macro_within_days: 1
trend_window_days: 30
trend_bull_threshold_pct: "0.05"
trend_bear_threshold_pct: "-0.05"
funding_bull_threshold_annualized: "0.20"
funding_bear_threshold_annualized: "-0.20"
funding_bull_threshold_annualized: "0.10"
funding_bear_threshold_annualized: "-0.10"
iron_condor_dvol_min: "55"
iron_condor_adx_max: "20"
iron_condor_trend_neutral_band_pct: "0.05"
@@ -43,11 +46,17 @@ entry:
# per vendere credit spread. Soglia conservativa, da rifinire dopo
# paper trading.
dealer_gamma_min: "0"
dealer_gamma_filter_enabled: true
dealer_gamma_filter_enabled: false
liquidation_filter_enabled: true
# IV richness gate (§2.9). Disabilitato di default.
iv_minus_rv_min: "0"
iv_minus_rv_filter_enabled: false
iv_minus_rv_filter_enabled: true
# IV richness gate adattivo — soglia P25 rolling su 60 giorni
iv_minus_rv_adaptive_enabled: true
iv_minus_rv_percentile: "0.25"
iv_minus_rv_window_target_days: 60
iv_minus_rv_window_min_days: 30
structure:
@@ -107,7 +116,7 @@ exit:
# atomica. Pipeline runtime non ancora attiva (hook futuro).
profit_take_partial_levels: []
monitor_cron: "0 2,14 * * *"
monitor_cron: "0 * * * *"
user_confirmation_timeout_min: 30
escalate_on_timeout: