Files
auto-stock/backend/tests/test_swing_hardening_r3_cap_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

158 lines
7.0 KiB
Python
Raw 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.
"""[하드닝 R3 2026-10-09] 스윙 매도 직전 KIS 상한 — DT 이중 차감 / 상한 매도의 '전량 청산' 오처리.
결함: 상한 = min(주문가능, 보유) − DT. 주문가능에는 대기 중인 매도 주문(DT 미체결 매도 포함)이 이미
빠져 있어 DT 를 이중 차감했고, 상한에 걸려 일부만 판 매도도 `sell_qty >= quantity(캡 적용 후)` 로
ActiveTrade close·loss_tracker·closed_today 까지 '전량 청산' 처리했다.
수정: 상한 = min(주문가능, 보유 − DT). 전량 판정은 상한 적용 전 스윙 보유(min(장부, 보유 − DT)) 대비.
KIS 시뮬레이터·주문 목(공통 종단 safe_place_order)은 R5 테스트의 _Sim 을 재사용한다(§4.1).
"""
from unittest.mock import AsyncMock
import pytest
from app.services.mean_reversion_strategy import ExitSignal
from tests.test_swing_r5_pending_sell_20261002 import UNKNOWN, _Sim, _reset # noqa: F401 (autouse fixture)
def _stop(remaining):
return ExitSignal("SELL_ALL", "손절", 1.0)
def _partial(remaining):
return ExitSignal("SELL_PARTIAL", "1차 익절", 0.5) if remaining > 0.5 else None
def _track(monkeypatch, sim):
"""ActiveTrade upsert 의 quantity·remaining_pct 와 closed_today 를 기록."""
import app.services.db_worker as dbw
from app.services.daytrading.position_manager import day_trading_state
sim.upserts = []
async def _upsert(p):
sim.upserts.append(dict(p))
if sim.at is not None and "remaining_pct" in p:
sim.at.remaining_pct = p["remaining_pct"]
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _upsert)
monkeypatch.setattr(day_trading_state, "closed_today", set())
return day_trading_state
@pytest.mark.asyncio
async def test_dt_pending_sell_capped_sale_is_not_full_close(monkeypatch):
"""[재현 반전] 스윙 10 + DT 5, DT 매도 5주 미체결(보유 15, 주문가능 10). 상한은 과다매도 방지를
위해 보수적으로 10−5=5 유지(대기분이 스윙 것일 수 있음). 이전: 5주만 팔고 '전량 청산'
(close·loss_tracker·closed_today). 이제: 분할 처리(잔량 5) → DT 대기 체결 후 잔량 5주 매도 시 전량 청산."""
sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop)
dts = _track(monkeypatch, sim)
sim.kis = {"hldg": 15, "psbl": 10}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert "005930" not in dts.closed_today
assert sim.upserts[-1]["quantity"] == 5
# DT 대기 매도 체결 → DT 인메모리 0, KIS 보유 = 스윙 잔량 5
sim.kis = {"hldg": 5, "psbl": 5}
monkeypatch.setattr(dts, "positions", {})
await sim.cycles(1)
assert sim.sent == [5, 5] and sim.kis["hldg"] == 0
assert sim.closed == 1
sim.loss.assert_awaited_once()
assert "005930" in dts.closed_today
@pytest.mark.asyncio
async def test_swing_own_open_order_not_resold_into_dt_shares(monkeypatch):
"""상한을 보유 − DT 로 완화하면 스윙 자신의 미체결 매도(10주)와 재매도가 모두 체결돼 DT 10주까지
팔린다. 보수적 상한 유지 → 재매도 없음(R5 test_c1_open_order_at_broker_blocks_resell 과 같은 불변식)."""
sim = _Sim(monkeypatch, swing=10, dt=10, signal=_stop)
_track(monkeypatch, sim)
sim.kis = {"hldg": 20, "psbl": 10} # 스윙 10주 매도 주문이 브로커에 미체결
await sim.cycles(2)
assert sim.sent == []
@pytest.mark.asyncio
async def test_capped_stop_is_partial_until_actual_remainder_zero(monkeypatch):
"""스윙 10, 지정가 익절 5주 미체결(보유 10, 주문가능 5)에서 손절. 이전: 5주 매도 + close +
loss_tracker + closed_today. 이제: 5주 매도 후 ActiveTrade 잔량 5·remaining_pct 0.5 만 갱신,
close·loss_tracker 없음. 묶인 주문이 풀리면 다음 주기 잔량 5주 매도 시 그때 전량 청산."""
sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop)
dts = _track(monkeypatch, sim)
sim.kis = {"hldg": 10, "psbl": 5}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert "005930" not in dts.closed_today
assert sim.upserts[-1]["quantity"] == 5
assert sim.upserts[-1]["remaining_pct"] == 0.5 # 단계 차감(1.0−1.0=0) 아님 — 실제 잔량 비율
# 묶여 있는 동안(주문가능 0)에는 주문하지 않는다
await sim.cycles(1)
assert sim.sent == [5]
# 지정가 취소 → 주문가능 회복 → 잔량 매도 = 이번엔 전량 청산
sim.kis["psbl"] = sim.kis["hldg"]
await sim.cycles(1)
assert sim.sent == [5, 5] and sim.kis["hldg"] == 0
assert sim.closed == 1
sim.loss.assert_awaited_once()
assert "005930" in dts.closed_today
@pytest.mark.asyncio
async def test_capped_partial_take_profit_not_closed(monkeypatch):
"""스윙 20 (보유 20, 주문가능 5)에서 50% 익절 10주 → 5주만 매도. 분할 단계는 소진(재발동 방지),
ActiveTrade 잔량 15, close·loss_tracker 없음."""
sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial)
_track(monkeypatch, sim)
sim.kis = {"hldg": 20, "psbl": 5}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert sim.upserts[-1]["quantity"] == 15
assert sim.upserts[-1]["remaining_pct"] == 0.5
await sim.cycles(2)
assert sim.sent == [5] # 같은 단계 재발동 없음
@pytest.mark.asyncio
async def test_capped_stop_unknown_reconciled_as_partial(monkeypatch):
"""상한에 걸린 손절이 결과 불명 → 체결 대조 시에도 스윙 보유(10) 대비 부분 체결로 반영."""
sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop, responses=[UNKNOWN])
_track(monkeypatch, sim)
sim.kis = {"hldg": 10, "psbl": 5}
await sim.cycles(1)
pend = sim.es._pending_unknown_sells["005930"]
assert pend["swing_qty"] == 10 and pend["sell_pct"] == 0.0
await sim.cycles(1)
assert "005930" not in sim.es._pending_unknown_sells
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert sim.upserts[-1]["quantity"] == 5
assert sim.upserts[-1]["remaining_pct"] == 0.5
@pytest.mark.asyncio
async def test_inflated_ledger_still_closes_when_kis_swing_empty(monkeypatch):
"""장부 스윙 10(Portfolio 15 − DT 5)인데 KIS 보유 12 → KIS 스윙 7주 전량 매도 = 전량 청산 유지."""
sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop)
_track(monkeypatch, sim)
sim.kis = {"hldg": 12, "psbl": 12}
await sim.cycles(1)
assert sim.sent == [7]
assert sim.closed == 1
sim.loss.assert_awaited_once()
@pytest.mark.asyncio
async def test_dt_unreflected_buy_never_sells_dt_inmemory_qty(monkeypatch):
"""DT 매수 직후 KIS 보유 미반영(보유 10, DT 인메모리 5) — 보수적으로 보유 − DT = 5 만 매도(과다매도 없음)."""
sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop)
_track(monkeypatch, sim)
sim.kis = {"hldg": 10, "psbl": 10}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.kis["hldg"] == 5