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