- 스윙 체결가 확인 전 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
206 lines
7.0 KiB
Python
206 lines
7.0 KiB
Python
"""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)
|