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:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View 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종목
"""

View 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

View 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

View 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

View 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