Files
trex-core/trex_core/runtime/config.py
T
genichinandClaude Opus 4.8 9f83c64a40 feat(multi-instance): dcy-009 BAT-013 — RabbitMQ 다중 인스턴스 토폴로지 (REQ-048~051)
dcy-009(다중 인스턴스 RabbitMQ 토폴로지) 전체 SDLC: draft-req → confirm-req →
draft-batch(BAT-013) → run-batch-delivery → confirm-req-implemented.

- REQ-048 (Configuration): DaemonInstanceConfig.from_toml + DaemonConfig.from_config
  (env 병합, portfolio_hub_token 주입) + 데몬 --config 필수 argparse + .gitignore 인스턴스
  TOML + config/instance.example.toml
- REQ-049 (Interface): topology 동적 큐명 빌더 intent/snapshot/control_queue(instance_key)
  — default=리터럴 큐명(하위호환), named=`{base}.{instance_key}` + daemon per-instance 구독
- REQ-050 (Integration): trex.direct(topic)/trex.broadcast(fanout) exchange +
  declare_topology(instance_key) per-instance binding + control dual binding + direct_routing_key
- REQ-051 (Documentation): interface-contract/runtime-flows/project-structure 정합

하위호환: instance_key="default" → 기존 리터럴 큐명·RPC 경로 무회귀(FR-5).
split-brain 방지(NFR-3): 인스턴스 전용 큐. 민감값(NFR-4): TOML + gitignore.

검증: 신규 9 단위 + RabbitMQ 라우팅 2, 단위 271 passed,
인프라 게이트 301 passed(directed 격리 S-2 + broadcast dual S-3 + default 무회귀 S-4),
ruff clean. REQ-048~051 Implemented, BAT-013 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:10:36 +09:00

229 lines
8.5 KiB
Python

"""Daemon configuration resolution (REQ-005, REQ-010, REQ-012/013)."""
from __future__ import annotations
import os
from dataclasses import dataclass, replace
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 PortfolioHubConfig:
"""portfolio-hub ledger adapter config (REQ-017)."""
base_url: str
token: str = ""
timeout: float = 5.0
@dataclass(frozen=True)
class PaperBrokerConfig:
"""Paper broker tuning for e2e test scenarios (REQ-011).
Defaults reproduce immediate, full fills with no rejects, so an unset
environment is byte-for-byte the prior `PaperBroker()` behaviour.
"""
fill_after_polls: int = 0
qty_per_poll: Optional[int] = None
reject_symbols: frozenset[str] = frozenset()
@dataclass(frozen=True)
class RabbitMQConfig:
"""RabbitMQ transport adapter config (REQ-022, dcy-006 OQ-6)."""
url: str
timeout: float = 5.0
@dataclass(frozen=True)
class SnapshotScheduleConfig:
"""Daily snapshot refresh schedule (REQ-030)."""
kst_time: str = "07:30"
tick_sec: float = 60.0
@dataclass(frozen=True)
class SafetySellConfig:
"""Safety-sell feature tuning (REQ-041 / BAT-010).
All five env vars are parsed here even though only WATCHDOG_TIMEOUT and
WATCHDOG_GRACE_PERIOD are consumed in BAT-010; the remaining three are
used by BAT-011 trigger loops once they land.
"""
watchdog_timeout_sec: int = 120
watchdog_grace_period_sec: int = 60
forced_close_check_interval_sec: int = 60
stop_loss_poll_interval_sec: int = 30
market_close_liquidation_time: Optional[str] = None # "HH:MM" KST, optional
@dataclass(frozen=True)
class DaemonInstanceConfig:
"""Per-instance TOML settings (REQ-048 / BAT-013).
`instance_key` and `portfolio_hub_token` live in a TOML file (not .env)
so they are not committed and so multiple instances can run distinct
queues. Loaded via `--config <path>.toml`.
"""
instance_key: str
portfolio_hub_token: str = ""
@staticmethod
def from_toml(path: str) -> "DaemonInstanceConfig":
import tomllib
with open(path, "rb") as fh:
data = tomllib.load(fh)
instance_key = data.get("instance_key")
if not instance_key:
raise RuntimeError(
f"instance TOML missing required 'instance_key': {path}"
)
return DaemonInstanceConfig(
instance_key=str(instance_key),
portfolio_hub_token=str(data.get("portfolio_hub_token") or ""),
)
@dataclass(frozen=True)
class DaemonConfig:
dsn: str
total_slots: int
broker: str # 'paper' | 'kis'
execution_interval_sec: float
# BAT-013 / REQ-049: instance identity for multi-instance queue naming.
# "default" keeps legacy literal queue names (backward-compat).
instance_key: str = "default"
kis: Optional[KISConfig] = None
# REQ-011: paper broker tuning (only consulted when broker == 'paper').
paper: PaperBrokerConfig = PaperBrokerConfig()
# REQ-017/019: ledger writer config (None => in-memory stub ledger,
# appropriate for slice-1 paper-broker integration tests).
portfolio_hub: Optional[PortfolioHubConfig] = None
ledger_interval_sec: float = 1.0
# REQ-022 (BAT-007): RabbitMQ transport config. Required by the new
# sync+thread daemon — HTTP API has been removed (REQ-025).
rabbitmq: Optional[RabbitMQConfig] = None
# REQ-030 (BAT-008): daily snapshot refresh.
snapshot_schedule: SnapshotScheduleConfig = SnapshotScheduleConfig()
# REQ-041 (BAT-010): safety-sell feature config.
safety_sell: SafetySellConfig = SafetySellConfig()
@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", ""),
)
qpp_raw = os.environ.get("TREX_PAPER_QTY_PER_POLL", "").strip()
reject_raw = os.environ.get("TREX_PAPER_REJECT_SYMBOLS", "")
paper = PaperBrokerConfig(
fill_after_polls=int(os.environ.get("TREX_PAPER_FILL_AFTER_POLLS", "0")),
qty_per_poll=int(qpp_raw) if qpp_raw else None,
reject_symbols=frozenset(
s.strip() for s in reject_raw.split(",") if s.strip()
),
)
portfolio_hub: Optional[PortfolioHubConfig] = None
pf_base = os.environ.get("TREX_PORTFOLIO_HUB_BASE_URL", "").strip()
if pf_base:
portfolio_hub = PortfolioHubConfig(
base_url=pf_base,
token=os.environ.get("TREX_PORTFOLIO_HUB_TOKEN", "").strip(),
timeout=float(os.environ.get("TREX_PORTFOLIO_HUB_TIMEOUT", "5")),
)
rabbitmq: Optional[RabbitMQConfig] = None
mq_url = os.environ.get("TREX_RABBITMQ_URL", "").strip()
if mq_url:
rabbitmq = RabbitMQConfig(
url=mq_url,
timeout=float(os.environ.get("TREX_RABBITMQ_TIMEOUT", "5")),
)
snapshot_schedule = SnapshotScheduleConfig(
kst_time=os.environ.get("TREX_SNAPSHOT_SCHEDULE_KST", "07:30").strip() or "07:30",
tick_sec=float(os.environ.get("TREX_SNAPSHOT_TICK_SEC", "60")),
)
_mc_time = os.environ.get("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "").strip() or None
safety_sell = SafetySellConfig(
watchdog_timeout_sec=int(os.environ.get("TREX_WATCHDOG_TIMEOUT", "120")),
watchdog_grace_period_sec=int(os.environ.get("TREX_WATCHDOG_GRACE_PERIOD", "60")),
forced_close_check_interval_sec=int(
os.environ.get("TREX_FORCED_CLOSE_CHECK_INTERVAL", "60")
),
stop_loss_poll_interval_sec=int(
os.environ.get("TREX_STOP_LOSS_POLL_INTERVAL", "30")
),
market_close_liquidation_time=_mc_time,
)
return DaemonConfig(
dsn=dsn,
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
broker=broker,
execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")),
kis=kis,
paper=paper,
portfolio_hub=portfolio_hub,
ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")),
rabbitmq=rabbitmq,
snapshot_schedule=snapshot_schedule,
safety_sell=safety_sell,
)
@staticmethod
def from_config(toml_path: str) -> "DaemonConfig":
"""Build config from env + a per-instance TOML file (REQ-048 / BAT-013).
`instance_key` and `portfolio_hub_token` come from TOML (not .env).
TOML `portfolio_hub_token` overrides the (env-derived) PortfolioHubConfig
token when a portfolio-hub base_url is configured.
"""
base = DaemonConfig.from_env()
inst = DaemonInstanceConfig.from_toml(toml_path)
pf = base.portfolio_hub
if inst.portfolio_hub_token and pf is not None:
pf = replace(pf, token=inst.portfolio_hub_token)
return replace(base, instance_key=inst.instance_key, portfolio_hub=pf)