1"""Configuration schema (pydantic) for one simulation run. Loaded from YAML; hashed into manifests."""2from __future__ import annotations34import hashlib5import json6from pathlib import Path7from typing import Any89import yaml10from pydantic import BaseModel, Field, model_validator111213class AssetsCfg(BaseModel):14 n_assets: int = 115 tick_cents: int = 116 initial_price_cents: int = 20_000 # 200.00 per lot; chosen to equal mu/r (= 50/0.0025) by default17 price_band_pct: float = 0.518 max_order_qty: int = 10_000192021class FundamentalsCfg(BaseModel):22 div_interval: int = 2023 r_period: float = 0.002524 mu: list[float] = [50.0]25 mu_shift: float = 0.026 p_switch: float = 0.027 rho: float = 0.9828 sigma: float = 2.029 jump_prob: float = 0.030 jump_sigma: float = 0.031 factor_loading: list[float] = [0.0]32 x0: float = 0.0333435class NewsCfg(BaseModel):36 rate: float = 0.0237 noise_sd: float = 1.038 misleading_share: float = 0.039 misleading_bias: float = 10.040 window: int = 20414243class ExchangeCfg(BaseModel):44 maker_fee_bps: float = 0.045 taker_fee_bps: float = 2.046 min_latency: float = 0.0547 halt_threshold: float | None = None48 halt_window: int = 1049 halt_duration: int = 2050 halt_cancel_resting: bool = True51 allow_short: bool = False52 max_short_lots: int = 053 margin_check: bool = True54 liquidation_fraction: float = 0.555 order_ttl: int | None = 5056 position_limit: int | None = None # max |position| in lots per agent (safeguard treatment)57 call_auction_interval: int | None = None # if set, batch orders and clear at a uniform price every k ticks (robustness)585960class EndowmentCfg(BaseModel):61 cash_cents: int = 2_000_000 # 20,000.0062 shares: int = 10063 wealth_dispersion: float = 0.3 # lognormal sd of the wealth multiplier across traders646566class LatencyCfg(BaseModel):67 min: float = 0.0568 max: float = 1.069 llm_latency: float | None = None # matched-latency design: LLM agents get this latency (None = drawn like others)707172class LLMCfg(BaseModel):73 provider_mix: dict[str, float] = {"mock": 1.0} # provider -> share of LLM slots74 models: dict[str, str] = {"mock": "mock-v1", "openai": "gpt-5.4-nano", "anthropic": "claude-haiku-5-5"}75 prompt_set: str = "single" # 'single' | 'diverse'76 prompt_version: str = "v1"77 memory: int = 0 # number of past own-decision summaries shown (0 = none)78 channel: bool = False # cohort communication channel79 cohort_size: int = 580 decision_interval: int = 2081 stagger: bool = True # spread decision offsets across the interval82 stagger_slots: int = 4 # number of distinct offsets (batches of agents decide together -> parallel API calls)83 informed_share: float | None = None # None: an LLM agent inherits the information set of the slot it replaces (default, keeps84 # information fixed within matched pairs); a number overrides it (information-equalization arm)85 precision: float = 2.086 max_order_qty: int = 5087 temperature: float | None = None88 reasoning_effort: str | None = "low" # openai gpt-5 family; anthropic 'effort'89 max_output_tokens: int = 120090 concurrency: int = 891 timeout_s: float = 60.092 max_retries: int = 393 usd_cap: float = 5.0 # hard cap for this run (USD)94 token_cap: int | None = None95 decision_cap: int | None = None # max decision opportunities per agent (fairness design)96 cache: bool = True # reuse identical (provider, model, prompt) decisions97 frozen_library: str | None = None # path to a recorded decision library for offline replay98 adaptive_prompt: bool = False # include P&L feedback line in the prompt99 price_assumptions: dict[str, dict[str, float]] = {100 # USD per 1M tokens: input, cached input, output — see docs/model_cards.md for sources and dates101 "gpt-5.4-nano": {"input": 0.20, "cached": 0.02, "output": 1.25},102 "gpt-5.4-mini": {"input": 0.75, "cached": 0.075, "output": 4.50},103 "gpt-5-nano": {"input": 0.05, "cached": 0.005, "output": 0.40},104 "gpt-5-mini": {"input": 0.25, "cached": 0.025, "output": 2.00},105 "gpt-4.1-nano": {"input": 0.10, "cached": 0.025, "output": 0.40},106 "gpt-4.1-mini": {"input": 0.40, "cached": 0.10, "output": 1.60},107 "claude-haiku-5-5": {"input": 0.10, "cached": 0.01, "output": 0.50},108 "claude-sonnet-5-5": {"input": 2.00, "cached": 0.20, "output": 10.00},109 "mock-v1": {"input": 0.0, "cached": 0.0, "output": 0.0},110 }111112113class PopulationCfg(BaseModel):114 n_traders: int = 100115 mix: dict[str, float] = {"fundamentalist": 0.25, "trend": 0.15, "meanrev": 0.10, "noise": 0.35,116 "market_maker": 0.05, "statarb": 0.0, "leveraged": 0.10}117 activity: dict[str, float] = {"fundamentalist": 0.20, "trend": 0.20, "meanrev": 0.20, "noise": 0.30,118 "market_maker": 0.5, "statarb": 0.2, "leveraged": 0.2}119 policy_kwargs: dict[str, dict[str, Any]] = {}120 llm_share: float = 0.0 # fraction of trader *slots* controlled by LLM policies121 llm_replace: str = "proportional" # 'proportional' (stratified across types) or a policy name122 llm_keep_market_makers: bool = True # never replace market makers unless llm_replace == 'market_maker'123 informed_share: float = 0.3 # share of fundamentalists receiving private signals124 precision: float = 2.0125 leverage_cap: float = 3.0 # for 'leveraged' slots126 maintenance_margin: float = 0.25127 endowment: EndowmentCfg = EndowmentCfg()128 latency: LatencyCfg = LatencyCfg()129 llm: LLMCfg = LLMCfg()130131 @model_validator(mode="after")132 def _check(self) -> "PopulationCfg":133 s = sum(self.mix.values())134 if abs(s - 1.0) > 1e-6:135 raise ValueError(f"population.mix must sum to 1 (got {s})")136 if not 0.0 <= self.llm_share <= 1.0:137 raise ValueError("llm_share must be in [0,1]")138 return self139140141class ShockCfg(BaseModel):142 time: int143 kind: str # fundamental_jump | rate | funding | liquidity | placebo | news_burst144 size: float = 0.0145 asset: int | None = None146 duration: int = 0147148149class OutputCfg(BaseModel):150 events: bool = False # write the full event log (large)151 trades: bool = True152 llm_log: bool = True153 lookback: int = 60 # ticks of mid history visible to agents154155156class RunConfig(BaseModel):157 name: str = "run"158 seed: int = 1159 n_ticks: int = 500160 warmup: int = 50 # ticks excluded from outcome metrics161 assets: AssetsCfg = AssetsCfg()162 fundamentals: FundamentalsCfg = FundamentalsCfg()163 news: NewsCfg = NewsCfg()164 exchange: ExchangeCfg = ExchangeCfg()165 population: PopulationCfg = PopulationCfg()166 shocks: list[ShockCfg] = Field(default_factory=list)167 output: OutputCfg = OutputCfg()168 treatment: dict[str, Any] = Field(default_factory=dict) # free-form labels copied to the manifest169 notes: str = ""170171 # ---- helpers ---------------------------------------------------------------------------172 def config_hash(self) -> str:173 payload = json.dumps(self.model_dump(mode="json"), sort_keys=True).encode()174 return hashlib.sha256(payload).hexdigest()[:16]175176 @classmethod177 def from_yaml(cls, path: str | Path, **overrides: Any) -> "RunConfig":178 with open(path) as f:179 raw = yaml.safe_load(f) or {}180 raw = deep_update(raw, overrides)181 return cls.model_validate(raw)182183 def with_overrides(self, **overrides: Any) -> "RunConfig":184 raw = deep_update(self.model_dump(mode="json"), overrides)185 return RunConfig.model_validate(raw)186187 def to_yaml(self, path: str | Path) -> None:188 with open(path, "w") as f:189 yaml.safe_dump(self.model_dump(mode="json"), f, sort_keys=False)190191192SECTIONS = {"assets", "fundamentals", "news", "exchange", "population", "llm", "endowment", "latency", "output", "treatment"}193194195def deep_update(base: dict, upd: dict) -> dict:196 """Recursive override. Config *sections* (nested models) are merged key by key; *leaf* dictionaries197 (provider_mix, mix, activity, models, policy_kwargs, price_assumptions, ...) are REPLACED wholesale, so198 `population.llm.provider_mix: {openai: 1.0}` yields exactly {openai: 1.0} and not a mock/openai mixture."""199 out = dict(base)200 for k, v in upd.items():201 if "." in k: # dotted override: population.llm_share=0.25202 head, rest = k.split(".", 1)203 out[head] = deep_update(out.get(head, {}) or {}, {rest: v})204 elif isinstance(v, dict) and isinstance(out.get(k), dict) and k in SECTIONS:205 out[k] = deep_update(out[k], v)206 else:207 out[k] = v208 return out209