421 lines
18 KiB
Python
421 lines
18 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
|
|
|
|
|
|
DEFAULT_CONFIG_DIR = "config"
|
|
|
|
|
|
def resolve_config_toml(name: str, config_path: Optional[str] = None) -> str:
|
|
"""Resolve `--config <name>` within a config directory (REQ-048 CHG-20260609-01).
|
|
|
|
config_dir = `--config-path` if given, else `./config`. `<name>` selects the
|
|
TOML inside it; a missing `.toml` extension is appended.
|
|
|
|
Backward-compat: if `name` contains a path separator (e.g. `config/test.toml`),
|
|
it is treated as an explicit path and used as-is so existing ops scripts that
|
|
pass a path keep working.
|
|
|
|
resolve_config_toml("test") -> config/test.toml
|
|
resolve_config_toml("test.toml") -> config/test.toml
|
|
resolve_config_toml("prod", "/etc/trex") -> /etc/trex/prod.toml
|
|
resolve_config_toml("config/test.toml") -> config/test.toml (path-form)
|
|
"""
|
|
if os.sep in name or (os.altsep and os.altsep in name):
|
|
return name # explicit path (backward-compat)
|
|
config_dir = config_path or DEFAULT_CONFIG_DIR
|
|
filename = name if name.endswith(".toml") else f"{name}.toml"
|
|
return os.path.join(config_dir, filename)
|
|
|
|
|
|
def _load_dotenv(config_path: Optional[str] = None, env_file: Optional[str] = None) -> None:
|
|
"""Best-effort load of a .env file into os.environ (REQ-010 / CHG-20260609-01).
|
|
|
|
Resolution (trex-orb-v1 aligned, deterministic & CWD-independent given a
|
|
config_path): `env_file` (explicit) wins, else `<config_dir>/.env` next to the
|
|
TOML. With neither (standalone `from_env()`), fall back to legacy CWD-relative
|
|
`find_dotenv` so existing root `.env` setups keep working (backward-compat).
|
|
|
|
Real OS env vars always take precedence (`override=False`). python-dotenv
|
|
missing or the resolved file absent => no-op (no regression).
|
|
"""
|
|
try:
|
|
from dotenv import load_dotenv
|
|
except ImportError: # pragma: no cover - optional at runtime
|
|
return
|
|
if env_file:
|
|
if os.path.isfile(env_file):
|
|
load_dotenv(env_file, override=False)
|
|
return
|
|
if config_path:
|
|
path = os.path.join(os.path.dirname(os.path.abspath(config_path)), ".env")
|
|
if os.path.isfile(path):
|
|
load_dotenv(path, override=False)
|
|
return
|
|
# No config context: legacy CWD-relative discovery (backward-compat fallback).
|
|
from dotenv import find_dotenv
|
|
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()
|
|
# REQ-011 CHG-20260612-01 (issue #4): optional Redis URL for a persistent
|
|
# order-seq store. Empty => in-memory counter (prior behaviour).
|
|
redis_url: str = ""
|
|
|
|
|
|
@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 AccountSnapshotConfig:
|
|
"""Opt-in bounded KIS account-read recovery (dcy-041 / GitHub #45)."""
|
|
|
|
enabled: bool = False
|
|
buy_max_age_sec: int = 600
|
|
sell_max_age_sec: int = 3600
|
|
refresh_interval_sec: float = 300.0
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.buy_max_age_sec < 0:
|
|
raise ValueError("account snapshot buy_max_age_sec must be >= 0")
|
|
if self.sell_max_age_sec < self.buy_max_age_sec:
|
|
raise ValueError(
|
|
"account snapshot sell_max_age_sec must be >= buy_max_age_sec"
|
|
)
|
|
if self.sell_max_age_sec > 21_600:
|
|
raise ValueError("account snapshot sell_max_age_sec exceeds six hours")
|
|
if self.refresh_interval_sec <= 0:
|
|
raise ValueError("account snapshot refresh_interval_sec must be > 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
|
|
# BAT-014 / REQ-053: persistent-rejection retry cooldown (dcy-010).
|
|
min_retry_interval_sec: int = 600 # per-slot cooldown between sell retries
|
|
max_retry_warn_count: int = 3 # WARNING after this many consecutive retries
|
|
|
|
|
|
@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 = ""
|
|
# dcy-014 / REQ-048: operator-local timezone (CLI date display etc.).
|
|
trading_timezone: str = "Asia/Seoul"
|
|
# dcy-020 / REQ-060: max concurrent active (holding) slots, M — the FCFS
|
|
# admission capacity cap. BAT-030: asset-centric is the only model; absent
|
|
# means no cap (operators are expected to set it in production).
|
|
max_active_slots: Optional[int] = None
|
|
# dcy-022 / REQ-068: default order timeout in seconds (opt-in per instance).
|
|
# None = OFF = indefinite wait (legacy behavior). Per-intent timeout_sec overrides.
|
|
default_order_timeout_sec: Optional[int] = None
|
|
# dcy-024 / REQ-048 CHG-20260611-01: disable autonomous safety-sell worker.
|
|
# Absent = True (safe default). False → trex-safety-sell thread not started.
|
|
safety_sell_enabled: bool = True
|
|
# dcy-027 / REQ-076: per-instance watchdog timeout (seconds). None = fall
|
|
# back to env TREX_WATCHDOG_TIMEOUT (default 120). Long-term instances
|
|
# polling every 1-10 min should set >= 1800 so expired stays meaningful.
|
|
watchdog_timeout_sec: Optional[int] = None
|
|
# dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default)
|
|
# = env-only KIS config (byte-for-byte backward compat). True = fetch 1st,
|
|
# TREX_KIS_BROKER_BREAK_GLASS=true = skip fetch and use env.
|
|
credential_fetch_enabled: bool = False
|
|
|
|
@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}"
|
|
)
|
|
raw_max = data.get("max_active_slots")
|
|
max_active = int(raw_max) if raw_max is not None else None
|
|
if max_active is not None and max_active <= 0:
|
|
raise RuntimeError(
|
|
f"instance TOML 'max_active_slots' must be > 0, got {raw_max}: {path}"
|
|
)
|
|
raw_timeout = data.get("default_order_timeout_sec")
|
|
default_timeout = int(raw_timeout) if raw_timeout is not None else None
|
|
if default_timeout is not None and default_timeout <= 0:
|
|
raise RuntimeError(
|
|
f"instance TOML 'default_order_timeout_sec' must be > 0, got {raw_timeout}: {path}"
|
|
)
|
|
raw_wd = data.get("watchdog_timeout_sec")
|
|
watchdog_timeout = int(raw_wd) if raw_wd is not None else None
|
|
if watchdog_timeout is not None and watchdog_timeout <= 0:
|
|
raise RuntimeError(
|
|
f"instance TOML 'watchdog_timeout_sec' must be > 0, got {raw_wd}: {path}"
|
|
)
|
|
return DaemonInstanceConfig(
|
|
instance_key=str(instance_key),
|
|
portfolio_hub_token=str(data.get("portfolio_hub_token") or ""),
|
|
trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"),
|
|
max_active_slots=max_active,
|
|
default_order_timeout_sec=default_timeout,
|
|
safety_sell_enabled=bool(data.get("safety_sell_enabled", True)),
|
|
watchdog_timeout_sec=watchdog_timeout,
|
|
credential_fetch_enabled=bool(data.get("credential_fetch_enabled", False)),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DaemonConfig:
|
|
dsn: str
|
|
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"
|
|
# dcy-020 / REQ-060: M (max concurrent active slots) — FCFS admission cap.
|
|
# BAT-030: asset-centric is the only model; None = no cap.
|
|
max_active_slots: Optional[int] = None
|
|
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()
|
|
# dcy-041 / GitHub #45: opt-in account read snapshot. Disabled keeps the
|
|
# previous live-only balance path byte-for-byte compatible.
|
|
account_snapshot: AccountSnapshotConfig = AccountSnapshotConfig()
|
|
# REQ-041 (BAT-010): safety-sell feature config.
|
|
safety_sell: SafetySellConfig = SafetySellConfig()
|
|
# dcy-013 / REQ-014 (BAT-017): Alert-only pending-order stale watchdog
|
|
# threshold in seconds. 0 disables (default). When >0, dispatched-pending
|
|
# slots older than this are surfaced (WARN + counter) without state change.
|
|
pending_stale_threshold_sec: int = 0
|
|
# dcy-022 / REQ-068: default order timeout (seconds). None = OFF = indefinite.
|
|
# Per-intent timeout_sec overrides. Passed to IntentService at wiring time.
|
|
default_order_timeout_sec: Optional[int] = None
|
|
# dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The
|
|
# daily preallocation trigger was removed with the pool model (BAT-030).
|
|
trading_timezone: str = "Asia/Seoul"
|
|
# dcy-024 / REQ-005 CHG-20260611-01: disable autonomous safety-sell worker.
|
|
# False → trex-safety-sell thread not started in daemon.start().
|
|
safety_sell_enabled: bool = True
|
|
# dcy-015 / REQ-019 (BAT-019): ledger outbox retry backoff. base*2^(n-1)
|
|
# capped at cap (+jitter); after max_attempts a retryable row dead-letters.
|
|
ledger_retry_base_sec: float = 1.0
|
|
ledger_retry_cap_sec: float = 300.0
|
|
ledger_max_attempts: int = 8
|
|
# dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default)
|
|
# = env-only KIS config (byte-for-byte backward compat). True = fetch 1st,
|
|
# TREX_KIS_BROKER_BREAK_GLASS=true env = skip fetch and use env instead.
|
|
credential_fetch_enabled: bool = False
|
|
|
|
@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()
|
|
),
|
|
redis_url=os.environ.get("TREX_PAPER_REDIS_URL", "").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")),
|
|
)
|
|
account_snapshot = AccountSnapshotConfig(
|
|
enabled=os.environ.get(
|
|
"TREX_ACCOUNT_SNAPSHOT_ENABLED", ""
|
|
).lower() in ("1", "true", "yes"),
|
|
buy_max_age_sec=int(
|
|
os.environ.get("TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC", "600")
|
|
),
|
|
sell_max_age_sec=int(
|
|
os.environ.get("TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC", "3600")
|
|
),
|
|
refresh_interval_sec=float(
|
|
os.environ.get("TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC", "300")
|
|
),
|
|
)
|
|
_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,
|
|
min_retry_interval_sec=int(
|
|
os.environ.get("TREX_SAFETY_SELL_MIN_RETRY_INTERVAL", "600")
|
|
),
|
|
max_retry_warn_count=int(
|
|
os.environ.get("TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT", "3")
|
|
),
|
|
)
|
|
return DaemonConfig(
|
|
dsn=dsn,
|
|
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,
|
|
account_snapshot=account_snapshot,
|
|
safety_sell=safety_sell,
|
|
pending_stale_threshold_sec=int(
|
|
os.environ.get("TREX_PENDING_STALE_THRESHOLD", "0")
|
|
),
|
|
ledger_retry_base_sec=float(
|
|
os.environ.get("TREX_LEDGER_RETRY_BASE_SEC", "1.0")
|
|
),
|
|
ledger_retry_cap_sec=float(
|
|
os.environ.get("TREX_LEDGER_RETRY_CAP_SEC", "300.0")
|
|
),
|
|
ledger_max_attempts=int(os.environ.get("TREX_LEDGER_MAX_ATTEMPTS", "8")),
|
|
)
|
|
|
|
@staticmethod
|
|
def from_config(toml_path: str, env_file: Optional[str] = None) -> "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.
|
|
|
|
REQ-010 CHG-20260609-01: the config-dir `.env` (next to the TOML, or
|
|
`--env-file`) is loaded first so it wins over the legacy root `.env`
|
|
fallback loaded inside `from_env()` (both `override=False`).
|
|
"""
|
|
_load_dotenv(toml_path, env_file)
|
|
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)
|
|
# dcy-027 / REQ-076: per-instance watchdog timeout. TOML wins over the
|
|
# env-derived value; absent keeps env TREX_WATCHDOG_TIMEOUT (default 120)
|
|
# so existing instances are byte-for-byte unaffected.
|
|
safety_sell = base.safety_sell
|
|
if inst.watchdog_timeout_sec is not None:
|
|
safety_sell = replace(
|
|
safety_sell, watchdog_timeout_sec=inst.watchdog_timeout_sec
|
|
)
|
|
return replace(
|
|
base,
|
|
instance_key=inst.instance_key,
|
|
portfolio_hub=pf,
|
|
trading_timezone=inst.trading_timezone,
|
|
max_active_slots=inst.max_active_slots,
|
|
safety_sell_enabled=inst.safety_sell_enabled,
|
|
safety_sell=safety_sell,
|
|
credential_fetch_enabled=inst.credential_fetch_enabled,
|
|
)
|