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