"""미국 주식 매매 전략 (US Stock Trading Strategy) S&P 500 / NASDAQ 100 기반 평균회귀 + 모멘텀 복합 전략 [전략 개요] - 기본: RSI + 볼린저 밴드 평균회귀 (국내 전략과 동일 기반) - 추가: 52주 신고/신저 모멘텀 필터 - 시간대: 미국 정규장 (09:30~16:00 ET) [매수 조건] - RSI(14) <= 35 (국내보다 여유있게) - 가격이 BB 하단 -3% ~ +2% 구간 - 거래량 >= 20일 평균 - $4 이상 (페니스탁 필터) [매도 조건] - 손절: -7% 또는 ATR x 2.5 (국내와 동일) - 1차 익절: RSI >= 55 + BB 중앙선 상회 (30% 매도) - 2차 익절: 트레일링 스탑 -4% (나머지) [포지션 사이징] - 종목당 최대 5% (미국은 변동성 낮으므로 국내 4%보다 높음) - 미국 주식 총 비중: 전체 자본의 최대 30% """ import logging from typing import Optional, Dict, List from app.services.mean_reversion_strategy import ExitSignal logger = logging.getLogger(__name__) # ── 상수 ──────────────────────────────────────────────────────────────────────── US_RSI_BUY_THRESHOLD = 35.0 # RSI 매수 기준 (국내 30 대비 여유) US_BB_LOWER_MARGIN_LOW = 0.97 # BB 하단 -3% US_BB_LOWER_MARGIN_HIGH = 1.02 # BB 하단 +2% US_MIN_PRICE_USD = 4.0 # 페니스탁 필터 US_STOP_LOSS_PCT = -7.0 # 손절 -7% US_ATR_STOP_MULT = 2.5 # ATR 손절 배수 US_FIRST_EXIT_RSI = 55.0 # 1차 익절 RSI 기준 US_FIRST_EXIT_SELL_PCT = 0.30 # 1차 익절 매도 비율 (30%) US_TRAILING_STOP_PCT = 0.04 # 트레일링 스탑 -4% US_MAX_PER_POSITION = 0.05 # 종목당 최대 5% US_MAX_CAPITAL_RATIO = 0.30 # 미국 주식 총 비중 최대 30% def check_us_buy_signal( rsi: float, close: float, bb_upper: float, bb_middle: float, bb_lower: float, avg_volume_20d: float, current_volume: float, current_price_usd: float, rsi_prev: Optional[float] = None, ) -> Optional[Dict]: """미국 주식 매수 신호 판별 조건 (모두 충족 필요): 1. RSI(14) <= 35 2. 가격이 BB 하단 -3% ~ +2% 구간 3. 거래량 >= 20일 평균 4. 현재가 >= $4 (페니스탁 필터) 5. RSI 반등 확인 (rsi_prev 제공 시) Args: rsi: 현재 RSI(14) 값 close: 현재 종가 bb_upper: 볼린저 밴드 상단 bb_middle: 볼린저 밴드 중앙선 bb_lower: 볼린저 밴드 하단 avg_volume_20d: 20일 평균 거래량 current_volume: 현재 거래량 current_price_usd: 현재가 (USD) rsi_prev: 이전 RSI 값 (반등 확인용, 3봉 전 RSI) Returns: 매수 신호 dict 또는 None """ # 페니스탁 필터 # [수정 10] trade_executor.py:1102의 `<= settings.MIN_PRICE_USD` 와 경계 조건 통일. # CLAUDE.md 5.4: "$4 초과" → $4.00 이하(=포함)는 진입 불가. if current_price_usd <= US_MIN_PRICE_USD: logger.debug( "US BUY skip: price $%.2f <= $%.2f (penny stock filter)", current_price_usd, US_MIN_PRICE_USD, ) return None # 거래량 필터 if current_volume < avg_volume_20d: return None # RSI 과매도 확인 if rsi > US_RSI_BUY_THRESHOLD: return None # RSI 반등 확인 (rsi_prev 제공 시, RSI 하락 중이면 진입 차단) if rsi_prev is not None and rsi <= rsi_prev: logger.debug( "US BUY skip: RSI 반등 미확인 — rsi=%.1f, rsi_prev=%.1f (하락 중)", rsi, rsi_prev, ) return None # BB 하단 구간 확인 (-3% ~ +2%) bb_low_minus = bb_lower * US_BB_LOWER_MARGIN_LOW bb_low_plus = bb_lower * US_BB_LOWER_MARGIN_HIGH if not (bb_low_minus <= close <= bb_low_plus): return None reversal_info = "" if rsi_prev is not None: reversal_info = f" RSI반등({rsi_prev:.1f}->{rsi:.1f})" return { "signal": "BUY", "strategy": "US_MEAN_REVERSION", "market": "US", "split_plan": [0.50, 0.50], # 2회 분할 매수 "description": ( f"RSI({rsi:.1f})<={US_RSI_BUY_THRESHOLD:.0f} + " f"BB 하단 -3%~+2% (${current_price_usd:.2f})" f"{reversal_info}" ), } def check_us_exit_signal( rsi: float, close: float, bb_upper: float, bb_middle: float, bb_lower: float, entry_price: float, atr: float, remaining_pct: float, trailing_high: Optional[float] = None, ) -> Optional[ExitSignal]: """미국 주식 청산 신호 판별 우선순위: 1. 손절: -7% 또는 ATR x 2.5 (높은 쪽 = 타이트한 손절) 2. 1차 익절: RSI >= 55 + BB 중앙선 상회 (30% 매도) 3. 2차 익절: 트레일링 스탑 -4% (나머지 전량) Args: rsi: 현재 RSI(14) 값 close: 현재 종가 bb_upper: 볼린저 밴드 상단 bb_middle: 볼린저 밴드 중앙선 bb_lower: 볼린저 밴드 하단 entry_price: 진입가 atr: ATR(14) 값 remaining_pct: 잔여 보유 비율 (1.0 = 전량, 0.7 = 1차 익절 후) trailing_high: 진입 후 최고가 (트레일링 스탑용) Returns: ExitSignal 또는 None """ # ── 1. 손절 (최우선) ────────────────────────────────────────────── atr_stop = entry_price - US_ATR_STOP_MULT * atr pct_stop = entry_price * (1 + US_STOP_LOSS_PCT / 100) # e.g. entry * 0.93 stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절 if close <= stop_price: return ExitSignal( action="SELL_ALL", reason=( f"US 손절: close=${close:.2f} <= stop=${stop_price:.2f} " f"(ATR기준=${atr_stop:.2f}, -7%기준=${pct_stop:.2f})" ), sell_pct=1.0, ) # ── 2. 1차 익절: remaining_pct > 0.7 (아직 1차 안 했을 때) ───────── # 최소 1×ATR 수익 조건 min_profit_price = entry_price + 1.0 * atr if remaining_pct > 0.7: if rsi >= US_FIRST_EXIT_RSI and close > bb_middle and close >= min_profit_price: return ExitSignal( action="SELL_PARTIAL", reason=( f"US 1차 익절(30%): RSI({rsi:.1f})>={US_FIRST_EXIT_RSI:.0f} + " f"BB중앙(${bb_middle:.2f}) 상회 + 1×ATR(${atr:.2f}) 수익" ), sell_pct=US_FIRST_EXIT_SELL_PCT, ) # ── 3. 2차 익절: 트레일링 스탑 -4% ────────────────────────────────── if remaining_pct <= 0.7: if trailing_high is not None and trailing_high > entry_price: trail_stop = trailing_high * (1 - US_TRAILING_STOP_PCT) if close < trail_stop: return ExitSignal( action="SELL_ALL", reason=( f"US 트레일링 청산: close=${close:.2f} < " f"최고가×{1 - US_TRAILING_STOP_PCT:.2f}=${trail_stop:.2f} " f"(최고가=${trailing_high:.2f})" ), sell_pct=1.0, ) # BB 상단 도달 시 50% 추가 익절 if rsi >= 65 and close >= bb_upper * 0.99 and remaining_pct > 0.25: return ExitSignal( action="SELL_PARTIAL", reason=( f"US 2차 익절(50%): RSI({rsi:.1f})>=65 + " f"BB상단(${bb_upper:.2f}) 도달" ), sell_pct=0.50, ) return None def calculate_us_position_size( total_assets_krw: float, available_cash_krw: float, current_us_allocation_krw: float, exchange_rate: float, max_per_position: float = US_MAX_PER_POSITION, max_us_capital: float = US_MAX_CAPITAL_RATIO, ) -> float: """미국 주식 포지션 사이징 (KRW 기준 반환) 종목당 최대 5%, 미국 주식 총 비중 30% 이내. Args: total_assets_krw: 전체 자산 (KRW) available_cash_krw: 가용 현금 (KRW) current_us_allocation_krw: 현재 미국 주식 할당 금액 (KRW) exchange_rate: USD/KRW 환율 (e.g. 1350.0) max_per_position: 종목당 최대 비중 (기본 0.05 = 5%) max_us_capital: 미국 주식 최대 비중 (기본 0.30 = 30%) Returns: 매수 가능 금액 (KRW). 0이면 매수 불가. """ if total_assets_krw <= 0 or exchange_rate <= 0: return 0.0 # 종목당 최대 금액 max_by_position = total_assets_krw * max_per_position # 미국 주식 잔여 예산 us_budget_remaining = (total_assets_krw * max_us_capital) - current_us_allocation_krw result = max(0.0, min(max_by_position, us_budget_remaining, available_cash_krw)) logger.debug( "US position_size: total=%.0f KRW, max_pos=%.0f, us_remaining=%.0f, cash=%.0f → %.0f KRW (≈$%.0f)", total_assets_krw, max_by_position, us_budget_remaining, available_cash_krw, result, result / exchange_rate if exchange_rate else 0, ) return result def get_us_watchlist() -> List[str]: """미국 주요 종목 감시 목록 반환 S&P 500 상위 종목 + 주요 테크/반도체/금융/헬스케어 대형주 50종. 실제 운용 시 API를 통해 동적으로 업데이트 가능. Returns: 티커 심볼 리스트 """ return [ # ── Mega Cap Tech ──────────────────────────────────────────── "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AVGO", "ORCL", "CRM", # ── Semiconductor ──────────────────────────────────────────── "AMD", "INTC", "QCOM", "TXN", "MU", "AMAT", "LRCX", "KLAC", "MRVL", "ON", # ── Software / Cloud ───────────────────────────────────────── "ADBE", "NOW", "SNOW", "PLTR", "PANW", "CRWD", "DDOG", "NET", "ZS", "FTNT", # ── Finance ────────────────────────────────────────────────── "JPM", "BAC", "GS", "MS", "V", "MA", "BRK-B", "C", "WFC", "AXP", # ── Healthcare / Bio ───────────────────────────────────────── "UNH", "JNJ", "LLY", "PFE", "ABBV", # ── Consumer / Industrial ──────────────────────────────────── "COST", "WMT", "HD", "MCD", "CAT", ] async def scan_us_stocks(api_client) -> List[Dict]: """미국 감시 목록 종목 스캔 → 매수 신호 필터링 api_client는 다음 메서드를 제공해야 함: - get_us_stock_quote(ticker) -> dict (close, volume, ...) - get_us_stock_history(ticker, period) -> list[dict] (일봉 데이터) Args: api_client: 미국 주식 데이터 API 클라이언트 Returns: 매수 신호가 발생한 종목 리스트 [{"ticker": ..., "signal": ..., ...}] """ from app.services.mean_reversion_strategy import ( calculate_rsi, calculate_bollinger, ) watchlist = get_us_watchlist() signals: List[Dict] = [] for ticker in watchlist: try: # 일봉 데이터 조회 (최소 30일 필요: BB 20일 + RSI 14+1일) history = await api_client.get_us_stock_history(ticker, period="3mo") if not history or len(history) < 30: continue closes = [bar["close"] for bar in history] volumes = [bar["volume"] for bar in history] current_price = closes[-1] current_volume = volumes[-1] # RSI 계산 rsi = calculate_rsi(closes, 14) if rsi is None: continue # RSI 반등 확인용 (3봉 전 RSI) rsi_prev = calculate_rsi(closes[:-3], 14) if len(closes) > 17 else None # 볼린저 밴드 계산 bb = calculate_bollinger(closes, 20) if bb is None: continue bb_upper, bb_middle, bb_lower = bb # 20일 평균 거래량 avg_volume_20d = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else sum(volumes) / len(volumes) # 매수 신호 체크 signal = check_us_buy_signal( rsi=rsi, close=current_price, bb_upper=bb_upper, bb_middle=bb_middle, bb_lower=bb_lower, avg_volume_20d=avg_volume_20d, current_volume=current_volume, current_price_usd=current_price, rsi_prev=rsi_prev, ) if signal: signal["ticker"] = ticker signal["rsi"] = round(rsi, 2) signal["price"] = round(current_price, 2) signal["volume_ratio"] = round(current_volume / avg_volume_20d, 2) if avg_volume_20d > 0 else 0 signals.append(signal) logger.info( "US BUY signal: %s — RSI=%.1f, $%.2f, vol_ratio=%.1fx", ticker, rsi, current_price, signal["volume_ratio"], ) except Exception as e: logger.warning("US scan error for %s: %s", ticker, e) continue logger.info("US scan complete: %d signals from %d watchlist stocks", len(signals), len(watchlist)) return signals