Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Daemon logs are real logs** — the daemon path writes `logs/daemon.log`
through a midnight-rotating handler (14-day retention) with ISO-8601 local
timestamps carrying the numeric tz offset; level/dir/retention configurable
via the manifest's new optional `logging:` section
(`application/log_setup.py`); tick errors now log their traceback
(previously invisible — no handler existed). Interactive CLI output
unchanged. (#226)

### Changed

### Fixed
Expand Down
16 changes: 16 additions & 0 deletions doc/dev/03-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ rejected approaches as tombstones.

---

### 2026-07-14 Daemon logging spine: root handlers, offset timestamps (PR #226) [accepted]
- **Choice**: the daemon's logging config lives in `application/log_setup.py`
and wires the **root** logger with two owned, tagged handlers (midnight
`TimedRotatingFileHandler` + stderr stream); third-party namespaces
(`apscheduler`/`uvicorn`/`httpx`/`websockets`) are capped at WARNING;
timestamps are **local ISO-8601 with the numeric UTC offset**.
- **Why**: one spine, N emitters — the runner/router/feed module loggers
(some already emitting into the void) flow into the rotated file without
knowing it exists; the owned-handler tag makes re-configuration idempotent
(no double-writes under tests or embedding); the explicit offset kills the
UTC-vs-CEST ambiguity that burned the 2026-07-14 audit.
- **Rejected alternatives**: UTC-only timestamps (the operator reads local
time; the offset carries both); per-module handlers (drift + duplication);
wiring in `interfaces/cli` (the seam is application-level composition,
reusable by a future systemd entrypoint).

### 2026-07-12 The venue oracle is an accounting identity; fees carry their asset (PR #223) [accepted]
- **Choice**: on a real venue the canary asserts the **identity** — our
fills == venue-reported fills, per-asset balance deltas == the fills math
Expand Down
5 changes: 5 additions & 0 deletions doc/dev/06-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ engine code: **real-key live enablement** (validate Kraken private endpoints +
venue-level idempotency against a real-key sandbox, then flip `live_enabled`) — the one
maintainer step in [`07-roadmap.md`](07-roadmap.md).

- **`daemon-logging` in flight (1/3)**: the logging spine landed (rotated,
tz-offset-timestamped `logs/daemon.log`, manifest `logging:` section, tick
tracebacks). Next: the per-unit event trail (leaf 02), tick-timing metrics
(leaf 03). Plan: `plans/daemon-logging/`.

## Known gaps / deferred

- ~~**Final project name**~~ — **decided**: kept as `trading_bot` (with `dccd` /
Expand Down
4 changes: 2 additions & 2 deletions doc/dev/plans/daemon-logging/00-plan.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
plan: daemon-logging
kind: global
status: planning
status: executing
roadmap: "3b. [ ] **Daemon observability — structured logs** (surfaced by the 2026-07-14 health audit; prereq for #4's systemd alerting + soak evidence): timestamped rotated `logs/daemon.log`, per-unit evaluation/order/skip trail, tick-duration metrics on `/api/health` (additive). Plan: `plans/daemon-logging/`."
release_on_done: true
---
Expand Down Expand Up @@ -53,7 +53,7 @@ What the audit measured (the evidence this epic answers):

## Leaf checklist

- [ ] 01 log-setup — `feat/daemon-log-setup` — medium
- [x] 01 log-setup — `feat/daemon-log-setup` — medium
- [ ] 02 unit-event-trail — `feat/daemon-unit-event-logs` — medium
- [ ] 03 tick-timing-metrics — `feat/tick-timing-metrics` — medium

Expand Down
129 changes: 0 additions & 129 deletions doc/dev/plans/daemon-logging/01-log-setup.md

This file was deleted.

56 changes: 56 additions & 0 deletions trading_bot/application/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@

from __future__ import annotations

import logging
import os
import pathlib
from decimal import Decimal
Expand All @@ -77,6 +78,7 @@
"StrategyConfig",
"PortfolioStrategyConfig",
"RiskConfig",
"LoggingConfig",
"AppConfig",
]

Expand Down Expand Up @@ -671,6 +673,54 @@ def _require_token_for_non_loopback(self) -> UIConfig:
return self


class LoggingConfig(BaseModel):
"""How the **daemon** logs — level, destination directory, retention.

The daemon path (``trading-bot start [--serve]``) routes its lifecycle and
per-tick lines through the standard-library :mod:`logging` root logger,
wired by :func:`~trading_bot.application.log_setup.configure_daemon_logging`
to a midnight-rotated ``daemon.log`` (plus ``stderr``, so systemd/journal
still sees output). This model is the manifest's optional ``logging:``
section that tunes that wiring; interactive CLI commands (``status`` /
``kpi`` / ``canary`` / ...) keep their rich-console output and ignore it.
The section is **additive** — a manifest with no ``logging:`` key keeps
every default below.

Parameters
----------
level : str, optional
Root log level, validated case-insensitively against the standard
library's level names (:func:`logging.getLevelNamesMapping` — ``DEBUG``
/ ``INFO`` / ``WARNING`` / ``ERROR`` / ``CRITICAL`` and their aliases)
and stored **upper-case**. Defaults to ``"INFO"`` — the engine's own
story, with the noisy third-party namespaces capped at ``WARNING`` by
the setup.
dir : pathlib.Path, optional
Directory the rotated ``daemon.log`` (and its dated backups) is written
to. A relative path (the default ``Path("logs")``) resolves against the
working directory the daemon runs from, like the other manifest paths;
it is created if missing.
retention_days : int, optional
Number of dated backups the midnight rotation keeps (``backupCount``);
older files are pruned. Must be ``>= 1``. Defaults to ``14``.

"""

level: str = "INFO"
dir: pathlib.Path = pathlib.Path("logs")
retention_days: int = Field(default=14, ge=1)

@field_validator("level")
@classmethod
def _valid_level(cls, v: str) -> str:
"""Accept any standard level name case-insensitively; store it upper-case."""
upper = v.strip().upper()
if upper not in logging.getLevelNamesMapping():
valid = ", ".join(sorted(logging.getLevelNamesMapping()))
raise ValueError(f"level {v!r} is not a valid log level (one of: {valid})")
return upper


class AppConfig(BaseModel):
"""Top-level engine configuration — brokers, strategies and risk.

Expand Down Expand Up @@ -757,6 +807,11 @@ class AppConfig(BaseModel):
storage : StorageConfig, optional
Where state is persisted (SQLite) and where the bars feed reads data
from (dccd dir). Defaults to all-unset (each layer's own default).
logging : LoggingConfig, optional
How the **daemon** path logs (level / directory / retention) — consumed
by :func:`~trading_bot.application.log_setup.configure_daemon_logging`.
Additive: a manifest with no ``logging:`` section keeps the defaults
(INFO, ``logs/``, 14 days). Interactive CLI commands ignore it.

Examples
--------
Expand Down Expand Up @@ -787,6 +842,7 @@ class AppConfig(BaseModel):
risk: RiskConfig = Field(default_factory=RiskConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
ui: UIConfig = Field(default_factory=UIConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)

@field_validator("display_currency")
@classmethod
Expand Down
Loading
Loading