하네스 규칙 검사 전역 위반 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>
354 lines
16 KiB
Python
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
|