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,452 @@
"""[4회차 2026-10-02] 검증 결함 회귀 — H-1 / M-2 / M-3 / L-2 / L-3 / L-4.
(M-1 reset 경합·L-1 장외 축소는 tests/test_mdd_reduction_retry_20261002.py)
주문·취소 종단은 trade_executor 가 실제로 참조하는 `te.korea_investment_api` 에 건다.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.core.config import settings
from app.services import trade_executor as te
class _MustNotCall(BaseException):
pass
def _boom(name):
async def _f(*a, **k):
raise _MustNotCall(f"{name} 가 호출되면 안 된다")
return _f
async def _nosleep(*a, **k):
return None
# ═════════════════════════════════════════════════════════════════════════════
# H-1 (a)(b): 매도 가격 — 낡은 배치가 대신 시장가 / 주문 직전 재산정
# ═════════════════════════════════════════════════════════════════════════════
def test_h1_price_policy_pure():
# 전량 청산 → 시장가
assert te._resolve_sell_order_price_v2("005930", 70000, True, True, 5.0)[1:] == (None, "market")
# 손실 청산(부분이어도) → 시장가
assert te._resolve_sell_order_price_v2("005930", 70000, True, False, -3.0)[1:] == (None, "market")
# 부분 익절 → 재산정
assert te._resolve_sell_order_price_v2("005930", 70000, True, False, 4.0)[2] == "limit_refresh"
# 해외 → 기존 지정가 유지
ref, px, kind = te._resolve_sell_order_price_v2("AAPL", 100.0, False, True, -5.0)
assert kind == "limit_overseas" and px == te._adjust_order_price(100.0, "SELL", False)
class _Scal:
def first(self):
return None
def all(self):
return []
class _SellDB:
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: _Scal(), scalar_one_or_none=lambda: None)
@pytest.fixture
def sell_env(monkeypatch):
import app.services.db_worker as dbw
from app.services.loss_tracker import loss_tracker
class _L(list):
pass
orders = _L()
async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3):
orders.append({"price": price, "quantity": quantity})
return {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}}
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(return_value=None))
async def _noop(*a, **k):
return None
verifies = []
async def _cap_verify(*a, **k):
verifies.append(k)
monkeypatch.setattr(te, "_verify_and_sync_execution", _cap_verify)
monkeypatch.setattr(te, "_verify_and_sync_execution_v2", _cap_verify)
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop)
monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop)
monkeypatch.setattr(dbw, "enqueue_db_write", _noop)
monkeypatch.setattr(loss_tracker, "record_trade_result_safe", AsyncMock())
monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock())
from app.services.daytrading.position_manager import day_trading_state
day_trading_state.positions.pop("005930", None)
orders.verifies = verifies # type: ignore[attr-defined]
return orders
def _holding(qty=10):
return SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0,
strategy_type="swing", stock_name="삼성", current_price=70000)
async def _sell(sell_pct, current_price=70000, avg=60000):
h = _holding()
h.average_price = avg
stock = {"stock_code": "005930", "strategy_type": "swing", "strategy_name": "swing",
"remaining_pct": 1.0}
return await te._execute_sell(
stock, _SellDB(), h, current_price, ["x"], True, "KRX", "005930", "삼성", sell_pct=sell_pct,
)
@pytest.mark.asyncio
async def test_h1_full_exit_is_market_order(sell_env, monkeypatch):
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
await _sell(1.0)
assert sell_env[-1]["price"] is None, "전량 청산이 낡은 배치가 지정가로 나갔다"
@pytest.mark.asyncio
async def test_h1_partial_take_profit_reprices_before_order(sell_env, monkeypatch):
monkeypatch.setattr(te.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "65000"}}))
await _sell(0.5)
assert sell_env[-1]["price"] == te._adjust_order_price(65000, "SELL", True), \
"부분 익절 지정가가 주문 직전 현재가로 재산정되지 않았다(배치가 70000 사용)"
@pytest.mark.asyncio
async def test_h1_partial_reprice_failure_falls_back_to_market(sell_env, monkeypatch):
async def _fail(*a, **k):
raise RuntimeError("시세 실패")
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _fail)
await _sell(0.5)
assert sell_env[-1]["price"] is None
# ═════════════════════════════════════════════════════════════════════════════
# H-1 (c) / L-4: 체결 확인 — 미체결 매도 취소, 불성립 매수 CANCELLED
# ═════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def verify_env(monkeypatch):
import app.services.db_worker as dbw
updates = []
async def _upd(order_id, fields):
updates.append((order_id, dict(fields)))
monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd)
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
async def _noop(*a, **k):
return None
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
return updates
def _exec(filled, remaining, found=True):
return AsyncMock(return_value={"ok": True, "found": found, "filled_qty": filled,
"filled_price": 70000, "remaining_qty": remaining})
async def _flush_tasks():
import asyncio as _aio
await _aio.sleep(0)
@pytest.mark.asyncio
async def test_h1r5_market_full_exit_never_cancelled(sell_env, monkeypatch):
"""[5회차] 시장가(전량·손절) 매도는 미체결 취소 대상이 아니다(종가 단일가·VI·하한가)."""
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
await _sell(1.0)
await _flush_tasks()
assert sell_env.verifies and sell_env.verifies[-1]["cancel_unfilled_limit"] is False
assert sell_env.verifies[-1]["rollback_active_trade"] is None
@pytest.mark.asyncio
async def test_h1r5_limit_partial_passes_cancel_and_rollback_snapshot(sell_env, monkeypatch):
monkeypatch.setattr(te.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "65000"}}))
await _sell(0.5)
await _flush_tasks()
k = sell_env.verifies[-1]
assert k["cancel_unfilled_limit"] is True
snap = k["rollback_active_trade"]
assert snap["quantity"] == 10 and snap["remaining_pct"] == 1.0
assert snap.get("strategy_name") == "swing", "롤백이 다른 전략 행을 덮으면 안 된다"
@pytest.mark.asyncio
async def test_h1r5_v1_verify_never_cancels(verify_env, monkeypatch):
"""기존 시그니처(_verify_and_sync_execution) = 시장가·종류 불명 경로 → 잔량이 살아 있어도 취소 금지."""
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(시장가)"))
await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "SELL", True, "KRX", 0, delay=0)
assert verify_env == []
_SNAP = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0,
"quantity": 10, "highest_since_entry": 72000.0}
@pytest.fixture
def at_capture(monkeypatch):
import app.services.db_worker as dbw
cap = []
async def _u(p):
cap.append(dict(p))
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u)
return cap
@pytest.mark.asyncio
async def test_h1r5_limit_unfilled_cancel_rolls_back_active_trade(verify_env, at_capture, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
cancel = AsyncMock(return_value={"rt_cd": "0"})
monkeypatch.setattr(k, "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(_SNAP),
)
cancel.assert_awaited_once_with("0001", org_no=None, quantity=5, stock_code="005930")
assert verify_env == [("0001", {"status": "CANCELLED", "executed_quantity": 0})]
assert at_capture == [_SNAP], "0체결 취소인데 ActiveTrade 가 주문 전 상태로 원복되지 않았다"
@pytest.mark.asyncio
async def test_h1r5_limit_partial_fill_cancel_fixes_quantity(verify_env, at_capture, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(2, 3))
monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "0"}))
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(_SNAP),
)
assert verify_env and verify_env[-1][1]["status"] == "COMPLETED"
assert verify_env[-1][1]["executed_quantity"] == 2
assert at_capture == [{"stock_code": "005930", "strategy_name": "swing", "quantity": 8}]
@pytest.mark.asyncio
async def test_h1r5_cancel_failure_logs_error_no_rollback(verify_env, at_capture, monkeypatch, caplog):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "1", "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(_SNAP),
)
assert verify_env == [] and at_capture == []
assert any("취소 거부" in r.message for r in caplog.records)
def test_l5_lookup_matched_order_is_never_cancel_target():
import inspect
src = inspect.getsource(te._execute_sell)
i = src.index("_cancel_unfilled_limit = (")
assert 'not order_result.get("_matched_by_lookup")' in src[i:i + 300]
assert "order_price is not None" in src[i:i + 300] and "not _full_exit" in src[i:i + 300]
@pytest.mark.asyncio
async def test_h1_buy_live_remainder_is_not_cancelled(verify_env, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 5))
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(BUY)"))
await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0)
assert verify_env == []
@pytest.mark.asyncio
async def test_l4_dead_buy_marked_cancelled(verify_env, monkeypatch):
k = te.korea_investment_api
monkeypatch.setattr(k, "get_order_execution", _exec(0, 0))
monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order"))
await te._verify_and_sync_execution("0002", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0)
assert verify_env == [("0002", {"status": "CANCELLED", "executed_quantity": 0})]
# ═════════════════════════════════════════════════════════════════════════════
# M-3: 일일 스윙 한도에 반영 지연 중인 사이클 매수 합산
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_m3_daily_count_includes_inflight_swing_buys():
class _DB:
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: ["000001"]))
te._recent_cycle_buys.clear()
try:
for code, st in (("000001", "swing"), ("000002", "swing"), ("000003", "manual"), ("AAPL", "swing")):
te._note_cycle_buy_v2(code, code, 1, 1000)
te._recent_cycle_buys[code]["strategy_type"] = st
te._note_cycle_buy_v2("000004", "D", 1, 1000) # 태그 없음(레거시) = 스윙 소유로 셈
# DB 000001 ∪ 지연 중 스윙 000002·000004 (manual·해외 제외, 000001 중복 제외)
assert await te._count_swing_buys_today_v2(_DB()) == 3
finally:
te._recent_cycle_buys.clear()
# ═════════════════════════════════════════════════════════════════════════════
# M-2 / L-2 / L-3: 루프
# ═════════════════════════════════════════════════════════════════════════════
class _LoopDB:
def __init__(self, held):
self.held = held
async def execute(self, stmt):
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: list(self.held)))
@pytest.fixture
def loop_env(monkeypatch):
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False)
monkeypatch.setattr(settings, "MAX_DAILY_TRADES", 2)
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (20, 1.0))
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
te._order_reject_cooldown.clear()
te._account_reject_at = None
te._eval_rotation_offset = 0
seen = []
async def _eat(stock, db):
seen.append((stock["stock_code"], bool(stock.get("_exit_only"))))
return {"action": "HOLD", "reason": "x"}
monkeypatch.setattr(te, "execute_auto_trade", _eat)
yield seen
te._order_reject_cooldown.clear()
te._account_reject_at = None
te._eval_rotation_offset = 0
def _recs(codes):
return {"recommendations_by_rule": [{"rule_name": "r", "stocks": [
{"stock_code": c, "rule_priority": 1, "score": 100 - i} for i, c in enumerate(codes)
]}]}
@pytest.mark.asyncio
@pytest.mark.parametrize("count", [2, None])
async def test_m2_held_exit_eval_when_buys_blocked(loop_env, monkeypatch, count):
"""일일 한도 소진(2) / 건수 조회 실패(None) — 신규는 막고 보유는 _exit_only 로 평가."""
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=count))
await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"]))
assert loop_env == [("HELD", True)]
@pytest.mark.asyncio
async def test_m2_regime_gate_still_evaluates_held(loop_env, monkeypatch):
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (0, 0.0))
monkeypatch.setattr(te, "_count_swing_buys_today_v2", _boom("count(국면 게이트 시 불필요)"))
await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"]))
assert loop_env == [("HELD", True)]
@pytest.mark.asyncio
async def test_l2_account_reject_pauses_all_new_buys(loop_env, monkeypatch):
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
te._note_order_reject_v2("X", {"rt_cd": "1", "msg_cd": settings.KIS_ACCOUNT_REJECT_MSG_CODES[0]})
assert te._account_reject_cooldown_remaining_v2() > 0
await te._execute_auto_trade_background_locked(_recs(["HELD", "N1", "N2"]), _LoopDB(["HELD"]))
assert loop_env == [("HELD", True)]
# 종목 단위 거부는 계좌 쿨다운을 걸지 않는다
te._account_reject_at = None
te._note_order_reject_v2("Y", {"rt_cd": "1", "msg_cd": "APBK0013"})
assert te._account_reject_cooldown_remaining_v2() == 0.0
@pytest.mark.asyncio
async def test_l3_rotation_offset_reaches_lower_candidates(loop_env, monkeypatch):
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
monkeypatch.setattr(settings, "AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE", 2)
cycles = []
for _ in range(3):
loop_env.clear()
await te._execute_auto_trade_background_locked(_recs(list("ABCDE")), _LoopDB([]))
cycles.append([c for c, _ in loop_env])
assert cycles == [["A", "B"], ["C", "D"], ["E", "A"]], cycles
# ═════════════════════════════════════════════════════════════════════════════
# safe_place_order(_v2) msg_cd — 전송 전/후 구분 (DT 재시도 판정용)
# ═════════════════════════════════════════════════════════════════════════════
# conftest 가 테스트마다 te.safe_place_order 를 차단 스텁으로 바꾸므로, 수집 시점의
# 원본을 잡아 둔다(KIS 클래스 메서드 차단은 그대로 유지된다).
from app.services.trade_executor import ( # noqa: E402
safe_place_order as _REAL_SAFE_PLACE_ORDER,
safe_place_order_v2 as _REAL_SAFE_PLACE_ORDER_V2,
)
@pytest.fixture
def safe_env(monkeypatch):
released = []
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True))
async def _rel(code, typ):
released.append((code, typ))
monkeypatch.setattr(te, "_release_order_lock", _rel)
return released
def _ctx(kis):
return SimpleNamespace(is_legacy=True, user_id=None, kis=kis)
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["v1", "v2"])
async def test_safe_order_lock_busy_has_msg_cd(monkeypatch, variant):
monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=False))
k = SimpleNamespace(place_order=_boom("place_order"))
monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order"))
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
assert r == {"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"}
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["v1", "v2"])
async def test_safe_order_presend_exception(safe_env, monkeypatch, variant):
def _bad_sem(key):
raise RuntimeError("semaphore broken")
monkeypatch.setattr(te, "_get_order_semaphore", _bad_sem)
k = SimpleNamespace(place_order=_boom("place_order"))
monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order"))
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_PRESEND_EXCEPTION"
assert safe_env, "락이 해제되지 않았다"
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["v1", "v2"])
@pytest.mark.parametrize("mode", ["raise", "none"])
async def test_safe_order_post_send_unknown(safe_env, monkeypatch, variant, mode):
async def _po(**kw):
if mode == "raise":
raise RuntimeError("read timeout")
return None
k = SimpleNamespace(place_order=_po)
monkeypatch.setattr(te.korea_investment_api, "place_order", _po)
r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1"
else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1))
assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_TRANSPORT_UNKNOWN"
@pytest.mark.asyncio
async def test_safe_order_success_passthrough(safe_env, monkeypatch):
ok = {"rt_cd": "0", "output": {"ODNO": "1"}}
monkeypatch.setattr(te.korea_investment_api, "place_order", AsyncMock(return_value=ok))
assert await _REAL_SAFE_PLACE_ORDER("005930", "BUY", 1) == ok