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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View 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