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?")