148 lines
6.8 KiB
Python
148 lines
6.8 KiB
Python
"""Common broker credential resolution and lifecycle boundary (REQ-119/120)."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from datetime import 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 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)
|
|
return 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)
|
|
|
|
|
|
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=timezone.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:
|
|
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:
|
|
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 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)
|