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,166 @@
import pytest
from app.services.mdd_manager import MDDManager
from app.services import mdd_state_backup as B
class _RedisDown(MDDManager):
async def load_from_redis(self):
raise AssertionError("unused")
@pytest.mark.asyncio
async def test_file_fallback_preserves_peak(tmp_path, monkeypatch):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 10_000_000.0
a.is_paused = True
a.reduction_applied = "10pct"
a._reduction_executed_10pct = True
assert await B.save_mdd_backup_v2(a, p)
b = MDDManager()
async def _no_redis():
return False
monkeypatch.setattr(b, "load_from_redis", _no_redis)
assert await B.restore_mdd_state_v2(b, p) == "file"
assert b.peak_balance == 10_000_000.0 and b.is_paused
assert b.reduction_applied == "10pct" and b._reduction_executed_10pct
# 복원된 peak 로 낙폭 계산 (재시드되지 않음)
r = await b.update(9_500_000.0)
assert round(r["drawdown"], 1) == -5.0
@pytest.mark.asyncio
async def test_none_when_no_sources(tmp_path, monkeypatch):
b = MDDManager()
async def _no_redis():
return False
monkeypatch.setattr(b, "load_from_redis", _no_redis)
assert await B.restore_mdd_state_v2(b, tmp_path / "x.json") == "none"
@pytest.mark.asyncio
async def test_unseeded_does_not_overwrite_backup(tmp_path):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 5.0
await B.save_mdd_backup_v2(a, p)
assert not await B.save_mdd_backup_v2(MDDManager(), p)
assert '"peak_balance":5' in p.read_text().replace(" ", "")
@pytest.mark.asyncio
async def test_corrupt_file_returns_none(tmp_path, monkeypatch):
p = tmp_path / "m.json"
p.write_text("{bad")
b = MDDManager()
assert not await B.restore_mdd_from_backup_v2(b, p)
def _redis_loader(mgr, peak, **flags):
async def _load():
mgr.peak_balance = peak
for k, v in flags.items():
setattr(mgr, k, v)
return True
return _load
@pytest.mark.asyncio
async def test_stale_redis_loses_to_newer_file(tmp_path, monkeypatch):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 12_000_000.0
a._reduction_executed_10pct = True
assert await B.save_mdd_backup_v2(a, p)
b = MDDManager()
monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0))
assert await B.restore_mdd_state_v2(b, p) == "file"
assert b.peak_balance == 12_000_000.0 and b._reduction_executed_10pct
@pytest.mark.asyncio
async def test_equal_peak_prefers_more_conservative_flags(tmp_path, monkeypatch):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 10_000_000.0
a._reduction_executed_10pct = True
a.is_paused = True
await B.save_mdd_backup_v2(a, p)
b = MDDManager()
monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0))
assert await B.restore_mdd_state_v2(b, p) == "file"
assert b.is_paused and b._reduction_executed_10pct
@pytest.mark.asyncio
async def test_redis_wins_when_newer_or_file_missing(tmp_path, monkeypatch):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 9_000_000.0
await B.save_mdd_backup_v2(a, p)
b = MDDManager()
monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0))
assert await B.restore_mdd_state_v2(b, p) == "redis"
assert b.peak_balance == 10_000_000.0
c = MDDManager()
monkeypatch.setattr(c, "load_from_redis", _redis_loader(c, 10_000_000.0))
assert await B.restore_mdd_state_v2(c, tmp_path / "none.json") == "redis"
@pytest.mark.asyncio
async def test_rebased_lower_peak_in_redis_wins_by_saved_at(tmp_path, monkeypatch):
"""reset 재기준으로 낮아진 Redis peak 를 옛(높은 peak) 파일이 되살리면 안 된다."""
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 10_000_000.0
a.is_paused = True
await B.save_mdd_backup_v2(a, p)
import json, time
file_at = json.loads(p.read_text())["saved_at"]
b = MDDManager()
monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 8_000_000.0))
async def _r_at():
return file_at + 5.0
monkeypatch.setattr(B, "_read_redis_saved_at_v2", _r_at)
assert await B.restore_mdd_state_v2(b, p) == "redis"
assert b.peak_balance == 8_000_000.0 and not b.is_paused
@pytest.mark.asyncio
async def test_newer_file_wins_by_saved_at_even_if_lower_peak(tmp_path, monkeypatch):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 7_000_000.0
await B.save_mdd_backup_v2(a, p)
import json
file_at = json.loads(p.read_text())["saved_at"]
b = MDDManager()
monkeypatch.setattr(b, "load_from_redis", _redis_loader(b, 10_000_000.0))
async def _r_at():
return file_at - 100.0
monkeypatch.setattr(B, "_read_redis_saved_at_v2", _r_at)
assert await B.restore_mdd_state_v2(b, p) == "file"
assert b.peak_balance == 7_000_000.0
@pytest.mark.asyncio
async def test_save_everywhere_stamps_same_saved_at(tmp_path, monkeypatch):
p = tmp_path / "m.json"
a = MDDManager()
a.peak_balance = 5_000_000.0
seen = {}
async def _w(key, state):
seen.update(state)
monkeypatch.setattr(a, "_write_state_v2", _w)
assert await B.save_mdd_state_everywhere_v2(a, p)
import json
assert seen["saved_at"] == json.loads(p.read_text())["saved_at"]
assert not await B.save_mdd_state_everywhere_v2(MDDManager(), p)