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

127 lines
6.0 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.
"""ActiveTrade 행이 없는 스윙 포지션의 부분매도 중복 실행 회귀 테스트.
2026-09-22 두산에너빌리티(034020): 동기화로 유입된 포지션(ActiveTrade 없음)에
"1차 익절(50%)"이 2분 간격으로 두 번 실행돼 전량 청산됨.
원인: exit_service가 remaining_pct 갱신 upsert에 quantity/entry_price를 넣지 않아
db_worker의 신규 INSERT가 NOT NULL 위반 → remaining_pct 미저장 + Portfolio.quantity 미감소.
"""
import asyncio
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy import select
from app.models.models import Base, Portfolio, ActiveTrade
from app.services.mean_reversion_strategy import ExitSignal
from app.services import db_worker
from app.services.swing import exit_service
def _daily_chart(n=30, close=86200.0):
return {"output2": [
{"stck_clpr": str(close), "stck_hgpr": str(close * 1.01), "stck_lwpr": str(close * 0.99)}
for _ in range(n)
]}
async def _drain(queue, session_factory):
"""db_worker 로직으로 큐를 처리 (실제 워커 코드 경로 사용)."""
with patch.object(db_worker, "AsyncSessionLocal", session_factory), \
patch.object(db_worker, "get_db_task_queue", return_value=queue):
worker = asyncio.create_task(db_worker.db_writer_worker())
await queue.join()
worker.cancel()
try:
await worker
except (asyncio.CancelledError, Exception):
pass
@pytest.mark.asyncio
async def test_partial_exit_without_active_trade_runs_once():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Portfolio.__table__, ActiveTrade.__table__]))
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with Session() as s:
s.add(Portfolio(
stock_code="034020", stock_name="두산에너빌리티", quantity=18,
average_price=Decimal("85589"), current_price=Decimal("86200"),
strategy_type="swing",
))
await s.commit()
queue = asyncio.Queue()
partial = ExitSignal(action="SELL_PARTIAL", reason="1차 익절(50%): RSI(55.2)>=55", sell_pct=0.5)
order_ok = {"rt_cd": "0", "output": {"ODNO": "0000002153"}}
api = SimpleNamespace(
get_current_price=AsyncMock(return_value={"output": {"stck_prpr": "86200"}}),
get_daily_chart=AsyncMock(return_value=_daily_chart()),
# [C-1 2026-10-02 6회차] 국내 스윙 매도 직전 KIS 실보유 확인(상한 = min(주문가능, 보유) − DT)
get_balance=AsyncMock(return_value={"rt_cd": "0", "output1": [
{"pdno": "034020", "hldg_qty": "18", "ord_psbl_qty": "18"}]}),
)
place_order = AsyncMock(return_value=order_ok)
with patch.object(exit_service, "korea_investment_api", api), \
patch.object(exit_service, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"})), \
patch.object(exit_service, "check_exit_signal", return_value=partial), \
patch.object(exit_service, "calculate_atr", return_value=1500.0), \
patch.object(exit_service, "calculate_rsi", return_value=55.2), \
patch.object(exit_service, "calculate_bollinger", return_value=(90000.0, 85370.0, 80000.0)), \
patch("app.services.trade_executor.safe_place_order", place_order), \
patch.object(db_worker, "db_task_queue", queue), \
patch.object(exit_service.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(exit_service.loss_tracker, "save_to_db", AsyncMock()):
# 1차 사이클: 18주 → 9주 매도
async with Session() as s:
exits = await exit_service.check_and_execute_exit_v2(s)
assert len(exits) == 1 and exits[0]["sell_quantity"] == 9
await _drain(queue, Session)
async with Session() as s:
at = (await s.execute(select(ActiveTrade).where(ActiveTrade.stock_code == "034020"))).scalars().all()
pf = (await s.execute(select(Portfolio).where(Portfolio.stock_code == "034020"))).scalar_one()
assert len(at) == 1, "부분매도 후 ActiveTrade 행이 생성돼야 함"
assert float(at[0].remaining_pct) == 0.5
assert at[0].quantity == 9 and float(at[0].entry_price) == 85589.0
assert pf.quantity == 9, "부분매도 후 Portfolio 잔량이 즉시 감소해야 함"
# 2차 사이클: remaining_pct=0.5 → check_exit_signal에 전달되는 값으로 재실행 차단 확인
seen = {}
def _capture(**kw):
seen.update(kw)
return None
with patch.object(exit_service, "check_exit_signal", side_effect=_capture):
async with Session() as s:
exits2 = await exit_service.check_and_execute_exit_v2(s)
assert exits2 == []
assert seen["remaining_pct"] == 0.5
assert place_order.await_count == 1, "매도 주문은 한 번만 나가야 함"
await engine.dispose()
@pytest.mark.asyncio
async def test_db_worker_skips_active_trade_insert_without_required_keys():
"""필수 키 없는 신규 upsert는 IntegrityError 재시도 대신 스킵."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Portfolio.__table__, ActiveTrade.__table__]))
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
queue = asyncio.Queue()
await queue.put({"type": "upsert_active_trade", "data": {"stock_code": "000000", "remaining_pct": 0.5}})
await asyncio.wait_for(_drain(queue, Session), timeout=5) # 재시도(1+2+4초) 없이 즉시 끝나야 함
async with Session() as s:
rows = (await s.execute(select(ActiveTrade))).scalars().all()
assert rows == []
await engine.dispose()