feat(mcp+runtime): allineamento a Cerbero MCP V2 e flag operativi
Adegua Cerbero Bite alla nuova versione 2.0.0 del server MCP unificato (testnet/mainnet routing per token, header X-Bot-Tag obbligatorio) e introduce due interruttori operativi indipendenti per separare la raccolta dati dall'esecuzione di strategia. Auth e collegamento MCP - Token bearer letto dalla nuova variabile CERBERO_BITE_MCP_TOKEN; il valore sceglie l'ambiente upstream (testnet vs mainnet) sul server. Rimosso il caricamento da file (`secrets/core.token`, CERBERO_BITE_CORE_TOKEN_FILE, Docker secret /run/secrets/core_token). - Aggiunto header X-Bot-Tag (default `BOT__CERBERO_BITE`, override via CERBERO_BITE_MCP_BOT_TAG) su ogni call MCP, con validazione lato client (non vuoto, ≤ 64 caratteri). - Cartella `secrets/` rimossa, `.gitignore` ripulito, Dockerfile e docker-compose.yml aggiornati con env passthrough e fail-fast quando manca il token. Modalità operativa (RuntimeFlags) - Nuovo modulo `config/runtime_flags.py` con `RuntimeFlags( data_analysis_enabled, strategy_enabled)` e loader che parserizza CERBERO_BITE_ENABLE_DATA_ANALYSIS e CERBERO_BITE_ENABLE_STRATEGY (true/false/yes/no/on/off/enabled/disabled, case-insensitive). - L'orchestratore espone i flag, audita e logga la modalità al boot (`engine started: env=… data_analysis=… strategy=…`), e in `install_scheduler` esclude i job `entry`/`monitor` quando strategy è off e il job `market_snapshot` quando data analysis è off. I job di infrastruttura (health, backup, manual_actions) restano sempre attivi. - Default profile = "solo analisi dati" (data_analysis=true, strategy=false), pensato per la finestra di soak post-deploy. GUI saldi - `gui/live_data.py::_fetch_deribit_currency` riconosce il campo soft `error` nel payload V2 (HTTP 200 con `error` valorizzato dal server quando l'auth Deribit fallisce) e lo propaga come `BalanceRow.error`, evitando di mostrare un fuorviante equity = 0,00. CLI - Sostituita l'opzione `--token-file` con `--token` (stringa) sui comandi start/dry-run/ping; il default proviene dall'env. Le chiamate al builder dell'orchestrator passano anche `bot_tag` e `flags`. Documentazione - `docs/04-mcp-integration.md`: descrizione del nuovo flusso di auth V2 (token = ambiente, X-Bot-Tag nell'audit) e router unificati. - `docs/06-operational-flow.md`: nuova sezione "Modalità operativa" con i tre profili canonici e tabella di gating per ogni job; aggiunto `market_snapshot` al cron summary. - `docs/10-config-spec.md`: nuova sezione "Variabili d'ambiente" tabellare con tutti gli env, comprese le bool dei flag operativi. - `docs/02-architecture.md`: layout del repo aggiornato (`secrets/` rimosso, `runtime_flags.py` aggiunto), descrizione di `config/` estesa. Test - 5 nuovi test su `_fetch_deribit_currency` (soft-error, payload pulito, eccezione, error blank, signature parity). - 7 nuovi test su `load_runtime_flags` (default, override, parsing truthy/falsy, blank fallback, valore invalido). - 4 nuovi test su `HttpToolClient` (X-Bot-Tag default e custom, blank e troppo lungo rifiutati). - 3 nuovi test integration sull'orchestratore (gating dei job in base ai flag). - Test esistenti su token/CLI ping/orchestrator aggiornati al nuovo schema. Suite intera: 404 passed, 1 skipped (sqlite3 CLI assente sull'host di sviluppo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+28
-16
@@ -31,9 +31,11 @@ from cerbero_bite.clients.sentiment import SentimentClient
|
||||
from cerbero_bite.config.loader import compute_config_hash, load_strategy
|
||||
from cerbero_bite.config.mcp_endpoints import (
|
||||
DEFAULT_ENDPOINTS,
|
||||
load_bot_tag,
|
||||
load_endpoints,
|
||||
load_token,
|
||||
)
|
||||
from cerbero_bite.config.runtime_flags import load_runtime_flags
|
||||
from cerbero_bite.logging import configure as configure_logging
|
||||
from cerbero_bite.logging import get_logger
|
||||
from cerbero_bite.runtime.orchestrator import Orchestrator, make_orchestrator
|
||||
@@ -205,9 +207,14 @@ def _engine_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
show_default=True,
|
||||
),
|
||||
click.option(
|
||||
"--token-file",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
"--token",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"MCP bearer token (overrides CERBERO_BITE_MCP_TOKEN). "
|
||||
"The server uses the token to choose between testnet "
|
||||
"and mainnet upstream environments."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--db",
|
||||
@@ -243,7 +250,7 @@ def _engine_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def _build_orchestrator(
|
||||
*,
|
||||
strategy_path: Path,
|
||||
token_file: Path | None,
|
||||
token: str | None,
|
||||
db: Path,
|
||||
audit: Path,
|
||||
environment: str,
|
||||
@@ -251,7 +258,7 @@ def _build_orchestrator(
|
||||
enforce_hash: bool = True,
|
||||
) -> Orchestrator:
|
||||
loaded = load_strategy(strategy_path, enforce_hash=enforce_hash)
|
||||
token = load_token(path=token_file)
|
||||
resolved_token = load_token(value=token)
|
||||
# Strategy file values win over the CLI defaults; explicit overrides
|
||||
# via env-style values (CLI flags) still apply when the user provides
|
||||
# them — Click signals "default" via Click's resilient_parsing flag,
|
||||
@@ -270,11 +277,13 @@ def _build_orchestrator(
|
||||
return make_orchestrator(
|
||||
cfg=loaded.config,
|
||||
endpoints=load_endpoints(),
|
||||
token=token,
|
||||
token=resolved_token,
|
||||
db_path=db,
|
||||
audit_path=audit,
|
||||
expected_environment=chosen_env, # type: ignore[arg-type]
|
||||
eur_to_usd=chosen_fx,
|
||||
bot_tag=load_bot_tag(),
|
||||
flags=load_runtime_flags(),
|
||||
)
|
||||
|
||||
|
||||
@@ -282,7 +291,7 @@ def _build_orchestrator(
|
||||
@_engine_options
|
||||
def start(
|
||||
strategy_path: Path,
|
||||
token_file: Path | None,
|
||||
token: str | None,
|
||||
db: Path,
|
||||
audit: Path,
|
||||
environment: str,
|
||||
@@ -292,7 +301,7 @@ def start(
|
||||
try:
|
||||
orch = _build_orchestrator(
|
||||
strategy_path=strategy_path,
|
||||
token_file=token_file,
|
||||
token=token,
|
||||
db=db,
|
||||
audit=audit,
|
||||
environment=environment,
|
||||
@@ -322,7 +331,7 @@ def start(
|
||||
)
|
||||
def dry_run(
|
||||
strategy_path: Path,
|
||||
token_file: Path | None,
|
||||
token: str | None,
|
||||
db: Path,
|
||||
audit: Path,
|
||||
environment: str,
|
||||
@@ -332,7 +341,7 @@ def dry_run(
|
||||
"""Execute one cycle without starting the scheduler."""
|
||||
orch = _build_orchestrator(
|
||||
strategy_path=strategy_path,
|
||||
token_file=token_file,
|
||||
token=token,
|
||||
db=db,
|
||||
audit=audit,
|
||||
environment=environment,
|
||||
@@ -506,10 +515,13 @@ def kill_switch_status(db: Path) -> None:
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--token-file",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
"--token",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to the bearer token file (default: secrets/core_token).",
|
||||
help=(
|
||||
"MCP bearer token (overrides CERBERO_BITE_MCP_TOKEN). The "
|
||||
"server uses the token to choose between testnet and mainnet."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
@@ -518,16 +530,16 @@ def kill_switch_status(db: Path) -> None:
|
||||
show_default=True,
|
||||
help="Per-service timeout in seconds for the ping call.",
|
||||
)
|
||||
def ping(token_file: Path | None, timeout: float) -> None:
|
||||
def ping(token: str | None, timeout: float) -> None:
|
||||
"""Print health status for every MCP service Cerbero Bite uses."""
|
||||
try:
|
||||
token = load_token(path=token_file)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
resolved_token = load_token(value=token)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]token error[/red]: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
endpoints = load_endpoints()
|
||||
rows = asyncio.run(_ping_all(endpoints, token=token, timeout=timeout))
|
||||
rows = asyncio.run(_ping_all(endpoints, token=resolved_token, timeout=timeout))
|
||||
|
||||
table = Table(title="MCP services")
|
||||
table.add_column("service")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""HTTP tool client common to every MCP wrapper.
|
||||
|
||||
Each MCP service exposes ``POST <base_url>/tools/<tool_name>`` with a
|
||||
JSON body and a ``Bearer <core_token>`` header. ``HttpToolClient`` is a
|
||||
thin wrapper around :class:`httpx.AsyncClient` that:
|
||||
JSON body, a ``Bearer <token>`` header (the token decides the upstream
|
||||
environment, testnet or mainnet, on the Cerbero MCP V2 server), and an
|
||||
``X-Bot-Tag`` header that identifies the calling bot in the audit log.
|
||||
``HttpToolClient`` is a thin wrapper around :class:`httpx.AsyncClient`
|
||||
that:
|
||||
|
||||
* Adds the auth header.
|
||||
* Adds the auth and bot-tag headers.
|
||||
* Applies the project-wide timeout (default 8 s, see
|
||||
``docs/10-config-spec.md`` ``mcp.call_timeout_s``).
|
||||
* Retries the call on transient failures with exponential backoff
|
||||
@@ -44,7 +47,7 @@ from cerbero_bite.clients._exceptions import (
|
||||
McpToolError,
|
||||
)
|
||||
|
||||
__all__ = ["HttpToolClient"]
|
||||
__all__ = ["DEFAULT_BOT_TAG", "HttpToolClient"]
|
||||
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.clients")
|
||||
@@ -53,6 +56,12 @@ _RETRYABLE: tuple[type[BaseException], ...] = (
|
||||
McpServerError,
|
||||
)
|
||||
|
||||
# Bot identifier sent on every MCP call via the ``X-Bot-Tag`` header.
|
||||
# The Cerbero MCP V2 server logs this value in the audit record so each
|
||||
# write operation can be traced back to the originating bot.
|
||||
DEFAULT_BOT_TAG = "BOT__CERBERO_BITE"
|
||||
_BOT_TAG_MAX_LEN = 64
|
||||
|
||||
|
||||
class HttpToolClient:
|
||||
"""Async client for ``POST <base>/tools/<tool>`` style MCP services.
|
||||
@@ -61,7 +70,14 @@ class HttpToolClient:
|
||||
service: short service identifier (``"deribit"``, ``"macro"`` …).
|
||||
base_url: e.g. ``"http://mcp-deribit:9011"``. Trailing slash
|
||||
is stripped.
|
||||
token: bearer token for the ``Authorization`` header.
|
||||
token: bearer token for the ``Authorization`` header. On
|
||||
Cerbero MCP V2 the value of the token decides whether the
|
||||
upstream environment is testnet or mainnet; the bot does
|
||||
not need to know which is which.
|
||||
bot_tag: value of the ``X-Bot-Tag`` header. Defaults to
|
||||
:data:`DEFAULT_BOT_TAG` (``"BOT__CERBERO_BITE"``). The
|
||||
server rejects requests with a missing/empty/over-long
|
||||
value with HTTP 400.
|
||||
timeout_s: per-request timeout, default 8 seconds.
|
||||
retry_max: max number of attempts (1 = no retry).
|
||||
retry_base_delay: base delay for exponential backoff.
|
||||
@@ -74,15 +90,24 @@ class HttpToolClient:
|
||||
service: str,
|
||||
base_url: str,
|
||||
token: str,
|
||||
bot_tag: str = DEFAULT_BOT_TAG,
|
||||
timeout_s: float = 8.0,
|
||||
retry_max: int = 3,
|
||||
retry_base_delay: float = 1.0,
|
||||
sleep: Callable[[int | float], Awaitable[None] | None] | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
cleaned_tag = bot_tag.strip()
|
||||
if not cleaned_tag:
|
||||
raise ValueError("bot_tag must be a non-empty string")
|
||||
if len(cleaned_tag) > _BOT_TAG_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"bot_tag exceeds {_BOT_TAG_MAX_LEN} characters: {cleaned_tag!r}"
|
||||
)
|
||||
self._service = service
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token = token
|
||||
self._bot_tag = cleaned_tag
|
||||
self._timeout = httpx.Timeout(timeout_s)
|
||||
self._retry_max = max(1, retry_max)
|
||||
self._retry_base_delay = retry_base_delay
|
||||
@@ -114,6 +139,7 @@ class HttpToolClient:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Bot-Tag": self._bot_tag,
|
||||
}
|
||||
payload = body or {}
|
||||
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
"""Resolve MCP service URLs and the bearer token.
|
||||
"""Resolve MCP service URLs, the bearer token and the bot tag.
|
||||
|
||||
Cerbero Bite runs in its own Docker container that joins the
|
||||
``cerbero-suite`` network: every MCP service is reachable by the
|
||||
container DNS name plus its internal port (``mcp-deribit:9011`` etc.).
|
||||
Cerbero MCP V2 (a single FastAPI image fronting Deribit, Hyperliquid,
|
||||
Macro, Sentiment and friends) is deployed on a dedicated VPS and reached
|
||||
through the public gateway at ``https://cerbero-mcp.tielogic.xyz``. The
|
||||
server decides the upstream environment (testnet vs mainnet) entirely
|
||||
from the bearer token attached to each request — Cerbero Bite does not
|
||||
have to be told which is which: swapping the token in ``.env`` is enough
|
||||
to switch environments.
|
||||
|
||||
The resolver supports two layers of override:
|
||||
The resolver supports the following layers of override:
|
||||
|
||||
1. Per-service environment variables (``CERBERO_BITE_MCP_DERIBIT_URL``,
|
||||
``CERBERO_BITE_MCP_MACRO_URL``…). Useful for dev when running
|
||||
outside Docker — point at ``http://localhost:9011`` etc.
|
||||
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var: path to the file that
|
||||
stores the bearer token (default
|
||||
``/run/secrets/core_token``). The file is read at boot, the
|
||||
trailing whitespace is stripped, and the value is *not* logged.
|
||||
1. Per-service URL env vars (``CERBERO_BITE_MCP_DERIBIT_URL``,
|
||||
``CERBERO_BITE_MCP_HYPERLIQUID_URL``, ``CERBERO_BITE_MCP_MACRO_URL``,
|
||||
``CERBERO_BITE_MCP_SENTIMENT_URL``). Useful for local dev when the
|
||||
bot must talk to a same-host MCP server (``http://localhost:9000``)
|
||||
instead of the public gateway.
|
||||
2. ``CERBERO_BITE_MCP_TOKEN`` env var: the bearer token used on every
|
||||
request. The token's value is *never* logged.
|
||||
3. ``CERBERO_BITE_MCP_BOT_TAG`` env var: identifier sent on the
|
||||
``X-Bot-Tag`` header (default ``BOT__CERBERO_BITE``). Must be a
|
||||
non-empty string of at most 64 characters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cerbero_bite.clients._base import DEFAULT_BOT_TAG
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_BOT_TAG",
|
||||
"DEFAULT_ENDPOINTS",
|
||||
"MCP_SERVICES",
|
||||
"McpEndpoints",
|
||||
"load_bot_tag",
|
||||
"load_endpoints",
|
||||
"load_token",
|
||||
]
|
||||
@@ -78,31 +88,58 @@ def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
|
||||
return McpEndpoints(**resolved)
|
||||
|
||||
|
||||
_DEFAULT_TOKEN_FILE = "/run/secrets/core_token"
|
||||
_TOKEN_FILE_ENV = "CERBERO_BITE_CORE_TOKEN_FILE"
|
||||
_TOKEN_ENV = "CERBERO_BITE_MCP_TOKEN"
|
||||
_BOT_TAG_ENV = "CERBERO_BITE_MCP_BOT_TAG"
|
||||
_BOT_TAG_MAX_LEN = 64
|
||||
|
||||
|
||||
def load_token(
|
||||
*,
|
||||
path: str | Path | None = None,
|
||||
value: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Read the bearer token from disk and return it stripped.
|
||||
"""Return the MCP bearer token, stripped of surrounding whitespace.
|
||||
|
||||
Resolution order:
|
||||
1. explicit ``path`` argument;
|
||||
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var;
|
||||
3. ``/run/secrets/core_token`` (Docker secrets default).
|
||||
1. explicit ``value`` argument (e.g. from a CLI flag);
|
||||
2. ``CERBERO_BITE_MCP_TOKEN`` env var.
|
||||
"""
|
||||
if value is not None:
|
||||
token = value.strip()
|
||||
if not token:
|
||||
raise ValueError("explicit MCP token is empty")
|
||||
return token
|
||||
e = env if env is not None else os.environ
|
||||
target = (
|
||||
Path(path)
|
||||
if path is not None
|
||||
else Path(e.get(_TOKEN_FILE_ENV, _DEFAULT_TOKEN_FILE))
|
||||
)
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(f"core token file not found: {target}")
|
||||
token = target.read_text(encoding="utf-8").strip()
|
||||
raw = e.get(_TOKEN_ENV, "")
|
||||
token = raw.strip()
|
||||
if not token:
|
||||
raise ValueError(f"core token file is empty: {target}")
|
||||
raise ValueError(
|
||||
f"{_TOKEN_ENV} is unset or empty; set it in .env to the testnet or "
|
||||
"mainnet bearer issued by Cerbero MCP"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def load_bot_tag(
|
||||
*,
|
||||
value: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Return the ``X-Bot-Tag`` value, with the project default as fallback.
|
||||
|
||||
Resolution order:
|
||||
1. explicit ``value`` argument;
|
||||
2. ``CERBERO_BITE_MCP_BOT_TAG`` env var;
|
||||
3. :data:`DEFAULT_BOT_TAG` (``"BOT__CERBERO_BITE"``).
|
||||
"""
|
||||
raw = value if value is not None else (env if env is not None else os.environ).get(
|
||||
_BOT_TAG_ENV, ""
|
||||
)
|
||||
cleaned = raw.strip() if raw else ""
|
||||
if not cleaned:
|
||||
return DEFAULT_BOT_TAG
|
||||
if len(cleaned) > _BOT_TAG_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"{_BOT_TAG_ENV} exceeds {_BOT_TAG_MAX_LEN} characters: {cleaned!r}"
|
||||
)
|
||||
return cleaned
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Operational mode flags read from the environment.
|
||||
|
||||
Cerbero Bite supports two independent runtime switches:
|
||||
|
||||
* ``CERBERO_BITE_ENABLE_DATA_ANALYSIS`` — when ``true``, the periodic
|
||||
market-snapshot job is scheduled and writes 15-minute snapshots to
|
||||
``market_snapshots``; when ``false``, the bot still pings MCP for
|
||||
health and reconciliation but does not record any market dataset.
|
||||
* ``CERBERO_BITE_ENABLE_STRATEGY`` — when ``true``, the entry and
|
||||
monitor cycles are scheduled and may propose/execute trades; when
|
||||
``false``, no entry or monitor logic runs autonomously (the methods
|
||||
remain callable from the CLI ``dry-run`` and via manual actions, so
|
||||
the operator can still test code paths on demand).
|
||||
|
||||
The default profile is "analysis only": data analysis on, strategy off.
|
||||
This is the mode used during the post-deploy soak window where the
|
||||
team observes data quality before opening any position.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"DATA_ANALYSIS_ENV",
|
||||
"STRATEGY_ENV",
|
||||
"RuntimeFlags",
|
||||
"load_runtime_flags",
|
||||
]
|
||||
|
||||
DATA_ANALYSIS_ENV = "CERBERO_BITE_ENABLE_DATA_ANALYSIS"
|
||||
STRATEGY_ENV = "CERBERO_BITE_ENABLE_STRATEGY"
|
||||
|
||||
_TRUE_TOKENS = frozenset({"1", "true", "yes", "on", "enabled"})
|
||||
_FALSE_TOKENS = frozenset({"0", "false", "no", "off", "disabled"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeFlags:
|
||||
"""Boolean switches that gate optional cycles.
|
||||
|
||||
Both fields default to the canonical "analysis only" profile.
|
||||
"""
|
||||
|
||||
data_analysis_enabled: bool = True
|
||||
strategy_enabled: bool = False
|
||||
|
||||
|
||||
def _parse_bool(raw: str, *, var: str, default: bool) -> bool:
|
||||
cleaned = raw.strip().lower()
|
||||
if not cleaned:
|
||||
return default
|
||||
if cleaned in _TRUE_TOKENS:
|
||||
return True
|
||||
if cleaned in _FALSE_TOKENS:
|
||||
return False
|
||||
raise ValueError(
|
||||
f"{var}: expected one of "
|
||||
f"{sorted(_TRUE_TOKENS | _FALSE_TOKENS)}, got {raw!r}"
|
||||
)
|
||||
|
||||
|
||||
def load_runtime_flags(env: dict[str, str] | None = None) -> RuntimeFlags:
|
||||
"""Build a :class:`RuntimeFlags` from environment variables."""
|
||||
e = env if env is not None else os.environ
|
||||
return RuntimeFlags(
|
||||
data_analysis_enabled=_parse_bool(
|
||||
e.get(DATA_ANALYSIS_ENV, ""),
|
||||
var=DATA_ANALYSIS_ENV,
|
||||
default=True,
|
||||
),
|
||||
strategy_enabled=_parse_bool(
|
||||
e.get(STRATEGY_ENV, ""),
|
||||
var=STRATEGY_ENV,
|
||||
default=False,
|
||||
),
|
||||
)
|
||||
@@ -6,7 +6,7 @@ constraint is relaxed: the dashboard fetches balances on demand,
|
||||
caches the result with Streamlit's TTL cache, and never holds the
|
||||
async client open between renders. Every fetch is a one-shot:
|
||||
|
||||
* read endpoints + token from env / file (same path used by the CLI),
|
||||
* read endpoints + token from env (same path used by the CLI),
|
||||
* spin up a short-lived ``httpx.AsyncClient``,
|
||||
* query Deribit `get_account_summary` for both ``USDC`` and ``USDT``,
|
||||
* query Hyperliquid `get_account_summary` (returns ``spot_usdc``,
|
||||
@@ -21,11 +21,9 @@ and the others are still rendered.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -90,14 +88,12 @@ def _decimal_or_none(value: Any) -> Decimal | None:
|
||||
|
||||
|
||||
def _resolve_token() -> str:
|
||||
"""Read the bearer token from disk, mirroring the CLI default chain."""
|
||||
explicit = os.environ.get("CERBERO_BITE_CORE_TOKEN_FILE")
|
||||
if explicit:
|
||||
return load_token(path=Path(explicit))
|
||||
# Fallback: project-relative `secrets/core.token` (typical local dev).
|
||||
local = Path("secrets") / "core.token"
|
||||
if local.is_file():
|
||||
return load_token(path=local)
|
||||
"""Read the MCP bearer token from the environment.
|
||||
|
||||
The token is sourced from ``CERBERO_BITE_MCP_TOKEN``; on Cerbero MCP
|
||||
V2 the same single token decides whether the upstream environment
|
||||
is testnet or mainnet.
|
||||
"""
|
||||
return load_token()
|
||||
|
||||
|
||||
@@ -115,6 +111,20 @@ async def _fetch_deribit_currency(
|
||||
unrealized_pnl=None,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
# Cerbero MCP V2 returns HTTP 200 with a soft ``error`` field when
|
||||
# the upstream Deribit call failed (e.g. invalid credentials). Treat
|
||||
# that as a row-level failure so the dashboard surfaces the cause
|
||||
# instead of showing a misleading equity=0.
|
||||
soft_error = summary.get("error")
|
||||
if soft_error:
|
||||
return BalanceRow(
|
||||
exchange="deribit",
|
||||
currency=currency,
|
||||
equity=None,
|
||||
available=None,
|
||||
unrealized_pnl=None,
|
||||
error=str(soft_error),
|
||||
)
|
||||
return BalanceRow(
|
||||
exchange="deribit",
|
||||
currency=currency,
|
||||
|
||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients._base import DEFAULT_BOT_TAG, HttpToolClient
|
||||
from cerbero_bite.clients.deribit import DeribitClient
|
||||
from cerbero_bite.clients.hyperliquid import HyperliquidClient
|
||||
from cerbero_bite.clients.macro import MacroClient
|
||||
@@ -78,6 +78,7 @@ def build_runtime(
|
||||
token: str,
|
||||
db_path: Path | str,
|
||||
audit_path: Path | str,
|
||||
bot_tag: str = DEFAULT_BOT_TAG,
|
||||
timeout_s: float = 8.0,
|
||||
retry_max: int = 3,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
@@ -140,6 +141,7 @@ def build_runtime(
|
||||
service=service,
|
||||
base_url=endpoints.for_service(service),
|
||||
token=token,
|
||||
bot_tag=bot_tag,
|
||||
timeout_s=timeout_s,
|
||||
retry_max=retry_max,
|
||||
client=http_client,
|
||||
|
||||
@@ -23,6 +23,7 @@ import structlog
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from cerbero_bite.config.mcp_endpoints import McpEndpoints
|
||||
from cerbero_bite.config.runtime_flags import RuntimeFlags
|
||||
from cerbero_bite.config.schema import StrategyConfig
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext, build_runtime
|
||||
from cerbero_bite.runtime.entry_cycle import EntryCycleResult, run_entry_cycle
|
||||
@@ -70,10 +71,12 @@ class Orchestrator:
|
||||
*,
|
||||
expected_environment: Environment,
|
||||
eur_to_usd: Decimal,
|
||||
flags: RuntimeFlags | None = None,
|
||||
) -> None:
|
||||
self._ctx = ctx
|
||||
self._expected_env = expected_environment
|
||||
self._eur_to_usd = eur_to_usd
|
||||
self._flags = flags or RuntimeFlags()
|
||||
self._health = HealthCheck(ctx, expected_environment=expected_environment)
|
||||
self._scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
@@ -85,6 +88,10 @@ class Orchestrator:
|
||||
def expected_environment(self) -> Environment:
|
||||
return self._expected_env
|
||||
|
||||
@property
|
||||
def flags(self) -> RuntimeFlags:
|
||||
return self._flags
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Boot
|
||||
# ------------------------------------------------------------------
|
||||
@@ -113,9 +120,18 @@ class Orchestrator:
|
||||
"environment": info.environment,
|
||||
"health": health.state,
|
||||
"config_version": self._ctx.cfg.config_version,
|
||||
"data_analysis_enabled": self._flags.data_analysis_enabled,
|
||||
"strategy_enabled": self._flags.strategy_enabled,
|
||||
},
|
||||
now=when,
|
||||
)
|
||||
_log.info(
|
||||
"engine started: env=%s health=%s data_analysis=%s strategy=%s",
|
||||
info.environment,
|
||||
health.state,
|
||||
self._flags.data_analysis_enabled,
|
||||
self._flags.strategy_enabled,
|
||||
)
|
||||
return _BootResult(environment=info.environment, health=health)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -266,24 +282,40 @@ class Orchestrator:
|
||||
|
||||
await _safe("market_snapshot", _do)
|
||||
|
||||
self._scheduler = build_scheduler(
|
||||
[
|
||||
JobSpec(name="entry", cron=entry_cron, coro_factory=_entry),
|
||||
JobSpec(name="monitor", cron=monitor_cron, coro_factory=_monitor),
|
||||
JobSpec(name="health", cron=health_cron, coro_factory=_health),
|
||||
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
|
||||
JobSpec(
|
||||
name="manual_actions",
|
||||
cron=manual_actions_cron,
|
||||
coro_factory=_manual_actions,
|
||||
),
|
||||
jobs: list[JobSpec] = [
|
||||
JobSpec(name="health", cron=health_cron, coro_factory=_health),
|
||||
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
|
||||
JobSpec(
|
||||
name="manual_actions",
|
||||
cron=manual_actions_cron,
|
||||
coro_factory=_manual_actions,
|
||||
),
|
||||
]
|
||||
if self._flags.strategy_enabled:
|
||||
jobs.append(JobSpec(name="entry", cron=entry_cron, coro_factory=_entry))
|
||||
jobs.append(
|
||||
JobSpec(name="monitor", cron=monitor_cron, coro_factory=_monitor)
|
||||
)
|
||||
else:
|
||||
_log.warning(
|
||||
"strategy disabled (CERBERO_BITE_ENABLE_STRATEGY=false): "
|
||||
"entry and monitor cycles are NOT scheduled"
|
||||
)
|
||||
if self._flags.data_analysis_enabled:
|
||||
jobs.append(
|
||||
JobSpec(
|
||||
name="market_snapshot",
|
||||
cron=market_snapshot_cron,
|
||||
coro_factory=_market_snapshot,
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
_log.warning(
|
||||
"data analysis disabled (CERBERO_BITE_ENABLE_DATA_ANALYSIS="
|
||||
"false): market_snapshot job is NOT scheduled"
|
||||
)
|
||||
|
||||
self._scheduler = build_scheduler(jobs)
|
||||
return self._scheduler
|
||||
|
||||
async def run_forever(self, *, lock_path: Path | None = None) -> None:
|
||||
@@ -376,17 +408,25 @@ def make_orchestrator(
|
||||
audit_path: Path,
|
||||
expected_environment: Environment,
|
||||
eur_to_usd: Decimal,
|
||||
bot_tag: str | None = None,
|
||||
flags: RuntimeFlags | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> Orchestrator:
|
||||
"""Build a fresh :class:`Orchestrator` ready for ``boot``/``run_*``."""
|
||||
ctx = build_runtime(
|
||||
cfg=cfg,
|
||||
endpoints=endpoints,
|
||||
token=token,
|
||||
db_path=db_path,
|
||||
audit_path=audit_path,
|
||||
clock=clock or (lambda: datetime.now(UTC)),
|
||||
)
|
||||
build_kwargs: dict[str, object] = {
|
||||
"cfg": cfg,
|
||||
"endpoints": endpoints,
|
||||
"token": token,
|
||||
"db_path": db_path,
|
||||
"audit_path": audit_path,
|
||||
"clock": clock or (lambda: datetime.now(UTC)),
|
||||
}
|
||||
if bot_tag is not None:
|
||||
build_kwargs["bot_tag"] = bot_tag
|
||||
ctx = build_runtime(**build_kwargs) # type: ignore[arg-type]
|
||||
return Orchestrator(
|
||||
ctx, expected_environment=expected_environment, eur_to_usd=eur_to_usd
|
||||
ctx,
|
||||
expected_environment=expected_environment,
|
||||
eur_to_usd=eur_to_usd,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user