Compare commits
3 Commits
16d73c309a
...
647e3e565f
| Author | SHA1 | Date | |
|---|---|---|---|
| 647e3e565f | |||
| a5a39a6d27 | |||
| 839285d75b |
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user