"""미국 주식 매매 전략 (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.core.config import settings from app.services.mean_reversion_strategy import ExitSignal # §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈) from app.services.sizing_core import budget_by_ratios logger = logging.getLogger(__name__) # ── 상수 ──────────────────────────────────────────────────────────────────────── # [§8.5-8 상수 출처 단일화 2026-09-27] # **config.py(settings) 가 정본이다.** 과거 이 자리에 있던 모듈 상수 11개는 전부 # config 로 옮겼고(값 변경 없음), 아래 이름들은 기존 호출부 호환을 위한 *얇은 별칭*이다. # - 별칭은 모듈 `__getattr__`(PEP 562)로 구현해 **접근 시점에** settings 를 읽는다. # import 시점에 값을 고정하지 않으므로 런타임 설정 변경(.env/DB 오버라이드)이 반영된다. # 단 `from ... import US_X` 로 받아 모듈 전역에 묶어두는 호출부 # (us_backtest_engine, tests)는 그 시점 값으로 고정된다 — 이는 원래부터의 한계. # - 이 모듈 내부 로직은 별칭을 쓰지 않고 settings 를 직접 읽는다. # - US_MAX_CAPITAL_RATIO 는 원래 모듈 상수(0.30)와 config 키(0.30)가 완전 중복이었고 # 값이 같아 config 쪽을 정본으로 채택했다. _SETTINGS_ALIASES = { "US_RSI_BUY_THRESHOLD": "US_RSI_BUY_THRESHOLD", "US_BB_LOWER_MARGIN_LOW": "US_BB_LOWER_MARGIN_LOW", "US_BB_LOWER_MARGIN_HIGH": "US_BB_LOWER_MARGIN_HIGH", "US_MIN_PRICE_USD": "US_MIN_PRICE_USD", "US_STOP_LOSS_PCT": "US_STOP_LOSS_PCT", # 음수 규약(-7.0) 유지 "US_ATR_STOP_MULT": "US_ATR_STOP_MULT", "US_FIRST_EXIT_RSI": "US_FIRST_EXIT_RSI", "US_FIRST_EXIT_SELL_PCT": "US_FIRST_EXIT_SELL_PCT", "US_TRAILING_STOP_PCT": "US_TRAILING_STOP_PCT", "US_MAX_PER_POSITION": "US_MAX_POSITION_SIZE", # 모듈 상수명 ≠ config 키명 "US_MAX_CAPITAL_RATIO": "US_MAX_CAPITAL_RATIO", } def __getattr__(name: str): """모듈 상수 별칭 → settings 지연 조회 (PEP 562).""" key = _SETTINGS_ALIASES.get(name) if key is None: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") return getattr(settings, key) 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 이하(=포함)는 진입 불가. min_price_usd = settings.US_MIN_PRICE_USD if current_price_usd <= min_price_usd: logger.debug( "US BUY skip: price $%.2f <= $%.2f (penny stock filter)", current_price_usd, min_price_usd, ) return None # 거래량 필터 if current_volume < avg_volume_20d: return None # RSI 과매도 확인 rsi_threshold = settings.US_RSI_BUY_THRESHOLD if rsi > rsi_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 * settings.US_BB_LOWER_MARGIN_LOW bb_low_plus = bb_lower * settings.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})<={rsi_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. 손절 (최우선) ────────────────────────────────────────────── # 값·수식 모두 편입 전과 동일(settings 가 정본). SL 은 가격 기준이므로 # max() 가 타이트한 쪽 — 방향 변경 금지(CLAUDE.md §8.1-5). atr_stop = entry_price - settings.US_ATR_STOP_MULT * atr pct_stop = entry_price * (1 + settings.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 first_exit_rsi = settings.US_FIRST_EXIT_RSI if remaining_pct > 0.7: if rsi >= 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})>={first_exit_rsi:.0f} + " f"BB중앙(${bb_middle:.2f}) 상회 + 1×ATR(${atr:.2f}) 수익" ), sell_pct=settings.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_pct = settings.US_TRAILING_STOP_PCT trail_stop = trailing_high * (1 - trail_pct) if close < trail_stop: return ExitSignal( action="SELL_ALL", reason=( f"US 트레일링 청산: close=${close:.2f} < " f"최고가×{1 - trail_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, # 기본값은 config 에서 읽는다(값은 편입 전과 동일: 0.05 / 0.30). # 시그니처 변경 금지 규칙 때문에 기본값만은 import 시점에 고정된다 — 런타임 오버라이드가 # 필요한 호출부는 인자를 명시적으로 넘길 것. max_per_position: float = settings.US_MAX_POSITION_SIZE, max_us_capital: float = settings.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 # [§8.5-2 통합 2026-09-27] 3중 min 식을 sizing_core.budget_by_ratios 로 위임. # = max(0, min(종목당 최대, 미국 잔여 예산, 가용현금)) — 수식·금액 동일. # (위 total<=0 / exchange_rate<=0 가드는 이 모듈 고유 로직이라 남긴다.) max_by_position = total_assets_krw * max_per_position # 로그용 us_budget_remaining = (total_assets_krw * max_us_capital) - current_us_allocation_krw # 로그용 result = budget_by_ratios( total_assets=total_assets_krw, available_cash=available_cash_krw, current_allocation=current_us_allocation_krw, max_per_position=max_per_position, max_strategy_ratio=max_us_capital, ) 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