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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user