Files
auto-stock/backend/app/services/us_stock_strategy.py
shuim de7921d270 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>
2026-03-26 16:05:41 +09:00

370 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""미국 주식 매매 전략 (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