Source code for trader.config.loader

"""Resolution and enforcement of the paper/live safety gate (requirements §9).

Two independent controls must BOTH be satisfied before any real-money order:
`TRADING_MODE=live` and `LIVE_TRADING_ENABLED=true`. If the first is set
without the second, this module fails closed — it raises, and the caller
exits. It never silently downgrades to paper, because a user who asked for
live and got paper without noticing would draw false conclusions from the
results.
"""

from dataclasses import dataclass, field

from pydantic import SecretStr
from sqlalchemy.engine import make_url

from trader.config.settings import Settings, TradingMode
from trader.errors import MissingCredentialsError, UnsafeConfigError

__all__ = [
    "EffectiveTradingConfig",
    "assemble_database_url",
    "load_trading_config",
    "resolve_trading_config",
]


[docs] @dataclass(frozen=True, slots=True) class EffectiveTradingConfig: """The validated, immutable trading configuration for this process.""" trading_mode: TradingMode live_trading_enabled: bool # `repr=False`: requirements §11 puts rotating FILE logging in v1 scope, so # a single `logger.debug("config=%s", config)` would otherwise write a live # API key to disk. These are plain `str` because this is where they get # used; keeping them out of `repr` is what stops them leaking. api_key: str = field(repr=False) api_secret: str = field(repr=False) # `repr=False`: this DSN carries a real Postgres password (issue #26). # Assembled once here, the same "unwrap a secret exactly once" pattern # as `api_key`/`api_secret` above -- never build it inline anywhere # else. database_url: str = field(repr=False) @property def is_live(self) -> bool: """True when the configured mode is live. A *mode* signal, not a permission — call `assert_live_orders_allowed()` before any order. """ return self.trading_mode is TradingMode.live @property def use_paper_endpoint(self) -> bool: """The `paper=` flag to hand to the Alpaca SDK.""" return not self.is_live
[docs] def assert_live_orders_allowed(self) -> None: """Re-check the gate immediately before placing an order. `resolve_trading_config` already rejects unsafe combinations, so this is defense in depth: any future code path that builds a config by another route still cannot place a live order without both controls. Slice 2's trade executor must call this before every submission. """ if self.is_live and not self.live_trading_enabled: raise UnsafeConfigError( "Refusing to place live orders: TRADING_MODE=live requires " "LIVE_TRADING_ENABLED=true." )
[docs] def assemble_database_url(template: str, password: SecretStr) -> str: """Insert `password` into a password-less DSN template. Uses SQLAlchemy's own URL parser (`make_url`/`.set(password=...)`) rather than string concatenation, so a password containing `@`, `:`, `/` or any other DSN-special character is correctly percent-encoded rather than silently producing a malformed or wrong-field DSN. """ return ( make_url(template) .set(password=password.get_secret_value()) .render_as_string(hide_password=False) )
[docs] def resolve_trading_config(settings: Settings) -> EffectiveTradingConfig: """Validate raw settings into an `EffectiveTradingConfig`. Raises: UnsafeConfigError: live mode requested without the explicit toggle. MissingCredentialsError: the selected mode's key pair is incomplete. """ wants_live = settings.trading_mode is TradingMode.live if wants_live and not settings.live_trading_enabled: raise UnsafeConfigError( "Unsafe configuration: TRADING_MODE=live but LIVE_TRADING_ENABLED " "is not true. Both controls are required before real-money orders. " "Refusing to start." ) # The single point where the secrets are unwrapped, so `EffectiveTradingConfig` # holds plain strings for the adapters to use. Unwrap BEFORE the emptiness # check below rather than relying on the wrapper's truthiness: pydantic # happens to make `bool(SecretStr(""))` False today, but the "are the # credentials present?" decision should not rest on that. if wants_live: api_key = settings.alpaca_live_api_key.get_secret_value() api_secret = settings.alpaca_live_api_secret.get_secret_value() key_names = ("ALPACA_LIVE_API_KEY", "ALPACA_LIVE_API_SECRET") else: api_key = settings.alpaca_paper_api_key.get_secret_value() api_secret = settings.alpaca_paper_api_secret.get_secret_value() key_names = ("ALPACA_PAPER_API_KEY", "ALPACA_PAPER_API_SECRET") if not api_key or not api_secret: raise MissingCredentialsError( f"Missing Alpaca credentials for {settings.trading_mode} mode: set " f"{key_names[0]} and {key_names[1]} in .env." ) database_url = assemble_database_url( settings.database_url_template, settings.database_password ) return EffectiveTradingConfig( trading_mode=settings.trading_mode, live_trading_enabled=settings.live_trading_enabled, api_key=api_key, api_secret=api_secret, database_url=database_url, )
[docs] def load_trading_config() -> EffectiveTradingConfig: """Load `.env` and resolve the trading config in one call.""" return resolve_trading_config(Settings())