- 스윙 체결가 확인 전 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
158 lines
7.0 KiB
Python
158 lines
7.0 KiB
Python
"""[하드닝 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
|