"""[하드닝 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