fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
backend/app/services/swing/__init__.py
Normal file
22
backend/app/services/swing/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""스윙 트레이딩 서비스 패키지
|
||||
|
||||
보유 기간 3일~3주의 스윙 포지션 전담 레이어.
|
||||
데이트레이딩(daytrading/)과 strategy_type으로 완전 분리.
|
||||
|
||||
모듈 구성:
|
||||
- position_manager : strategy_type='swing' 포지션 DB 조회 (읽기 전용)
|
||||
- exit_service : 일봉 ATR 기반 청산 (RSI, BB, 트레일링스탑, 모멘텀 전용)
|
||||
- entry_service : 배치 분석 추천 기반 진입 (execute_auto_trade_background 재활용)
|
||||
- scheduler : 60초 주기 독립 스케줄러 (MDD 축소 + 청산 + 진입)
|
||||
|
||||
전략 (CLAUDE.md 6, 8.4):
|
||||
- 평균회귀: RSI+BB 과매도 진입 → RSI 55 부분익절 → RSI 70 전량청산
|
||||
- 모멘텀 돌파: 20일 신고가 돌파 → +10% 30%매도 → +20% 30%매도 → 트레일링
|
||||
- 눌림목 매수: MA20>MA60 + MA20 근처 조정 후 반등
|
||||
|
||||
리스크 규칙:
|
||||
- MDD -10%: 신규 진입 차단 + 30% 축소
|
||||
- MDD -20%: 50% 강제 축소 (레버리지 ETF 우선)
|
||||
- ATR 손절: 진입가 - 2.5*ATR (하드스탑과 비교하여 타이트한 쪽)
|
||||
- 상관관계 필터: 섹터당 최대 3종목
|
||||
"""
|
||||
146
backend/app/services/swing/entry_service.py
Normal file
146
backend/app/services/swing/entry_service.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""스윙 트레이딩 진입 서비스
|
||||
|
||||
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종목
|
||||
|
||||
진입 전 체크:
|
||||
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
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import Portfolio
|
||||
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이지만,
|
||||
# 스윙 전용으로 25개까지 허용 -- 데이트레이딩과 분리 운영)
|
||||
MAX_SWING_POSITIONS = 25
|
||||
|
||||
|
||||
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
|
||||
362
backend/app/services/swing/exit_service.py
Normal file
362
backend/app/services/swing/exit_service.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""스윙 트레이딩 청산 서비스
|
||||
|
||||
기존 portfolio.py의 check_and_execute_exit_trades() 로직을 기반으로
|
||||
strategy_type='swing' 포지션만 처리하도록 분리한 서비스.
|
||||
|
||||
청산 기준 (CLAUDE.md 섹션 5.2, 6.3):
|
||||
- ATR 손절: 진입가 - 2.5*ATR (하드스탑과 비교하여 타이트한 쪽)
|
||||
- 고정비율 폴백 손절: -7% (ATR 계산 불가 시)
|
||||
- 1차 익절: RSI >= 55 OR 가격 > BB 중앙선 OR 수익 > 2*ATR -> 50% 매도
|
||||
- 2차 익절: RSI >= 70 OR 가격 > BB 상단(99%) -> 전량 매도
|
||||
- 트레일링스탑: 고점 - 1.5*ATR (잔여 50% 물량)
|
||||
|
||||
전략별 차별화 (CLAUDE.md 8.4):
|
||||
- 평균회귀/눌림목: RSI 기반 단계별 익절
|
||||
- 모멘텀 돌파: +10%/+20% 단계별 30% 매도 + 넓은 트레일링
|
||||
|
||||
아키텍처 규칙:
|
||||
- DB 기록: db_worker 큐 경유 (enqueue_trade_history, enqueue_close_active_trade)
|
||||
- loss_tracker: 전량 매도 시 record_trade_result_safe() + save_to_db()
|
||||
- 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호)
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import Portfolio, ActiveTrade
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.mean_reversion_strategy import (
|
||||
calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal,
|
||||
MarketRegime,
|
||||
)
|
||||
from app.services.market_analyzer import check_market_regime
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
|
||||
from app.services.swing.position_manager import get_swing_holdings, get_swing_active_trade
|
||||
from app.services.utils import _is_domestic
|
||||
from app.core.json_helpers import _dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_momentum_exit(
|
||||
current_price: float,
|
||||
entry_price: float,
|
||||
atr: float,
|
||||
remaining_pct: float,
|
||||
trailing_high: float | None,
|
||||
) -> "ExitSignal | None":
|
||||
"""모멘텀 돌파 전용 청산 로직 (CLAUDE.md 8.4).
|
||||
|
||||
일반 평균회귀와 다른 익절 전략을 사용:
|
||||
- 이익을 최대한 키우기 위해 RSI 기반 익절 대신 수익률 기반 단계별 매도
|
||||
- 손절은 평균회귀와 동일 (ATR 2.5배 vs 하드스탑 중 타이트한 쪽)
|
||||
|
||||
익절 단계:
|
||||
1. +10% 이상: 30% 매도 (remaining_pct > 0.75일 때만)
|
||||
2. +20% 이상: 추가 30% 매도 (remaining_pct > 0.45일 때만)
|
||||
3. 잔량: 트레일링스탑 (고점 - 1.5*ATR)
|
||||
|
||||
Args:
|
||||
current_price: 현재가
|
||||
entry_price: 진입가
|
||||
atr: ATR(14) 값
|
||||
remaining_pct: 잔여 비율 (1.0=전량 보유)
|
||||
trailing_high: 진입 이후 최고가 (트레일링스탑 기준)
|
||||
|
||||
Returns:
|
||||
ExitSignal | None: 청산 신호, 없으면 None
|
||||
|
||||
Note:
|
||||
[CRITICAL] 손절 계산에서 stop_price = max(atr_stop, pct_stop) 사용.
|
||||
이것은 가격 비교이므로 max()가 올바름 (높은 가격 = 타이트한 손절).
|
||||
CLAUDE.md에서 min()을 언급하는 것은 백분율 비교 기준.
|
||||
"""
|
||||
from app.services.mean_reversion_strategy import ExitSignal
|
||||
|
||||
if entry_price <= 0:
|
||||
return None
|
||||
|
||||
profit_pct = (current_price - entry_price) / entry_price * 100
|
||||
|
||||
# 손절: ATR 2.5배 vs 하드스탑 (CLAUDE.md 5.2)
|
||||
# stop_loss_pct는 음수 (-7.0 등), pct_stop은 진입가 대비 하드스탑 가격
|
||||
# max()를 사용하는 이유: 가격 비교에서 높은 값 = 진입가에 가까운 = 타이트한 손절
|
||||
stop_loss_pct = -abs(settings.STOP_LOSS_PERCENT)
|
||||
atr_stop = entry_price - 2.5 * atr
|
||||
pct_stop = entry_price * (1 + stop_loss_pct / 100)
|
||||
stop_price = max(atr_stop, pct_stop)
|
||||
|
||||
if current_price <= stop_price:
|
||||
return ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"모멘텀 손절 ({profit_pct:.1f}%, stop={stop_price:.0f})",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
# 트레일링스탑 (잔량에 대해)
|
||||
if trailing_high and trailing_high > entry_price:
|
||||
trail_stop = trailing_high - 1.5 * atr
|
||||
if current_price <= trail_stop and profit_pct > 0:
|
||||
return ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
# 1차 익절: +10% → 30% 매도 (remaining > 0.65 = 아직 1차 매도 안 함)
|
||||
# 1차 매도 후 remaining = 1.0 - 0.30 = 0.70 → 다음 사이클에서 0.70 > 0.65 방지
|
||||
# 따라서 0.75 기준 유지: 1.0 > 0.75 트리거, 매도 후 0.70 <= 0.75 → 재트리거 방지
|
||||
if profit_pct >= 10.0 and remaining_pct > 0.75:
|
||||
return ExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"모멘텀 1차 익절 +{profit_pct:.1f}% → 30% 매도",
|
||||
sell_pct=0.30,
|
||||
)
|
||||
|
||||
# 2차 익절: +20% → 30% 매도
|
||||
# 1차 매도 후 remaining = 0.70, 2차 매도 후 remaining = 0.40
|
||||
# 조건: remaining > 0.45 AND remaining <= 0.75 (1차와 겹치지 않게 상한 설정)
|
||||
if profit_pct >= 20.0 and 0.45 < remaining_pct <= 0.75:
|
||||
return ExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"모멘텀 2차 익절 +{profit_pct:.1f}% → 30% 매도",
|
||||
sell_pct=0.30,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
"""스윙 포지션 손절/익절 체크 및 자동 매도 실행.
|
||||
|
||||
portfolio.py의 check_and_execute_exit_trades()와 동일한 로직이나
|
||||
strategy_type='swing' 필터를 적용해 데이트레이딩 포지션을 제외함.
|
||||
스케줄러에서 60초 주기로 호출.
|
||||
|
||||
처리 흐름 (종목별):
|
||||
1. 스윙 보유 종목 조회 (get_swing_holdings)
|
||||
2. 시장 국면 조회 (BULL/BEAR)
|
||||
3. 실시간 현재가 조회 (KIS API)
|
||||
4. ActiveTrade 조회 (분할 청산 상태)
|
||||
5. 전략별 청산 분기:
|
||||
- MOMENTUM_BREAKOUT: _check_momentum_exit()
|
||||
- 평균회귀/눌림목/기본: check_exit_signal()
|
||||
6. 고정비율 폴백 손절 (-7%)
|
||||
7. 매도 수량 결정 (소량 분할 방지)
|
||||
8. KIS API 매도 주문 실행
|
||||
9. DB 기록 + loss_tracker 업데이트
|
||||
|
||||
Args:
|
||||
db: AsyncSession 인스턴스
|
||||
|
||||
Returns:
|
||||
list[dict]: 청산 실행 결과 목록
|
||||
"""
|
||||
executed_exits = []
|
||||
|
||||
try:
|
||||
# strategy_type='swing' 포지션만 조회
|
||||
holdings = await get_swing_holdings(db)
|
||||
if not holdings:
|
||||
logger.debug("[SwingExit] 스윙 보유 종목 없음")
|
||||
return executed_exits
|
||||
|
||||
# 시장 국면 (동적 계산 — 기존 코드의 하드코딩 BULL 개선)
|
||||
try:
|
||||
regime_dict = await check_market_regime()
|
||||
regime_str = regime_dict.get("regime_200d", "bull") if isinstance(regime_dict, dict) else "bull"
|
||||
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
|
||||
except Exception:
|
||||
regime = MarketRegime.BULL # 폴백
|
||||
|
||||
for holding in holdings:
|
||||
try:
|
||||
stock_code = holding.stock_code
|
||||
stock_name = holding.stock_name or stock_code
|
||||
quantity = holding.quantity
|
||||
avg_price = float(holding.average_price or 0)
|
||||
market = getattr(holding, "market", None) or (
|
||||
"KRX" if _is_domestic(stock_code) else "NASD"
|
||||
)
|
||||
domestic = _is_domestic(stock_code, market)
|
||||
|
||||
# ── 실시간 현재가 조회 ────────────────────────────────────
|
||||
latest_price = float(holding.current_price or 0)
|
||||
if domestic:
|
||||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||||
if price_resp and price_resp.get("output"):
|
||||
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
|
||||
else:
|
||||
price_resp = await korea_investment_api.get_overseas_price(stock_code, market)
|
||||
if price_resp and price_resp.get("output"):
|
||||
latest_price = float(price_resp["output"].get("last", latest_price))
|
||||
|
||||
if latest_price <= 0 or avg_price <= 0:
|
||||
continue
|
||||
|
||||
# ── ActiveTrade 조회 (분할 청산 상태) ────────────────────
|
||||
active_trade = await get_swing_active_trade(db, stock_code)
|
||||
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0
|
||||
trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None
|
||||
|
||||
# ── 전략별 청산 분기 ────────────────────────────────────
|
||||
exit_signal = None
|
||||
_strategy = active_trade.strategy_name if active_trade else "swing"
|
||||
|
||||
if domestic:
|
||||
try:
|
||||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
|
||||
chart_data = chart_resp.get("output2", [])
|
||||
if len(chart_data) >= 20:
|
||||
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
|
||||
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
|
||||
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
|
||||
|
||||
atr = calculate_atr(highs, lows, closes)
|
||||
rsi = calculate_rsi(closes)
|
||||
bb = calculate_bollinger(closes)
|
||||
|
||||
if atr and rsi is not None and bb:
|
||||
bb_upper, bb_middle, bb_lower = bb
|
||||
lowest_since = min(lows[-5:]) if lows else avg_price
|
||||
|
||||
# 모멘텀 돌파 전용 청산 (CLAUDE.md 8.4)
|
||||
if _strategy == "MOMENTUM_BREAKOUT":
|
||||
exit_signal = _check_momentum_exit(
|
||||
latest_price, avg_price, atr,
|
||||
remaining_pct, trailing_high,
|
||||
)
|
||||
else:
|
||||
# 평균회귀 / 눌림목 / 기본 스윙
|
||||
exit_signal = check_exit_signal(
|
||||
regime=regime,
|
||||
rsi=rsi,
|
||||
close=latest_price,
|
||||
bb_upper=bb_upper,
|
||||
bb_middle=bb_middle,
|
||||
bb_lower=bb_lower,
|
||||
entry_price=avg_price,
|
||||
atr=atr,
|
||||
lowest_since_entry=lowest_since,
|
||||
remaining_pct=remaining_pct,
|
||||
trailing_high=trailing_high,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[SwingExit] ATR 계산 실패 ({stock_code}): {e}")
|
||||
|
||||
# ── 고정 비율 폴백 손절 ───────────────────────────────────
|
||||
if exit_signal is None:
|
||||
stop_loss_pct = abs(float(getattr(settings, "STOP_LOSS_PERCENT", 6.0)))
|
||||
profit_rate = ((latest_price - avg_price) / avg_price) * 100
|
||||
if profit_rate <= -stop_loss_pct:
|
||||
from app.services.mean_reversion_strategy import ExitSignal
|
||||
exit_signal = ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"고정비율 손절 ({profit_rate:.1f}%)",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
if not exit_signal:
|
||||
continue
|
||||
|
||||
# ── 매도 수량 결정 (소량 분할 방지) ─────────────────────
|
||||
sell_pct = getattr(exit_signal, "sell_pct", 1.0)
|
||||
if sell_pct < 1.0 and quantity <= 3:
|
||||
# 3주 이하: 분할 매도 비효율 → 전량 매도
|
||||
sell_qty = quantity
|
||||
logger.info(f"[SwingExit] {stock_code}: {quantity}주 소량 → 전량매도 (분할 비효율)")
|
||||
else:
|
||||
sell_qty = min(max(int(quantity * sell_pct), 1), quantity)
|
||||
if sell_qty <= 0:
|
||||
continue
|
||||
|
||||
# ── KIS API 매도 주문 ─────────────────────────────────────
|
||||
logger.info(
|
||||
f"[SwingExit] 청산 신호 → {stock_code} {stock_name} "
|
||||
f"수량={sell_qty} 이유={exit_signal.reason}"
|
||||
)
|
||||
# [C1] 분산락+세마포어 보호 주문
|
||||
from app.services.trade_executor import safe_place_order
|
||||
order_resp = await safe_place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=sell_qty,
|
||||
price=None if domestic else latest_price,
|
||||
is_domestic=domestic,
|
||||
exchange_code=market or "NASD",
|
||||
)
|
||||
|
||||
if not order_resp or order_resp.get("rt_cd") != "0":
|
||||
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
|
||||
logger.error(f"[SwingExit] 매도 주문 실패 ({stock_code}): {err}")
|
||||
continue
|
||||
|
||||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
||||
profit_loss = (latest_price - avg_price) * sell_qty
|
||||
profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100
|
||||
|
||||
# ── DB 기록 (db_task_queue 경유) ──────────────────────────
|
||||
await enqueue_trade_history({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"order_type": "SELL",
|
||||
"order_price": latest_price,
|
||||
"order_quantity": sell_qty,
|
||||
"executed_price": latest_price,
|
||||
"executed_quantity": sell_qty,
|
||||
"total_amount": latest_price * sell_qty,
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": exit_signal.reason,
|
||||
"net_profit_loss": profit_loss,
|
||||
"profit_loss_rate": profit_loss_rate,
|
||||
"strategy_type": "swing",
|
||||
})
|
||||
|
||||
# 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신
|
||||
if sell_qty >= quantity:
|
||||
await enqueue_close_active_trade(stock_code, strategy_name=None)
|
||||
else:
|
||||
# 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지)
|
||||
new_remaining = max(0.0, remaining_pct - sell_pct)
|
||||
from app.services.db_worker import enqueue_active_trade_upsert
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": stock_code,
|
||||
"remaining_pct": new_remaining,
|
||||
"highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high,
|
||||
})
|
||||
|
||||
# LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지)
|
||||
if sell_qty >= quantity:
|
||||
is_loss = profit_loss < 0
|
||||
await loss_tracker.record_trade_result_safe(
|
||||
stock_code=stock_code,
|
||||
is_loss=is_loss,
|
||||
)
|
||||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||||
|
||||
executed_exits.append({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"sell_quantity": sell_qty,
|
||||
"price": latest_price,
|
||||
"profit_loss": profit_loss,
|
||||
"profit_loss_rate": profit_loss_rate,
|
||||
"reason": exit_signal.reason,
|
||||
})
|
||||
logger.info(
|
||||
f"[SwingExit] ✅ 청산 완료: {stock_code} {stock_name} "
|
||||
f"수익률={profit_loss_rate:.1f}% 이유={exit_signal.reason}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SwingExit] 종목 처리 오류 ({holding.stock_code}): {e}", exc_info=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SwingExit] 전체 오류: {e}", exc_info=True)
|
||||
|
||||
return executed_exits
|
||||
109
backend/app/services/swing/position_manager.py
Normal file
109
backend/app/services/swing/position_manager.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""스윙 포지션 관리 -- DB 조회 전용
|
||||
|
||||
strategy_type='swing' 필터를 적용해 데이트레이딩 포지션과 완전히 분리.
|
||||
모든 함수는 읽기 전용 -- 쓰기는 db_worker.py의 큐를 통해서만 수행.
|
||||
|
||||
DB 테이블 매핑:
|
||||
- Portfolio: 보유 종목 (strategy_type 컬럼으로 swing/daytrading 구분)
|
||||
- ActiveTrade: 활성 매매 (strategy_name 컬럼으로 구분, 분할 청산 상태 추적)
|
||||
|
||||
사용 위치:
|
||||
- exit_service.py: 청산 대상 포지션 조회
|
||||
- entry_service.py: 포지션 한도 체크
|
||||
- scheduler.py: 스윙 스케줄러 사이클
|
||||
"""
|
||||
import logging
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.models import Portfolio, ActiveTrade
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 스윙 전략 식별자 -- Portfolio.strategy_type과 매칭
|
||||
STRATEGY_TYPE = "swing"
|
||||
|
||||
|
||||
async def get_swing_holdings(db: AsyncSession) -> list:
|
||||
"""strategy_type='swing'인 보유 종목 목록 반환.
|
||||
|
||||
Portfolio 테이블에서 수량 > 0이고 strategy_type='swing'인 레코드 조회.
|
||||
|
||||
Args:
|
||||
db: AsyncSession 인스턴스
|
||||
|
||||
Returns:
|
||||
list[Portfolio]: 스윙 보유 종목 ORM 객체 목록
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.quantity > 0,
|
||||
Portfolio.strategy_type == STRATEGY_TYPE,
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_swing_active_trades(db: AsyncSession) -> list:
|
||||
"""활성 스윙 ActiveTrade 목록 반환.
|
||||
|
||||
strategy_name != 'day_trade' 조건으로 데이트레이딩 포지션 제외.
|
||||
분할 청산 진행 상태(remaining_pct), 최고가(highest_since_entry) 포함.
|
||||
|
||||
Args:
|
||||
db: AsyncSession 인스턴스
|
||||
|
||||
Returns:
|
||||
list[ActiveTrade]: 활성 스윙 매매 ORM 객체 목록
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.status == "active",
|
||||
ActiveTrade.strategy_name != "day_trade",
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTrade | None:
|
||||
"""특정 종목의 스윙 ActiveTrade 반환.
|
||||
|
||||
분할 청산 상태(remaining_pct)와 최고가(highest_since_entry) 조회용.
|
||||
청산 로직에서 전략별 분기(모멘텀/평균회귀)에도 사용.
|
||||
|
||||
Args:
|
||||
db: AsyncSession 인스턴스
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
ActiveTrade | None: 활성 매매 ORM 객체, 없으면 None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
ActiveTrade.strategy_name != "day_trade",
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_swing_positions(db: AsyncSession) -> int:
|
||||
"""현재 스윙 보유 종목 수 반환.
|
||||
|
||||
MAX_SWING_POSITIONS(25) 한도 체크에 사용.
|
||||
|
||||
Args:
|
||||
db: AsyncSession 인스턴스
|
||||
|
||||
Returns:
|
||||
int: 스윙 보유 종목 수
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(
|
||||
select(func.count(Portfolio.id)).where(
|
||||
Portfolio.quantity > 0,
|
||||
Portfolio.strategy_type == STRATEGY_TYPE,
|
||||
)
|
||||
)
|
||||
return result.scalar_one() or 0
|
||||
195
backend/app/services/swing/scheduler.py
Normal file
195
backend/app/services/swing/scheduler.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""스윙 트레이딩 독립 스케줄러
|
||||
|
||||
main.py의 lifespan에서 asyncio.create_task()로 시작되는
|
||||
백그라운드 태스크. 기존 auto_trade_scheduler()를 대체.
|
||||
|
||||
60초 주기 실행 흐름:
|
||||
1. 장 오픈 체크 (장외 시 30분 대기)
|
||||
2. 스윙 포지션 손절/익절 체크 (check_and_execute_exit_v2)
|
||||
3. MDD 체크 + 강제 축소 실행 (10% → 30% 축소, 20% → 50% 축소)
|
||||
4. AUTO_TRADE_ENABLED 체크
|
||||
5. Redis 캐시 또는 DB에서 추천 조회
|
||||
6. MCP RISK_ASSESSMENT → 승인 시 스윙 매수 실행
|
||||
|
||||
MDD 축소 (CLAUDE.md 5.3):
|
||||
- reduction_applied == "10pct": 30% 축소 (REDUCE_AND_PAUSE)
|
||||
- reduction_applied == "20pct": 50% 축소 (FORCE_REDUCE, 레버리지 ETF 우선)
|
||||
|
||||
추천 데이터 소스 (우선순위):
|
||||
1. Redis: "recommendations:latest" 키 (배치 분석 결과 캐시)
|
||||
2. DB: get_rule_based_recommendations_from_db() (Redis 미스 시 폴백)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytz
|
||||
from datetime import timedelta as _td
|
||||
|
||||
from app.core.config import settings
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
|
||||
def _normalize_recommendations(rec: dict) -> dict:
|
||||
"""recommendations_by_rule 키 정규화.
|
||||
|
||||
Redis 캐시에 "recommendations" 키로 저장된 경우
|
||||
"recommendations_by_rule"로 변환하여 execute_swing_entries()와 호환.
|
||||
|
||||
Args:
|
||||
rec: 추천 딕셔너리
|
||||
|
||||
Returns:
|
||||
dict: 정규화된 추천 딕셔너리
|
||||
"""
|
||||
if not isinstance(rec, dict):
|
||||
return rec
|
||||
if "recommendations_by_rule" in rec:
|
||||
return rec
|
||||
if "recommendations" in rec:
|
||||
rec["recommendations_by_rule"] = rec["recommendations"]
|
||||
return rec
|
||||
|
||||
|
||||
async def swing_scheduler() -> None:
|
||||
"""60초 주기 스윙 트레이딩 스케줄러.
|
||||
|
||||
무한 루프로 실행되며 asyncio.CancelledError로만 종료.
|
||||
각 사이클에서 예외 발생 시 로그 기록 후 다음 사이클 계속.
|
||||
|
||||
DB 세션은 사이클마다 AsyncSessionLocal()로 새로 생성하여
|
||||
장시간 세션 유지로 인한 커넥션 풀 고갈 방지.
|
||||
"""
|
||||
await asyncio.sleep(2)
|
||||
from app.api.endpoints.recommendations import (
|
||||
get_rule_based_recommendations_from_db,
|
||||
)
|
||||
from app.api.endpoints.mcp import request_mcp_risk_assessment
|
||||
from app.core.redis import get_json
|
||||
|
||||
logger.info("📈 [Swing] Scheduler started (every 60s)")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
logger.info("[Swing] ── 사이클 시작 ──")
|
||||
|
||||
if not is_market_open():
|
||||
logger.info("[Swing] 장 미오픈 — 30분 대기")
|
||||
await asyncio.sleep(1800)
|
||||
continue
|
||||
|
||||
# ── Phase 1: 청산 체크 (별도 세션) ──
|
||||
async with AsyncSessionLocal() as exit_session:
|
||||
try:
|
||||
exits = await check_and_execute_exit_v2(exit_session)
|
||||
if exits:
|
||||
logger.info(f"[Swing] 청산 실행: {len(exits)}건")
|
||||
except Exception as e:
|
||||
logger.error(f"[Swing] 청산 체크 오류: {e}", exc_info=True)
|
||||
|
||||
# ── Phase 2: MDD 체크 + 강제 축소 (세션 불필요) ──
|
||||
can_open_new = await mdd_manager.can_open_new_position()
|
||||
logger.info(f"[Swing] MDD 체크: can_open={can_open_new}")
|
||||
if not can_open_new:
|
||||
logger.warning("[Swing] MDD 초과 — 신규 진입 차단")
|
||||
if mdd_manager.reduction_applied:
|
||||
try:
|
||||
reduce_pct = 0.50 if mdd_manager.reduction_applied == "20pct" else 0.30
|
||||
mdd_action = {
|
||||
"action": "FORCE_REDUCE" if mdd_manager.reduction_applied == "20pct" else "REDUCE_AND_PAUSE",
|
||||
"reduce_pct": reduce_pct,
|
||||
"prioritize_leverage": mdd_manager.reduction_applied == "20pct",
|
||||
}
|
||||
results = await mdd_manager.execute_reduction(mdd_action)
|
||||
if results:
|
||||
logger.warning(f"[Swing] MDD 강제 축소 완료: {len(results)}종목")
|
||||
except Exception as e:
|
||||
logger.error(f"[Swing] MDD 강제 축소 실패: {e}", exc_info=True)
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# 3. 자동매매 활성화 여부
|
||||
if not settings.AUTO_TRADE_ENABLED:
|
||||
logger.info("[Swing] AUTO_TRADE_ENABLED=False — 스킵")
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# ── Phase 3: 추천 조회 + 진입 실행 (별도 세션) ──
|
||||
# 4. Redis 캐시 확인 → cache miss 시 DB 조회
|
||||
rec = await get_json("recommendations:latest")
|
||||
if rec:
|
||||
rec = _normalize_recommendations(rec)
|
||||
logger.info(f"[Swing] Redis 캐시 히트 (추천 {len(rec.get('recommendations_by_rule', []))}그룹)")
|
||||
else:
|
||||
logger.info("[Swing] Redis 캐시 미스 → DB 조회")
|
||||
async with AsyncSessionLocal() as rec_session:
|
||||
rec = await get_rule_based_recommendations_from_db(rec_session)
|
||||
|
||||
if not rec or not rec.get("recommendations_by_rule"):
|
||||
logger.info("[Swing] 추천 데이터 없음 — 스킵")
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# 5. MCP RISK_ASSESSMENT
|
||||
recommendations = rec.get("recommendations_by_rule", [])
|
||||
if isinstance(recommendations, dict):
|
||||
rec_count = sum(len(v) for v in recommendations.values())
|
||||
top_stocks = [
|
||||
item.get("stock_code", "")
|
||||
for items in recommendations.values()
|
||||
for item in (items[:3] if isinstance(items, list) else [])
|
||||
][:10]
|
||||
else:
|
||||
rec_count = sum(
|
||||
len(g.get("stocks", [])) for g in recommendations
|
||||
if isinstance(g, dict)
|
||||
)
|
||||
top_stocks = [
|
||||
s.get("stock_code", "")
|
||||
for g in recommendations if isinstance(g, dict)
|
||||
for s in g.get("stocks", [])[:3]
|
||||
][:10]
|
||||
|
||||
trade_plan = {
|
||||
"action": "BUY",
|
||||
"strategy": "swing",
|
||||
"total_recommendations": rec_count,
|
||||
"top_stocks": top_stocks,
|
||||
}
|
||||
|
||||
logger.info(f"[Swing] MCP 리스크 평가 요청: {len(top_stocks)}종목")
|
||||
mcp_approved, mcp_reason = await request_mcp_risk_assessment(
|
||||
trade_plan, {"strategy": "swing"}, {"top_stocks": top_stocks}
|
||||
)
|
||||
logger.info(f"[Swing] MCP 결과: approved={mcp_approved}, reason={mcp_reason}")
|
||||
if not mcp_approved:
|
||||
logger.warning(f"[Swing] MCP 거부: {mcp_reason}")
|
||||
from app.services.db_worker import enqueue_db_write
|
||||
await enqueue_db_write({
|
||||
"type": "log_mcp_deny",
|
||||
"reason": mcp_reason,
|
||||
"trade_plan": trade_plan,
|
||||
})
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# 6. 스윙 진입 실행 (별도 세션)
|
||||
async with AsyncSessionLocal() as entry_session:
|
||||
logger.info(f"[Swing] 진입 실행 시작: {top_stocks[:5]}")
|
||||
await execute_swing_entries(rec, entry_session)
|
||||
logger.info("[Swing] ── 사이클 완료 ──")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Swing] 스케줄러 사이클 오류: {e}", exc_info=True)
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[Swing] Scheduler stopped")
|
||||
raise
|
||||
Reference in New Issue
Block a user