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

453 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""[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