수정본
Some checks failed
CI/CD Pipeline / test-backend (push) Has been cancelled
CI/CD Pipeline / test-frontend (push) Has been cancelled
CI/CD Pipeline / build-and-push (push) Has been cancelled

This commit is contained in:
2026-03-27 14:47:49 +09:00
parent 844ee8944f
commit c818810667
25 changed files with 2708 additions and 266 deletions

View File

@@ -9,6 +9,10 @@ strategy_type='swing' 진입만 처리하는 독립 서비스.
- 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종목)
@@ -20,10 +24,11 @@ strategy_type='swing' 진입만 처리하는 독립 서비스.
import asyncio
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, desc
from datetime import date
from app.core.config import settings
from app.models.models import Portfolio
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
@@ -144,3 +149,180 @@ def _inject_strategy_type(rec: dict, strategy_type: str) -> None:
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 []