"""Daemon configuration resolution (REQ-005, REQ-010, REQ-012/013).""" from __future__ import annotations import os from dataclasses import dataclass from typing import Optional def _load_dotenv() -> None: """Best-effort load of a .env file into os.environ (REQ-010). Real OS environment variables take precedence over .env (override=False), matching 12-factor expectations. If python-dotenv is not installed or no .env exists, this is a no-op (no regression). """ try: from dotenv import find_dotenv, load_dotenv except ImportError: # pragma: no cover - optional at runtime return 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() @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 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 @dataclass(frozen=True) class DaemonConfig: dsn: str total_slots: int broker: str # 'paper' | 'kis' execution_interval_sec: float 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() # REQ-041 (BAT-010): safety-sell feature config. safety_sell: SafetySellConfig = SafetySellConfig() @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() ), ) 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")), ) _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, ) return DaemonConfig( dsn=dsn, total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")), 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, safety_sell=safety_sell, )