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

@@ -137,6 +137,8 @@ def entry_env(monkeypatch):
monkeypatch.setattr(pcb, "reflect_buy", AsyncMock())
monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment",
AsyncMock(return_value=(True, "")), raising=False)
# [R1-2 2026-10-09] 주문 직전 신선가 조회 — 이 픽스처의 테스트는 분봉가 경로(조회 실패 폴백)를 본다
monkeypatch.setattr(ens, "_fetch_fresh_price_v2_impl", AsyncMock(return_value=None))
return captured
@@ -483,7 +485,7 @@ async def test_m2_positions_endpoint_serializes_inf():
pos = _pos()
pos["target_price"] = float("inf")
day_trading_state.positions["555555"] = pos
with patch.object(dt, "_get_current_price", AsyncMock(return_value=10_100.0)):
with patch.object(dt, "_get_current_price_v2", AsyncMock(return_value=10_100.0)):
res = await dt.get_positions()
JSONResponse(content=res) # inf 가 남아 있으면 ValueError(=500)
assert res["positions"][0]["target_price"] is None
@@ -497,7 +499,7 @@ def test_m4_orb_branch_runs_stop_check_before_collection():
from app.services.daytrading import scheduler as sched
src = inspect.getsource(sched.daytrading_scheduler)
orb = src[src.index("if is_opening_range_period():"):]
orb = orb[:orb.index("await asyncio.sleep(30)")]
orb = orb[:orb.index("await _cycle_sleep(30)")]
assert "check_stops_only_v2()" in orb
assert orb.index("check_stops_only_v2()") < orb.index("collect_opening_ranges()")
@@ -541,9 +543,13 @@ async def test_m5_manual_cycle_rejects_while_scheduler_holds_lock():
async def test_m5_manual_cycle_gates_and_holds_lock():
from fastapi import HTTPException
from app.api.endpoints import day_trading as dt
with patch.object(settings, "DAY_TRADE_ENABLED", False):
with pytest.raises(HTTPException):
# 장 운영 시간 밖이면 409 (청산·진입 모두 호출하지 않음 — 예외형 목)
with patch.object(dt, "is_market_open", return_value=False), \
patch.object(es, "check_and_exit_positions", _boom("check_and_exit_positions")), \
patch.object(ens, "scan_and_enter", _boom("scan_and_enter")):
with pytest.raises(HTTPException) as ei:
await dt.manual_cycle()
assert ei.value.status_code == 409
seen = []
@@ -565,6 +571,56 @@ async def test_m5_manual_cycle_gates_and_holds_lock():
assert res["entry_skipped"]
@pytest.mark.asyncio
async def test_r4_manual_cycle_runs_exit_when_dt_disabled():
"""[R4 2026-10-09] DAY_TRADE_ENABLED=False 여도 청산 체크는 수행(스케줄러 규약과 일치).
진입 시간창 안이어도 scan_and_enter 는 호출되면 안 된다 — 예외형 목으로 고정.
"""
from app.api.endpoints import day_trading as dt
seen = []
async def _exit():
seen.append(("exit", dt_cycle_lock.locked()))
return [{"stock_code": "111111", "reason": "손절"}]
with patch.object(dt, "is_market_open", return_value=True), \
patch.object(dt, "is_day_trade_entry_window", return_value=True), \
patch.object(settings, "DAY_TRADE_ENABLED", False), \
patch.object(es, "check_and_exit_positions", _exit), \
patch.object(ens, "scan_and_enter", _boom("scan_and_enter")):
res = await dt.manual_cycle()
assert seen == [("exit", True)], "비활성이어도 청산은 락 안에서 1회 수행"
assert res["exits"] == [{"stock_code": "111111", "reason": "손절"}]
assert res["entries"] == []
assert res["entry_skipped"] == "DAY_TRADE_ENABLED=False"
@pytest.mark.asyncio
async def test_r4_manual_cycle_enabled_in_window_scans():
"""활성 + 진입창 안이면 청산 후 진입 스캔(기존 동작 유지)."""
from app.api.endpoints import day_trading as dt
order = []
async def _exit():
order.append("exit")
return []
async def _scan():
order.append("scan")
return [{"stock_code": "222222"}]
with patch.object(dt, "is_market_open", return_value=True), \
patch.object(dt, "is_day_trade_entry_window", return_value=True), \
patch.object(settings, "DAY_TRADE_ENABLED", True), \
patch.object(es, "check_and_exit_positions", _exit), \
patch.object(ens, "scan_and_enter", _scan):
res = await dt.manual_cycle()
assert order == ["exit", "scan"]
assert res["entry_skipped"] is None
assert res["entries"] == [{"stock_code": "222222"}]
# ═════════════════════════════════════════════════════════════════════════════
# M6 / M8 — scan_and_enter 게이트
# ═════════════════════════════════════════════════════════════════════════════
@@ -1421,7 +1477,7 @@ async def test_r5_lookup_failures_hold_and_alert_telegram(monkeypatch):
sent = await _r5_setup(monkeypatch, AsyncMock(side_effect=RuntimeError("KIS down")))
alert = AsyncMock()
from app.services import telegram_notifier as tn
monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", alert)
monkeypatch.setattr(tn.TelegramNotifier, "notify_system_warning", alert)
monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 3)
day_trading_state.positions["333333"] = p = _pos(10)
await es._execute_exit("333333", p, 9_600.0, "손절")
@@ -1477,3 +1533,63 @@ async def test_r5_stale_row_without_holdings_drop_is_not_acceptance(monkeypatch)
with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
await _run_stale(2, _stale_env(_order, bal=_bal_seq("064400", (4, 4))))
assert len(calls) == 2, "잔고 감소 없는 주문내역 행을 접수로 오인해 재매도를 막았다"
# ═════════════════════════════════════════════════════════════════════════════
# R7 — /positions 현재가 조회 실패 시 -100% 표시 금지
# ═════════════════════════════════════════════════════════════════════════════
def _boom_price(*a, **k):
raise RuntimeError("KIS 현재가 장애(테스트)")
@pytest.mark.asyncio
async def test_r7_positions_price_failure_uses_engine_cached_price(monkeypatch, caplog):
from app.services.korea_investment_api import KoreaInvestmentAPI
from app.api.endpoints import day_trading as dt
monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(side_effect=_boom_price))
dt._price_fail_logged_at.clear()
pos = _pos()
entry = float(pos["entry_price"])
pos["current_price"] = entry * 1.01
day_trading_state.positions["666666"] = pos
with caplog.at_level("WARNING"):
res = await dt.get_positions()
row = [r for r in res["positions"] if r["stock_code"] == "666666"][0]
assert row["price_source"] == "engine_cached"
assert row["current_price"] == pytest.approx(entry * 1.01)
assert row["pnl_pct"] == pytest.approx(1.0, abs=0.01)
assert any("현재가 조회 실패" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_r7_positions_price_failure_without_cache_returns_none(monkeypatch, caplog):
from fastapi.responses import JSONResponse
from app.services.korea_investment_api import KoreaInvestmentAPI
from app.api.endpoints import day_trading as dt
monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price", AsyncMock(side_effect=_boom_price))
dt._price_fail_logged_at.clear()
pos = _pos()
pos.pop("current_price", None)
day_trading_state.positions["777777"] = pos
with caplog.at_level("WARNING"):
res = await dt.get_positions()
await dt.get_positions() # 두 번째 실패는 스로틀로 경고 생략
JSONResponse(content=res)
row = [r for r in res["positions"] if r["stock_code"] == "777777"][0]
assert row["current_price"] is None
assert row["pnl"] is None and row["pnl_pct"] is None
assert row["price_source"] == "unavailable"
warns = [r for r in caplog.records if "777777" in r.message and "현재가 조회 실패" in r.message]
assert len(warns) == 1
@pytest.mark.asyncio
async def test_r7_get_current_price_legacy_contract_and_zero_price(monkeypatch):
from app.services.korea_investment_api import KoreaInvestmentAPI
from app.api.endpoints import day_trading as dt
monkeypatch.setattr(KoreaInvestmentAPI, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "0"}}))
dt._price_fail_logged_at.clear()
assert await dt._get_current_price_v2("888888") is None
assert await dt._get_current_price("888888") == 0.0