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>
This commit is contained in:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -0,0 +1,340 @@
"""스윙 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