수정본
This commit is contained in:
@@ -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 []
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.core.database import AsyncSessionLocal
|
||||
from app.core.market_schedule import is_market_open
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.swing.exit_service import check_and_execute_exit_v2
|
||||
from app.services.swing.entry_service import execute_swing_entries
|
||||
from app.services.swing.entry_service import execute_swing_entries, scan_dual_strategy_signals
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
@@ -57,6 +57,25 @@ def _normalize_recommendations(rec: dict) -> dict:
|
||||
return rec
|
||||
|
||||
|
||||
def _filter_unsuitable_stocks(rec: dict, unsuitable_codes: set) -> None:
|
||||
"""장 전 AI 분석에서 부적합 판정된 종목을 추천 목록에서 제거."""
|
||||
groups = rec.get("recommendations_by_rule", [])
|
||||
if isinstance(groups, list):
|
||||
for group in groups:
|
||||
if isinstance(group, dict) and "stocks" in group:
|
||||
group["stocks"] = [
|
||||
s for s in group["stocks"]
|
||||
if isinstance(s, dict) and s.get("stock_code") not in unsuitable_codes
|
||||
]
|
||||
elif isinstance(groups, dict):
|
||||
for key, stocks in groups.items():
|
||||
if isinstance(stocks, list):
|
||||
groups[key] = [
|
||||
s for s in stocks
|
||||
if isinstance(s, dict) and s.get("stock_code") not in unsuitable_codes
|
||||
]
|
||||
|
||||
|
||||
async def swing_scheduler() -> None:
|
||||
"""60초 주기 스윙 트레이딩 스케줄러.
|
||||
|
||||
@@ -149,7 +168,25 @@ async def swing_scheduler() -> None:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# 5. MCP RISK_ASSESSMENT
|
||||
# 5. 장 전 AI 분석 결과 활용 + MCP 리스크 평가 (최적화)
|
||||
# 장 전 Sonnet 배치 분석 결과가 Redis에 있으면 부적합 종목을 사전 필터링
|
||||
# MCP RISK_ASSESSMENT는 실제 매수 직전에만 호출 (비용 절감)
|
||||
_ai_filtered_codes = set()
|
||||
try:
|
||||
_ai_pre = await get_json("ai:pre_market:latest")
|
||||
if _ai_pre and isinstance(_ai_pre, dict):
|
||||
for s in _ai_pre.get("suitable_stocks", []):
|
||||
if isinstance(s, dict) and not s.get("suitable", True):
|
||||
_ai_filtered_codes.add(s.get("stock_code", ""))
|
||||
if _ai_filtered_codes:
|
||||
logger.info(f"[Swing] AI 장 전 분석 필터: {len(_ai_filtered_codes)}종목 제외")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 추천 목록에서 AI 부적합 종목 제거
|
||||
if _ai_filtered_codes:
|
||||
_filter_unsuitable_stocks(rec, _ai_filtered_codes)
|
||||
|
||||
recommendations = rec.get("recommendations_by_rule", [])
|
||||
if isinstance(recommendations, dict):
|
||||
rec_count = sum(len(v) for v in recommendations.values())
|
||||
@@ -169,6 +206,12 @@ async def swing_scheduler() -> None:
|
||||
for s in g.get("stocks", [])[:3]
|
||||
][:10]
|
||||
|
||||
if not top_stocks:
|
||||
logger.info("[Swing] 필터링 후 추천 종목 없음 — 스킵")
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# MCP 리스크 평가 (Haiku — 실제 진입 직전 1회)
|
||||
trade_plan = {
|
||||
"action": "BUY",
|
||||
"strategy": "swing",
|
||||
@@ -196,7 +239,17 @@ async def swing_scheduler() -> None:
|
||||
async with AsyncSessionLocal() as entry_session:
|
||||
logger.info(f"[Swing] 진입 실행 시작: {top_stocks[:5]}")
|
||||
await execute_swing_entries(rec, entry_session)
|
||||
logger.info("[Swing] ── 사이클 완료 ──")
|
||||
|
||||
# 7. 듀얼 전략 시그널 스캔 (모멘텀 돌파 + 눌림목 매수, CLAUDE.md 8.4)
|
||||
try:
|
||||
async with AsyncSessionLocal() as dual_session:
|
||||
dual_results = await scan_dual_strategy_signals(dual_session)
|
||||
if dual_results:
|
||||
logger.info(f"[Swing] 듀얼 전략 진입: {len(dual_results)}건")
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swing] 듀얼 전략 스캔 오류: {e}")
|
||||
|
||||
logger.info("[Swing] ── 사이클 완료 ──")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Swing] 스케줄러 사이클 오류: {e}", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user