Files
auto-stock/backend/tests/test_db_worker_hardening_20261009.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

206 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)