trader.config.schema module

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.

class trader.config.schema.DaemonConfig(*, cycle_interval_minutes=15, backoff_base_seconds=30, backoff_max_seconds=900, retry_attempts=3, retry_base_seconds=2, closed_poll_max_minutes=60)[source]

Bases: 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.

Parameters:
  • cycle_interval_minutes (int)

  • backoff_base_seconds (int)

  • backoff_max_seconds (int)

  • retry_attempts (int)

  • retry_base_seconds (int)

  • closed_poll_max_minutes (int)

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

cycle_interval_minutes: int
backoff_base_seconds: int
backoff_max_seconds: int
retry_attempts: int
retry_base_seconds: int
closed_poll_max_minutes: int
class trader.config.schema.DiscoverySettings(*, enabled=True, themes=<factory>, max_candidates_per_theme=5, min_price=Decimal('5.00'), min_avg_volume=1000000, max_discovered_positions=3, sources=<factory>, analyst_scan_max_age_hours=24)[source]

Bases: 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.

Parameters:
  • enabled (bool)

  • themes (list[str])

  • max_candidates_per_theme (int)

  • min_price (Decimal)

  • min_avg_volume (int)

  • max_discovered_positions (int)

  • sources (list[str])

  • analyst_scan_max_age_hours (int)

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

enabled: bool
themes: list[str]

Phrases to search. Empty means discovery does nothing, which is the safe default for an unedited config — the same reasoning as mode: shadow.

max_candidates_per_theme: int
min_price: Decimal

this is compared against a share price.

Type:

Decimal, not float

min_avg_volume: int
max_discovered_positions: int

Concurrent positions in discovered symbols, separate from fixed_tickers. Ten thematic hits at max_position_pct: 10 would otherwise be the entire portfolio.

sources: list[str]
analyst_scan_max_age_hours: int

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.

class trader.config.schema.IdeasScraperConfig(*, output_dir='docs/external_ideas', model='gpt-oss:20b', num_ctx=16384, fetch_timeout_seconds=30, llm_timeout_seconds=300, max_page_chars=20000)[source]

Bases: 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.

Parameters:
  • output_dir (str)

  • model (str)

  • num_ctx (int)

  • fetch_timeout_seconds (int)

  • llm_timeout_seconds (int)

  • max_page_chars (int)

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

output_dir: str

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.

model: str

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.

num_ctx: int

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.

Type:

Wider than OllamaProvider.DEFAULT_NUM_CTX (4096)

fetch_timeout_seconds: int

Page fetch, not model inference — the two are unrelated clocks. A slow server should fail fast; a slow model should not.

llm_timeout_seconds: int

this command runs on demand, not on a schedule nothing else is waiting on.

Type:

Generous on purpose

max_page_chars: int

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.

class trader.config.schema.PipelineConfig(*, limit_buffer_bps=25, trail_percent=Decimal('8'), floor_pct=Decimal('10'), max_position_pct=Decimal('10'), max_total_exposure_pct=Decimal('80'), daily_loss_limit_pct=Decimal('2'), min_backtest_return_pct=Decimal('0'), max_backtest_drawdown_pct=Decimal('25'), evaluation_lookback_days=400, bar_refresh_days=5, cancel_entries_before_close_minutes=20, confidence_ordering_enabled=False, eviction_enabled=False, eviction_margin_confidence=0.15, confidence_sizing_enabled=False, confidence_sizing_floor=Decimal('0.5'), volatility_sizing_enabled=False, volatility_sizing_target_atr_pct=Decimal('3.0'))[source]

Bases: 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.

Parameters:
  • limit_buffer_bps (int)

  • trail_percent (Decimal)

  • floor_pct (Decimal)

  • max_position_pct (Decimal)

  • max_total_exposure_pct (Decimal)

  • daily_loss_limit_pct (Decimal)

  • min_backtest_return_pct (Decimal)

  • max_backtest_drawdown_pct (Decimal)

  • evaluation_lookback_days (int)

  • bar_refresh_days (int)

  • cancel_entries_before_close_minutes (int)

  • confidence_ordering_enabled (bool)

  • eviction_enabled (bool)

  • eviction_margin_confidence (float)

  • confidence_sizing_enabled (bool)

  • confidence_sizing_floor (Decimal)

  • volatility_sizing_enabled (bool)

  • volatility_sizing_target_atr_pct (Decimal)

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

limit_buffer_bps: int
trail_percent: Decimal
floor_pct: Decimal
max_position_pct: Decimal
max_total_exposure_pct: Decimal

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.

daily_loss_limit_pct: Decimal
min_backtest_return_pct: Decimal
max_backtest_drawdown_pct: Decimal
evaluation_lookback_days: int
bar_refresh_days: int

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.

cancel_entries_before_close_minutes: int

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.

confidence_ordering_enabled: bool

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).

eviction_enabled: bool

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_margin_confidence: float

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.

confidence_sizing_enabled: bool

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_floor: Decimal

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.

volatility_sizing_enabled: bool

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_target_atr_pct: Decimal

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.

class trader.config.schema.ReportingConfig(*, benchmark_tickers=<factory>, default_report_start_date=None)[source]

Bases: 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.

Parameters:
  • benchmark_tickers (list[str])

  • default_report_start_date (date | None)

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

benchmark_tickers: list[str]

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.

Type:

Plural and a list, not a single hardcoded field

default_report_start_date: date | None

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.

class trader.config.schema.SimulationSettings(*, sim_starting_cash=Decimal('100000'), sim_interval='1d')[source]

Bases: 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).

Parameters:
  • sim_starting_cash (Decimal)

  • sim_interval (str)

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

sim_starting_cash: Decimal

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_interval: str

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.

class trader.config.schema.StrategiesConfig(*, strategies=<factory>)[source]

Bases: BaseModel

All configured strategies (requirements §6).

Parameters:

strategies (list[StrategyConfig])

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

strategies: list[StrategyConfig]
class trader.config.schema.StrategyConfig(*, id, type, mode=StrategyMode.SHADOW, params=<factory>)[source]

Bases: BaseModel

One configured strategy instance.

Parameters:
  • id (str)

  • type (str)

  • mode (StrategyMode)

  • params (dict[str, object])

model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

id: str
type: str
mode: StrategyMode
params: dict[str, object]
class trader.config.schema.StrategyMode(*values)[source]

Bases: 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'
class trader.config.schema.WatchlistConfig(*, fixed_tickers=<factory>, discovery=<factory>, benchmark_symbol='SPY')[source]

Bases: BaseModel

The user-curated fixed list plus discovery settings.

Parameters:
model_config = {'extra': 'forbid'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

fixed_tickers: list[str]
discovery: DiscoverySettings
benchmark_symbol: str

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.

trader.config.schema.load_daemon_config(path)[source]

Read and validate daemon.yaml.

Raises:

ConfigError – the file is missing, unreadable, malformed, or invalid.

Parameters:

path (Path)

Return type:

DaemonConfig

trader.config.schema.load_ideas_scraper_config(path)[source]

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.

Parameters:

path (Path)

Return type:

IdeasScraperConfig

trader.config.schema.load_pipeline_config(path)[source]

Read and validate pipeline.yaml.

Raises:

ConfigError – the file is missing, unreadable, malformed, or invalid.

Parameters:

path (Path)

Return type:

PipelineConfig

trader.config.schema.load_reporting_config(path)[source]

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.

Parameters:

path (Path)

Return type:

ReportingConfig

trader.config.schema.load_simulation_config(path)[source]

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.

Parameters:

path (Path)

Return type:

SimulationSettings

trader.config.schema.load_strategies_config(path)[source]

Read and validate strategies.yaml.

Raises:

ConfigError – the file is missing, unreadable, malformed, or invalid.

Parameters:

path (Path)

Return type:

StrategiesConfig

trader.config.schema.load_watchlist_config(path)[source]

Read and validate watchlist.yaml.

Raises:

ConfigError – the file is missing, unreadable, malformed, or invalid.

Parameters:

path (Path)

Return type:

WatchlistConfig