Files
auto-stock/backend/tests/test_swing_hardening_r3_cap_20261009.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

161 lines
7.2 KiB
Python
Raw Permalink 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
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)