## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
722 lines
33 KiB
Python
722 lines
33 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)을 재사용하여
|
||
# 지수 데이터 중복 조회를 피한다.
|
||
# 2026-09-17: 사용자 결정으로 기본 OFF (SWING_INDEX_BREAKER_ENABLED=true 로 재활성화 가능).
|
||
if settings.SWING_INDEX_BREAKER_ENABLED and 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 재활용 (시그니처 변경 없음)
|
||
# [2026-09-27] execute_auto_trade_background() 는 항상 list 를 반환한다(과거엔
|
||
# 암묵적 None). 이 list 에는 성공한 BUY 뿐 아니라 주문 실패(success=False)한
|
||
# BUY/SELL, 청산 우선 로직으로 섞인 SELL 도 함께 담기므로 len(result) 를 그대로
|
||
# "진입 n건"으로 로깅하지 않고 action/success 기준으로 분리 집계한다.
|
||
try:
|
||
result = await execute_auto_trade_background(rec, db)
|
||
if isinstance(result, list):
|
||
buy_success = sum(
|
||
1 for r in result
|
||
if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success")
|
||
)
|
||
sell_count = sum(
|
||
1 for r in result if isinstance(r, dict) and r.get("action") == "SELL"
|
||
)
|
||
fail_count = sum(
|
||
1 for r in result if isinstance(r, dict) and not r.get("success")
|
||
)
|
||
if buy_success or sell_count or fail_count:
|
||
logger.info(
|
||
"[SwingEntry] 진입 %d건 / 청산 %d건 / 주문실패 %d건",
|
||
buy_success, sell_count, fail_count,
|
||
)
|
||
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,
|
||
)
|
||
from app.services.signal_types import BuySignal
|
||
|
||
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,
|
||
# [2026-09-27 FIX] signals(복수) 누락으로 trade_executor 의
|
||
# should_buy 판정(:1906)이 항상 False 였다 → ML 앙상블이 독립적으로
|
||
# BUY 를 낼 때만 진입되던 버그. 진입 근거와 **의미가 일치하는**
|
||
# 키워드만 채운다(거짓 신호는 저널·ML 학습 데이터를 오염시킴).
|
||
# · 신고가돌파 ← current_close > max(highs[-20:]) (StrongBuySignal)
|
||
# · 거래량증가상승 ← vol_ratio >= 2.0 & 상승 돌파
|
||
"signals": [
|
||
BuySignal.신고가돌파.value,
|
||
BuySignal.거래량증가상승.value,
|
||
],
|
||
"signal": momentum_signal["description"],
|
||
"strategy_type": "swing",
|
||
# ActiveTrade.strategy_name 으로 저장돼 swing/exit_service 의 모멘텀 전용
|
||
# 청산(+10% 30% → +20% 30% → 트레일링, CLAUDE.md §8.4)을 타게 한다.
|
||
"strategy_name": "MOMENTUM_BREAKOUT",
|
||
"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,
|
||
# [2026-09-27 FIX] 모멘텀 돌파와 동일 사유. check_pullback_buy_signal 의
|
||
# 진입 근거와 1:1 대응하는 키워드만 채운다.
|
||
# · 정배열눌림목 ← MA20>MA60(정배열) & MA20 ±3% 조정 (StrongBuySignal)
|
||
# · 눌림목진입 ← RSI 40~55 반등 + 거래량 ≥1.2×20MA (StrongBuySignal)
|
||
# · MA20상향 ← ma20 > ma60 ("20일선 > 60일선")
|
||
"signals": [
|
||
BuySignal.정배열눌림목.value,
|
||
BuySignal.눌림목진입.value,
|
||
BuySignal.MA20상향.value,
|
||
],
|
||
"signal": pullback_signal["description"],
|
||
"strategy_type": "swing",
|
||
"strategy_name": "PULLBACK_BUY",
|
||
"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,
|
||
}]
|
||
}
|
||
|
||
# [관측성] execute_auto_trade_background() 는 [2026-09-27 수정]으로 내부
|
||
# trade_results 를 항상 list 로 반환한다(과거엔 암묵적 None 을 돌려줘 아래
|
||
# isinstance(result, list) 가 항상 False 였다 — 지금은 정상 동작).
|
||
# 다만 이 list 에는 성공한 BUY 뿐 아니라 **주문 실패(success=False)한
|
||
# BUY/SELL 과, 청산 우선 로직으로 섞여 들어온 SELL 도 함께 담긴다.**
|
||
# 따라서 단순 len(result) 는 "진입 n건"으로 오인시킬 수 있어, 아래에서
|
||
# action/success 기준으로 분리 집계한다. 위임 직전 후보 로그는 관측성
|
||
# 확보를 위해 계속 남긴다.
|
||
logger.info(
|
||
"[DualStrategy] %d건 시그널 → 매매 파이프라인 위임: %s",
|
||
len(dual_stocks),
|
||
", ".join(
|
||
f"{s['stock_code']}({s.get('strategy_name')})" for s in dual_stocks
|
||
),
|
||
)
|
||
|
||
try:
|
||
result = await execute_auto_trade_background(rec, db)
|
||
if isinstance(result, list):
|
||
buy_success = sum(
|
||
1 for r in result
|
||
if isinstance(r, dict) and r.get("action") == "BUY" and r.get("success")
|
||
)
|
||
sell_count = sum(
|
||
1 for r in result if isinstance(r, dict) and r.get("action") == "SELL"
|
||
)
|
||
fail_count = sum(
|
||
1 for r in result if isinstance(r, dict) and not r.get("success")
|
||
)
|
||
logger.info(
|
||
"[DualStrategy] 진입 %d건 / 청산 %d건 / 주문실패 %d건",
|
||
buy_success, sell_count, fail_count,
|
||
)
|
||
return result
|
||
# [2026-09-27] execute_auto_trade_background() 는 이제 항상 list 를 반환하므로
|
||
# 이 분기는 정상 경로에서 도달하지 않는다. 다만 향후 시그니처 회귀(반환 타입
|
||
# 변경) 시 조용히 무시되지 않도록 isinstance 방어는 유지하고 경고로 남긴다.
|
||
logger.warning(
|
||
"[DualStrategy] 예상치 못한 반환 타입(list 아님, %r) — 방어 로직 발동",
|
||
type(result),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"[DualStrategy] 진입 실행 오류: {e}", exc_info=True)
|
||
|
||
return []
|
||
|
||
|
||
async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
|
||
"""박스권(SIDEWAYS) 레인지 매매 시그널 스캔 및 진입 실행.
|
||
|
||
CLAUDE.md §8.5 항목 9 — SIDEWAYS 국면 담당 전략이 없어 코스피가 BULL 이 아닌
|
||
구간에서 거래가 전무한 문제를 메우기 위한 **신규 신호원**.
|
||
|
||
⚠️ **백테스트 미검증.** `settings.RANGE_TRADE_ENABLED` 가 False(기본값)이면
|
||
함수 진입 즉시 [] 를 반환한다 — 어떤 조회·판정·주문도 수행하지 않으므로
|
||
플래그가 꺼져 있는 동안 기존 동작은 완전히 불변이다.
|
||
|
||
┌─ SIDEWAYS 차단 우회에 관한 명시적 설계 결정 ────────────────────────────┐
|
||
│ 이 전략은 **SIDEWAYS 국면 전용**이므로, CLAUDE.md §6.1 의 "SIDEWAYS = │
|
||
│ 모든 진입 무시" 전면 차단을 의도적으로 우회한다. │
|
||
│ · market_analyzer.check_market_regime() 은 sideways 에서 │
|
||
│ position_multiplier=0, max_trades=0 을 반환한다. │
|
||
│ → execute_auto_trade_background() 는 max_trades==0 에서 즉시 []을 │
|
||
│ 반환하므로(2026-09-27 수정 이후에도 동일 — 반환 타입만 list로 정정 │
|
||
│ 됐을 뿐 조기 종료 자체는 그대로) 사용할 수 없다. 종목별로 │
|
||
│ execute_auto_trade() 를 직접 호출하고 │
|
||
│ _position_multiplier 를 RANGE_TRADE_POSITION_MULTIPLIER 로 주입한다. │
|
||
│ · trade_executor._execute_buy 는 _regime_str=="sideways" 를 하드 차단 │
|
||
│ 한다(방어코드). 따라서 자본 한도가 가장 보수적인 "bear" 라벨을 주입해 │
|
||
│ BEAR 전략 자본 상한(30%) 아래에서 동작시킨다. │
|
||
│ · 우회는 **RANGE_TRADE_ENABLED 뒤에서만** 일어난다. market_analyzer 와 │
|
||
│ mean_reversion_strategy 는 수정하지 않았으므로 다른 전략(평균회귀· │
|
||
│ 모멘텀·눌림목)의 SIDEWAYS 차단은 그대로 유지된다. │
|
||
└──────────────────────────────────────────────────────────────────────────┘
|
||
|
||
리스크 게이트 (scan_dual_strategy_signals 와 동일 + 레인지 전용 추가):
|
||
1. RANGE_TRADE_ENABLED 플래그
|
||
2. 장 오픈 여부 (정규장만)
|
||
3. 시장 국면 == SIDEWAYS (조회 실패 시 진입 중단 — fail-closed)
|
||
4. 스윙 포지션 한도 (MAX_SWING_POSITIONS)
|
||
5. 레인지 전용 포지션 한도 (RANGE_TRADE_MAX_POSITIONS ∩ 자본 비율 유도 상한)
|
||
6. MDD (await mdd_manager.can_open_new_position())
|
||
7. 적응형 Crisis 모드 (조회 실패 시 fail-closed)
|
||
8. 플레이북 이벤트 차단
|
||
9. 보유 종목 중복 진입 방지
|
||
10. 연속손실 밴 (loss_tracker.is_banned_safe)
|
||
11. 가격 필터 (MIN_PRICE_KRW)
|
||
12. 이후 execute_auto_trade() 내부에서 가격필터·MDD·밴·보유수·당일매도쿨다운,
|
||
_execute_buy 내부에서 correlation_filter·레버리지ETF한도·뉴스필터·
|
||
MIN_BUY_AMOUNT_KRW 가 다시 적용된다.
|
||
|
||
Returns:
|
||
list[dict]: 실행된 매수 결과 목록
|
||
"""
|
||
# ── 게이트 1: 기능 플래그 (백테스트 미검증 — 기본 False) ──────────────
|
||
if not settings.RANGE_TRADE_ENABLED:
|
||
return []
|
||
|
||
from app.core.market_schedule import is_market_open
|
||
from app.models.models import ActiveTrade
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
from app.services.loss_tracker import loss_tracker
|
||
from app.services.mean_reversion_strategy import (
|
||
calculate_adx,
|
||
calculate_atr,
|
||
calculate_bollinger,
|
||
calculate_rsi,
|
||
)
|
||
from app.services.range_trading_strategy import (
|
||
RANGE_TRADE_STRATEGY_NAME,
|
||
calculate_range_stop_loss,
|
||
check_range_buy_signal,
|
||
)
|
||
from app.services.signal_types import BuySignal
|
||
|
||
# ── 게이트 2: 정규장만 ────────────────────────────────────────────────
|
||
if not is_market_open():
|
||
return []
|
||
|
||
# ── 게이트 3: SIDEWAYS 국면에서만 동작 (fail-closed) ──────────────────
|
||
try:
|
||
regime = await check_market_regime()
|
||
except Exception as e:
|
||
logger.warning(f"[RangeTrade] 시장 국면 조회 실패 — 진입 중단(fail-closed): {e}")
|
||
return []
|
||
regime_str = regime.get("regime_200d", "") if isinstance(regime, dict) else ""
|
||
if regime_str != "sideways":
|
||
logger.debug("[RangeTrade] SIDEWAYS 아님 (%s) — 레인지 전략 스킵", regime_str or "unknown")
|
||
return []
|
||
|
||
# ── 게이트 4: 스윙 전체 포지션 한도 ───────────────────────────────────
|
||
current_count = await count_swing_positions(db)
|
||
remaining_slots = MAX_SWING_POSITIONS - current_count
|
||
if remaining_slots <= 0:
|
||
logger.info("[RangeTrade] 스윙 포지션 한도 도달 (%d) — 스킵", current_count)
|
||
return []
|
||
|
||
# ── 게이트 5: 레인지 전용 포지션/자본 한도 ────────────────────────────
|
||
# 자본 배분: 새 자본 몫을 만들지 않고 스윙 예산(SWING_MAX_CAPITAL_RATIO=0.70)을
|
||
# 공유한다. 레인지 몫은 스윙 몫 **내부** 비율(RANGE_TRADE_MAX_CAPITAL_RATIO).
|
||
# 실효 상한 = 총자본 × 0.70 × 0.10 = 7%
|
||
# 종목당 = MAX_POSITION_SIZE(4%) × RANGE_TRADE_POSITION_MULTIPLIER(0.5) = 2%
|
||
# → 허용 종목 수 = floor(7% / 2%) = 3
|
||
_per_position_ratio = settings.MAX_POSITION_SIZE * settings.RANGE_TRADE_POSITION_MULTIPLIER
|
||
_range_capital_ratio = settings.SWING_MAX_CAPITAL_RATIO * settings.RANGE_TRADE_MAX_CAPITAL_RATIO
|
||
_ratio_cap = int(_range_capital_ratio / _per_position_ratio) if _per_position_ratio > 0 else 0
|
||
max_range_positions = max(0, min(settings.RANGE_TRADE_MAX_POSITIONS, _ratio_cap))
|
||
if max_range_positions <= 0:
|
||
logger.info("[RangeTrade] 자본 배분 상한이 0 — 스킵")
|
||
return []
|
||
|
||
_range_count_result = await db.execute(
|
||
select(ActiveTrade.stock_code).where(
|
||
ActiveTrade.status == "active",
|
||
ActiveTrade.strategy_name == RANGE_TRADE_STRATEGY_NAME,
|
||
)
|
||
)
|
||
_range_held = {row[0] for row in _range_count_result.fetchall()}
|
||
range_slots = max_range_positions - len(_range_held)
|
||
if range_slots <= 0:
|
||
logger.info(
|
||
"[RangeTrade] 레인지 포지션 한도 도달 (%d/%d) — 스킵",
|
||
len(_range_held), max_range_positions,
|
||
)
|
||
return []
|
||
remaining_slots = min(remaining_slots, range_slots)
|
||
|
||
# ── 게이트 6: MDD ────────────────────────────────────────────────────
|
||
if not await mdd_manager.can_open_new_position():
|
||
logger.warning("[RangeTrade] MDD 초과 — 신규 진입 차단")
|
||
return []
|
||
|
||
# ── 게이트 7: 적응형 Crisis 모드 (fail-closed) ────────────────────────
|
||
try:
|
||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||
_adp = get_current_adaptive_params()
|
||
_adp_crisis = (not _adp.fallback_used) and _adp.trading_mode == "crisis"
|
||
except Exception as e:
|
||
logger.warning(f"[RangeTrade] 적응형 파라미터 조회 실패 — 진입 중단(fail-closed): {e}")
|
||
return []
|
||
if _adp_crisis:
|
||
logger.warning("[RangeTrade] 적응형 CRISIS 모드 — 레인지 진입 차단")
|
||
return []
|
||
|
||
# ── 게이트 8: 플레이북 ───────────────────────────────────────────────
|
||
if not is_entry_allowed_by_playbook():
|
||
logger.info("[RangeTrade] 플레이북 차단 — 신규 진입 비활성화")
|
||
return []
|
||
|
||
# ── 후보 조회 ────────────────────────────────────────────────────────
|
||
held_result = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0))
|
||
held_codes = {row[0] for row in held_result.fetchall()}
|
||
|
||
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("[RangeTrade] 분석 데이터 없음 — 스킵")
|
||
return []
|
||
|
||
range_stocks: list[dict] = []
|
||
scan_count = 0
|
||
|
||
for stock in candidates:
|
||
if scan_count >= settings.RANGE_TRADE_SCAN_LIMIT:
|
||
break
|
||
if len(range_stocks) >= remaining_slots:
|
||
break
|
||
# 게이트 9: 중복 진입 방지
|
||
if stock.stock_code in held_codes:
|
||
continue
|
||
# 게이트 11: 가격 필터
|
||
if float(stock.current_price or 0) <= settings.MIN_PRICE_KRW:
|
||
continue
|
||
# 게이트 10: 연속손실 밴 (try 밖 — 실패를 삼키지 않는다)
|
||
if await loss_tracker.is_banned_safe(stock.stock_code):
|
||
logger.debug("[RangeTrade] %s 연속손실 밴 — 스킵", stock.stock_code)
|
||
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) < 40:
|
||
continue
|
||
|
||
bars = list(reversed(output2)) # 최신이 앞 → 오래된 순으로
|
||
opens = [float(b.get("stck_oprc", 0)) for b in bars]
|
||
closes = [float(b.get("stck_clpr", 0)) for b in bars]
|
||
highs = [float(b.get("stck_hgpr", 0)) for b in bars]
|
||
lows = [float(b.get("stck_lwpr", 0)) for b in bars]
|
||
volumes = [int(b.get("acml_vol", 0)) for b in bars]
|
||
|
||
if not closes or closes[-1] <= 0:
|
||
continue
|
||
scan_count += 1
|
||
|
||
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)
|
||
bb = calculate_bollinger(closes, period=20)
|
||
if bb is None:
|
||
continue
|
||
bb_upper, bb_middle, bb_lower = bb
|
||
adx = calculate_adx(highs, lows, closes, period=14)
|
||
atr = calculate_atr(highs, lows, closes, period=14) or 0.0
|
||
|
||
signal = check_range_buy_signal(
|
||
highs=highs, lows=lows, closes=closes, volumes=volumes,
|
||
current_close=current_close, current_volume=current_volume,
|
||
rsi=rsi, bb_upper=bb_upper, bb_middle=bb_middle, bb_lower=bb_lower,
|
||
adx=adx, rsi_prev=rsi_prev, opens=opens,
|
||
)
|
||
if not signal:
|
||
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
||
continue
|
||
|
||
stops = calculate_range_stop_loss(
|
||
entry_price=current_close, atr=atr, box_low=signal["box_low"],
|
||
)
|
||
range_stocks.append({
|
||
"stock_code": stock.stock_code,
|
||
"stock_name": stock.stock_name,
|
||
"current_price": int(current_close),
|
||
"score": 5.0,
|
||
# signals: trade_executor 의 BUY_KEYWORDS/STRONG_BUY_KEYWORDS 매칭용.
|
||
# 레인지 진입은 'BB 하단 터치 + RSI 바닥 탈출 반등' 과 동일 성격이다.
|
||
"signals": [
|
||
BuySignal.볼린저하한선.value,
|
||
BuySignal.RSI바닥탈출.value,
|
||
BuySignal.횡보장평균회귀.value,
|
||
],
|
||
"signal": signal["description"],
|
||
"strategy_type": "swing", # 스윙 예산/청산 파이프라인 공유
|
||
"strategy_name": RANGE_TRADE_STRATEGY_NAME, # ActiveTrade.strategy_name
|
||
"entry_reason": f"[{RANGE_TRADE_STRATEGY_NAME}] {signal['description']}",
|
||
"stop_loss": stops["stop_loss_pct"], # 음수 보장 (-abs)
|
||
"take_profit": max(
|
||
0.0,
|
||
(signal["target_price"] - current_close) / current_close * 100.0,
|
||
),
|
||
"atr": atr,
|
||
"closes": closes,
|
||
"rsi": rsi,
|
||
"bb": (bb_upper, bb_middle, bb_lower),
|
||
})
|
||
logger.info(
|
||
"[RangeTrade] 박스권 진입 시그널: %s %s — %s",
|
||
stock.stock_code, stock.stock_name, signal["description"],
|
||
)
|
||
await asyncio.sleep(0.3)
|
||
|
||
except Exception as e:
|
||
logger.debug(f"[RangeTrade] {stock.stock_code} 스캔 실패: {e}")
|
||
continue
|
||
|
||
if not range_stocks:
|
||
logger.debug("[RangeTrade] 시그널 없음")
|
||
return []
|
||
|
||
range_stocks = range_stocks[:remaining_slots]
|
||
|
||
# ── 진입 실행 ────────────────────────────────────────────────────────
|
||
# execute_auto_trade_background() 는 SIDEWAYS 의 max_trades=0 에서 즉시 종료하므로
|
||
# 종목별 execute_auto_trade() 를 직접 호출한다(상단 설계 결정 주석 참조).
|
||
# _regime_str="bear": _execute_buy 의 sideways 하드 차단을 피하면서 가장 보수적인
|
||
# 자본 한도(BEAR 30%)를 적용받는다. _position_multiplier 로 종목당 비중을 2%로 축소.
|
||
results: list[dict] = []
|
||
for stock_dict in range_stocks:
|
||
stock_dict["_regime_str"] = "bear"
|
||
stock_dict["_position_multiplier"] = float(settings.RANGE_TRADE_POSITION_MULTIPLIER)
|
||
try:
|
||
result = await execute_auto_trade(stock_dict, db)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"[RangeTrade] {stock_dict.get('stock_code')} 진입 실행 오류: {e}",
|
||
exc_info=True,
|
||
)
|
||
continue
|
||
if isinstance(result, dict) and result.get("action") == "BUY":
|
||
results.append(result)
|
||
else:
|
||
logger.info(
|
||
"[RangeTrade] %s 진입 보류: action=%s reason=%s",
|
||
stock_dict.get("stock_code"),
|
||
(result or {}).get("action") if isinstance(result, dict) else "?",
|
||
(result or {}).get("reason", "미상") if isinstance(result, dict) else "?",
|
||
)
|
||
await asyncio.sleep(0.5) # KIS API 레이트 리밋
|
||
|
||
if results:
|
||
logger.info(f"[RangeTrade] {len(results)}건 진입 실행 완료")
|
||
return results
|