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:
166
backend/tests/test_mdd_state_backup.py
Normal file
166
backend/tests/test_mdd_state_backup.py
Normal 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)
|
||||
Reference in New Issue
Block a user