"""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