"""Common broker credential resolution and lifecycle boundary (REQ-119/120).""" from __future__ import annotations import os from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta, timezone from typing import Literal, Protocol from trex_core.adapters.broker.paper import PaperBroker from trex_core.application.broker_port import BrokerPort from trex_core.application.portfolio import PortfolioHubUnavailableError from trex_core.runtime.config import DaemonConfig _KST = timezone(timedelta(hours=9)) @dataclass(frozen=True) class BrokerSource: """Non-secret credential-source metadata safe for CLI/log output.""" mode: Literal["env", "fetched", "break_glass"] identity_label: str class BrokerCredentialUnavailable(RuntimeError): _CODES = frozenset({"portfolio_hub_unavailable", "broker_absent", "trading_disabled", "malformed_credential", "broker_initialization_failed"}) def __init__(self, code: str) -> None: if code not in self._CODES: raise ValueError(f"unsupported broker credential error code: {code}") self.code = code super().__init__("broker credential unavailable") class BrokerProvider(Protocol): def get_broker_for_read(self) -> tuple[BrokerPort, BrokerSource]: ... def get_broker_for_reconciliation(self) -> tuple[BrokerPort, BrokerSource]: ... def refresh_day_start_credential(self) -> BrokerSource: ... def _break_glass_enabled() -> bool: return os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower() in {"1", "true", "yes"} def _make_env_broker(config: DaemonConfig) -> BrokerPort: if config.broker != "kis": seq_store = None if config.paper.redis_url: from trex_core.adapters.broker.seq_store import RedisSeqStore seq_store = RedisSeqStore(config.paper.redis_url, key=f"trex:broker:paper:{config.instance_key}:order-seq") return PaperBroker(fill_after_polls=config.paper.fill_after_polls, qty_per_poll=config.paper.qty_per_poll, reject_symbols=set(config.paper.reject_symbols), seq_store=seq_store) from trex_core.adapters.broker.kis import KISBroker k = config.kis if k is None: raise BrokerCredentialUnavailable("broker_initialization_failed") token_cache = None if k.redis_url: from trex_core.adapters.broker.token_cache import RedisTokenCache token_cache = RedisTokenCache(k.redis_url) broker: BrokerPort = KISBroker( k.account_ref, app_key=k.app_key, app_secret=k.app_secret, base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live, token_cache=token_cache, ) ownership_path = os.environ.get("TREX_KIS_STRATEGY_OWNERSHIP_PATH", "").strip() if ownership_path: from trex_core.adapters.broker.ownership import ( OwnershipAwareBroker, StrategyOwnershipStore, ) broker = OwnershipAwareBroker(broker, StrategyOwnershipStore(ownership_path)) return broker class ConfigBrokerProvider: """One provider owns one process/command broker and fetched-source refresh.""" def __init__(self, config: DaemonConfig, *, read_port=None, clock=None) -> None: self._config = config self._read = read_port self._clock = clock or (lambda: datetime.now(tz=UTC)) self._broker: BrokerPort | None = None self._source: BrokerSource | None = None self._credential_date: date | None = None def _read_port(self): if self._read is None: if self._config.portfolio_hub is None: raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter p = self._config.portfolio_hub self._read = PortfolioHubReadAdapter(p.base_url, token=p.token or None, timeout=p.timeout) return self._read def _fetch(self) -> BrokerSource: try: ctx = self._read_port().fetch_broker_credential() except PortfolioHubUnavailableError: raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from None except BrokerCredentialUnavailable: raise except Exception: # noqa: BLE001 - sanitize arbitrary read adapter failures raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from None if ctx is None or getattr(ctx, "broker", None) is None: raise BrokerCredentialUnavailable("broker_absent") if not getattr(ctx, "trading_enabled", False): raise BrokerCredentialUnavailable("trading_disabled") credential = ctx.broker if not all(isinstance(getattr(credential, x, None), str) and getattr(credential, x).strip() for x in ("account_ref", "app_key", "app_secret")): raise BrokerCredentialUnavailable("malformed_credential") try: if self._broker is None: broker = _make_env_broker(self._config) reinitialize = getattr(broker, "reinitialize", None) if not callable(reinitialize): raise RuntimeError("KIS broker lacks credential reload") reinitialize(credential) self._broker = broker else: self._broker.reinitialize(credential) # type: ignore[attr-defined] except Exception: # noqa: BLE001 - sanitize broker construction/reload failures raise BrokerCredentialUnavailable("broker_initialization_failed") from None self._credential_date = self._clock().astimezone(_KST).date() # Version is designated non-secret by the portfolio contract; no account/key material. return BrokerSource("fetched", f"credential-version:{getattr(credential, 'credential_version', 'unknown')}") def get_broker_for_read(self) -> tuple[BrokerPort, BrokerSource]: if self._config.broker != "kis": if self._broker is None: self._broker = _make_env_broker(self._config) self._source = BrokerSource("env", "paper-config") return self._broker, self._source if not self._config.credential_fetch_enabled: if self._broker is None: self._broker = _make_env_broker(self._config) self._source = BrokerSource("env", "kis-config") return self._broker, self._source if _break_glass_enabled(): if self._broker is None: self._broker = _make_env_broker(self._config) self._source = BrokerSource("break_glass", "operator-break-glass") return self._broker, self._source source = self._fetch() self._source = source return self._broker, source # type: ignore[return-value] def get_broker_for_reconciliation(self) -> tuple[BrokerPort, BrokerSource]: broker, source = self.get_broker_for_read() authoritative_reader = getattr(broker, "authoritative_balance_reader", None) if callable(authoritative_reader): return authoritative_reader(), source return broker, source def refresh_day_start_credential(self) -> BrokerSource: # Paper/env/break-glass paths deliberately do no portfolio-hub call. if self._config.broker != "kis" or not self._config.credential_fetch_enabled or _break_glass_enabled(): return self.get_broker_for_read()[1] today = self._clock().astimezone(_KST).date() if self._broker is not None and self._credential_date == today and self._source is not None: return self._source _, source = self.get_broker_for_read() return source def build_broker_provider(config: DaemonConfig, *, read_port=None) -> ConfigBrokerProvider: return ConfigBrokerProvider(config, read_port=read_port)