Files
auto-stock/backend/app/services/us_stock_strategy.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

407 lines
16 KiB
Python
Raw Permalink 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.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