fix(broker): enforce operator broker truth boundary
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user