Source code for trader.config.settings
"""Raw configuration sourcing from `.env`.
This module only *reads* values. Deciding whether those values are safe to
act on is `loader.py`'s job — keeping the two separate means a later change
to where config lives does not touch the safety gate.
"""
from enum import StrEnum
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
__all__ = ["LogLevel", "Settings", "TradingMode"]
[docs]
class TradingMode(StrEnum):
"""Which Alpaca environment to talk to."""
paper = "paper"
live = "live"
[docs]
class LogLevel(StrEnum):
"""Verbosity for the rotating log file.
A closed set rather than a free string: `LOG_LEVEL=VERBOSE` should fail at
load with the valid values named, not fall back to INFO and leave someone
hunting for why their debug output never appeared.
"""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
[docs]
class Settings(BaseSettings):
"""Values read from `.env` / the process environment.
Secrets live here and only here — never in YAML (requirements §14).
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# `SecretStr`, not `str`: requirements §11 puts rotating FILE logging in v1
# scope, so the first `logger.debug("settings=%s", settings)` against a
# plain-str field would write a live API key to disk. `SecretStr.__repr__`
# and `__str__` both render `**********`; call `.get_secret_value()` to read
# the real value, which `resolve_trading_config` does exactly once.
alpaca_paper_api_key: SecretStr = SecretStr("")
alpaca_paper_api_secret: SecretStr = SecretStr("")
alpaca_live_api_key: SecretStr = SecretStr("")
alpaca_live_api_secret: SecretStr = SecretStr("")
# Read now, used in Slice 3.
anthropic_api_key: SecretStr = SecretStr("")
# The two independent live-trading controls (requirements §9).
trading_mode: TradingMode = TradingMode.paper
live_trading_enabled: bool = False
# Postgres migration (issue #26): a DSN *template* with no password
# embedded (`postgresql+psycopg://trader_ro@localhost/trader_live`),
# plus the password kept in its own `SecretStr` field. Deliberately not
# a single `database_url: str` field carrying the password inline --
# that would defeat `SecretStr`'s `repr()`-masking the instant anyone
# logs `Settings()` wholesale, same reasoning as the Alpaca key pair
# above. Both are REQUIRED (no default): a missing or misconfigured
# database connection must fail loudly at `Settings()` construction,
# never silently fall back to a file on disk that happens to exist.
database_url_template: str
database_password: SecretStr
# Where the local Ollama server listens. Not a secret, but it belongs with
# the other runtime endpoints rather than in YAML.
ollama_base_url: str = "http://localhost:11434"
# `trader web`'s bind address/port. Defaults to loopback-only — this is a
# single-operator local report, not a service meant to be reachable from
# the network.
web_host: str = "127.0.0.1"
web_port: int = 8000
# Verbosity of logs/trader.log. Nothing is written to stdout at any level;
# the CLI's own output is `typer.echo` and stays separate.
log_level: LogLevel = LogLevel.INFO
@field_validator("live_trading_enabled", mode="before")
@classmethod
def _blank_is_false(cls, value: object) -> object:
"""Treat a present-but-empty flag as OFF.
Pydantic rejects `""` for `bool`, but `LIVE_TRADING_ENABLED=` in a
`.env` file is a plausible way to write "off". Failing to start would
be defensible; reading it as ON would not. So it means OFF.
"""
if isinstance(value, str) and value.strip() == "":
return False
return value
@field_validator("log_level", mode="before")
@classmethod
def _log_level_is_case_insensitive(cls, value: object) -> object:
"""Accept `LOG_LEVEL=debug`, which is what a person actually types.
`TradingMode`'s members are already lowercase, so `case_sensitive=False`
on `model_config` (which only governs env *variable name* matching) was
enough there. `LogLevel`'s members are uppercase, so the value itself
needs normalising before pydantic matches it against the enum.
"""
if isinstance(value, str):
return value.upper()
return value