Files
auto-stock/backend/tests/test_swing_r5_pending_sell_20261002.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

341 lines
15 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.
"""스윙 5·6회차 — 결과 불명 매도·DT 물량 과다매도 방지 회귀 테스트 (2026-10-02).
모든 시나리오는 상태를 가진 KIS 시뮬레이터(보유/주문가능 수량이 주문에 따라 실제로 줄어듦)
위에서 **3주기 이상** 돌린다. 5회차 수정은 확인 대기를 1주기만 보호해 3주기째 DT 물량을 팔았다
(6회차 C-1 재현 ①②). 6회차 수정:
(c) 국내 매도 직전 항상 KIS 실보유 확인, 상한 = min(주문가능, 보유) − DT 인메모리
(a) 결과 불명 체결분을 KIS 보유 감소로 확인해 장부(TradeHistory·reflect_sell·ActiveTrade·
loss_tracker) 반영 후 그 주기 skip
(b) 결과 불명 직후 Portfolio sync 요청
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order). 기대값은 settings.*.
"""
import asyncio
import logging
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.services.mean_reversion_strategy as mr
from app.core.config import settings
from app.services.mean_reversion_strategy import ExitSignal
UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout", "odno": None}
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
import app.services.swing.exit_service as es
monkeypatch.setattr(es, "_pending_unknown_sells", {})
monkeypatch.setattr(es, "_last_live_prices", {})
monkeypatch.setattr(es, "_last_atr", {})
monkeypatch.setattr(es, "_presell_check_failures", {})
monkeypatch.setattr(es, "_no_price_skips", {})
from app.core import log_throttle
log_throttle.reset_for_tests()
import app.services.adaptive_strategy as ad
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
import app.services.trade_executor as te
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
class _Sim:
"""KIS 계좌 + 앱 장부 시뮬레이터."""
def __init__(self, monkeypatch, *, swing, dt, price=8000, avg=10000, signal=None,
responses=None, fill=None, balance_fail=False):
import app.services.swing.exit_service as es
import app.services.trade_executor as te
import app.services.db_worker as dbw
import app.services.portfolio_cache_bridge as pcb
from app.core.timeutil import now_kst
from app.services.daytrading.position_manager import day_trading_state
self.es = es
self.kis = {"hldg": swing + dt, "psbl": swing + dt}
self.sent = [] # 브로커에 실제 도달한 주문 수량
self.history = [] # enqueue_trade_history payload
self.closed = 0
self.balance_fail = balance_fail
self.responses = list(responses or [])
self.fill = fill # 주문별 실제 체결 수량 함수(q, n) → filled
# 장부: Portfolio 행(sync 없음 — reflect_sell 이 장부 수량만 줄인다)
self.holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=swing + dt,
average_price=avg, current_price=price, market="KRX",
updated_at=now_kst())
self.at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
created_at=now_kst() - timedelta(days=1), entry_reason=None)
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=lambda db: [self.holding]))
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(side_effect=lambda db, c: self.at))
api = MagicMock()
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
bars = [{"stck_clpr": str(avg), "stck_hgpr": str(avg + 50), "stck_lwpr": str(avg - 50)}
for _ in range(25)]
api.get_daily_chart = AsyncMock(return_value={"output2": bars if signal else []})
api.get_balance = AsyncMock(side_effect=self._balance)
self.api = api
monkeypatch.setattr(es, "korea_investment_api", api)
if signal is not None:
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal(kw["remaining_pct"]))
monkeypatch.setattr(day_trading_state, "positions", {"005930": {"quantity": dt}} if dt else {})
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
monkeypatch.setattr(te, "safe_place_order", self._order)
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock(side_effect=lambda p: self.history.append(p)))
async def _close(code, strategy_name=None):
self.closed += 1
self.at = None
monkeypatch.setattr(es, "enqueue_close_active_trade", _close)
async def _upsert(p):
if self.at is not None and "remaining_pct" in p:
self.at.remaining_pct = p["remaining_pct"]
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _upsert)
async def _reflect(code, q, price, remaining_qty=None):
self.holding.quantity = max(0, self.holding.quantity - q)
monkeypatch.setattr(pcb, "reflect_sell", _reflect)
self.loss = AsyncMock()
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", self.loss)
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
async def _balance(self):
if self.balance_fail:
raise RuntimeError("KIS down")
return {"rt_cd": "0", "output1": [{"pdno": "005930", "hldg_qty": str(self.kis["hldg"]),
"ord_psbl_qty": str(self.kis["psbl"])}]}
async def _order(self, **kw):
q = kw["quantity"]
if q > self.kis["psbl"]:
return {"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"}
n = len(self.sent)
filled = self.fill(q, n) if self.fill else q
self.kis["hldg"] -= filled
self.kis["psbl"] -= filled
self.sent.append(filled)
return self.responses[n] if n < len(self.responses) else OK
async def cycles(self, n):
for _ in range(n):
self.es._recent_exits.clear()
await self.es.check_and_execute_exit_v2(MagicMock())
def _partial_signal(remaining):
return ExitSignal("SELL_PARTIAL", "1차 익절", 0.5) if remaining > 0.5 else None
# ── C-1 재현 ① : 손절 전량 불명(체결) → 3주기째 DT 물량 매도 ────────────────
@pytest.mark.asyncio
async def test_c1_unknown_full_stop_never_sells_dt_shares_over_4_cycles(monkeypatch):
"""[재현 반전] 스윙 10 + DT 10, 손절 10주 결과 불명(실제 체결). 이전: 3주기째 DT 10주 매도
(합계 20). 이제: 2주기에 체결분 장부 반영(COMPLETED·close·loss_tracker), 이후 KIS 상한 0."""
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN])
await sim.cycles(4)
assert sum(sim.sent) == 10 and sim.kis["hldg"] == 10 # DT 10주 보존
done = [h for h in sim.history if h["status"] == "COMPLETED" and h["executed_quantity"] == 10]
assert len(done) == 1 and "체결 확인" in done[0]["exit_reason"]
assert sim.closed == 1
sim.loss.assert_awaited_once()
assert "005930" not in sim.es._pending_unknown_sells
# ── C-1 재현 ② : 부분익절 불명(체결) → 같은 단계 재발동 → 전량 매도 ────────
@pytest.mark.asyncio
async def test_c1_unknown_partial_does_not_refire_stage_over_4_cycles(monkeypatch):
"""[재현 반전] 스윙 20 + DT 5, 50% 익절 10주 불명(체결). 이전: remaining_pct 미갱신으로
같은 단계 재발동 → 25주 전량. 이제: 대조 시 remaining_pct=0.5 기록 → 재발동 없음."""
sim = _Sim(monkeypatch, swing=20, dt=5, price=11500, signal=_partial_signal, responses=[UNKNOWN])
await sim.cycles(4)
assert sum(sim.sent) == 10
assert sim.kis["hldg"] == 15
assert sim.at.remaining_pct == 0.5
assert sim.closed == 0
sim.loss.assert_not_awaited() # 분할매도는 loss_tracker 기록 대상 아님
@pytest.mark.asyncio
async def test_c1_unknown_not_filled_resells_once(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[UNKNOWN], fill=lambda q, n: 0 if n == 0 else q)
await sim.cycles(3)
assert sim.sent == [0, 10] # 1주기 미체결 → 2주기 재매도(스윙 10주만)
assert sim.kis["hldg"] == 5
@pytest.mark.asyncio
async def test_c1_unknown_partial_fill_reconciled_then_remainder_sold(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN], fill=lambda q, n: 3 if n == 0 else q)
await sim.cycles(4)
assert sim.sent == [3, 7]
assert sim.kis["hldg"] == 10 # DT 10주 보존
assert any(h["executed_quantity"] == 3 and "체결 확인" in h["exit_reason"] for h in sim.history)
@pytest.mark.asyncio
async def test_c1_open_order_at_broker_blocks_resell(monkeypatch):
"""불명 주문이 브로커에 미체결로 남음(보유 불변·주문가능 감소) → 대기 유지, 상한 0."""
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN], fill=lambda q, n: 0)
orig = sim._order
async def _open(**kw):
r = await orig(**kw)
sim.kis["psbl"] -= kw["quantity"] # 미체결 주문분이 주문가능에서 빠짐
return r
import app.services.trade_executor as te
monkeypatch.setattr(te, "safe_place_order", _open)
await sim.cycles(3)
assert len(sim.sent) == 1
assert "005930" in sim.es._pending_unknown_sells
# ── (c) 불명 없이도: 장부가 부풀어 있으면 KIS 기준 상한 ─────────────────────
@pytest.mark.asyncio
async def test_c_inflated_ledger_capped_by_kis(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=5)
sim.kis = {"hldg": 12, "psbl": 12} # 외부 매도 등으로 실보유 12 (장부 15)
await sim.cycles(3)
assert sim.sent == [7] # 12 − DT 5
assert sim.kis["hldg"] == 5
@pytest.mark.asyncio
async def test_c_balance_failure_holds_and_escalates(monkeypatch, caplog):
sim = _Sim(monkeypatch, swing=10, dt=5, balance_fail=True)
n = int(settings.SWING_PRESELL_HOLDING_CHECK_ERROR_AFTER)
with caplog.at_level(logging.WARNING, logger=sim.es.logger.name):
await sim.cycles(n)
assert sim.sent == []
msgs = [r for r in caplog.records if "KIS 잔고 확인 실패" in r.getMessage()]
assert [r.levelno for r in msgs] == [logging.WARNING] * (n - 1) + [logging.ERROR]
sim.balance_fail = False
await sim.cycles(1)
assert sim.sent == [10]
assert sim.es._presell_check_failures == {}
@pytest.mark.asyncio
async def test_c_balance_failure_while_pending_holds(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN])
await sim.cycles(1)
sim.balance_fail = True
await sim.cycles(3)
assert sum(sim.sent) == 10 and "005930" in sim.es._pending_unknown_sells
sim.balance_fail = False
await sim.cycles(2)
assert sum(sim.sent) == 10 and sim.closed == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("bal", [{"rt_cd": "1", "msg1": "err"}, {"rt_cd": "0"}, None])
async def test_c_bad_balance_response_holds(monkeypatch, bal):
sim = _Sim(monkeypatch, swing=10, dt=5)
sim.api.get_balance = AsyncMock(return_value=bal)
await sim.cycles(2)
assert sim.sent == []
@pytest.mark.asyncio
async def test_b_unknown_triggers_portfolio_sync(monkeypatch):
import app.services.trade_executor as te
sim = _Sim(monkeypatch, swing=10, dt=0, responses=[UNKNOWN])
await sim.cycles(1)
await asyncio.sleep(0)
te._delayed_sync_portfolio.assert_called()
# ── 분류: 전송 전 실패·업무 거부는 재시도, 불명은 대기 ───────────────────────
@pytest.mark.asyncio
@pytest.mark.parametrize("resp", [
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "connect"},
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CIRCUIT_OPEN", "msg1": "cb"},
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION", "msg1": "x"},
{"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE", "msg1": "token"},
{"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"},
{"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"},
])
async def test_presend_and_business_reject_retry_next_cycle(monkeypatch, resp):
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[resp], fill=lambda q, n: 0 if n == 0 else q)
await sim.cycles(1)
assert "005930" not in sim.es._pending_unknown_sells
await sim.cycles(2)
assert sim.sent == [0, 10] and sim.kis["hldg"] == 5
@pytest.mark.asyncio
async def test_msgcd_less_minus_one_is_pending(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[{"rt_cd": "-1", "msg1": "wrapped"}])
await sim.cycles(1)
assert "005930" in sim.es._pending_unknown_sells
await sim.cycles(2)
assert sum(sim.sent) == 10 and sim.kis["hldg"] == 5
@pytest.mark.parametrize("resp,expected", [
({"rt_cd": "0"}, "accepted"),
(UNKNOWN, "unknown"),
({"rt_cd": "-1"}, "unknown"),
(None, "unknown"),
({}, "unknown"),
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}, "retry"),
({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}, "retry"),
({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY"}, "retry"),
({"rt_cd": "-1", "msg1": "order_lock_busy"}, "retry"),
({"rt_cd": "1", "msg_cd": "APBK0013"}, "retry"),
])
def test_classify_swing_sell_response_v2(resp, expected):
import app.services.swing.exit_service as es
assert es._classify_swing_sell_response_v2(resp) == expected
# ── H-1(c) 전량 청산 시 캐시 폐기 / M-4 shield ─────────────────────────────
@pytest.mark.asyncio
async def test_full_close_drops_price_and_atr_cache(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=0)
import time as _t
from app.core.timeutil import now_kst
sim.es._last_atr["005930"] = (100.0, _t.monotonic(), now_kst())
await sim.cycles(1)
assert sim.sent == [10] and sim.closed == 1
assert "005930" not in sim.es._last_live_prices
assert "005930" not in sim.es._last_atr
@pytest.mark.asyncio
async def test_m4_cancel_during_order_still_records(monkeypatch):
"""주문 대기 중 루프가 취소돼도 shield 된 작업이 TradeHistory·ActiveTrade 반영까지 완주."""
sim = _Sim(monkeypatch, swing=10, dt=0)
gate = asyncio.Event()
entered = asyncio.Event()
orig = sim._order
async def _slow(**kw):
entered.set()
await gate.wait()
return await orig(**kw)
import app.services.trade_executor as te
monkeypatch.setattr(te, "safe_place_order", _slow)
sim.es._recent_exits.clear()
task = asyncio.ensure_future(sim.es.check_and_execute_exit_v2(MagicMock()))
await asyncio.wait_for(entered.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
gate.set()
for _ in range(50):
if sim.closed:
break
await asyncio.sleep(0)
assert len(sim.history) == 1 and sim.closed == 1