├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤ │ M2 │ ADX 히스테리시스 18/22 (플리핑 방지) │ mean_reversion_strategy.py │ ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤ │ M3 │ 분할매도 최소 수익 1.0%→0.5% │ mean_reversion_strategy.py │ ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤ │ M4 │ ORB 갭 필터 4% + 레인지 폭 0.3% 최소 │ day_trading_strategy.py │ ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤ │ M5 │ 쿨다운 스트릭 부분매도 시 미변경 │ daytrading/position_manager.py │ ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤ │ M6 │ MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지) │ mdd_manager.py │ ├─────┼─────────────────────────────────────────────────────┼────────────────────────────────┤ │ M7 │ RSI Wilder 스무딩으로 통일 │ mean_reversion_strategy.py
328 lines
12 KiB
Python
328 lines
12 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
|
|
|
|
|
|
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 []
|
|
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_values = calculate_rsi(closes, period=14)
|
|
rsi = rsi_values[-1] if rsi_values else 50.0
|
|
rsi_prev = rsi_values[-2] if len(rsi_values) >= 2 else None
|
|
|
|
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 []
|