Port the trex-orbit Redis token-cache pattern so the KIS access token
survives daemon restarts and is shared across processes, avoiding KIS
token-issuance rate limits. KISBroker._ensure_token now resolves L1
in-memory -> L2 cache -> issue, storing issued tokens back to the cache.
- adapters/broker/token_cache.py: TokenCache protocol + RedisTokenCache
(key trex:broker:kis:{account_ref}:token, token+expiry+app_key suffix
JSON, ex=ttl, best-effort with graceful fallback) + InMemoryTokenCache.
- kis.py: optional token_cache injection.
- runtime config/daemon: TREX_KIS_REDIS_URL wires a RedisTokenCache.
- pyproject: redis>=5.
Verification: 96 passed, ruff clean — cache hit avoids issuance, cache is
shared across instances (restart/multi-process survival), backend errors
fall back to issuance (no regression), and app_key-suffix mismatch
invalidates a stale cache. Tested deterministically with an in-memory
double (no Redis server required). Processed via the minor-change fast
path: REQ-016 -> Implemented, BAT-005 (batch-lite) -> release-candidate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
73 lines
2.5 KiB
Python
73 lines
2.5 KiB
Python
"""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 DaemonConfig:
|
|
dsn: str
|
|
total_slots: int
|
|
host: str
|
|
port: int
|
|
broker: str # 'paper' | 'kis'
|
|
execution_interval_sec: float
|
|
kis: Optional[KISConfig] = None
|
|
|
|
@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", ""),
|
|
)
|
|
return DaemonConfig(
|
|
dsn=dsn,
|
|
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
|
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
|
|
port=int(os.environ.get("TREX_API_PORT", "8080")),
|
|
broker=broker,
|
|
execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")),
|
|
kis=kis,
|
|
)
|