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,205 @@
"""2026-10-09 db_worker 하드닝 회귀: DLQ 경보, 비차단 백오프/회로, 추가매수 remaining_pct 불변식."""
import asyncio
from types import SimpleNamespace
import pytest
import app.services.db_worker as dbw
from app.core import log_throttle
def _run(coro):
return asyncio.run(coro)
@pytest.fixture(autouse=True)
def _iso(monkeypatch, tmp_path):
log_throttle.reset_for_tests()
monkeypatch.setattr(dbw, "DLQ_DIR", tmp_path / "dlq")
monkeypatch.setattr(dbw, "_db_task_queue", None)
yield
log_throttle.reset_for_tests()
# ── 1. DLQ 텔레그램 ──────────────────────────────────────────────────────────
def test_save_to_dlq_sends_throttled_telegram(monkeypatch):
sent = []
async def fake_send(category, message):
sent.append((category, message))
monkeypatch.setattr(dbw, "_send_alert_impl", fake_send)
async def go():
await dbw._save_to_dlq({"type": "close_active_trade", "data": {}}, "boom")
await dbw._save_to_dlq({"type": "close_active_trade", "data": {}}, "boom")
await asyncio.sleep(0.05)
_run(go())
assert len(sent) == 1 and sent[0][0] == "DLQ" # throttle
assert len(list(dbw.DLQ_DIR.glob("*.json"))) == 2
def test_dlq_write_failure_alerts(monkeypatch):
sent = []
async def fake_send(category, message):
sent.append(message)
monkeypatch.setattr(dbw, "_send_alert_impl", fake_send)
def bad(*a, **k):
raise OSError("disk full")
monkeypatch.setattr(dbw, "DLQ_DIR", type("P", (), {"mkdir": bad, "__truediv__": bad})())
async def go():
await dbw._save_to_dlq({"type": "upsert_stock_ban", "data": {}}, "x")
await asyncio.sleep(0.05)
_run(go())
assert len(sent) == 1 and "유실" in sent[0]
# ── 2. 비차단 백오프 ─────────────────────────────────────────────────────────
def test_failed_task_does_not_block_following_tasks(monkeypatch):
done = []
async def fake_exec(task):
if task["type"] == "bad":
raise RuntimeError("db down")
done.append(task["type"])
monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec)
async def fake_dlq(task, err):
return None
monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq)
monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999)
async def go():
q = dbw.get_db_task_queue()
w = asyncio.ensure_future(dbw.db_writer_worker())
await q.put({"type": "bad", "data": {}})
await q.put({"type": "good1", "data": {}})
await q.put({"type": "good2", "data": {}})
await asyncio.sleep(0.2) # 백오프 최소 1초 — 그 안에 정상 태스크는 처리돼야 한다
snapshot = list(done)
w.cancel()
try:
await w
except asyncio.CancelledError:
pass
return snapshot
assert _run(go()) == ["good1", "good2"]
def test_circuit_open_keeps_retry_count(monkeypatch):
async def fake_exec(task):
raise RuntimeError("db down")
monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec)
monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 2)
monkeypatch.setattr(dbw, "CIRCUIT_RETRY_DELAY_SEC", 0.01)
dropped = []
async def fake_dlq(task, err):
dropped.append(task)
monkeypatch.setattr(dbw, "_save_to_dlq", fake_dlq)
async def fake_one(task, started): # 종료 drain 은 별개 — 여기서는 워커 루프 중 DLQ 만 본다
return None
monkeypatch.setattr(dbw, "_drain_one_impl", fake_one)
async def go():
q = dbw.get_db_task_queue()
w = asyncio.ensure_future(dbw.db_writer_worker())
t = {"type": "x", "data": {}}
await q.put(t)
await q.put({"type": "y", "data": {}}) # 연속 실패 2회 → 회로 열림
await asyncio.sleep(0.3)
rc = t.get("_retry_count", 0)
state = dbw.get_worker_health_v2()
w.cancel()
try:
await w
except asyncio.CancelledError:
pass
return rc, state
rc, state = _run(go())
assert dropped == [] # 장애 구간에서 DLQ 로 조기 유실되지 않는다
assert rc <= 1 # 회로 열림 전 1회만 소모
assert state["circuit_open"] is True
def test_cancel_during_backoff_drains_task(monkeypatch):
drained = []
async def fake_exec(task):
raise RuntimeError("once")
monkeypatch.setattr(dbw, "_execute_db_task_impl", fake_exec)
monkeypatch.setattr(dbw, "CIRCUIT_OPEN_FAILURES", 999)
async def fake_one(task, started):
drained.append("drain:" + task["type"])
monkeypatch.setattr(dbw, "_drain_one_impl", fake_one)
async def go():
q = dbw.get_db_task_queue()
w = asyncio.ensure_future(dbw.db_writer_worker())
await q.put({"type": "t", "data": {}})
await asyncio.sleep(0.1) # 백오프(1s) 대기 중
w.cancel()
try:
await w
except asyncio.CancelledError:
pass
_run(go())
assert drained == ["drain:t"]
# ── 3. 추가매수 remaining_pct 불변식 ─────────────────────────────────────────
def _rec(**kw):
base = dict(id=7, quantity=100, entry_price=10000.0, highest_since_entry=10500.0, remaining_pct=0.5)
base.update(kw)
return SimpleNamespace(**base)
def test_accumulate_after_partial_exit_keeps_held_invariant():
r = _rec() # 원본 100, 실보유 50
data = {"accumulate_entry": True, "quantity": 50, "entry_price": 8000.0}
dbw._apply_entry_price_adjustments_v2(r, data)
assert r.quantity == 150
assert r.quantity * r.remaining_pct == pytest.approx(100.0, abs=0.01) # 실보유 100
r2 = _rec() # 재실행(종료 drain)도 같은 값
dbw._apply_entry_price_adjustments_v2(r2, data)
assert (r2.quantity, r2.remaining_pct) == (r.quantity, r.remaining_pct)
def test_accumulate_without_prior_exit_leaves_remaining_pct():
r = _rec(remaining_pct=1.0)
dbw._apply_entry_price_adjustments_v2(r, {"accumulate_entry": True, "quantity": 10, "entry_price": 9000.0})
assert r.remaining_pct == 1.0 and r.quantity == 110
def test_accumulate_ignores_caller_remaining_pct():
r = _rec()
skip = dbw._apply_entry_price_adjustments_v2(
r, {"accumulate_entry": True, "quantity": 50, "entry_price": 8000.0, "remaining_pct": 1.0})
assert "remaining_pct" in skip
def test_fill_adjust_uses_held_quantity_as_denominator():
# 실보유 100(원본150×2/3), 추가매수 50주가 주문가보다 100원 싸게 체결
r = _rec(quantity=150, remaining_pct=100 / 150, entry_price=9000.0)
d = {"entry_fill_adjust": {"order_price": 8000.0, "filled_price": 7900.0, "filled_qty": 50}}
dbw._apply_entry_price_adjustments_v2(r, d)
assert r.entry_price == pytest.approx(9000.0 - 100 * 50 / 100, abs=0.01)