fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
147
backend/tests/test_strategy_config_dt_readonly_20261009.py
Normal file
147
backend/tests/test_strategy_config_dt_readonly_20261009.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user