"""DAY_TRADE 그룹은 settings 단일 소스 — strategy_configs 쓰기·캐시 노출·시드 차단.""" import asyncio from types import SimpleNamespace from unittest.mock import MagicMock import pytest from app.core.config import settings from app.services import strategy_config_service as scs class _Boom: def __call__(self, *a, **k): raise AssertionError("DB 세션이 열리면 안 된다") @pytest.fixture(autouse=True) def _no_db(monkeypatch): monkeypatch.setattr(scs, "AsyncSessionLocal", _Boom()) def test_dt_keys_are_read_only(): dt = [k for k, v in scs.DEFAULT_PARAMS.items() if v[0] == "DAY_TRADE"] assert len(dt) >= 18 assert set(dt) <= scs.READ_ONLY_KEYS assert "STOP_LOSS_PERCENT" not in scs.READ_ONLY_KEYS # 실매매 배선 키는 편집 가능 유지 @pytest.mark.parametrize("key", ["DAY_TRADE_ENABLED", "DAY_TRADE_RSI_BREAKEVEN_THRESHOLD", "DAY_TRADE_EOD_HOUR"]) def test_upsert_and_reset_rejected_before_db(key): svc = scs.StrategyConfigService() with pytest.raises(ValueError): asyncio.run(svc.upsert(key, "0")) with pytest.raises(ValueError): asyncio.run(svc.reset_to_default(key)) def test_cache_excludes_dt_rows(monkeypatch): rows = [ SimpleNamespace(key="DAY_TRADE_ENABLED", group="DAY_TRADE", value="False", default_value="True", value_type="bool"), SimpleNamespace(key="MAX_POSITION_SIZE", group="RISK", value="0.04", default_value="0.04", value_type="float"), SimpleNamespace(key="STOP_LOSS_PERCENT", group="RISK", value="-8.0", default_value="-7.0", value_type="float"), ] class _Sess: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *_): r = MagicMock() r.scalars.return_value.all.return_value = rows return r monkeypatch.setattr(scs, "AsyncSessionLocal", lambda: _Sess()) svc = scs.StrategyConfigService() asyncio.run(svc._refresh()) assert "DAY_TRADE_ENABLED" not in svc._cache assert "MAX_POSITION_SIZE" not in svc._cache # settings 단일 소스 assert svc._cache["STOP_LOSS_PERCENT"] == -8.0 def test_effective_values_come_from_settings(): eff = scs.get_effective_dt_params_v2() assert eff assert all(getattr(settings, scs._effective_settings_key_v2(k)) == v for k, v in eff.items()) def test_serialize_marks_dt_read_only_and_shows_settings_value(): row = SimpleNamespace(id=1, key="DAY_TRADE_RSI_BB_RSI_THRESHOLD", group="DAY_TRADE", label="l", description=None, value_type="float", value="40.0", default_value="40.0", min_value=None, max_value=None, is_active=True, updated_by="x") d = scs.serialize_config_v2(row) assert d["read_only"] is True assert d["value"] == str(settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD) def test_serialize_non_dt_passthrough(): row = SimpleNamespace(id=2, key="STOP_LOSS_PERCENT", group="RISK", label="l", description=None, value_type="float", value="-7.0", default_value="-7.0", min_value=None, max_value=None, is_active=True, updated_by=None) d = scs.serialize_config_v2(row) assert d["read_only"] is False and d["value"] == "-7.0" def test_reset_endpoint_returns_422_for_dt_key(): """reset 엔드포인트가 DT 키 ValueError 를 500 이 아니라 422 로 변환.""" from fastapi import HTTPException from app.api.endpoints import strategy_config as ep with pytest.raises(HTTPException) as ei: asyncio.run(ep.reset_config("DAY_TRADE_ENABLED")) assert ei.value.status_code == 422 @pytest.mark.parametrize("disp,eng", sorted(scs.DT_EFFECTIVE_KEY_ALIASES.items())) def test_alias_keys_show_engine_effective_value(disp, eng): cfg = SimpleNamespace( id=1, key=disp, group="DAY_TRADE", label="x", description="d", value_type="float", value="999", default_value="999", min_value=None, max_value=None, is_active=True, updated_by="u", ) d = scs.serialize_config_v2(cfg) assert d["value"] == str(getattr(settings, eng)) assert scs.get_effective_dt_params_v2()[disp] == getattr(settings, eng) def test_rsi_bb_display_is_gate_value_and_stop_loss_label(): cfg = SimpleNamespace( id=1, key="DAY_TRADE_RSI_BREAKEVEN_THRESHOLD", group="DAY_TRADE", label="x", description="d", value_type="float", value="40.0", default_value="40.0", min_value=None, max_value=None, is_active=True, updated_by=None, ) assert scs.serialize_config_v2(cfg)["value"] == str(settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD) sl = SimpleNamespace(**{**cfg.__dict__, "key": "DAY_TRADE_STOP_LOSS_PCT"}) assert scs.serialize_config_v2(sl)["label"] == "복원 폴백 손절" _RISK_UNWIRED = ["MAX_POSITION_SIZE", "MAX_DAILY_TRADES", "MAX_HOLDINGS_COUNT", "MIN_PRICE_KRW", "MIN_PRICE_USD", "CONSECUTIVE_LOSS_BAN_COUNT", "CONSECUTIVE_LOSS_BAN_DAYS"] @pytest.mark.parametrize("key", _RISK_UNWIRED) def test_risk_unwired_keys_read_only_and_rejected_before_db(key): assert key in scs.READ_ONLY_KEYS svc = scs.StrategyConfigService() with pytest.raises(ValueError): asyncio.run(svc.upsert(key, "1")) with pytest.raises(ValueError): asyncio.run(svc.reset_to_default(key)) @pytest.mark.parametrize("key", _RISK_UNWIRED) def test_risk_unwired_serialize_shows_settings_value(key): row = SimpleNamespace(id=3, key=key, group="RISK", label="l", description=None, value_type="float", value="0.02", default_value="0.02", min_value=None, max_value=None, is_active=True, updated_by="ui") d = scs.serialize_config_v2(row) assert d["read_only"] is True assert d["value"] == str(getattr(settings, key)) def test_wired_risk_keys_remain_editable(): for k in ("STOP_LOSS_PERCENT", "STOP_LOSS_HIGH_VOL", "ATR_STOP_MULTIPLIER", "ATR_TRAILING_MULTIPLIER", "MDD_PAUSE_THRESHOLD", "MDD_FORCE_THRESHOLD"): assert k not in scs.READ_ONLY_KEYS