하네스 규칙 검사 전역 위반 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>
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""스윙 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
|