Files
auto-stock/backend/tests/test_trade_executor_hardening_r1_20261009.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +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_v3", _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_v3", _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_v3", _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_v3", _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_v3", _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_v3", _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_v3", _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_v3", _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_v3", _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