Files
auto-stock/backend/tests/test_trade_executor_hardening_r1_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

217 lines
8.9 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.
"""[하드닝 R1 2026-10-09] trade_executor 회귀 테스트.
1. 추천 경로 매도(_execute_sell) ↔ swing/exit_service 공용 가드
- 공용 청산 쿨다운(exit_service._recent_exits / EXIT_COOLDOWN_SEC)
- 매도 직전 KIS 실보유 상한(min(주문가능, 보유) − DT) — 목은 두 경로의 공통 종단인
exit_service._fetch_kis_holding_v2 에 건다.
2. 가격 필터 ETF/리츠 예외의 종목명 판정이 부분문자열이 아니다.
"""
import inspect
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import app.services.trade_executor as te
import app.services.swing.exit_service as es
CODE = "005930"
class _Boom(Exception):
pass
def _boom(name):
async def _f(*a, **k):
raise _Boom(f"{name} 가 호출되면 안 된다")
return _f
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 env(monkeypatch):
"""주문·DB 큐·체결확인을 격리. 주문은 기록만, 공용 쿨다운 딕셔너리는 테스트별 새 객체."""
import app.services.db_worker as dbw
from app.services.loss_tracker import loss_tracker
from app.services.daytrading.position_manager import day_trading_state
class _Orders(list):
result = {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}}
orders = _Orders()
async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3):
orders.append({"quantity": quantity, "price": price})
return orders.result
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))
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
async def _noop(*a, **k):
return None
for name in ("_verify_and_sync_execution", "_verify_and_sync_execution_v2",
"_verify_and_sync_execution_v3"):
if hasattr(te, name):
monkeypatch.setattr(te, name, _noop)
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())
# 시장가 경로(전량)만 쓰므로 시세 조회는 호출되면 안 된다
monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price"))
monkeypatch.setattr(es, "_recent_exits", {})
monkeypatch.setattr(es, "_presell_check_failures", {})
monkeypatch.setattr(day_trading_state, "positions", {})
return orders
def _kis(hldg, sellable):
async def _f(stock_code):
return (hldg, sellable)
return _f
async def _sell(sell_pct=1.0, qty=10, remaining_pct=1.0):
h = SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0,
strategy_type="swing", stock_name="삼성", current_price=70000)
stock = {"stock_code": CODE, "strategy_type": "swing", "strategy_name": "swing",
"remaining_pct": remaining_pct}
return await te._execute_sell(stock, _SellDB(), h, 70000, ["x"], True, "KRX", CODE, "삼성",
sell_pct=sell_pct)
# ── 1. 공용 청산 쿨다운 ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_recent_swing_exit_blocks_recommendation_sell(env, monkeypatch):
"""exit_service 가 막 청산한 종목은 추천 경로가 같은 단계를 다시 팔지 않는다."""
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _boom("_fetch_kis_holding_v2"))
es._recent_exits[CODE] = time.time() - 5
r = await _sell(0.5)
assert r["action"] == "SKIP" and r["reason"] == "recent_swing_exit_cooldown"
assert env == []
@pytest.mark.asyncio
async def test_cooldown_expired_allows_sell(env, monkeypatch):
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10))
es._recent_exits[CODE] = time.time() - float(es.EXIT_COOLDOWN_SEC) - 1
r = await _sell(1.0)
assert r["action"] == "SELL" and env and env[-1]["quantity"] == 10
@pytest.mark.asyncio
async def test_successful_sell_marks_shared_cooldown(env, monkeypatch):
"""추천 경로 매도 접수 → exit_service 도 같은 쿨다운을 본다."""
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10))
before = time.time()
await _sell(1.0)
assert es._recent_exits.get(CODE, 0) >= before
@pytest.mark.asyncio
async def test_rejected_sell_does_not_mark_cooldown(env, monkeypatch):
"""업무 거부(FAILED)는 체결이 없으므로 쿨다운에 기록하지 않는다(즉시 재시도 허용)."""
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10))
env.result = {"rt_cd": "1", "msg_cd": "APBK0000", "msg1": "거부"}
r = await _sell(1.0)
assert r.get("success") is False
assert CODE not in es._recent_exits
# ── 2. 매도 직전 KIS 실보유 상한 ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_kis_cap_limits_stale_portfolio_quantity(env, monkeypatch):
"""Portfolio 10주(낡음)인데 KIS 실보유 6주 중 DT 4주 → 스윙 매도는 2주까지만."""
from app.services.daytrading.position_manager import day_trading_state
day_trading_state.positions[CODE] = {"quantity": 4}
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(6, 6))
r = await _sell(1.0, qty=10)
assert r["action"] == "SELL"
assert env[-1]["quantity"] == 2, "KIS 실보유 − DT 를 넘는 수량이 주문됐다(DT 물량 과다매도)"
@pytest.mark.asyncio
async def test_kis_cap_uses_orderable_quantity(env, monkeypatch):
"""대기 매도 주문분(주문가능 < 보유)은 다시 팔지 않는다."""
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 3))
await _sell(1.0, qty=10)
assert env[-1]["quantity"] == 3
@pytest.mark.asyncio
async def test_kis_check_failure_holds_sell(env, monkeypatch):
async def _none(stock_code):
return None
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _none)
r = await _sell(1.0)
assert r["action"] == "SKIP" and r["reason"] == "kis_holding_check_failed"
assert env == []
@pytest.mark.asyncio
async def test_kis_sellable_zero_no_order(env, monkeypatch):
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(0, 0))
r = await _sell(0.5)
assert r["action"] == "SKIP" and r["reason"] == "kis_sellable_zero"
assert env == []
@pytest.mark.asyncio
async def test_kis_cap_not_applied_when_holding_matches(env, monkeypatch):
"""정상 상태(장부=KIS)에서는 분할매도 수량이 바뀌지 않는다 — 원본 대비 50% = 5주."""
monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10))
await _sell(0.5, qty=10)
assert env[-1]["quantity"] == 5
def test_guards_are_outside_try():
"""쿨다운·KIS 상한 가드는 주문 try 블록보다 앞(§4: 가드를 try 안에 두지 말 것)."""
src = inspect.getsource(te._execute_sell)
i_try = src.index("recent_cutoff = _get_kst_now()")
assert src.index("_swing_exit_cooldown_block_v2(stock_code)") < i_try
assert src.index("_recommendation_sell_kis_cap_v2(") < i_try
# ── 3. 가격 필터 ETF/리츠 예외 판정 ─────────────────────────────────────────────
@pytest.mark.parametrize("name", [
"KODEX 200", "TIGER 미국S&P500", "SOL 미국배당다우존스", "ACE 미국S&P500", "HANARO 200",
"KBSTAR 200", "ARIRANG 고배당주", "KODEX200", "신한 인버스 2X 원유선물 ETN",
"SK리츠", "롯데리츠", "맥쿼리인프라", "SPDR S&P 500 ETF TRUST", "Realty Income REIT",
])
def test_price_exempt_names(name):
assert te._is_price_filter_exempt_name_v2(name) is True
@pytest.mark.parametrize("name", [
"인프라웨어", "NETFLIX", "SOLUM", "ACELYRIN", "ACEMAN", "리츠칼튼호텔", "에이스테크", "삼성전자",
"SOLID POWER", "", None,
])
def test_non_exempt_names(name):
"""부분문자열 일치로 일반주가 가격 필터(§5.1 >MIN_PRICE)를 우회하지 않는다."""
assert te._is_price_filter_exempt_name_v2(name) is False
def test_price_filter_uses_positional_helper():
src = inspect.getsource(te.execute_auto_trade)
assert "_is_etf = _is_price_filter_exempt_name_v2(stock_name)" in src
assert "_ETF_KEYWORDS" not in src