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:
@@ -0,0 +1,3 @@
|
||||
"""Cerbero Bite — rule-based deterministic engine for ETH credit spreads."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Allow ``python -m cerbero_bite`` to invoke the CLI."""
|
||||
|
||||
from cerbero_bite.cli import _entrypoint
|
||||
|
||||
if __name__ == "__main__":
|
||||
_entrypoint()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user