"""Configuration: pydantic models over local TOML files + .env secrets. Replaces the v1 Google Sheets config entirely. Three files under config/: config.toml engine/wallet/risk/execution settings strategy.toml named parameter profiles markets.toml the trade list (market -> profile + overrides) Secrets (private key, wallet address) come only from the environment / .env. """ from __future__ import annotations import os import tomllib from pathlib import Path from typing import Any from dotenv import load_dotenv from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class WalletConfig(BaseModel): chain_id: int = 137 signature_type: int = 2 clob_host: str = "https://clob.polymarket.com" gamma_host: str = "https://gamma-api.polymarket.com" data_api_host: str = "https://data-api.polymarket.com" polygon_rpc: str = "https://polygon-bor-rpc.publicnode.com" class EngineConfig(BaseModel): debounce_ms: int = 200 reconcile_interval_s: float = 30.0 catalog_refresh_s: float = 900.0 heartbeat: bool = True heartbeat_interval_s: float = 5.0 journal: bool = True loop: str = "uvloop" class RiskConfig(BaseModel): max_total_exposure_usdc: float = 5000.0 max_event_group_loss_usdc: float = 1000.0 max_market_notional_usdc: float = 800.0 daily_loss_kill_usdc: float = 250.0 ws_stale_halt_s: float = 10.0 # user WS down this long -> we can't see our fills -> pull all quotes user_ws_blind_halt_s: float = 15.0 # consecutive heartbeat failures -> exchange is auto-cancelling us -> halt heartbeat_halt_failures: int = 3 max_order_error_rate: float = 0.25 class ExecutionConfig(BaseModel): rate_budget_fraction: float = 0.25 post_only: bool = True max_orders_per_batch: int = 15 class PathsConfig(BaseModel): db: str = "state.db" journal_dir: str = "journal" log_dir: str = "logs" class StrategyProfile(BaseModel): """One named parameter set. Every knob the quoter uses lives here.""" model_config = ConfigDict(extra="forbid") # fair value micro_levels: int = 3 flow_ewma_halflife_s: float = 120.0 # spread / skew gamma: float = 0.5 delta_min_ticks: int = 2 c_vol: float = 1.2 c_tox: float = 2.0 # vol horizons vol_short_halflife_s: float = 10.0 vol_long_halflife_s: float = 900.0 # sizing / inventory base_size_usdc: float = 50.0 q_max_usdc: float = 500.0 q_soft_frac: float = 0.6 layers: int = 2 layer_step_ticks: int = 2 # placement / churn reprice_ticks: int = 2 resize_frac: float = 0.15 min_edge_ticks: int = 1 # regime event_cooloff_s: float = 60.0 event_jump_ticks: int = 8 event_sweep_levels: int = 3 trend_flow_z: float = 1.5 # lifecycle end_date_taper_days: float = 7.0 reduce_only_hours: float = 24.0 halt_before_hours: float = 2.0 # exits exit_urgency_s: float = 900.0 merge_min_size: float = 20.0 def with_overrides(self, overrides: dict[str, Any]) -> StrategyProfile: """Return a copy with per-market override values applied.""" if not overrides: return self data = self.model_dump() for k, v in overrides.items(): if k in data: data[k] = v return StrategyProfile(**data) # Keys allowed on a market entry that are NOT profile overrides. _MARKET_RESERVED = {"slug", "condition_id", "profile", "enabled"} class MarketEntry(BaseModel): """One line of the trade list. Extra keys are treated as profile overrides.""" model_config = ConfigDict(extra="allow") slug: str | None = None condition_id: str | None = None profile: str = "political-longdated" enabled: bool = True @model_validator(mode="after") def _need_identifier(self) -> MarketEntry: if not self.slug and not self.condition_id: raise ValueError("market entry needs a slug or condition_id") return self @property def overrides(self) -> dict[str, Any]: extra = self.model_extra or {} return {k: v for k, v in extra.items() if k not in _MARKET_RESERVED} @property def ref(self) -> str: return self.slug or self.condition_id or "?" class Secrets(BaseSettings): """Loaded from environment / .env. Never written to disk by us.""" model_config = SettingsConfigDict(env_file=".env", extra="ignore") pk: str = Field(default="", alias="PK") browser_address: str = Field(default="", alias="BROWSER_ADDRESS") polygon_rpc: str | None = Field(default=None, alias="POLYGON_RPC") alert_webhook_url: str | None = Field(default=None, alias="ALERT_WEBHOOK_URL") @property def has_wallet(self) -> bool: return bool(self.pk and self.browser_address) class Config(BaseModel): """Fully-resolved configuration tree.""" wallet: WalletConfig = WalletConfig() engine: EngineConfig = EngineConfig() risk: RiskConfig = RiskConfig() execution: ExecutionConfig = ExecutionConfig() paths: PathsConfig = PathsConfig() profiles: dict[str, StrategyProfile] = {} markets: list[MarketEntry] = [] secrets: Secrets = Field(default_factory=Secrets) config_dir: Path = Path("config") @property def proxy(self) -> str | None: # Standard proxy env var; ALL_PROXY lets you route through an SSH tunnel # (e.g. simulate colocation during local testing). httpx and web3 honor # it automatically once load_dotenv() has run. return os.environ.get("ALL_PROXY") or os.environ.get("HTTPS_PROXY") @property def enabled_markets(self) -> list[MarketEntry]: return [m for m in self.markets if m.enabled] def profile_for(self, entry: MarketEntry) -> StrategyProfile: base = self.profiles.get(entry.profile) if base is None: raise KeyError(f"unknown strategy profile: {entry.profile!r}") return base.with_overrides(entry.overrides) @classmethod def load(cls, config_dir: str | Path = "config", *, load_env: bool = True) -> Config: cdir = Path(config_dir) if load_env: load_dotenv() main = _read_toml(cdir / "config.toml") strat = _read_toml(cdir / "strategy.toml") mkts = _read_toml(cdir / "markets.toml") profiles = { name: StrategyProfile(**params) for name, params in (strat.get("profiles") or {}).items() } markets = [MarketEntry(**m) for m in (mkts.get("markets") or [])] return cls( wallet=WalletConfig(**main.get("wallet", {})), engine=EngineConfig(**main.get("engine", {})), risk=RiskConfig(**main.get("risk", {})), execution=ExecutionConfig(**main.get("execution", {})), paths=PathsConfig(**main.get("paths", {})), profiles=profiles, markets=markets, secrets=Secrets(), config_dir=cdir, ) def reload_markets(self) -> Config: """Re-read markets.toml only (used by the hot-reload path).""" mkts = _read_toml(self.config_dir / "markets.toml") self.markets = [MarketEntry(**m) for m in (mkts.get("markets") or [])] return self def _read_toml(path: Path) -> dict[str, Any]: if not path.exists(): return {} with path.open("rb") as fh: return tomllib.load(fh)