Phase 0: project skeleton

- pyproject.toml with uv, deps for runtime + gui + backtest + dev
- ruff/mypy strict config, pre-commit hooks for ruff/mypy/pytest
- src/cerbero_bite/ layout with empty modules ready for Phase 1+
- structlog JSONL logger with daily rotation
- click CLI with placeholder subcommands (status, start, kill-switch,
  gui, replay, config hash, audit verify)
- 6 smoke tests passing, mypy --strict clean, ruff clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 23:10:30 +02:00
commit 881bc8a1bf
40 changed files with 6018 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Cerbero Bite — rule-based deterministic engine for ETH credit spreads."""
__version__ = "0.0.1"
+6
View File
@@ -0,0 +1,6 @@
"""Allow ``python -m cerbero_bite`` to invoke the CLI."""
from cerbero_bite.cli import _entrypoint
if __name__ == "__main__":
_entrypoint()
+152
View File
@@ -0,0 +1,152 @@
"""Command-line interface for Cerbero Bite.
The CLI is the single user-facing entry point. Each subcommand maps to a
specific operational action documented in ``docs/06-operational-flow.md``
and ``docs/07-risk-controls.md``. All commands are placeholders in
Phase 0; subsequent phases will replace the bodies with real logic
without changing the surface.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
from rich.console import Console
from cerbero_bite import __version__
from cerbero_bite.logging import configure as configure_logging
from cerbero_bite.logging import get_logger
console = Console()
log = get_logger("cli")
def _phase0_notice(action: str) -> None:
console.print(f"[yellow]\\[phase 0 placeholder][/yellow] {action}")
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(__version__, prog_name="cerbero-bite")
@click.option(
"--log-dir",
type=click.Path(file_okay=False, path_type=Path),
default=Path("data/log"),
show_default=True,
help="Directory for JSONL log files.",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
default="INFO",
show_default=True,
)
@click.pass_context
def main(ctx: click.Context, log_dir: Path, log_level: str) -> None:
"""Cerbero Bite — rule-based ETH credit spread engine."""
configure_logging(log_dir=log_dir, level=log_level.upper())
ctx.ensure_object(dict)
ctx.obj["log_dir"] = log_dir
@main.command()
def status() -> None:
"""Print engine status snapshot."""
console.print(
f"[bold cyan]Cerbero Bite[/bold cyan] v{__version__}\n"
f"engine state: [yellow]idle[/yellow]\n"
f"kill_switch: [green]0 (disarmed)[/green]\n"
f"open positions: 0\n"
f"phase: 0 (skeleton)"
)
@main.command()
def start() -> None:
"""Start the engine main loop (scheduler + monitoring)."""
_phase0_notice("start command not yet implemented; engine remains idle.")
@main.command()
def stop() -> None:
"""Gracefully stop a running engine."""
_phase0_notice("stop command not yet implemented.")
@main.command(name="dry-run")
def dry_run() -> None:
"""Run the decision loop once in dry-run mode (no MCP writes)."""
_phase0_notice("dry-run command not yet implemented.")
@main.group(name="kill-switch")
def kill_switch() -> None:
"""Manage the engine kill switch."""
@kill_switch.command(name="arm")
@click.option("--reason", required=True, help="Why you are arming the kill switch.")
def kill_switch_arm(reason: str) -> None:
"""Arm the kill switch (engine refuses new entries)."""
_phase0_notice(f"kill-switch arm placeholder (reason: {reason!r}).")
@kill_switch.command(name="disarm")
@click.option("--reason", required=True, help="Why you are disarming.")
def kill_switch_disarm(reason: str) -> None:
"""Disarm the kill switch."""
_phase0_notice(f"kill-switch disarm placeholder (reason: {reason!r}).")
@main.command()
def gui() -> None:
"""Launch the Streamlit dashboard."""
_phase0_notice("gui command not yet implemented (will run streamlit on 127.0.0.1:8765).")
@main.command()
@click.option("--from", "date_from", required=True, help="ISO date YYYY-MM-DD.")
@click.option("--to", "date_to", required=True, help="ISO date YYYY-MM-DD.")
@click.option("--capital", type=float, default=1500.0, show_default=True)
@click.option("--dry-run/--no-dry-run", default=True, show_default=True)
def replay(date_from: str, date_to: str, capital: float, dry_run: bool) -> None:
"""Replay historical period through the decision engine."""
_phase0_notice(
f"replay {date_from}{date_to}, capital={capital}, dry_run={dry_run}"
)
@main.group()
def config() -> None:
"""Strategy configuration utilities."""
@config.command(name="hash")
def config_hash() -> None:
"""Compute and print SHA-256 of strategy.yaml."""
_phase0_notice("config hash placeholder; will read strategy.yaml and compute SHA-256.")
@main.group()
def audit() -> None:
"""Audit log utilities."""
@audit.command(name="verify")
def audit_verify() -> None:
"""Verify audit chain integrity."""
_phase0_notice("audit verify placeholder; will walk audit.log hash chain.")
def _entrypoint() -> None:
"""Wrapper used by ``cerbero-bite`` console script."""
try:
main(prog_name="cerbero-bite")
except KeyboardInterrupt:
console.print("[red]interrupted[/red]")
sys.exit(130)
if __name__ == "__main__":
_entrypoint()
View File
View File
View File
+109
View File
@@ -0,0 +1,109 @@
"""Structured logging setup for Cerbero Bite.
Outputs JSONL to a daily file under ``data/log/`` and pretty-prints to
stderr in development mode. Every event line is the canonical record for
audit purposes; the audit chain (see ``safety/audit_log.py``) is built on
top of this stream for trade-relevant events.
"""
from __future__ import annotations
import logging
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import structlog
from structlog.stdlib import BoundLogger
from structlog.types import EventDict, Processor
def _utc_timestamp(_: object, __: str, event_dict: EventDict) -> EventDict:
"""Add ISO-8601 UTC timestamp with millisecond precision."""
event_dict["ts"] = datetime.now(UTC).isoformat(timespec="milliseconds")
return event_dict
def _daily_log_path(log_dir: Path) -> Path:
today = datetime.now(UTC).date().isoformat()
return log_dir / f"cerbero-bite-{today}.jsonl"
def configure(
log_dir: Path | str = "data/log",
*,
level: str = "INFO",
pretty_console: bool = True,
) -> None:
"""Configure structlog + stdlib logging.
Args:
log_dir: Directory where the daily JSONL log file is written.
Created if missing.
level: Minimum log level for both handlers.
pretty_console: When True, also write a human-readable stream to
stderr. Disable in production daemon mode if not desired.
"""
log_dir_path = Path(log_dir)
log_dir_path.mkdir(parents=True, exist_ok=True)
log_file = _daily_log_path(log_dir_path)
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(level)
handlers: list[logging.Handler] = [file_handler]
if pretty_console:
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(level)
handlers.append(console_handler)
logging.basicConfig(
format="%(message)s",
handlers=handlers,
level=level,
force=True,
)
shared_processors: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
_utc_timestamp,
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
]
structlog.configure(
processors=[
*shared_processors,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.stdlib.BoundLogger,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
if pretty_console:
# Replace stderr handler's formatter with a pretty renderer
formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.dev.ConsoleRenderer(colors=True),
],
foreign_pre_chain=shared_processors,
)
for handler in handlers:
if isinstance(handler, logging.StreamHandler) and not isinstance(
handler, logging.FileHandler
):
handler.setFormatter(formatter)
def get_logger(name: str | None = None, **initial_context: Any) -> BoundLogger:
"""Return a bound structlog logger with optional initial context."""
logger: BoundLogger = structlog.get_logger(name)
if initial_context:
logger = logger.bind(**initial_context)
return logger
View File
View File