Skip to content

Commit 4ae947c

Browse files
authored
Merge pull request #418 from easygap/fix/account-routing-visibility
fix: 다중 계좌 라우팅 침묵 결함 가시화 — 콜론 키 env·미선언 경고·live 폴백 경고
2 parents 0c6e57c + c7cc18f commit 4ae947c

2 files changed

Lines changed: 94 additions & 4 deletions

File tree

config/config_loader.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,30 @@ def _override_with_env(settings: dict) -> dict:
5959
settings["kis_api"]["app_key"] = os.environ.get("KIS_APP_KEY", "")
6060
settings["kis_api"]["app_secret"] = os.environ.get("KIS_APP_SECRET", "")
6161
settings["kis_api"]["account_no"] = os.environ.get("KIS_ACCOUNT_NO", settings["kis_api"].get("account_no", ""))
62-
# 전략별 계좌 (다중 계좌): KIS_ACCOUNT_NO_SCORING, KIS_ACCOUNT_NO_MEAN_REVERSION 등으로 덮어씀
62+
# 전략별 계좌 (다중 계좌): KIS_ACCOUNT_NO_SCORING, KIS_ACCOUNT_NO_MEAN_REVERSION 등으로 덮어씀.
63+
# 키 파생 시 영숫자 외 문자(':', '-')는 '_'로 정규화한다 — 바스켓 승인 단위
64+
# ('basket_rebalance:<name>')처럼 콜론이 든 키도 env로 설정 가능해야 한다
65+
# (콜론은 Windows env 이름에 쓸 수 없어 기존 파생식으로는 영구 설정 불가였다).
66+
import re as _re
6367
accounts = settings["kis_api"].get("accounts", {}) or {}
68+
consumed_env_keys = set()
6469
for key in list(accounts.keys()):
65-
env_key = f"KIS_ACCOUNT_NO_{key.upper().replace('-', '_')}"
70+
env_key = "KIS_ACCOUNT_NO_" + _re.sub(r"[^A-Z0-9]", "_", key.upper())
71+
consumed_env_keys.add(env_key)
6672
accounts[key] = os.environ.get(env_key, accounts[key])
6773
settings["kis_api"]["accounts"] = accounts
74+
# YAML에 선언되지 않은 KIS_ACCOUNT_NO_* env는 조용히 무시되면 운영자가
75+
# "덮어썼다"고 믿은 채 기본 계좌로 라우팅된다(침묵 공유) — 명시 경고로 드러낸다.
76+
for env_name in os.environ:
77+
if (
78+
env_name.startswith("KIS_ACCOUNT_NO_")
79+
and env_name not in consumed_env_keys
80+
):
81+
logging.getLogger("config_loader").warning(
82+
"%s 환경변수가 설정돼 있지만 kis_api.accounts에 대응하는 키가 없어 "
83+
"무시됩니다 — settings.yaml의 accounts에 해당 전략 키를 선언하세요.",
84+
env_name,
85+
)
6886
if "MAX_CALLS_PER_SEC" in os.environ:
6987
settings["kis_api"]["max_calls_per_sec"] = float(os.environ["MAX_CALLS_PER_SEC"])
7088
if "MAX_CALLS_PER_MIN" in os.environ:
@@ -499,15 +517,31 @@ def resolved_hash(self) -> str:
499517
"""환경변수 반영 후 실행 설정 해시."""
500518
return self._resolved_hash
501519

520+
# live에서 기본 계좌 폴백 경고를 전략당 1회만 내기 위한 기록 (프로세스 전역)
521+
_default_account_warned: set = set()
522+
502523
def get_account_no(self, strategy: str = "") -> str:
503524
"""
504525
전략에 해당하는 계좌번호 반환 (다중 계좌 분리).
505526
kis_api.accounts에 전략명이 있으면 해당 계좌, 없으면 kis_api.account_no(기본) 사용.
527+
528+
live 모드에서 전략 키가 미선언/빈 값이라 기본 계좌로 폴백하면 경고를 남긴다 —
529+
침묵 폴백은 여러 전략·바스켓이 모르게 같은 실계좌(자본 풀)를 공유하게 만들고,
530+
DB상 account_key는 서로 달라 보여 공유 사실이 가려진다.
506531
"""
507532
kis = self.kis_api
508533
accounts = kis.get("accounts", {}) or {}
509-
if strategy and strategy in accounts:
510-
return accounts[strategy] or kis.get("account_no", "")
534+
if strategy and strategy in accounts and accounts[strategy]:
535+
return accounts[strategy]
536+
if strategy and str(self.trading.get("mode", "paper")).lower() == "live":
537+
if strategy not in Config._default_account_warned:
538+
Config._default_account_warned.add(strategy)
539+
logging.getLogger("config_loader").warning(
540+
"live 계좌 라우팅: 전략 '%s'의 계좌가 kis_api.accounts에 %s — "
541+
"기본 계좌로 폴백합니다(다른 전략과 자본 풀 공유 가능).",
542+
strategy,
543+
"선언되지 않음" if strategy not in accounts else "빈 값",
544+
)
511545
return kis.get("account_no", "")
512546

513547
def with_strategy_overrides(self, strategy_name: str, overrides: dict) -> "ConfigOverlay":

tests/test_config_auto_entry.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,62 @@ def test_scheduler_default_no_env(self):
321321
assert scheduler.auto_entry is False
322322

323323

324+
class TestAccountRoutingVisibility:
325+
"""다중 계좌 라우팅의 침묵 결함 가시화 — env 키 정규화·미선언 경고·live 폴백 경고."""
326+
327+
def test_basket_key_env_override_with_colon_normalized(self, monkeypatch):
328+
"""'basket_rebalance:<name>' 키도 콜론을 '_'로 정규화한 env 이름으로 덮어쓸 수 있다
329+
(콜론은 Windows env 이름에 불가 — 기존 파생식으로는 영구 설정 불가였다)."""
330+
from config.config_loader import _override_with_env
331+
332+
monkeypatch.setenv("KIS_ACCOUNT_NO_BASKET_REBALANCE_KR_DIVERSIFIED_HOLD", "9999-01")
333+
s = _override_with_env({
334+
"kis_api": {"accounts": {"basket_rebalance:kr_diversified_hold": "1111-01"}},
335+
})
336+
assert s["kis_api"]["accounts"]["basket_rebalance:kr_diversified_hold"] == "9999-01"
337+
338+
def test_undeclared_account_env_warns(self, monkeypatch, caplog):
339+
"""YAML 미선언 KIS_ACCOUNT_NO_* env는 무시되되 명시 경고를 남긴다(침묵 라우팅 방지)."""
340+
import logging
341+
from config.config_loader import _override_with_env
342+
343+
monkeypatch.setenv("KIS_ACCOUNT_NO_GHOST_STRATEGY", "7777-01")
344+
with caplog.at_level(logging.WARNING, logger="config_loader"):
345+
_override_with_env({"kis_api": {"accounts": {}}})
346+
assert any("KIS_ACCOUNT_NO_GHOST_STRATEGY" in r.message for r in caplog.records)
347+
348+
def test_live_default_fallback_warns_once(self, caplog):
349+
"""live에서 미선언 전략이 기본 계좌로 폴백하면 1회 경고(공유 가시화)."""
350+
import logging
351+
from config.config_loader import Config
352+
353+
cfg = Config.__new__(Config)
354+
cfg._settings = {
355+
"trading": {"mode": "live"},
356+
"kis_api": {"account_no": "1111-01", "accounts": {}},
357+
}
358+
Config._default_account_warned = set()
359+
with caplog.at_level(logging.WARNING, logger="config_loader"):
360+
assert cfg.get_account_no("scoring") == "1111-01"
361+
assert cfg.get_account_no("scoring") == "1111-01" # 2회째는 경고 없음
362+
warns = [r for r in caplog.records if "기본 계좌로 폴백" in r.message]
363+
assert len(warns) == 1
364+
365+
def test_paper_default_fallback_silent(self, caplog):
366+
"""paper에서는 기본 계좌 폴백이 정상 동작 — 경고 없음."""
367+
import logging
368+
from config.config_loader import Config
369+
370+
cfg = Config.__new__(Config)
371+
cfg._settings = {
372+
"trading": {"mode": "paper"},
373+
"kis_api": {"account_no": "1111-01", "accounts": {}},
374+
}
375+
Config._default_account_warned = set()
376+
with caplog.at_level(logging.WARNING, logger="config_loader"):
377+
assert cfg.get_account_no("scoring") == "1111-01"
378+
assert not [r for r in caplog.records if "기본 계좌" in r.message]
379+
324380
def test_yaml_string_false_is_not_truthy(monkeypatch):
325381
"""YAML에 따옴표로 'false'를 쓰면 bool('false')==True 함정 — 엄격 파싱으로 False여야 한다.
326382
(auto_entry 마스터 스위치가 따옴표 하나로 뒤집히면 live에서 실돈 자동매수)"""

0 commit comments

Comments
 (0)