Files
auto-stock/backend/tests/test_round5_fixes_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

354 lines
16 KiB
Python

"""[5회차 2026-10-02] 최종 게이트 결함 회귀 — H-3 / M-1(MDD) / M-2(주문 락) / L-6.
(H-1 시장가 미취소·지정가 롤백, L-5 는 tests/test_round4_fixes_20261002.py)
재현: scratchpad/r5/test_r5_repro.py 의 MDD·스윙 재시도 시나리오를 뒤집어 고정한다.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.core.config import settings
from app.services import mdd_manager as mm
from app.services import trade_executor as te
async def _nosleep(*a, **k):
return None
# ═════════════════════════════════════════════════════════════════════════════
# H-3: 전송 후 결과 불명은 재전송 금지
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.parametrize("resp,unknown,retry", [
({"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"}, True, False),
({"rt_cd": "-1", "msg1": "x"}, True, False), # msg_cd 없음
({}, True, False), # 응답 없음
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}, False, True),
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CIRCUIT_OPEN"}, False, True),
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION"}, False, True),
({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}, False, True),
({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY"}, False, True),
({"rt_cd": "1", "msg_cd": "EGW00201"}, False, True),
({"rt_cd": "1", "msg_cd": "EGW00123"}, False, True),
({"rt_cd": "1", "msg_cd": "APBK0013"}, False, False), # 업무 거부
])
def test_h3_classification(resp, unknown, retry):
assert te._is_order_outcome_unknown_v2(resp) is unknown
assert te._is_retryable_order_failure(resp) is retry
@pytest.mark.asyncio
async def test_h3_repro_transport_unknown_not_resent(monkeypatch):
"""재현 뒤집기: KIS_TRANSPORT_UNKNOWN + 주문내역 조회 [] → 재전송하지 않는다(sent == [10])."""
sent = []
async def _po(code, side, qty, price):
sent.append(qty)
if len(sent) == 1:
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"}
return {"rt_cd": "0", "output": {"ODNO": "9"}}
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
monkeypatch.setattr(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[]))
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
r = await te._place_order_with_retry(True, "005930", "BUY", 10, 70000.0)
assert sent == [10], sent
assert te._is_order_outcome_unknown_v2(r)
@pytest.mark.asyncio
async def test_h3_unknown_found_in_orders_returns_lookup_marked(monkeypatch):
async def _po(*a):
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"}
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
from datetime import datetime
now = datetime.now().strftime("%H%M%S")
monkeypatch.setattr(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[
{"pdno": "005930", "sll_buy_dvsn_cd": "01", "ord_qty": "3", "ord_tmd": now, "odno": "77"},
]))
r = await te._place_order_with_retry(True, "005930", "SELL", 3, None)
assert r["rt_cd"] == "0" and r["_matched_by_lookup"] is True
@pytest.mark.asyncio
async def test_h3_sell_unknown_recorded_pending_not_failed(monkeypatch):
"""결과 불명 매도는 PENDING — 30초 중복매도 차단·당일 매도 판정이 이 주문을 센다."""
import app.services.db_worker as dbw
rows = []
async def _th(p):
rows.append(p)
async def _po(**kw):
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"}
monkeypatch.setattr(te, "_place_order_with_retry", _po)
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True))
monkeypatch.setattr(te, "_release_order_lock", AsyncMock())
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
monkeypatch.setattr(dbw, "enqueue_trade_history", _th)
monkeypatch.setattr(te.korea_investment_api, "get_current_price", AsyncMock(side_effect=AssertionError))
class _DB:
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: SimpleNamespace(first=lambda: None, all=lambda: []))
from app.services.daytrading.position_manager import day_trading_state
day_trading_state.positions.pop("005930", None)
h = SimpleNamespace(quantity=10, average_price=60000, profit_loss_rate=0, strategy_type="swing",
stock_name="삼성", current_price=70000)
r = await te._execute_sell({"stock_code": "005930", "strategy_type": "swing", "remaining_pct": 1.0},
_DB(), h, 70000, ["x"], True, "KRX", "005930", "삼성", sell_pct=1.0)
assert r["success"] is False
assert rows and rows[-1]["status"] == "PENDING"
def test_h3_buy_unknown_is_pending_and_counted():
import inspect
src = inspect.getsource(te._execute_buy)
assert 'status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED")' in src
i = src.index("elif _outcome_unknown:")
blk = src[i:i + 900]
assert "note_system_buy(stock_code, quantity)" in blk and "_note_cycle_buy_v2(" in blk
# 거부 쿨다운은 결과 불명에 걸지 않는다
assert "not _outcome_unknown and not _is_retryable_order_failure(order_result)" in src
# ═════════════════════════════════════════════════════════════════════════════
# M-1: MDD 축소 — 결과 불명은 잔고 확인 전 재매도 금지, 일시 실패는 횟수 미산정
# ═════════════════════════════════════════════════════════════════════════════
class _Acct:
def __init__(self, qty, with_psbl=True):
self.qty = qty
self.open_sell = 0 # KIS 에 살아 있는 미체결 매도
self.with_psbl = with_psbl
self.sent = []
self.script = [] # safe_place_order 응답 순서
async def get_balance(self, *a, **k):
row = {"pdno": "005930", "prdt_name": "S", "hldg_qty": str(self.qty), "prpr": "70000",
"pchs_avg_pric": "80000"}
if self.with_psbl:
row["ord_psbl_qty"] = str(self.qty - self.open_sell)
return {"rt_cd": "0", "output1": [row]}
async def order(self, stock_code, order_type, quantity, price=None, **kw):
self.sent.append(quantity)
r = self.script.pop(0) if self.script else {"rt_cd": "0", "output": {"ODNO": "1"}}
if r.get("rt_cd") == "0":
self.qty -= quantity
return r
@pytest.fixture
def mdd_env(monkeypatch):
import app.services.db_worker as dbw
import app.core.market_schedule as ms
async def _noop(*a, **k):
return None
monkeypatch.setattr(mm, "_sync_active_trade_after_reduction", _noop)
monkeypatch.setattr(mm, "_reflect_reduction", _noop)
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(ms, "is_market_open", lambda: True)
monkeypatch.setattr(mm.asyncio, "sleep", _nosleep)
def _make(acct):
# mdd_manager 는 함수 안에서 모듈 싱글톤을 import 한다 — 모듈 재적재로 te 쪽 참조와
# 다를 수 있으므로 둘 다 덮는다.
import app.services.korea_investment_api as kmod
monkeypatch.setattr(kmod.korea_investment_api, "get_balance", acct.get_balance)
monkeypatch.setattr(te.korea_investment_api, "get_balance", acct.get_balance)
monkeypatch.setattr(te, "safe_place_order", acct.order)
m = mm.MDDManager()
m.save_to_redis = AsyncMock()
return m
return _make
_ACT = {"action": "REDUCE_AND_PAUSE", "reduce_pct": 0.3}
_UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"}
async def _armed(m):
await m.update(100.0)
await m.update(89.0)
@pytest.mark.asyncio
async def test_m1_repro_unknown_not_resold_without_confirmation(mdd_env):
"""재현 뒤집기: 잔고에 주문가능 수량이 없어 확인 불가 → 재매도하지 않는다(sent == [30])."""
a = _Acct(100, with_psbl=False)
a.script = [dict(_UNKNOWN)]
m = mdd_env(a)
await _armed(m)
await m.execute_reduction(dict(_ACT))
await m.execute_reduction(dict(_ACT))
assert a.sent == [30], a.sent
@pytest.mark.asyncio
async def test_m1_unknown_but_sold_marks_done(mdd_env):
a = _Acct(100)
a.script = [dict(_UNKNOWN)]
m = mdd_env(a)
await _armed(m)
await m.execute_reduction(dict(_ACT))
a.qty = 70 # 실제로는 체결됨
await m.execute_reduction(dict(_ACT))
assert a.sent == [30]
assert "005930" in m._reduced_codes["10pct"] and m._reduction_executed_10pct is True
@pytest.mark.asyncio
async def test_m1_unknown_open_order_waits_then_not_sent_reorders(mdd_env):
a = _Acct(100)
a.script = [dict(_UNKNOWN)]
m = mdd_env(a)
await _armed(m)
await m.execute_reduction(dict(_ACT))
a.open_sell = 30 # 미체결 매도가 살아 있음 → 대기
await m.execute_reduction(dict(_ACT))
assert a.sent == [30]
a.open_sell = 0 # 미접수로 판명(보유·주문가능 불변) → 정상 재주문
await m.execute_reduction(dict(_ACT))
assert a.sent == [30, 30] and a.qty == 70
@pytest.mark.asyncio
async def test_m1_transient_failures_not_counted(mdd_env):
cap = settings.MDD_REDUCTION_MAX_RETRIES
a = _Acct(100)
a.script = [{"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}] * (cap + 2)
m = mdd_env(a)
await _armed(m)
for _ in range(cap + 2):
await m.execute_reduction(dict(_ACT))
assert "005930" not in m._reduction_gave_up.get("10pct", set()), "일시 장애로 당일 포기"
assert m._reduction_attempts.get("10pct", {}).get("005930", 0) == 0
await m.execute_reduction(dict(_ACT))
assert a.qty == 70
def test_m1_unconfirmed_persisted():
m = mm.MDDManager()
m._reduction_unconfirmed = {"10pct": {"005930": {"qty_before": 100, "sell_qty": 30, "cycles": 1}}}
st = json.loads(json.dumps(m._reduction_progress_state()))
m2 = mm.MDDManager()
m2._restore_reduction_progress(st)
assert m2._reduction_unconfirmed == m._reduction_unconfirmed
# ═════════════════════════════════════════════════════════════════════════════
# M-2: 프로세스 내 in-flight 주문 락
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
@pytest.mark.parametrize("redis_mode", ["none", "error"])
async def test_m2_inflight_blocks_duplicate_when_redis_unavailable(monkeypatch, redis_mode):
import app.core.redis as r
def _gr():
if redis_mode == "error":
raise TimeoutError("redis timeout")
return None
monkeypatch.setattr(r, "get_redis", _gr)
monkeypatch.setattr(r, "delete_key", AsyncMock())
assert await te._acquire_order_lock("005930", "SELL") is True
assert await te._acquire_order_lock("005930", "SELL") is False, "같은 종목 SELL 중복 허용"
assert await te._acquire_order_lock("005930", "BUY") is True # 방향이 다르면 독립
await te._release_order_lock("005930", "SELL")
assert await te._acquire_order_lock("005930", "SELL") is True
@pytest.mark.asyncio
async def test_m2_redis_held_elsewhere_does_not_leave_local_entry(monkeypatch):
import app.core.redis as r
client = SimpleNamespace(set=AsyncMock(return_value=None))
monkeypatch.setattr(r, "get_redis", lambda: client)
assert await te._acquire_order_lock("005930", "SELL") is False
assert ("005930", "SELL") not in te._inflight_order_locks
def test_m2_local_entry_expires_after_ttl(monkeypatch):
te._inflight_order_locks[("005930", "SELL")] = te.time.monotonic() - te._ORDER_LOCK_TTL - 1
assert te._local_order_lock_held_v2(("005930", "SELL")) is False
# ═════════════════════════════════════════════════════════════════════════════
# L-6: MDD Redis 쓰기는 Lock 밖
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_l6_redis_write_outside_lock(monkeypatch):
import app.core.redis as r
m = mm.MDDManager()
held = []
async def _set(key, val, **kw):
held.append(m._lock.locked())
return True
monkeypatch.setattr(r, "get_redis", lambda: SimpleNamespace(set=_set))
await m.update(100.0)
await m.update(89.0) # -11% 전이(이전엔 락 안에서 save_to_redis)
await m.reset()
assert held and not any(held), held
# ═════════════════════════════════════════════════════════════════════════════
# H-1 + 인프라 cancel_order_v2: 원주문 org_no 전달, ORGNO_NOT_FOUND 는 롤백 없음
# ═════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def cancel_env(monkeypatch):
import app.services.db_worker as dbw
marks, ats = [], []
async def _upd(order_id, fields):
marks.append((order_id, dict(fields)))
async def _u(p):
ats.append(dict(p))
monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u)
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
monkeypatch.setattr(te.korea_investment_api, "get_order_execution", AsyncMock(return_value={
"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 5}))
return SimpleNamespace(marks=marks, ats=ats)
_SNAP5 = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0, "quantity": 10}
@pytest.mark.asyncio
async def test_h1_cancel_uses_v2_with_org_no(cancel_env, monkeypatch):
cancel = AsyncMock(return_value={"rt_cd": "0"})
monkeypatch.setattr(te.korea_investment_api, "cancel_order_v2", cancel)
await te._verify_and_sync_execution_v2(
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP5), order_org_no="06010",
)
cancel.assert_awaited_once_with("0001", org_no="06010", quantity=5, stock_code="005930")
assert cancel_env.marks and cancel_env.ats == [_SNAP5]
@pytest.mark.asyncio
async def test_h1_orgno_not_found_no_rollback(cancel_env, monkeypatch, caplog):
monkeypatch.setattr(te.korea_investment_api, "cancel_order_v2", AsyncMock(
return_value={"rt_cd": "-1", "msg_cd": "ORGNO_NOT_FOUND", "msg1": "주문조직번호 없음"}))
with caplog.at_level("ERROR"):
await te._verify_and_sync_execution_v2(
"0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0,
cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP5),
)
assert cancel_env.marks == [] and cancel_env.ats == [], "취소 미확정인데 CANCELLED/롤백"
assert any("수동 취소" in r.message for r in caplog.records)
def test_h1_execute_sell_passes_org_no_from_order_response():
import inspect
src = inspect.getsource(te._execute_sell)
assert "order_org_no=_extract_org_no_v2(order_result)" in src