Phase 4: orchestrator + cycles auto-execute
Componente runtime/ che cabla core+clients+state+safety in un engine autonomo notify-only: nessuna conferma manuale, ordini combo piazzati direttamente quando le regole passano. 311 test pass, copertura totale 94%, runtime/ 90%, mypy strict pulito, ruff clean. Moduli: - runtime/alert_manager.py: escalation tree LOW/MEDIUM/HIGH/CRITICAL → audit + Telegram + kill switch. - runtime/dependencies.py: build_runtime() costruisce RuntimeContext con tutti i client MCP, repository, audit log, kill switch, alert manager. - runtime/entry_cycle.py: flusso settimanale (snapshot parallelo spot/dvol/funding/macro/holdings/equity → validate_entry → compute_bias → options_chain → select_strikes → liquidity_gate → sizing_engine → combo_builder.build → place_combo_order → notify_position_opened). - runtime/monitor_cycle.py: loop 12h con dvol_history per il return_4h, exit_decision.evaluate, close auto-execute. - runtime/health_check.py: probe parallelo MCP + SQLite + environment match; 3 strikes consecutivi → kill switch HIGH. - runtime/recovery.py: riconciliazione SQLite vs broker all'avvio; mismatch → kill switch CRITICAL. - runtime/scheduler.py: AsyncIOScheduler builder con cron entry (lun 14:00), monitor (02/14), health (5min). - runtime/orchestrator.py: façade boot() + run_entry/monitor/health + install_scheduler + run_forever, con env check vs strategy. CLI: - start: avvia engine bloccante (asyncio.run + scheduler). - dry-run --cycle entry|monitor|health: esegue un singolo ciclo per debug/test in produzione. - stop: documenta lo shutdown via SIGTERM al container. Documentazione: - docs/06-operational-flow.md riscritto per il modello notify-only auto-execute (no conferma manuale, no memory, no brain-bridge). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
"""Position monitoring loop (``docs/06-operational-flow.md`` §3).
|
||||
|
||||
Walks every ``open`` position, builds the live snapshot, asks
|
||||
:func:`exit_decision.evaluate`, and — when the action is not ``HOLD``
|
||||
— sends the inverse combo order to close. Decisions are persisted to
|
||||
the ``decisions`` table; transitions to the audit log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from cerbero_bite.clients.deribit import ComboLegOrder
|
||||
from cerbero_bite.core.exit_decision import (
|
||||
PositionSnapshot,
|
||||
evaluate,
|
||||
)
|
||||
from cerbero_bite.core.types import OptionLeg, PutOrCall
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext
|
||||
from cerbero_bite.state import (
|
||||
DecisionRecord,
|
||||
DvolSnapshot,
|
||||
InstructionRecord,
|
||||
PositionRecord,
|
||||
transaction,
|
||||
)
|
||||
from cerbero_bite.state import connect as connect_state
|
||||
|
||||
__all__ = [
|
||||
"MonitorCycleResult",
|
||||
"PositionOutcome",
|
||||
"run_monitor_cycle",
|
||||
]
|
||||
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.runtime.monitor")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PositionOutcome:
|
||||
proposal_id: str
|
||||
action: str
|
||||
closed: bool
|
||||
reason: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonitorCycleResult:
|
||||
outcomes: list[PositionOutcome]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _build_position_snapshot(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
record: PositionRecord,
|
||||
spot: Decimal,
|
||||
dvol: Decimal,
|
||||
return_4h: Decimal,
|
||||
eth_price_usd_now: Decimal,
|
||||
now: datetime,
|
||||
) -> PositionSnapshot | None:
|
||||
"""Fetch tickers for the two legs and build a :class:`PositionSnapshot`.
|
||||
|
||||
Returns ``None`` if the broker no longer quotes one of the legs —
|
||||
the caller will arm an alert in that case.
|
||||
"""
|
||||
tickers = await ctx.deribit.get_tickers(
|
||||
[record.short_instrument, record.long_instrument]
|
||||
)
|
||||
by_name: dict[str, dict[str, Any]] = {
|
||||
str(t.get("instrument_name")): t for t in tickers if isinstance(t, dict)
|
||||
}
|
||||
short = by_name.get(record.short_instrument)
|
||||
long_ = by_name.get(record.long_instrument)
|
||||
if short is None or long_ is None:
|
||||
return None
|
||||
short_mid = Decimal(str(short.get("mark_price") or 0))
|
||||
long_mid = Decimal(str(long_.get("mark_price") or 0))
|
||||
if short_mid <= 0 or long_mid <= 0:
|
||||
return None
|
||||
|
||||
short_delta = Decimal(
|
||||
str((short.get("greeks") or {}).get("delta") or record.delta_at_entry)
|
||||
)
|
||||
debit_per_contract = short_mid - long_mid
|
||||
debit_total = debit_per_contract * Decimal(record.n_contracts)
|
||||
|
||||
legs = [
|
||||
OptionLeg(
|
||||
instrument=record.short_instrument,
|
||||
side="SELL",
|
||||
strike=record.short_strike,
|
||||
expiry=record.expiry,
|
||||
type=_option_type_from_name(record.short_instrument),
|
||||
size=record.n_contracts,
|
||||
mid_price_eth=short_mid,
|
||||
delta=short_delta,
|
||||
gamma=Decimal(str((short.get("greeks") or {}).get("gamma") or 0)),
|
||||
theta=Decimal(str((short.get("greeks") or {}).get("theta") or 0)),
|
||||
vega=Decimal(str((short.get("greeks") or {}).get("vega") or 0)),
|
||||
),
|
||||
OptionLeg(
|
||||
instrument=record.long_instrument,
|
||||
side="BUY",
|
||||
strike=record.long_strike,
|
||||
expiry=record.expiry,
|
||||
type=_option_type_from_name(record.long_instrument),
|
||||
size=record.n_contracts,
|
||||
mid_price_eth=long_mid,
|
||||
delta=Decimal(str((long_.get("greeks") or {}).get("delta") or 0)),
|
||||
gamma=Decimal(str((long_.get("greeks") or {}).get("gamma") or 0)),
|
||||
theta=Decimal(str((long_.get("greeks") or {}).get("theta") or 0)),
|
||||
vega=Decimal(str((long_.get("greeks") or {}).get("vega") or 0)),
|
||||
),
|
||||
]
|
||||
|
||||
return PositionSnapshot(
|
||||
proposal_id=record.proposal_id,
|
||||
spread_type=record.spread_type, # type: ignore[arg-type]
|
||||
legs=legs,
|
||||
credit_received_eth=record.credit_eth,
|
||||
credit_received_usd=record.credit_usd,
|
||||
spot_at_entry=record.spot_at_entry,
|
||||
dvol_at_entry=record.dvol_at_entry,
|
||||
expiry=record.expiry,
|
||||
opened_at=record.opened_at or record.created_at,
|
||||
eth_price_usd_now=eth_price_usd_now,
|
||||
spot_now=spot,
|
||||
dvol_now=dvol,
|
||||
mark_combo_now_eth=debit_total,
|
||||
delta_short_now=short_delta,
|
||||
return_4h_now=return_4h,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def _option_type_from_name(name: str) -> PutOrCall:
|
||||
suffix = name.rsplit("-", 1)[-1]
|
||||
if suffix not in ("P", "C"):
|
||||
raise ValueError(f"unknown option type suffix in {name!r}")
|
||||
return suffix # type: ignore[return-value]
|
||||
|
||||
|
||||
async def _fetch_return_4h(ctx: RuntimeContext, *, now: datetime) -> Decimal:
|
||||
"""Compute ETH 4h return from the locally stored dvol_history snapshots.
|
||||
|
||||
The orchestrator records a snapshot at the start of every monitor
|
||||
cycle (see :func:`run_monitor_cycle`); this helper reads the most
|
||||
recent snapshot at least 3.5h old and computes ``(now / past) - 1``.
|
||||
Returns 0 if no historical sample is available — in that branch the
|
||||
orchestrator emits a LOW alert about insufficient history.
|
||||
"""
|
||||
cutoff = now - timedelta(hours=3, minutes=30)
|
||||
floor = now - timedelta(hours=8)
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT timestamp, eth_spot FROM dvol_history "
|
||||
"WHERE timestamp <= ? AND timestamp >= ? "
|
||||
"ORDER BY timestamp DESC LIMIT 1",
|
||||
(cutoff.isoformat(), floor.isoformat()),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row is None:
|
||||
return Decimal("0")
|
||||
past_spot = Decimal(str(row[1]))
|
||||
if past_spot == 0:
|
||||
return Decimal("0")
|
||||
spot_now = await ctx.deribit.index_price_eth()
|
||||
return spot_now / past_spot - Decimal("1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def run_monitor_cycle(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> MonitorCycleResult:
|
||||
"""Walk every open position, evaluate exit, place close orders if needed."""
|
||||
when = (now or ctx.clock()).astimezone(UTC)
|
||||
if ctx.kill_switch.is_armed():
|
||||
await ctx.alert_manager.low(
|
||||
source="monitor_cycle", message="kill switch armed — skipping"
|
||||
)
|
||||
return MonitorCycleResult(outcomes=[])
|
||||
|
||||
# Refresh spot+DVOL snapshot first; it goes into dvol_history so the
|
||||
# next cycle can compute return_4h.
|
||||
spot_t = asyncio.create_task(ctx.deribit.index_price_eth())
|
||||
dvol_t = asyncio.create_task(ctx.deribit.latest_dvol(currency="ETH", now=when))
|
||||
return_4h_t = asyncio.create_task(_fetch_return_4h(ctx, now=when))
|
||||
await asyncio.gather(spot_t, dvol_t, return_4h_t)
|
||||
spot = spot_t.result()
|
||||
dvol = dvol_t.result()
|
||||
return_4h = return_4h_t.result()
|
||||
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.record_dvol_snapshot(
|
||||
conn,
|
||||
DvolSnapshot(timestamp=when, dvol=dvol, eth_spot=spot),
|
||||
)
|
||||
positions = ctx.repository.list_positions(conn, status="open")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
outcomes: list[PositionOutcome] = []
|
||||
for record in positions:
|
||||
outcome = await _evaluate_position(
|
||||
ctx,
|
||||
record=record,
|
||||
spot=spot,
|
||||
dvol=dvol,
|
||||
return_4h=return_4h,
|
||||
now=when,
|
||||
)
|
||||
outcomes.append(outcome)
|
||||
return MonitorCycleResult(outcomes=outcomes)
|
||||
|
||||
|
||||
async def _evaluate_position(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
record: PositionRecord,
|
||||
spot: Decimal,
|
||||
dvol: Decimal,
|
||||
return_4h: Decimal,
|
||||
now: datetime,
|
||||
) -> PositionOutcome:
|
||||
snapshot = await _build_position_snapshot(
|
||||
ctx,
|
||||
record=record,
|
||||
spot=spot,
|
||||
dvol=dvol,
|
||||
return_4h=return_4h,
|
||||
eth_price_usd_now=spot,
|
||||
now=now,
|
||||
)
|
||||
if snapshot is None:
|
||||
await ctx.alert_manager.high(
|
||||
source="monitor_cycle",
|
||||
message=(
|
||||
f"missing tickers for legs of position {record.proposal_id} — "
|
||||
"broker may have de-listed an instrument"
|
||||
),
|
||||
)
|
||||
return PositionOutcome(
|
||||
proposal_id=str(record.proposal_id),
|
||||
action="HOLD",
|
||||
closed=False,
|
||||
reason="missing_ticker",
|
||||
)
|
||||
|
||||
decision = evaluate(snapshot, ctx.cfg)
|
||||
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.record_decision(
|
||||
conn,
|
||||
DecisionRecord(
|
||||
decision_type="exit_check",
|
||||
timestamp=now,
|
||||
inputs_json=json.dumps(
|
||||
{
|
||||
"spot": str(spot),
|
||||
"dvol": str(dvol),
|
||||
"mark_combo_now_eth": str(snapshot.mark_combo_now_eth),
|
||||
"delta_short_now": str(snapshot.delta_short_now),
|
||||
"return_4h_now": str(return_4h),
|
||||
},
|
||||
default=str,
|
||||
sort_keys=True,
|
||||
),
|
||||
outputs_json=json.dumps(
|
||||
{
|
||||
"action": decision.action,
|
||||
"reason": decision.reason,
|
||||
"pnl_eth": str(decision.pnl_estimate_eth),
|
||||
"pnl_usd": str(decision.pnl_estimate_usd),
|
||||
},
|
||||
default=str,
|
||||
sort_keys=True,
|
||||
),
|
||||
action_taken=decision.action,
|
||||
proposal_id=record.proposal_id,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if decision.action == "HOLD":
|
||||
ctx.audit_log.append(
|
||||
event="HOLD",
|
||||
payload={
|
||||
"proposal_id": str(record.proposal_id),
|
||||
"reason": decision.reason,
|
||||
},
|
||||
now=now,
|
||||
)
|
||||
return PositionOutcome(
|
||||
proposal_id=str(record.proposal_id),
|
||||
action="HOLD",
|
||||
closed=False,
|
||||
reason=decision.reason,
|
||||
)
|
||||
|
||||
# Place inverse combo to close.
|
||||
short_leg = ComboLegOrder(
|
||||
instrument_name=record.short_instrument, direction="buy"
|
||||
)
|
||||
long_leg = ComboLegOrder(
|
||||
instrument_name=record.long_instrument, direction="sell"
|
||||
)
|
||||
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.update_position_status(
|
||||
conn,
|
||||
record.proposal_id,
|
||||
status="closing",
|
||||
now=now,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
order = await ctx.deribit.place_combo_order(
|
||||
legs=[short_leg, long_leg],
|
||||
side="buy",
|
||||
n_contracts=record.n_contracts,
|
||||
limit_price_eth=snapshot.mark_combo_now_eth / Decimal(record.n_contracts),
|
||||
label=f"bite-close-{record.proposal_id}",
|
||||
)
|
||||
except Exception as exc:
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.update_position_status(
|
||||
conn, record.proposal_id, status="open", now=now
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
await ctx.alert_manager.critical(
|
||||
source="monitor_cycle",
|
||||
message=(
|
||||
f"close failed for {record.proposal_id}: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
),
|
||||
component="runtime.monitor_cycle",
|
||||
)
|
||||
return PositionOutcome(
|
||||
proposal_id=str(record.proposal_id),
|
||||
action=decision.action,
|
||||
closed=False,
|
||||
reason=f"close_error:{type(exc).__name__}",
|
||||
)
|
||||
|
||||
instruction_id = uuid4()
|
||||
is_filled = order.state in {"filled", "open"}
|
||||
next_status = "closed" if is_filled else "open"
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.create_instruction(
|
||||
conn,
|
||||
InstructionRecord(
|
||||
instruction_id=instruction_id,
|
||||
proposal_id=record.proposal_id,
|
||||
kind="close_combo",
|
||||
payload_json=json.dumps(order.raw, default=str, sort_keys=True),
|
||||
sent_at=now,
|
||||
actual_fill_eth=order.average_price_eth,
|
||||
),
|
||||
)
|
||||
ctx.repository.update_position_status(
|
||||
conn,
|
||||
record.proposal_id,
|
||||
status=next_status, # type: ignore[arg-type]
|
||||
closed_at=now if is_filled else None,
|
||||
close_reason=decision.action if is_filled else None,
|
||||
debit_paid_eth=order.average_price_eth if is_filled else None,
|
||||
pnl_eth=decision.pnl_estimate_eth if is_filled else None,
|
||||
pnl_usd=decision.pnl_estimate_usd if is_filled else None,
|
||||
now=now,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not is_filled:
|
||||
await ctx.alert_manager.critical(
|
||||
source="monitor_cycle",
|
||||
message=(
|
||||
f"close rejected by broker for {record.proposal_id} "
|
||||
f"(state={order.state})"
|
||||
),
|
||||
component="runtime.monitor_cycle",
|
||||
)
|
||||
return PositionOutcome(
|
||||
proposal_id=str(record.proposal_id),
|
||||
action=decision.action,
|
||||
closed=False,
|
||||
reason=f"broker_reject:{order.state}",
|
||||
)
|
||||
|
||||
ctx.audit_log.append(
|
||||
event="EXIT_FILLED",
|
||||
payload={
|
||||
"proposal_id": str(record.proposal_id),
|
||||
"action": decision.action,
|
||||
"pnl_usd": str(decision.pnl_estimate_usd),
|
||||
"combo_instrument": order.combo_instrument,
|
||||
},
|
||||
now=now,
|
||||
)
|
||||
await ctx.telegram.notify_position_closed(
|
||||
instrument=order.combo_instrument,
|
||||
realized_pnl_usd=decision.pnl_estimate_usd,
|
||||
reason=decision.action,
|
||||
)
|
||||
_log.info(
|
||||
"exit filled: proposal=%s action=%s pnl_usd=%s",
|
||||
record.proposal_id,
|
||||
decision.action,
|
||||
decision.pnl_estimate_usd,
|
||||
)
|
||||
return PositionOutcome(
|
||||
proposal_id=str(record.proposal_id),
|
||||
action=decision.action,
|
||||
closed=True,
|
||||
reason=decision.reason,
|
||||
)
|
||||
Reference in New Issue
Block a user