pydantic-config

star 4

"Pydantic-based configuration management with frozen models, nested hierarchy" TOML/env parsing, and module-level singleton

paulpas By paulpas schedule Updated 6/4/2026

name: pydantic-config compatibility: opencode completeness: 95 content-types:

  • code
  • guidance
  • do-dont
  • examples description: '"Pydantic-based configuration management with frozen models, nested hierarchy" TOML/env parsing, and module-level singleton' license: MIT maturity: stable metadata: domain: coding output-format: code related-skills: null role: implementation scope: implementation triggers: configuration, management, pydantic config, pydantic-based, pydantic-config archetypes:
    • tactical
    • generation anti_triggers:
    • brainstorming
    • vague ideation
    • code golf
    • over-engineering response_profile: verbosity: low directive_strength: high abstraction_level: operational version: "1.0.0"

Skill: coding-pydantic-config

Pydantic-based configuration management with frozen models, nested hierarchy, TOML/env parsing, and module-level singleton

Role / Purpose

This skill covers the canonical pattern for application configuration in a trading system using Pydantic v2. Configuration is parsed once at the application boundary (from TOML or environment), validated, and frozen into an immutable singleton. All downstream code reads trusted, typed config values — never raw strings or unchecked dicts.


Key Patterns

1. model_config = ConfigDict(frozen=True) — Immutable After Creation

Every config class carries frozen=True. Once instantiated, no field can be mutated. This eliminates entire categories of bugs where config bleeds across contexts.

from pydantic import BaseModel, ConfigDict

class ExchangeConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    name: str
    api_key: str = ""
    api_secret: str = ""
    testnet: bool = False

2. Annotated[float, Field(ge=0.0, le=1.0)] — Constraints at the Type Level

Numeric constraints are expressed directly in the type annotation so they're enforced automatically by Pydantic. No manual range checks in application code.

from typing import Annotated
from pydantic import Field

class RiskConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    max_position_size: Annotated[float, Field(ge=0.0, le=1.0)] = Field(
        default=0.1, description="Max position as % of portfolio (0.0-1.0)"
    )
    max_daily_loss: Annotated[float, Field(ge=0.0)] = Field(
        default=0.05, description="Max daily loss as % (0.0-1.0)"
    )
    max_drawdown: Annotated[float, Field(ge=0.0, le=1.0)] = Field(
        default=0.15, description="Max drawdown as % (0.0-1.0)"
    )
    max_open_positions: Annotated[int, Field(ge=1, le=50)] = Field(
        default=5, description="Max concurrent open positions"
    )
    stop_loss_pct: Annotated[float, Field(ge=0.0, le=1.0)] = Field(
        default=0.02, description="Stop loss as % of entry price"
    )
    take_profit_pct: Annotated[float, Field(ge=0.0, le=10.0)] = Field(
        default=0.05, description="Take profit as % of entry price"
    )
    risk_reward_ratio: Annotated[float, Field(ge=1.0)] = Field(
        default=2.0, description="Min R:R ratio for trades"
    )

3. @field_validator with @classmethod — Fail-Fast Boundary Validation

Field validators run at parse time. Invalid values halt construction with a descriptive ValueError before the object can exist in a bad state.

from pydantic import field_validator

class ExchangeConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    name: str = Field(..., pattern=r"^[a-zA-Z0-9_-]+$")

    @field_validator("name")
    @classmethod
    def name_not_empty(cls, v: str) -> str:
        """Fail fast - empty exchange names are invalid."""
        if not v or v.strip() == "":
            raise ValueError("Exchange name cannot be empty")
        return v.strip()

class RiskConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    take_profit_pct: Annotated[float, Field(ge=0.0, le=10.0)] = Field(default=0.05)

    @field_validator("take_profit_pct")
    @classmethod
    def tp_not_zero(cls, v: float) -> float:
        """Fail fast - zero take profit is invalid."""
        if v == 0:
            raise ValueError("Take profit cannot be zero")
        return v

4. Nested Config Hierarchy → ApexConfig

All sub-configs compose into a single root config. The root's validator ensures minimum viability (e.g., at least one exchange must be configured).

class Mode(str, Enum):
    PAPER = "paper"
    LIVE = "live"

class ConvictionConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    minimum_entry: Annotated[float, Field(ge=0.0, le=1.0)] = Field(default=0.7)
    minimum_exit: Annotated[float, Field(ge=0.0, le=1.0)] = Field(default=0.5)
    momentum_weight: Annotated[float, Field(ge=0.0, le=1.0)] = Field(default=0.3)
    trend_weight: Annotated[float, Field(ge=0.0, le=1.0)] = Field(default=0.3)
    volatility_weight: Annotated[float, Field(ge=0.0, le=1.0)] = Field(default=0.2)
    volume_weight: Annotated[float, Field(ge=0.0, le=1.0)] = Field(default=0.2)

    @field_validator("momentum_weight", "trend_weight", "volatility_weight", "volume_weight")
    @classmethod
    def weights_non_negative(cls, v: float) -> float:
        """Fail fast - negative weights are invalid."""
        if v < 0:
            raise ValueError("Signal weights cannot be negative")
        return v

class LoggingConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    level: str = Field(default="INFO")
    format: str = Field(default="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
    file_path: FilePath | None = Field(default=None)

    @field_validator("level")
    @classmethod
    def level_valid(cls, v: str) -> str:
        valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
        if v.upper() not in valid_levels:
            raise ValueError(f"Invalid log level. Must be one of: {valid_levels}")
        return v.upper()

class AlertingConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    enabled: bool = Field(default=True)
    email_recipients: list[str] = Field(default_factory=list)
    slack_webhook: str = Field(default="")
    discord_webhook: str = Field(default="")

    @field_validator("slack_webhook", "discord_webhook")
    @classmethod
    def webhook_format(cls, v: str) -> str:
        if v and not v.startswith(("http://", "https://")):
            raise ValueError("Webhook URL must start with http:// or https://")
        return v

class ApexConfig(BaseModel):
    model_config = ConfigDict(frozen=True)

    mode: Mode = Field(default=Mode.PAPER)
    log_level: str = Field(default="INFO")
    environment: str = Field(default="development")

    exchanges: dict[str, ExchangeConfig] = Field(default_factory=dict)
    risk: RiskConfig = Field(default_factory=RiskConfig)
    conviction: ConvictionConfig = Field(default_factory=ConvictionConfig)
    logging: LoggingConfig = Field(default_factory=LoggingConfig)
    metrics: MetricsConfig = Field(default_factory=MetricsConfig)
    alerting: AlertingConfig = Field(default_factory=AlertingConfig)

    @field_validator("exchanges")
    @classmethod
    def at_least_one_exchange(cls, v: dict) -> dict:
        """Fail fast - must have at least one exchange configured."""
        if not v:
            raise ValueError("At least one exchange must be configured")
        return v

5. ApexConfig.from_toml(path) — Parse at Boundary

TOML is loaded and immediately passed to model_validate. Any validation error is re-raised as a descriptive ValueError. tomllib (Python 3.11+) with tomli fallback.

from pathlib import Path
from pydantic import ValidationError

@classmethod
def from_toml(cls, path: Path) -> "ApexConfig":
    """Parse TOML config file - parse at boundary, fail fast on errors."""
    try:
        import tomllib  # Python 3.11+
    except ImportError:
        try:
            import tomli as tomllib  # type: ignore[import-not-found]
        except ImportError as e:
            raise ImportError(
                "tomli required for TOML parsing. Install with: pip install tomli"
            ) from e

    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    with open(path, "rb") as f:
        raw_data = tomllib.load(f)

    try:
        return cls.model_validate(raw_data)
    except ValidationError as e:
        raise ValueError(f"Configuration validation failed: {e}") from e

6. ApexConfig.from_env() — Environment Variable Parsing

Loads .env file via python-dotenv (optional), then calls model_validate({}) to let pydantic-settings resolve values from the environment.

@classmethod
def from_env(cls) -> "ApexConfig":
    """Parse environment variables - parse at boundary."""
    try:
        from dotenv import load_dotenv
        load_dotenv()
    except ImportError:
        pass  # dotenv optional

    try:
        return cls.model_validate({})
    except ValidationError as e:
        raise ValueError(f"Environment configuration failed: {e}") from e

7. Module-Level Singleton

_config starts as None. init_config() raises if called twice. get_config() raises if not yet initialized. Neither silently returns None.

_config: ApexConfig | None = None


def get_config() -> ApexConfig:
    """Get current config - fail loud if not initialized."""
    if _config is None:
        raise RuntimeError("Configuration not initialized. Call init_config() first.")
    return _config


def init_config(config: ApexConfig) -> None:
    """Initialize global config - single entry point, fail fast on duplicates."""
    global _config
    if _config is not None:
        raise RuntimeError("Configuration already initialized")
    _config = config

Code Examples

Typical Startup Sequence

from pathlib import Path
from apex.core.config import ApexConfig, init_config, get_config

# Load from TOML at application startup
config = ApexConfig.from_toml(Path("config/apex.toml"))
init_config(config)

# Anywhere in the codebase
cfg = get_config()
print(cfg.risk.max_position_size)   # 0.1 (trusted float, no re-validation)
print(cfg.mode)                     # Mode.PAPER

Sample TOML Config

mode = "paper"
log_level = "INFO"
environment = "development"

[exchanges.binance]
name = "binance"
api_key = "your_key"
api_secret = "your_secret"
testnet = true

[risk]
max_position_size = 0.05
max_daily_loss = 0.02
stop_loss_pct = 0.01

[conviction]
minimum_entry = 0.75
momentum_weight = 0.3
trend_weight = 0.3
volatility_weight = 0.2
volume_weight = 0.2

Philosophy Checklist

  • Early Exit: from_toml exits immediately if file doesn't exist; get_config exits immediately if not initialized
  • Parse Don't Validate: Raw TOML/env data parsed once at boundary; internal code receives trusted ApexConfig
  • Atomic Predictability: frozen=True on every model; no mutations after construction
  • Fail Fast: init_config raises on double-init; @field_validator raises on bad values at construction
  • Intentional Naming: from_toml, from_env, init_config, get_config — every function name describes exactly what it does

Constraints

MUST DO

  • Include at least one BAD/GOOD code example pair
  • Reference a relevant standard (OWASP, SOLID, DRY, KISS, etc.)
  • Use type hints on all function signatures

MUST NOT DO

  • Use magic numbers or hardcoded configuration values
  • Bypass error handling for assumed-valid inputs
  • Write functions longer than 50 lines without decomposition
Install via CLI
npx skills add https://github.com/paulpas/agent-skill-router --skill pydantic-config
Repository Details
star Stars 4
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator