Source code for trader.config.schema

"""Pydantic models for the YAML config files.

`watchlist.yaml` is consumed since Slice 1. `strategies.yaml` is consumed
starting in Slice 2, feeding the strategy registry.

Secrets never appear in these files (requirements §14) — the safety flags and
API keys come from `.env` via `settings.py`.
"""

from datetime import date
from decimal import Decimal
from enum import StrEnum
from pathlib import Path

import yaml
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    field_validator,
    model_validator,
)

from trader.errors import ConfigError

__all__ = [
    "DaemonConfig",
    "DiscoverySettings",
    "IdeasScraperConfig",
    "PipelineConfig",
    "ReportingConfig",
    "SimulationSettings",
    "StrategiesConfig",
    "StrategyConfig",
    "StrategyMode",
    "WatchlistConfig",
    "load_daemon_config",
    "load_ideas_scraper_config",
    "load_pipeline_config",
    "load_reporting_config",
    "load_simulation_config",
    "load_strategies_config",
    "load_watchlist_config",
]


[docs] class DiscoverySettings(BaseModel): """News-driven discovery (requirements §7, built in Slice 3d). `scan_interval_hours` was removed: it was parsed and ignored, and the scan now runs once per daemon cycle, so `cycle_interval_minutes` in `daemon.yaml` is the real interval. A government deal announced at 10am is worth hours, not days. """ model_config = ConfigDict(extra="forbid") enabled: bool = True #: Phrases to search. Empty means discovery does nothing, which is the #: safe default for an unedited config — the same reasoning as #: `mode: shadow`. themes: list[str] = Field(default_factory=list) max_candidates_per_theme: int = Field(default=5, ge=1, le=50) #: `Decimal`, not float: this is compared against a share price. min_price: Decimal = Field(default=Decimal("5.00"), ge=0) min_avg_volume: int = Field(default=1_000_000, ge=0) #: Concurrent positions in discovered symbols, separate from #: `fixed_tickers`. Ten thematic hits at `max_position_pct: 10` would #: otherwise be the entire portfolio. max_discovered_positions: int = Field(default=3, ge=0) # Extensible source list; Slice 1 ships Yahoo Finance only. sources: list[str] = Field(default_factory=lambda: ["yahoo_finance"]) #: How old a symbol's `analyst_consensus_history` row may be before #: `trader scan-universe` re-fetches it and before `discover_symbols`'s #: analyst-scan candidate source treats it as fresh enough to act on #: (issue #27). An operational freshness knob, not a "what do I care #: about" one — `themes` already covers that, so there is no companion #: "which sectors" field here. analyst_scan_max_age_hours: int = Field(default=24, ge=1)
[docs] class WatchlistConfig(BaseModel): """The user-curated fixed list plus discovery settings.""" model_config = ConfigDict(extra="forbid") fixed_tickers: list[str] = Field(default_factory=list) discovery: DiscoverySettings = Field(default_factory=DiscoverySettings) #: The relative-strength comparison symbol for the LLM prompt (issue #: #87). SPY by default because it is already a `fixed_tickers` entry on #: this project's live watchlist, so its bars are already in the cycle's #: `BarCache` — pointing this at a symbol outside the evaluated universe #: still works (`BarCache.ensure` fetches any symbol), it just costs a #: fetch this project's "no new fetch" framing was written to avoid. benchmark_symbol: str = "SPY" @field_validator("fixed_tickers") @classmethod def _normalize(cls, tickers: list[str]) -> list[str]: """Uppercase and de-duplicate while preserving the author's order.""" seen: set[str] = set() result: list[str] = [] for ticker in tickers: symbol = ticker.strip().upper() if symbol and symbol not in seen: seen.add(symbol) result.append(symbol) return result @field_validator("benchmark_symbol") @classmethod def _normalize_benchmark_symbol(cls, symbol: str) -> str: """Same casing discipline as `fixed_tickers`, so a lowercase config value and a live `Position.symbol`/`Bar.symbol` compare equal.""" return symbol.strip().upper()
[docs] def load_watchlist_config(path: Path) -> WatchlistConfig: """Read and validate `watchlist.yaml`. Raises: ConfigError: the file is missing, unreadable, malformed, or invalid. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read watchlist config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return WatchlistConfig.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid watchlist config in {path}:\n{exc}") from exc
[docs] class StrategyMode(StrEnum): """Whether a configured strategy may actually place orders. `trading`, not `live`: `.env`'s TRADING_MODE=live means *real money*, and a strategy set to trade under TRADING_MODE=paper places real orders on the paper account. Two unrelated axes sharing a word cost an afternoon on 2026-08-03, so they no longer share it. `shadow` is unchanged — it is the industry-idiomatic term for a strategy that runs on live data and routes nothing. """ TRADING = "trading" SHADOW = "shadow"
[docs] class StrategyConfig(BaseModel): """One configured strategy instance.""" model_config = ConfigDict(extra="forbid") id: str type: str # Defaults to SHADOW deliberately. This file configures an autonomous # trader, so the safe reading of an unspecified entry is "evaluate and # record, but do not spend money". Note this is a behavioural change from # Slice 2, where every configured strategy could trade. mode: StrategyMode = StrategyMode.SHADOW params: dict[str, object] = Field(default_factory=dict)
[docs] class StrategiesConfig(BaseModel): """All configured strategies (requirements §6).""" model_config = ConfigDict(extra="forbid") strategies: list[StrategyConfig] = Field(default_factory=list) @field_validator("strategies") @classmethod def _ids_are_unique(cls, values: list[StrategyConfig]) -> list[StrategyConfig]: """Duplicate ids would make results ambiguous, so reject them.""" seen: set[str] = set() for entry in values: if entry.id in seen: raise ValueError(f"duplicate strategy id: {entry.id}") seen.add(entry.id) return values
# There is deliberately no "at most one trading strategy" validator any more. # # There was one until 2026-08-11, and its reasoning was: "two trading # strategies could issue conflicting orders for one symbol… a second trading # strategy is not a richer configuration, it is a race." The race was real, # but it was never *general* — it lived in exactly one place, and the rest of # the pipeline had already grown the machinery to resolve it: # # Entries — resolved. `_one_candidate_per_symbol` funds at most one entry # per symbol per cycle and records every loser, and both # exposure caps are mutable across the cycle, so a second # strategy's entry is measured against what this cycle has # already spent. # Exits — was NOT resolved. `_process_symbol` submitted a sell inline # per (symbol, strategy) pair, and `exit_position` sells the # position's whole quantity, so two SELL signals on one position # meant two full-quantity market sells. The second offers shares # that no longer exist, which on a long-only account opens a # short — the failure `cancel_before_sell` exists to prevent, # arriving by another route. # # So the fix was to close the exit gap (`sold_this_cycle` in # `pipeline/run_once.py`) rather than to keep forbidding the configuration. # This was not a hypothetical: measured over eight days of real decision # rows, fourteen cycles had two strategies signalling SELL on the same # symbol. # # Do not reinstate this validator without first checking that both halves # above still hold. And note what still constrains the configuration, because # it is easy to read this relaxation as more permissive than it is: one # position per symbol, no pyramiding, one entry and one exit per symbol per # cycle. Two trading strategies may disagree; they cannot both act.
[docs] def load_strategies_config(path: Path) -> StrategiesConfig: """Read and validate `strategies.yaml`. Raises: ConfigError: the file is missing, unreadable, malformed, or invalid. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read strategies config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return StrategiesConfig.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid strategies config in {path}:\n{exc}") from exc
[docs] class PipelineConfig(BaseModel): """Entry, protection, and guardrail settings for the live path. Percentages are whole numbers — `8` means 8% — and `Decimal` rather than `float` because they multiply prices. YAML parses `7.5` as a float, so the annotation is doing real work: without it, a fractional percentage would carry binary-fraction error into money on the one path that spends cash. """ model_config = ConfigDict(extra="forbid") limit_buffer_bps: int = Field(default=25, ge=0) trail_percent: Decimal = Field(default=Decimal("8"), gt=0) floor_pct: Decimal = Field(default=Decimal("10"), gt=0) max_position_pct: Decimal = Field(default=Decimal("10"), gt=0) #: Ceiling on TOTAL deployed capital, as a share of portfolio value. #: `max_position_pct` bounds one name; nothing bounded the sum until #: 2026-08-04, when a single cycle submitted twelve limit buys — eight #: fixed tickers plus three discovered slots is 110% of equity, and #: Alpaca's paper margin would not have refused it. 80 leaves a cash #: buffer and keeps the app off margin entirely. max_total_exposure_pct: Decimal = Field(default=Decimal("80"), gt=0) daily_loss_limit_pct: Decimal = Field(default=Decimal("2"), gt=0) min_backtest_return_pct: Decimal = Field(default=Decimal("0")) max_backtest_drawdown_pct: Decimal = Field(default=Decimal("25"), gt=0) evaluation_lookback_days: int = Field(default=400, ge=30) #: How far back to re-fetch bars on every cycle, in CALENDAR days. The #: current day's bar is written partial and `save_bars` skips timestamps it #: already holds, so without this it is never corrected to the real close. #: #: Five, not three. A Monday cycle with a three-calendar-day tail spans #: Saturday and Sunday, so Friday's partial close would never be #: corrected. Five reaches back past a weekend from either side. bar_refresh_days: int = Field(default=5, ge=1) #: How close to the session's close an unfilled entry gets cancelled by #: this app, in minutes. The entry is GTC (issue #3's atomic-protection #: fix), so nothing else expires it — without this, a stale unfilled BUY #: would sit open indefinitely, holding exposure against #: `max_total_exposure_pct` and blocking a fresh attempt via #: `pending_entry_for` forever. `ge=0` rather than `gt=0`: zero is a legal, #: if aggressive, choice — cancel the instant the window check runs at all. cancel_entries_before_close_minutes: int = Field(default=20, ge=0) #: Force pure confidence-descending order over this cycle's approved BUY #: candidates, bypassing whatever `Ranker` is configured (issue #86, #: `docs/superpowers/specs/2026-08-24-analyst-factors-gap-design.md` #: Decision 2). `False` by default: leaves today's allocation phase #: exactly as it is — an `LlmRanker`'s order when one is configured, #: `fallback_ranking`'s confidence-descending order when it is not or it #: fails (`trader/ranking/base.py`, merged 2026-08-06, predating this #: issue and its design doc, both of which describe today's order as #: plain `fixed → discovered → held` list order — that premise is stale; #: this flag's real job is to let an operator isolate the #: confidence-only mechanism from the LLM-ranking one for measurement, #: per the design doc's "land Decision 2 alone before compounding it" #: guidance, generalized to the ranker that already exists). confidence_ordering_enabled: bool = Field(default=False) #: Evict the weakest currently-held discovered position to free a slot #: for a new discovered candidate that is capped out *only* by #: `max_discovered_positions` (issue #83) — instead of rejecting that #: candidate outright, which is what a `False` here still does, exactly #: as before this field existed. `False` by default, the same "ships #: gated off" pattern as `confidence_ordering_enabled` above (issue #: #86): the live-account ROI gate (`requirements.md` §18) is still #: open, and forced churn between two similarly-ranked names has a real #: cost (an extra round trip, slippage-equivalent on a paper account) #: this project is not ready to accept by default. See #: `eviction_margin_confidence` for the whipsaw guard, and #: `pipeline/run_once.py`'s `_DiscoveredHolder`/`_evict_discovered_holder` #: for the mechanism itself — gated behind this flag and never triggered #: while it is `False`. eviction_enabled: bool = Field(default=False) #: How much a new discovered candidate's confidence must exceed the #: weakest currently-held discovered position's *entry-time* confidence #: before eviction fires. Strictly greater-than, never `>=`, so a tie #: never evicts — the whole point of a margin is to require a real #: improvement, not any improvement. Same 0..1 scale as #: `Candidate.confidence`/`decisions.inputs_json["signal_confidence"]` #: (`llm/prompt.py`'s `parse_decision` clamps every model confidence into #: that range), so this compares directly against both with no unit #: conversion. Unused while `eviction_enabled` is `False`. eviction_margin_confidence: float = Field(default=0.15, ge=0.0, le=1.0) #: Scale a BUY's proposed size by the strategy's own `signal_confidence` #: (issue #85, Decision 1 of #: `docs/superpowers/specs/2026-08-24-analyst-factors-gap-design.md`). #: `False` by default, the same "ships gated off" pattern as #: `confidence_ordering_enabled`/`eviction_enabled` above: an unedited #: config proposes the full `cap` for every approved BUY, exactly as #: before this field existed. The design doc's own sequencing note says #: to land and simulate Decision 2 (`confidence_ordering_enabled`, issue #: #86) alone first, so a simulated performance change is attributable #: to one mechanism — #86 merged 2026-08-30 (`a1edfb2`), so this #: decision is no longer blocked on it. confidence_sizing_enabled: bool = Field(default=False) #: The size fraction at `confidence=0`; `confidence=1` always sizes at #: the unmodified `cap`. `Decimal`, not `float`: this multiplies `cap` #: (money) directly in `_fund`'s sizing formula, so it follows the same #: "money is Decimal end to end" rule as `max_position_pct` above, not #: `eviction_margin_confidence`'s `float`, which only ever compares #: against another confidence and never touches money. confidence_sizing_floor: Decimal = Field(default=Decimal("0.5"), ge=0, le=1) #: Scale a BUY's proposed size down for a high-volatility name, using #: `atr_pct_of_price` — already computed by `build_technical_summary` #: for the prompt (`llm/prompt.py:213-233`) and reused here, never #: recomputed — as the source (issue #88, Decision 4 of #: `docs/superpowers/specs/2026-08-24-analyst-factors-gap-design.md`). #: `False` by default, the same "ships gated off" pattern as #: `confidence_sizing_enabled` above: an unedited config proposes the #: full `cap` (times the confidence factor, if that is separately #: enabled) for every approved BUY, exactly as before this field #: existed. Composes multiplicatively with `confidence_sizing_enabled` #: when both are on — two independent factors on the same `cap`, per #: the design doc's formula. volatility_sizing_enabled: bool = Field(default=False) #: The reference volatility (as `atr_pct_of_price`'s own percentage #: scale, e.g. `3.0` means 3%) a name is sized at 100% of `cap`; #: anything more volatile is sized down proportionally, #: `min(1.0, target / atr_pct_of_price)` — deliberately shrink-only, #: never inflating a calm name above `max_position_pct`. `Decimal`, not #: `float`, for the same "money is Decimal end to end" reason as #: `confidence_sizing_floor`: this multiplies `cap` (money) directly in #: `_fund`'s sizing formula. `gt=0`: a zero or negative reference would #: make every name divide-by-zero or size-down-to-nothing, which is not #: what "reference volatility" means. volatility_sizing_target_atr_pct: Decimal = Field(default=Decimal("3.0"), gt=0)
[docs] def load_pipeline_config(path: Path) -> PipelineConfig: """Read and validate `pipeline.yaml`. Raises: ConfigError: the file is missing, unreadable, malformed, or invalid. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read pipeline config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return PipelineConfig.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid pipeline config in {path}:\n{exc}") from exc
[docs] class DaemonConfig(BaseModel): """Cadence and resilience settings for `trader run` (requirements §5). Separate from `PipelineConfig` on purpose: that one is scoped to entry, protection, and guardrails — *what* a cycle does. These control *when* a cycle happens and how the process survives one failing, which is a different concern with a different blast radius. Every bound is here to prevent a pathological daemon rather than a wrong number. `ge=1` on the interval is what stops a busy loop hammering a rate-limited broker; the retry ceiling stops one wedged read from consuming an entire cycle's budget in sleeps. """ model_config = ConfigDict(extra="forbid") cycle_interval_minutes: int = Field(default=15, ge=1) backoff_base_seconds: int = Field(default=30, ge=1) backoff_max_seconds: int = Field(default=900, ge=1) # Reads only. No write is ever retried — see `resilience/retrying.py`. retry_attempts: int = Field(default=3, ge=1, le=10) retry_base_seconds: int = Field(default=2, ge=1) # A cap on any single sleep, so a three-day weekend does not become a # three-day sleep: SIGTERM still lands promptly, and the daemon re-reads # the clock in case the schedule changed underneath it. closed_poll_max_minutes: int = Field(default=60, ge=1) @model_validator(mode="after") def _cap_must_not_be_below_base(self) -> "DaemonConfig": """Reject a cap below the base delay. No per-field bound can express this, and the failure it prevents is quiet: `min(base * 2**n, cap)` with a cap below the base yields the cap every single time, so the delay never grows. Backoff that does not back off looks like backoff in the logs. """ if self.backoff_max_seconds < self.backoff_base_seconds: raise ValueError( f"backoff_max_seconds ({self.backoff_max_seconds}) is below " f"backoff_base_seconds ({self.backoff_base_seconds}): the delay " "would never grow under repeated failure." ) return self
[docs] def load_daemon_config(path: Path) -> DaemonConfig: """Read and validate `daemon.yaml`. Raises: ConfigError: the file is missing, unreadable, malformed, or invalid. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read daemon config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return DaemonConfig.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid daemon config in {path}:\n{exc}") from exc
[docs] class ReportingConfig(BaseModel): """Benchmark settings for performance reporting (requirements §12.1, issue #19). Not the same feature as the backtest's own buy-and-hold baseline (`trader.backtest.metrics.buy_and_hold`, MR !41): that one compares one strategy's simulated bars against the SAME symbol it traded. This is a whole-account question — did the real account, over its real `account_snapshots` history, beat a market index — so it lives in its own file rather than folded into `PipelineConfig`, which is scoped to the live entry/protection/guardrail path and has nothing to do with reporting. """ model_config = ConfigDict(extra="forbid") #: Plural and a list, not a single hardcoded field: the requirement is #: "QQQ and VOO by default, config accepts either or another ticker — not #: hardcoded to just those two". A list default of both, freely editable #: to any tickers (including a single one, or none), satisfies that #: without an enum or a `Literal["QQQ", "VOO"]` anywhere in the type. benchmark_tickers: list[str] = Field(default_factory=lambda: ["QQQ", "VOO"]) #: Optional (issue #57). When set, every report command #: (`trader performance`, `trader outcomes`, `trader sim-report`, #: `tools/roi_investigation.py`, `trader web`'s `/pnl` page) defaults its #: start-of-window to this date instead of full history, unless an #: explicit `--start` is given (always wins) or `--since-creation` is #: passed (opts back into full history). `None` — the field's own default, #: and what an absent file resolves to — means "no change from today's #: behaviour": every command still shows full history by default. This is #: deployment-specific config, not a code constant: it names the date a #: PARTICULAR deployment's history became trustworthy (see #: `docs/operational-timeline.md`), not a fact the code should assume. #: See `trader.reporting.window.resolve_report_start` for exactly how #: this interacts with `--start`/`--since-creation`. default_report_start_date: date | None = None @field_validator("benchmark_tickers") @classmethod def _normalize(cls, tickers: list[str]) -> list[str]: """Uppercase and de-duplicate while preserving order — same rule as `WatchlistConfig.fixed_tickers`, so `benchmark_tickers: [qqq, QQQ]` does not silently fetch and report the same ticker twice.""" seen: set[str] = set() result: list[str] = [] for ticker in tickers: symbol = ticker.strip().upper() if symbol and symbol not in seen: seen.add(symbol) result.append(symbol) return result
[docs] def load_reporting_config(path: Path) -> ReportingConfig: """Read and validate `reporting.yaml`. Raises: ConfigError: the file exists but is unreadable, malformed, or invalid. A *missing* file is not an error — see `build_reporting_config` in the CLI, which supplies `ReportingConfig()`'s defaults instead, the same "absence is fine, wrongness is not" rule `daemon.yaml` uses. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read reporting config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return ReportingConfig.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid reporting config in {path}:\n{exc}") from exc
[docs] class IdeasScraperConfig(BaseModel): """`ollama_feature_scraping` settings (issue #75) — a CLI capability, not a trading strategy: `trader scrape-ideas` fetches one web page and asks a local model what this codebase could build from it. Deliberately its own model selection, not the `llm` strategy's `config/strategies.yaml` params: this runs off the 15-minute trading cycle clock entirely, so it can use a larger/slower model without any latency pressure, and must not silently move if `ollama_news`'s model is retuned. """ model_config = ConfigDict(extra="forbid") #: Where synthesized ideas markdown files are written. Not `data/` — this #: is a reviewable, committable backlog, the same reasoning #: `docs/external_ideas/` was chosen for in the issue's own prototype. output_dir: str = "docs/external_ideas" #: Measured live 2026-08-23 against a full Wikipedia article, three #: models tried: `qwen3.8:27b` (issue #75's own prototype model) ran #: ~8.4 tokens/sec, close enough to `llm_timeout_seconds` below to risk #: a timeout on a page longer than the prototype's blog post; #: `llama3.1:latest` was fast (~17s) but generated only ~24 tokens under #: the JSON-schema-constrained call before stopping itself — a heading, #: then nothing, deterministically (temperature=0, fixed seed), so not a #: fluke. `gpt-oss:20b` was the sweet spot: ~2 minutes, complete, #: well-grounded output. This command has no latency budget to respect, #: unlike `ollama_news`'s per-cycle prompt, so slower-but-correct beats #: `llama3.1`'s speed. model: str = "gpt-oss:20b" #: Wider than `OllamaProvider.DEFAULT_NUM_CTX` (4096): a scraped page's #: extracted text is routinely larger than a trading prompt's news corpus. #: 8192 truncated `gpt-oss:20b` mid-response on the Wikipedia page above #: (prompt alone was 7,535 tokens) — Ollama truncates silently on #: overflow rather than raising, the same failure mode #: `OllamaProvider.DEFAULT_NUM_CTX`'s own comment describes for the #: trading path. 16384 completed with total usage of 9,060 tokens (55% #: of the window): comfortable headroom, not maxed out on a guess. num_ctx: int = 16384 #: Page fetch, not model inference — the two are unrelated clocks. A slow #: server should fail fast; a slow model should not. fetch_timeout_seconds: int = 30 #: Generous on purpose: this command runs on demand, not on a schedule #: nothing else is waiting on. llm_timeout_seconds: int = 300 #: Bounds how much extracted page text reaches the prompt, independent of #: `num_ctx` above (a token budget, not a character one) — this is the #: cheap, static tripwire against an unexpectedly huge page. max_page_chars: int = 20000
[docs] def load_ideas_scraper_config(path: Path) -> IdeasScraperConfig: """Read and validate `ideas_scraper.yaml`. Raises: ConfigError: the file exists but is unreadable, malformed, or invalid. A *missing* file is not an error — see `build_ideas_scraper_config` in the CLI, which supplies `IdeasScraperConfig()`'s defaults instead, the same "absence is fine, wrongness is not" rule `reporting.yaml` uses. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read ideas-scraper config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return IdeasScraperConfig.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid ideas-scraper config in {path}:\n{exc}") from exc
[docs] class SimulationSettings(BaseModel): """The shadow-portfolio simulator's own inputs (issue #33, Slice 3e Part 4). Not folded into `PipelineConfig`: that one is scoped to the live entry/protection/guardrail path — money that can actually leave the account. This is a forward-only measurement instrument that never reaches a broker, so it earns its own file the same way `ReportingConfig` did for the identical reason (a whole-account question unrelated to what a cycle does with real money). """ model_config = ConfigDict(extra="forbid") #: The simulated portfolio's starting cash, one per configured strategy. #: `Decimal`, not float — this ledger's own money math is real `Decimal` #: arithmetic even though nothing here ever reaches a broker. sim_starting_cash: Decimal = Field(default=Decimal("100000"), gt=0) #: How wide a step is. `bar_width()` is the single source of which #: strings are valid ("<digits><m|h|d|wk>") — validated here so an #: unsupported value is refused at config-load time, not guessed at #: first simulator run. Defaulting to `1d` and not, say, `15m`: intraday #: bars are a separate, unbuilt piece of this project (Slice 3e Part 3), #: and the whole point of this setting is that widening it later is a #: config change, not a rewrite of the step logic. sim_interval: str = Field(default="1d") @field_validator("sim_interval") @classmethod def _validate_interval(cls, value: str) -> str: # Import kept local to the validator: `persistence/bars.py` has no # other reason to be a config-module dependency, and `bar_width` is a # pure function (regex plus a `timedelta`, no I/O, no SQLAlchemy) — # safe to borrow as the one source of truth for "which interval # strings are real" rather than duplicating its pattern here. from trader.persistence.bars import bar_width try: bar_width(value) except ValueError as exc: raise ValueError(str(exc)) from exc return value
[docs] def load_simulation_config(path: Path) -> SimulationSettings: """Read and validate `simulation.yaml`. Raises: ConfigError: the file exists but is unreadable, malformed, or invalid. A *missing* file is not an error — see `build_simulation_settings` in the CLI, which supplies `SimulationSettings()`'s defaults instead, the same "absence is fine, wrongness is not" rule `reporting.yaml` uses. """ try: raw_text = path.read_text() except OSError as exc: raise ConfigError(f"Cannot read simulation config at {path}: {exc}") from exc try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): raise ConfigError(f"Expected a mapping at the top level of {path}.") try: return SimulationSettings.model_validate(data) except ValidationError as exc: raise ConfigError(f"Invalid simulation config in {path}:\n{exc}") from exc