Source code for trader.daemon.preflight

"""Startup checks for the daemon.

An unattended process must refuse to start rather than run for hours in a
state a human would have spotted in seconds. The checks are split from the CLI
so each is testable without a runner, and split from each other so a single
startup reports *every* problem — telling someone about one missing dependency
at a time costs one restart per dependency.
"""

import logging
from collections.abc import Callable, Sequence

import psycopg

from trader.concurrency import _psycopg_dsn
from trader.config.schema import StrategyConfig
from trader.errors import TraderError
from trader.persistence.migrations import current_revision, head_revision

__all__ = ["confirm_live_trading", "preflight_failures"]

_logger = logging.getLogger("trader.daemon")


def _can_write_decisions(database_url: str) -> bool | None:
    """Whether the configured role can `INSERT` into `decisions`.

    `None` means the check itself failed (connection refused, database
    unreachable) — a separate condition from "connected fine, but the role
    is read-only" (`False`). Distinguished so a preflight failure names the
    *actual* problem rather than always saying "read-only role" when the
    real issue is e.g. the database being unreachable at all.

    Issue #26: a read-only DSN (the dev checkout's `trader_ro` role) still
    acquires `single_instance_lock` successfully — `pg_try_advisory_lock`
    needs no DML privilege — and would otherwise only fail three steps into
    a cycle on the first `INSERT`. This makes that failure immediate and
    clear instead.
    """
    try:
        with psycopg.connect(_psycopg_dsn(database_url), autocommit=True) as connection:
            (can_write,) = connection.execute(
                "SELECT has_table_privilege(current_user, 'decisions', 'INSERT')"
            ).fetchone()
            return bool(can_write)
    except Exception:  # noqa: BLE001 - a failed check is reported, not raised
        return None


[docs] def preflight_failures( *, database_url: str, strategy_entries: Sequence[StrategyConfig], llm_health: Callable[[], str] | None, ) -> list[str]: """Every reason the daemon should not start, as readable sentences. Returns an empty list when everything is in order. Never raises for a failed *check* — a check that blows up is a failure to report, not a traceback to show. """ failures: list[str] = [] stamped = current_revision(database_url) expected = head_revision(database_url) if stamped != expected: failures.append( f"The configured database is at revision {stamped or 'none'}, but " f"this version expects {expected}. Run 'trader migrate'." ) can_write = _can_write_decisions(database_url) if can_write is False: failures.append( "The configured database role cannot write to 'decisions' — this " "looks like a read-only role (e.g. trader_ro). 'trader run'/" "'run-once' need a role with write access (trader_rw)." ) # Only when a model is actually configured: a rules-only setup must not # need a model server to start. `trailing_stop_floor` is included here as # a fail-fast convenience, not a correctness requirement — its floor # enforcement never depends on the model being reachable (an unreachable # Ollama makes it fall back to the base YAML trail, every cycle, forever; # see `TrailingStopFloorStrategy._adjust_trail`), but a daemon that starts # anyway and silently ignores every note for the rest of the session is # worth surfacing at startup rather than only in the log. if llm_health is not None and any( e.type in ("llm", "trailing_stop_floor") for e in strategy_entries ): try: llm_health() except TraderError as exc: failures.append( f"The configured Ollama model is not usable: {exc}. " "Start the server and pull the model, or run 'trader llm-check'." ) return failures
[docs] def confirm_live_trading( *, is_live: bool, isatty: bool, ask: Callable[[str], bool] ) -> bool: """Whether to proceed when the account is not paper. On a terminal this asks. Without one it does **not** ask and does **not** refuse — it logs and proceeds. That is a deliberate choice with a known cost: launchd gives the daemon no TTY, so an unattended live daemon starts unconfirmed. See the 2026-08-03 design doc; do not "fix" this into a prompt, which under launchd would block on stdin forever and the daemon would silently never trade. This is a speed bump for a human, not a control. The safety gate (`TRADING_MODE=live` plus `LIVE_TRADING_ENABLED=true`) and `assert_live_orders_allowed()` before every submission are the controls. """ if not is_live: return True if not isatty: _logger.critical( "Starting a LIVE-money daemon unconfirmed: no terminal is attached, " "so the confirmation prompt was skipped." ) return True return ask("TRADING_MODE is live — this will place REAL-MONEY orders. Continue?")