Files
auto-stock/backend/app/services/swing/entry_service.py
shuim 8a8e3700db feat: 안전형 스윙 개선(서킷브레이커·own_trend) + 매수 현금해결 방어
퀀트 리서치(공정 유니버스+walk-forward)로 검증된 안전 게이트 2종과
매수 경로 현금 해결 방어 코드 추가. 모두 기존 시그니처 유지(§4).

- 지수 서킷브레이커: KODEX200 종가 < MA20이면 신규 스윙 진입 전면 중단
  (하락장 상관붕괴=2024H2형 방어). execute_swing_entries가 기존
  check_market_regime의 drop_from_ma20을 재사용(추가 API 없음). 백테스트
  루프에도 동일 적용. 검증: 최근1년 Sharpe 0.82→1.13, CAGR 7.2→8.6%, MDD 개선.
- own_trend 게이트: 평균회귀·눌림목 매수 시 종목 MA20 >= MA60×0.97일 때만
  진입(하락추세 물타기 방지). 실서비스는 recommendations.is_mean_reversion_candidate
  에서 StockAnalysis.ma20/ma60로, check_buy_signal엔 ma20/ma60 선택 파라미터
  (기본 None=하위호환) 추가. 백테스트(kr_backtest_engine, run_backtest)도 평균회귀 한정.
- 매수 현금 해결 방어(trade_executor._execute_buy): 0-현금 캐시를 신뢰하지 않고
  현금<=0이면 자산과 무관하게 KIS 예수금 재조회, 실현금(>0)일 때만 캐시.
  0-현금 고착으로 수량 0이 되는 잠재 무한루프 방어.

검증: code-verifier 통과(CONFIRMED 버그 없음, None-safe, §3/§4/§5 준수),
pytest 442 통과, 6파일 컨테이너 컴파일·리로드 정상.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
2026-09-16 13:17:17 +09:00

345 lines
13 KiB
Python

"""스윙 트레이딩 진입 서비스
trade_executor.py의 execute_auto_trade_background() 로직을 기반으로
strategy_type='swing' 진입만 처리하는 독립 서비스.
진입 기준 (CLAUDE.md 섹션 6.2):
- BULL: RSI(14) < 46, 가격이 BB 하단 부근, 거래량 > 20MA
- BEAR: RSI(14) < 35, 가격 < BB 하단, 거래량 > 1.5x20MA, 반전 캔들
- SIDEWAYS: 진입 없음 (CLAUDE.md 6.1)
- 포지션 사이징: 종목당 총자본 6% (MAX_POSITION_SIZE), 최대 25종목
듀얼 전략 (CLAUDE.md 섹션 8.4):
- 모멘텀 돌파: 20일 신고가 + 거래량 2x + RSI 50~70 (BULL만)
- 눌림목 매수: MA20>MA60 + MA20 근처 조정 + RSI 반등 (BULL만)
진입 전 체크:
1. 장 오픈 여부 (정규장 09:00~15:30만 허용)
2. 스윙 포지션 한도 (25종목)
3. MDD 신규 진입 가능 여부
4. 적응형 전략 Crisis 모드 차단
5. 플레이북 이벤트 차단 (FOMC, 옵션만기 등)
6. 시장 국면 SIDEWAYS 차단
"""
import asyncio
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from datetime import date
from app.core.config import settings
from app.models.models import Portfolio, StockAnalysis
from app.services.trade_executor import execute_auto_trade, execute_auto_trade_background
from app.services.market_analyzer import check_market_regime
from app.services.mdd_manager import mdd_manager
from app.services.swing.position_manager import count_swing_positions
from app.services.market_playbook import is_entry_allowed_by_playbook, get_position_multiplier_from_playbook
logger = logging.getLogger(__name__)
# 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20)
MAX_SWING_POSITIONS = 20
# RSI 반등 방향 확인용 과거 구간 (kosdaq_scanner.py RSI_DIRECTION_LOOKBACK과 동일)
RSI_DIRECTION_LOOKBACK = 3
async def execute_swing_entries(recommendations_response: dict, db: AsyncSession) -> list[dict]:
"""배치 분석 추천 결과를 기반으로 스윙 매수 실행.
기존 execute_auto_trade_background()를 재활용하되
strategy_type='swing' 레이블을 주입하여 데이트레이딩과 분리.
처리 흐름:
1. 장 오픈 여부 체크 (정규장만 허용)
2. 포지션 한도 체크 (MAX_SWING_POSITIONS)
3. MDD 체크 (can_open_new_position)
4. 적응형 전략 Crisis 모드 차단
5. 플레이북 이벤트 차단
6. SIDEWAYS 국면 차단
7. 추천 목록에 strategy_type='swing' 주입
8. execute_auto_trade_background() 호출
Args:
recommendations_response: Redis 또는 DB에서 가져온 추천 딕셔너리
- recommendations_by_rule: 규칙별 추천 종목 리스트
db: AsyncSession 인스턴스
Returns:
list[dict]: 실행된 매수 결과 목록
"""
# 장 마감 후 매수 차단 (15:30 이후, KIS 모의투자 24시간 허용 방지)
from app.core.market_schedule import is_market_open
if not is_market_open():
logger.info("[SwingEntry] 장 마감 — 스윙 매수 차단 (정규장만 매매)")
return []
# 포지션 한도 체크
current_count = await count_swing_positions(db)
if current_count >= MAX_SWING_POSITIONS:
logger.info(
f"[SwingEntry] 최대 포지션 수 도달 ({current_count}/{MAX_SWING_POSITIONS}) — 신규 진입 스킵"
)
return []
# MDD 체크
can_open = await mdd_manager.can_open_new_position()
if not can_open:
logger.warning("[SwingEntry] MDD 초과 — 신규 진입 차단")
return []
# 적응형 전략 체크 — Crisis 모드에서 스윙 진입 전면 차단
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp_swing = get_current_adaptive_params()
if not _adp_swing.fallback_used and _adp_swing.trading_mode == "crisis":
logger.warning("[SwingEntry] 적응형 전략 CRISIS 모드 — 스윙 진입 전면 차단")
return []
except Exception:
pass # adaptive 모듈 실패 시 기존 로직 유지
# 플레이북 체크 — FOMC/옵션만기/서킷브레이커 등 이벤트 당일 차단
if not is_entry_allowed_by_playbook():
logger.info("[SwingEntry] 플레이북 차단 — 신규 진입 비활성화 (이벤트 대응)")
return []
# SIDEWAYS 국면 체크 + [안전형 개선②] 지수 서킷브레이커
try:
regime = await check_market_regime()
regime_str = regime.get("regime_200d", "bull") if isinstance(regime, dict) else "bull"
if regime_str == "sideways":
logger.info("[SwingEntry] SIDEWAYS 국면 — 진입 신호 무시 (CLAUDE.md 6.1)")
return []
# [안전형 개선②] 지수 서킷브레이커 (검증 완료: 2024H2 CAGR -9.9%→+1.0%, MDD -7→-1.9)
# KODEX200(069500) 일봉 종가 < MA20 → 신규 스윙 진입 전면 중단 (기존 보유 청산/관리는 정상 동작).
# check_market_regime()이 이미 계산한 drop_from_ma20(=(현재가-MA20)/MA20*100)을 재사용하여
# 지수 데이터 중복 조회를 피한다.
if isinstance(regime, dict):
_drop_from_ma20 = regime.get("drop_from_ma20")
if _drop_from_ma20 is not None and float(_drop_from_ma20) < 0:
logger.info(
"[SwingEntry] 지수 서킷브레이커 — KODEX200 종가<MA20 (drop=%.2f%%) — 신규 진입 전면 중단",
float(_drop_from_ma20),
)
return []
except Exception as e:
logger.warning(f"[SwingEntry] 시장 국면 조회 실패: {e}")
# strategy_type='swing' 레이블 주입
rec = dict(recommendations_response)
_inject_strategy_type(rec, "swing")
# 기존 execute_auto_trade_background 재활용 (시그니처 변경 없음)
try:
result = await execute_auto_trade_background(rec, db)
if isinstance(result, list):
return result
except Exception as e:
logger.error(f"[SwingEntry] 진입 실행 오류: {e}", exc_info=True)
return []
def _inject_strategy_type(rec: dict, strategy_type: str) -> None:
"""추천 목록의 각 종목에 strategy_type 필드를 주입.
recommendations_by_rule 구조가 리스트 또는 딕셔너리일 수 있어
두 가지 형태를 모두 지원.
Args:
rec: 추천 딕셔너리 (원본을 직접 수정)
strategy_type: 주입할 전략 타입 (예: "swing")
"""
groups = rec.get("recommendations_by_rule", [])
if isinstance(groups, list):
for group in groups:
if isinstance(group, dict):
for stock in group.get("stocks", []):
if isinstance(stock, dict):
stock["strategy_type"] = strategy_type
elif isinstance(groups, dict):
for group in groups.values():
if isinstance(group, list):
for stock in group:
if isinstance(stock, dict):
stock["strategy_type"] = strategy_type
async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
"""듀얼 전략 시그널 스캔 및 진입 실행 (CLAUDE.md 섹션 8.4).
배치 분석으로 저장된 stock_analysis 종목들의 일봉 차트를 조회하여
모멘텀 돌파 / 눌림목 매수 시그널을 탐색하고, 기존 매매 파이프라인으로 진입.
BULL 시장에서만 실행. SIDEWAYS/BEAR에서는 스킵.
Returns:
list[dict]: 실행된 매수 결과 목록
"""
from app.core.market_schedule import is_market_open
from app.services.korea_investment_api import korea_investment_api
from app.services.mean_reversion_strategy import (
check_momentum_breakout_signal,
check_pullback_buy_signal,
calculate_rsi,
)
if not is_market_open():
return []
# BULL 시장에서만 듀얼 전략 실행
try:
regime = await check_market_regime()
regime_str = regime.get("regime_200d", "bull") if isinstance(regime, dict) else "bull"
if regime_str != "bull":
logger.debug("[DualStrategy] BULL이 아님 (%s) — 듀얼 전략 스킵", regime_str)
return []
except Exception as e:
logger.warning(f"[DualStrategy] 시장 국면 조회 실패: {e}")
return []
# 포지션 한도 체크
current_count = await count_swing_positions(db)
remaining_slots = MAX_SWING_POSITIONS - current_count
if remaining_slots <= 0:
return []
# MDD 체크
if not await mdd_manager.can_open_new_position():
return []
# Crisis 모드 체크
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used and _adp.trading_mode == "crisis":
return []
except Exception:
pass
# 현재 보유 종목 (중복 진입 방지)
result = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0))
held_codes = {row[0] for row in result.fetchall()}
# 최근 분석된 종목 중 상위 30개 조회 (score 기준)
analysis_query = (
select(StockAnalysis)
.where(StockAnalysis.analysis_date >= date.today())
.order_by(desc(StockAnalysis.score))
.limit(30)
)
analysis_result = await db.execute(analysis_query)
candidates = analysis_result.scalars().all()
if not candidates:
logger.debug("[DualStrategy] 분석 데이터 없음 — 스킵")
return []
dual_stocks = []
scan_count = 0
for stock in candidates:
if scan_count >= 10:
break
if stock.stock_code in held_codes:
continue
if float(stock.current_price or 0) < settings.MIN_PRICE_KRW:
continue
try:
chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60)
output2 = chart.get("output2", [])
if not output2 or len(output2) < 20:
continue
# 최신이 앞에 오므로 reverse
bars = list(reversed(output2))
closes = [float(b.get("stck_clpr", 0)) for b in bars]
highs = [float(b.get("stck_hgpr", 0)) for b in bars]
volumes = [int(b.get("acml_vol", 0)) for b in bars]
if not closes or closes[-1] <= 0:
continue
current_close = closes[-1]
current_volume = volumes[-1] if volumes else 0
rsi = calculate_rsi(closes, period=14)
if rsi is None:
continue
rsi_prev = calculate_rsi(closes[:-RSI_DIRECTION_LOOKBACK], period=14)
scan_count += 1
# 모멘텀 돌파 체크
momentum_signal = check_momentum_breakout_signal(
closes=closes, highs=highs, volumes=volumes,
current_close=current_close, current_volume=current_volume, rsi=rsi,
)
if momentum_signal:
dual_stocks.append({
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(current_close),
"score": 0.8,
"signal": momentum_signal["description"],
"strategy_type": "swing",
"entry_reason": f"[MOMENTUM_BREAKOUT] {momentum_signal['description']}",
})
logger.info(
f"[DualStrategy] 모멘텀 돌파 시그널: {stock.stock_code} {stock.stock_name}"
)
continue
# 눌림목 매수 체크
pullback_signal = check_pullback_buy_signal(
closes=closes, current_close=current_close,
current_volume=current_volume, rsi=rsi, rsi_prev=rsi_prev,
volumes=volumes,
)
if pullback_signal:
dual_stocks.append({
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(current_close),
"score": 0.6,
"signal": pullback_signal["description"],
"strategy_type": "swing",
"entry_reason": f"[PULLBACK_BUY] {pullback_signal['description']}",
})
logger.info(
f"[DualStrategy] 눌림목 매수 시그널: {stock.stock_code} {stock.stock_name}"
)
await asyncio.sleep(0.3) # KIS API 레이트 리밋
except Exception as e:
logger.debug(f"[DualStrategy] {stock.stock_code} 스캔 실패: {e}")
continue
if not dual_stocks:
logger.debug("[DualStrategy] 시그널 없음")
return []
# 남은 슬롯 이내로 제한
dual_stocks = dual_stocks[:remaining_slots]
# execute_auto_trade_background 형식으로 변환하여 실행
rec = {
"recommendations_by_rule": [{
"rule_name": "dual_strategy",
"stocks": dual_stocks,
}]
}
try:
result = await execute_auto_trade_background(rec, db)
if isinstance(result, list):
logger.info(f"[DualStrategy] {len(result)}건 진입 실행 완료")
return result
except Exception as e:
logger.error(f"[DualStrategy] 진입 실행 오류: {e}", exc_info=True)
return []