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

@@ -278,10 +278,15 @@ def manual_buy_env(monkeypatch):
async def _noop(*a, **k):
return None
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
verifies = []
async def _verify(**kw):
verifies.append(kw)
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _verify)
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _noop)
monkeypatch.setattr(sbr, "note_system_buy", lambda *a, **k: None)
return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, ad=ad, k=k, cf=cf)
return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, verifies=verifies, ad=ad, k=k, cf=cf)
@pytest.mark.asyncio
@@ -522,3 +527,123 @@ async def test_low_delete_pending_only_before_today():
assert out["deleted_count"] == 0
sql = str(db.stmts[0].compile(compile_kwargs={"literal_binds": False}))
assert "created_at <" in sql, "당일 이전 조건이 없다 — 당일 PENDING(일일 한도 근거)까지 삭제"
@pytest.mark.asyncio
async def test_r2_swing_manual_sell_loss_is_net_of_costs(swing_sell_env, monkeypatch):
"""왕복 비용 이내 소폭 이익 수동 청산은 패(순손익 기준, §5.1), TradeHistory 는 순손익 저장."""
import app.services.db_worker as dbw
e = swing_sell_env
hist = []
async def _h(d):
hist.append(d)
monkeypatch.setattr(dbw, "enqueue_trade_history", _h)
# 현재가 10000, 진입가 settings 비용(약 0.21%)보다 작은 0.05% 이익
entry = 10000 / 1.0005
at = SimpleNamespace(entry_price=entry)
monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade",
AsyncMock(return_value=at))
monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2",
AsyncMock(return_value=None))
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing")
from app.services.swing.exit_service import _swing_net_pnl_v2
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
expected = _swing_net_pnl_v2(entry, 10000, 6, "KOSPI")["net_profit_loss"]
assert expected < 0 < (10000 - entry) * 6, "전제: gross 이익 / net 손실"
assert hist[0]["net_profit_loss"] == pytest.approx(expected)
assert e.rec.await_args.kwargs["is_loss"] is True
@pytest.mark.asyncio
async def test_r2_swing_manual_sell_prior_leg_profit_makes_win(swing_sell_env, monkeypatch):
"""앞 레그 실현이익이 마지막 소폭 손실 레그를 상쇄하면 승."""
e = swing_sell_env
entry = 10100.0 # 현재가 10000 → gross 손실
monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade",
AsyncMock(return_value=SimpleNamespace(entry_price=entry)))
monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2",
AsyncMock(return_value=100000.0))
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing")
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
assert e.rec.await_args.kwargs["is_loss"] is False
# ── R3 2026-10-09: 수동 매수 결과 불명 / 지정가 PENDING ──────────────────────
@pytest.fixture
def mb_capture(manual_buy_env, monkeypatch):
import app.services.db_worker as dbw
import app.services.trade_executor as te
hist, ports = [], []
async def _h(d):
hist.append(d)
async def _p(d):
ports.append(d)
monkeypatch.setattr(dbw, "enqueue_trade_history", _h)
monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _p)
e = manual_buy_env
e.mb._last_done.clear()
e.hist, e.ports, e.te = hist, ports, te
monkeypatch.setattr(e.mb, "spawn_tracked_v2", lambda c: c.close())
return e
@pytest.mark.asyncio
async def test_r3_manual_buy_transport_unknown_sets_cooldown(mb_capture, monkeypatch):
e = mb_capture
async def _unk(**kw):
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"}
monkeypatch.setattr(e.te, "safe_place_order", _unk)
with pytest.raises(HTTPException) as ei:
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
assert ei.value.status_code == 504
assert "005930" in e.mb._last_done
assert e.hist and e.hist[0]["status"] == "PENDING" and not e.ports
@pytest.mark.asyncio
async def test_r3_manual_buy_presend_failure_no_cooldown(mb_capture, monkeypatch):
e = mb_capture
async def _pre(**kw):
return {"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "x"}
monkeypatch.setattr(e.te, "safe_place_order", _pre)
with pytest.raises(HTTPException) as ei:
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
assert ei.value.status_code == 500
assert "005930" not in e.mb._last_done and not e.hist
@pytest.mark.asyncio
async def test_r3_manual_buy_limit_order_pending_no_portfolio_row(mb_capture):
e = mb_capture
px = int(e.px * 0.9)
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", price=px), "005930", _FakeDB([None, 0, []]))
assert e.hist[0]["status"] == "PENDING" and e.hist[0]["executed_quantity"] == 0
assert not e.ports
@pytest.mark.asyncio
async def test_r3_manual_buy_market_order_unchanged(mb_capture):
e = mb_capture
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
assert e.hist[0]["status"] == "COMPLETED" and e.hist[0]["executed_quantity"] == e.orders[0]
assert len(e.ports) == 1
@pytest.mark.asyncio
async def test_r7_manual_limit_buy_spawns_fill_verification(manual_buy_env):
"""지정가 수동 매수의 PENDING 행을 확정할 체결 확인 태스크가 ODNO 로 연결돼야 한다."""
import asyncio
e = manual_buy_env
req = e.mb.BuyRequest(stock_code="005930", price=int(e.px))
await e.mb._buy_stock_impl(req, "005930", _FakeDB([None, 0, []]))
await asyncio.sleep(0)
await asyncio.sleep(0)
assert len(e.verifies) == 1
v = e.verifies[0]
assert v["order_id"] == "1" and v["order_type"] == "BUY" and v["is_domestic"] is True
assert v["sell_pnl_basis"] == {"strategy_type": "manual"}