"""Daemon configuration resolution (REQ-005, REQ-010, REQ-012/013).""" from __future__ import annotations import os from dataclasses import dataclass, replace from typing import Optional DEFAULT_CONFIG_DIR = "config" def resolve_config_toml(name: str, config_path: Optional[str] = None) -> str: """Resolve `--config ` within a config directory (REQ-048 CHG-20260609-01). config_dir = `--config-path` if given, else `./config`. `` selects the TOML inside it; a missing `.toml` extension is appended. Backward-compat: if `name` contains a path separator (e.g. `config/test.toml`), it is treated as an explicit path and used as-is so existing ops scripts that pass a path keep working. resolve_config_toml("test") -> config/test.toml resolve_config_toml("test.toml") -> config/test.toml resolve_config_toml("prod", "/etc/trex") -> /etc/trex/prod.toml resolve_config_toml("config/test.toml") -> config/test.toml (path-form) """ if os.sep in name or (os.altsep and os.altsep in name): return name # explicit path (backward-compat) config_dir = config_path or DEFAULT_CONFIG_DIR filename = name if name.endswith(".toml") else f"{name}.toml" return os.path.join(config_dir, filename) def _load_dotenv(config_path: Optional[str] = None, env_file: Optional[str] = None) -> None: """Best-effort load of a .env file into os.environ (REQ-010 / CHG-20260609-01). Resolution (trex-orb-v1 aligned, deterministic & CWD-independent given a config_path): `env_file` (explicit) wins, else `/.env` next to the TOML. With neither (standalone `from_env()`), fall back to legacy CWD-relative `find_dotenv` so existing root `.env` setups keep working (backward-compat). Real OS env vars always take precedence (`override=False`). python-dotenv missing or the resolved file absent => no-op (no regression). """ try: from dotenv import load_dotenv except ImportError: # pragma: no cover - optional at runtime return if env_file: if os.path.isfile(env_file): load_dotenv(env_file, override=False) return if config_path: path = os.path.join(os.path.dirname(os.path.abspath(config_path)), ".env") if os.path.isfile(path): load_dotenv(path, override=False) return # No config context: legacy CWD-relative discovery (backward-compat fallback). from dotenv import find_dotenv load_dotenv(find_dotenv(usecwd=True), override=False) @dataclass(frozen=True) class KISConfig: base_url: str app_key: str app_secret: str account_ref: str timeout: float allow_live: bool redis_url: str = "" # REQ-016: optional token cache backend @dataclass(frozen=True) class PortfolioHubConfig: """portfolio-hub ledger adapter config (REQ-017).""" base_url: str token: str = "" timeout: float = 5.0 @dataclass(frozen=True) class PaperBrokerConfig: """Paper broker tuning for e2e test scenarios (REQ-011). Defaults reproduce immediate, full fills with no rejects, so an unset environment is byte-for-byte the prior `PaperBroker()` behaviour. """ fill_after_polls: int = 0 qty_per_poll: Optional[int] = None reject_symbols: frozenset[str] = frozenset() # REQ-011 CHG-20260612-01 (issue #4): optional Redis URL for a persistent # order-seq store. Empty => in-memory counter (prior behaviour). redis_url: str = "" @dataclass(frozen=True) class RabbitMQConfig: """RabbitMQ transport adapter config (REQ-022, dcy-006 OQ-6).""" url: str timeout: float = 5.0 @dataclass(frozen=True) class SnapshotScheduleConfig: """Daily snapshot refresh schedule (REQ-030).""" kst_time: str = "07:30" tick_sec: float = 60.0 @dataclass(frozen=True) class AccountSnapshotConfig: """Opt-in bounded KIS account-read recovery (dcy-041 / GitHub #45).""" enabled: bool = False buy_max_age_sec: int = 600 sell_max_age_sec: int = 3600 refresh_interval_sec: float = 300.0 def __post_init__(self) -> None: if self.buy_max_age_sec < 0: raise ValueError("account snapshot buy_max_age_sec must be >= 0") if self.sell_max_age_sec < self.buy_max_age_sec: raise ValueError( "account snapshot sell_max_age_sec must be >= buy_max_age_sec" ) if self.sell_max_age_sec > 21_600: raise ValueError("account snapshot sell_max_age_sec exceeds six hours") if self.refresh_interval_sec <= 0: raise ValueError("account snapshot refresh_interval_sec must be > 0") @dataclass(frozen=True) class SafetySellConfig: """Safety-sell feature tuning (REQ-041 / BAT-010). All five env vars are parsed here even though only WATCHDOG_TIMEOUT and WATCHDOG_GRACE_PERIOD are consumed in BAT-010; the remaining three are used by BAT-011 trigger loops once they land. """ watchdog_timeout_sec: int = 120 watchdog_grace_period_sec: int = 60 forced_close_check_interval_sec: int = 60 stop_loss_poll_interval_sec: int = 30 market_close_liquidation_time: Optional[str] = None # "HH:MM" KST, optional # BAT-014 / REQ-053: persistent-rejection retry cooldown (dcy-010). min_retry_interval_sec: int = 600 # per-slot cooldown between sell retries max_retry_warn_count: int = 3 # WARNING after this many consecutive retries @dataclass(frozen=True) class DaemonInstanceConfig: """Per-instance TOML settings (REQ-048 / BAT-013). `instance_key` and `portfolio_hub_token` live in a TOML file (not .env) so they are not committed and so multiple instances can run distinct queues. Loaded via `--config .toml`. """ instance_key: str portfolio_hub_token: str = "" # dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). trading_timezone: str = "Asia/Seoul" # dcy-020 / REQ-060: max concurrent active (holding) slots, M — the FCFS # admission capacity cap. BAT-030: asset-centric is the only model; absent # means no cap (operators are expected to set it in production). max_active_slots: Optional[int] = None # dcy-022 / REQ-068: default order timeout in seconds (opt-in per instance). # None = OFF = indefinite wait (legacy behavior). Per-intent timeout_sec overrides. default_order_timeout_sec: Optional[int] = None # dcy-024 / REQ-048 CHG-20260611-01: disable autonomous safety-sell worker. # Absent = True (safe default). False → trex-safety-sell thread not started. safety_sell_enabled: bool = True # dcy-027 / REQ-076: per-instance watchdog timeout (seconds). None = fall # back to env TREX_WATCHDOG_TIMEOUT (default 120). Long-term instances # polling every 1-10 min should set >= 1800 so expired stays meaningful. watchdog_timeout_sec: Optional[int] = None # dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default) # = env-only KIS config (byte-for-byte backward compat). True = fetch 1st, # TREX_KIS_BROKER_BREAK_GLASS=true = skip fetch and use env. credential_fetch_enabled: bool = False @staticmethod def from_toml(path: str) -> "DaemonInstanceConfig": import tomllib with open(path, "rb") as fh: data = tomllib.load(fh) instance_key = data.get("instance_key") if not instance_key: raise RuntimeError( f"instance TOML missing required 'instance_key': {path}" ) raw_max = data.get("max_active_slots") max_active = int(raw_max) if raw_max is not None else None if max_active is not None and max_active <= 0: raise RuntimeError( f"instance TOML 'max_active_slots' must be > 0, got {raw_max}: {path}" ) raw_timeout = data.get("default_order_timeout_sec") default_timeout = int(raw_timeout) if raw_timeout is not None else None if default_timeout is not None and default_timeout <= 0: raise RuntimeError( f"instance TOML 'default_order_timeout_sec' must be > 0, got {raw_timeout}: {path}" ) raw_wd = data.get("watchdog_timeout_sec") watchdog_timeout = int(raw_wd) if raw_wd is not None else None if watchdog_timeout is not None and watchdog_timeout <= 0: raise RuntimeError( f"instance TOML 'watchdog_timeout_sec' must be > 0, got {raw_wd}: {path}" ) return DaemonInstanceConfig( instance_key=str(instance_key), portfolio_hub_token=str(data.get("portfolio_hub_token") or ""), trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"), max_active_slots=max_active, default_order_timeout_sec=default_timeout, safety_sell_enabled=bool(data.get("safety_sell_enabled", True)), watchdog_timeout_sec=watchdog_timeout, credential_fetch_enabled=bool(data.get("credential_fetch_enabled", False)), ) @dataclass(frozen=True) class DaemonConfig: dsn: str broker: str # 'paper' | 'kis' execution_interval_sec: float # BAT-013 / REQ-049: instance identity for multi-instance queue naming. # "default" keeps legacy literal queue names (backward-compat). instance_key: str = "default" # dcy-020 / REQ-060: M (max concurrent active slots) — FCFS admission cap. # BAT-030: asset-centric is the only model; None = no cap. max_active_slots: Optional[int] = None kis: Optional[KISConfig] = None # REQ-011: paper broker tuning (only consulted when broker == 'paper'). paper: PaperBrokerConfig = PaperBrokerConfig() # REQ-017/019: ledger writer config (None => in-memory stub ledger, # appropriate for slice-1 paper-broker integration tests). portfolio_hub: Optional[PortfolioHubConfig] = None ledger_interval_sec: float = 1.0 # REQ-022 (BAT-007): RabbitMQ transport config. Required by the new # sync+thread daemon — HTTP API has been removed (REQ-025). rabbitmq: Optional[RabbitMQConfig] = None # REQ-030 (BAT-008): daily snapshot refresh. snapshot_schedule: SnapshotScheduleConfig = SnapshotScheduleConfig() # dcy-041 / GitHub #45: opt-in account read snapshot. Disabled keeps the # previous live-only balance path byte-for-byte compatible. account_snapshot: AccountSnapshotConfig = AccountSnapshotConfig() # REQ-041 (BAT-010): safety-sell feature config. safety_sell: SafetySellConfig = SafetySellConfig() # dcy-013 / REQ-014 (BAT-017): Alert-only pending-order stale watchdog # threshold in seconds. 0 disables (default). When >0, dispatched-pending # slots older than this are surfaced (WARN + counter) without state change. pending_stale_threshold_sec: int = 0 # dcy-022 / REQ-068: default order timeout (seconds). None = OFF = indefinite. # Per-intent timeout_sec overrides. Passed to IntentService at wiring time. default_order_timeout_sec: Optional[int] = None # dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The # daily preallocation trigger was removed with the pool model (BAT-030). trading_timezone: str = "Asia/Seoul" # dcy-024 / REQ-005 CHG-20260611-01: disable autonomous safety-sell worker. # False → trex-safety-sell thread not started in daemon.start(). safety_sell_enabled: bool = True # dcy-015 / REQ-019 (BAT-019): ledger outbox retry backoff. base*2^(n-1) # capped at cap (+jitter); after max_attempts a retryable row dead-letters. ledger_retry_base_sec: float = 1.0 ledger_retry_cap_sec: float = 300.0 ledger_max_attempts: int = 8 # dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default) # = env-only KIS config (byte-for-byte backward compat). True = fetch 1st, # TREX_KIS_BROKER_BREAK_GLASS=true env = skip fetch and use env instead. credential_fetch_enabled: bool = False @staticmethod def from_env() -> "DaemonConfig": _load_dotenv() dsn = os.environ.get("TREX_POSTGRESQL_DSN") if not dsn: raise RuntimeError( "TREX_POSTGRESQL_DSN is required to start the slot-core daemon." ) broker = os.environ.get("TREX_BROKER", "paper").strip().lower() kis: Optional[KISConfig] = None if broker == "kis": kis = KISConfig( base_url=os.environ["TREX_KIS_BROKER_BASE_URL"], app_key=os.environ["TREX_KIS_BROKER_APP_KEY"], app_secret=os.environ["TREX_KIS_BROKER_APP_SECRET"], account_ref=os.environ["TREX_KIS_BROKER_ACCOUNT_REF"], timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "5")), allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"), redis_url=os.environ.get("TREX_KIS_REDIS_URL", ""), ) qpp_raw = os.environ.get("TREX_PAPER_QTY_PER_POLL", "").strip() reject_raw = os.environ.get("TREX_PAPER_REJECT_SYMBOLS", "") paper = PaperBrokerConfig( fill_after_polls=int(os.environ.get("TREX_PAPER_FILL_AFTER_POLLS", "0")), qty_per_poll=int(qpp_raw) if qpp_raw else None, reject_symbols=frozenset( s.strip() for s in reject_raw.split(",") if s.strip() ), redis_url=os.environ.get("TREX_PAPER_REDIS_URL", "").strip(), ) portfolio_hub: Optional[PortfolioHubConfig] = None pf_base = os.environ.get("TREX_PORTFOLIO_HUB_BASE_URL", "").strip() if pf_base: portfolio_hub = PortfolioHubConfig( base_url=pf_base, token=os.environ.get("TREX_PORTFOLIO_HUB_TOKEN", "").strip(), timeout=float(os.environ.get("TREX_PORTFOLIO_HUB_TIMEOUT", "5")), ) rabbitmq: Optional[RabbitMQConfig] = None mq_url = os.environ.get("TREX_RABBITMQ_URL", "").strip() if mq_url: rabbitmq = RabbitMQConfig( url=mq_url, timeout=float(os.environ.get("TREX_RABBITMQ_TIMEOUT", "5")), ) snapshot_schedule = SnapshotScheduleConfig( kst_time=os.environ.get("TREX_SNAPSHOT_SCHEDULE_KST", "07:30").strip() or "07:30", tick_sec=float(os.environ.get("TREX_SNAPSHOT_TICK_SEC", "60")), ) account_snapshot = AccountSnapshotConfig( enabled=os.environ.get( "TREX_ACCOUNT_SNAPSHOT_ENABLED", "" ).lower() in ("1", "true", "yes"), buy_max_age_sec=int( os.environ.get("TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC", "600") ), sell_max_age_sec=int( os.environ.get("TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC", "3600") ), refresh_interval_sec=float( os.environ.get("TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC", "300") ), ) _mc_time = os.environ.get("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "").strip() or None safety_sell = SafetySellConfig( watchdog_timeout_sec=int(os.environ.get("TREX_WATCHDOG_TIMEOUT", "120")), watchdog_grace_period_sec=int(os.environ.get("TREX_WATCHDOG_GRACE_PERIOD", "60")), forced_close_check_interval_sec=int( os.environ.get("TREX_FORCED_CLOSE_CHECK_INTERVAL", "60") ), stop_loss_poll_interval_sec=int( os.environ.get("TREX_STOP_LOSS_POLL_INTERVAL", "30") ), market_close_liquidation_time=_mc_time, min_retry_interval_sec=int( os.environ.get("TREX_SAFETY_SELL_MIN_RETRY_INTERVAL", "600") ), max_retry_warn_count=int( os.environ.get("TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT", "3") ), ) return DaemonConfig( dsn=dsn, broker=broker, execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")), kis=kis, paper=paper, portfolio_hub=portfolio_hub, ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")), rabbitmq=rabbitmq, snapshot_schedule=snapshot_schedule, account_snapshot=account_snapshot, safety_sell=safety_sell, pending_stale_threshold_sec=int( os.environ.get("TREX_PENDING_STALE_THRESHOLD", "0") ), ledger_retry_base_sec=float( os.environ.get("TREX_LEDGER_RETRY_BASE_SEC", "1.0") ), ledger_retry_cap_sec=float( os.environ.get("TREX_LEDGER_RETRY_CAP_SEC", "300.0") ), ledger_max_attempts=int(os.environ.get("TREX_LEDGER_MAX_ATTEMPTS", "8")), ) @staticmethod def from_config(toml_path: str, env_file: Optional[str] = None) -> "DaemonConfig": """Build config from env + a per-instance TOML file (REQ-048 / BAT-013). `instance_key` and `portfolio_hub_token` come from TOML (not .env). TOML `portfolio_hub_token` overrides the (env-derived) PortfolioHubConfig token when a portfolio-hub base_url is configured. REQ-010 CHG-20260609-01: the config-dir `.env` (next to the TOML, or `--env-file`) is loaded first so it wins over the legacy root `.env` fallback loaded inside `from_env()` (both `override=False`). """ _load_dotenv(toml_path, env_file) base = DaemonConfig.from_env() inst = DaemonInstanceConfig.from_toml(toml_path) pf = base.portfolio_hub if inst.portfolio_hub_token and pf is not None: pf = replace(pf, token=inst.portfolio_hub_token) # dcy-027 / REQ-076: per-instance watchdog timeout. TOML wins over the # env-derived value; absent keeps env TREX_WATCHDOG_TIMEOUT (default 120) # so existing instances are byte-for-byte unaffected. safety_sell = base.safety_sell if inst.watchdog_timeout_sec is not None: safety_sell = replace( safety_sell, watchdog_timeout_sec=inst.watchdog_timeout_sec ) return replace( base, instance_key=inst.instance_key, portfolio_hub=pf, trading_timezone=inst.trading_timezone, max_active_slots=inst.max_active_slots, safety_sell_enabled=inst.safety_sell_enabled, safety_sell=safety_sell, credential_fetch_enabled=inst.credential_fetch_enabled, )