Files
trex-core/trex_core/runtime/broker_provider.py
T

163 lines
7.2 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)
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=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)