SPB Git

spb/wp27_uqo

Public
8.8 KB · 209 lines python
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
Files · main
Menu

Theme