Files
auto-stock/backend/app/services/us_market_sentiment.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

214 lines
7.7 KiB
Python

"""미장 심리 분석 + 한국 시장 레짐 보정 (07:00 KST 프리마켓 배치)
[목적]
전날 밤 미국 시장(S&P500, NASDAQ, VIX) 종가를 기반으로
한국 장 개장 전 적응형 전략 모드를 보정한다.
[데이터 소스]
1순위: 네이버 금융 세계지수 (S&P500, NASDAQ)
2순위: Yahoo Finance API (VIX)
폴백: KIS 해외주식 현재가 (SPY, QQQ)
[보정 규칙]
- VIX >= 30: Defensive 모드 강제 (위기 접근)
- VIX >= 35: Crisis 모드 강제
- S&P500 전일 -2% 이상 하락: Defensive 이상으로 상향
- S&P500 전일 -3% 이상 하락: Crisis 강제
- 미장 정상 (VIX < 25, S&P +/-1% 이내): 보정 없음
[출력]
Redis 캐시: us_market_sentiment:latest (TTL 4시간)
적응형 전략 모드 오버라이드 (필요 시)
"""
import asyncio
import logging
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Optional, Dict
import pytz
logger = logging.getLogger(__name__)
_KST = pytz.timezone("Asia/Seoul")
@dataclass
class USMarketSentiment:
"""미장 심리 분석 결과."""
# S&P500
sp500_price: float = 0.0
sp500_change_pct: float = 0.0
# NASDAQ
nasdaq_price: float = 0.0
nasdaq_change_pct: float = 0.0
# DOW
dow_price: float = 0.0
dow_change_pct: float = 0.0
# VIX
vix_price: float = 20.0
vix_change_pct: float = 0.0
# 종합 판단
sentiment: str = "NEUTRAL" # RISK_ON / NEUTRAL / RISK_OFF / PANIC
regime_override: str = "" # 빈 문자열이면 오버라이드 없음
description: str = ""
fetched_at: str = ""
data_source: str = "naver+yahoo"
async def fetch_us_market_data() -> USMarketSentiment:
"""미장 데이터 수집 (네이버 세계지수 + Yahoo VIX)."""
from app.services.naver_finance import get_world_indices, get_vix_index
result = USMarketSentiment()
result.fetched_at = datetime.now(_KST).isoformat()
# 1. 네이버 세계지수 (S&P500, NASDAQ, DOW)
try:
indices = await get_world_indices()
if "SP500" in indices:
result.sp500_price = indices["SP500"]["price"]
result.sp500_change_pct = indices["SP500"]["change_pct"]
if "NASDAQ" in indices:
result.nasdaq_price = indices["NASDAQ"]["price"]
result.nasdaq_change_pct = indices["NASDAQ"]["change_pct"]
if "DOW" in indices:
result.dow_price = indices["DOW"]["price"]
result.dow_change_pct = indices["DOW"]["change_pct"]
except Exception as e:
logger.warning("[US_SENTIMENT] 네이버 세계지수 조회 실패: %s", e)
# 2. VIX (Yahoo Finance)
try:
vix = await get_vix_index()
if vix:
result.vix_price = vix["price"]
result.vix_change_pct = vix["change_pct"]
except Exception as e:
logger.warning("[US_SENTIMENT] VIX 조회 실패: %s", e)
# 3. 폴백: KIS API (네이버/Yahoo 모두 실패 시)
if result.sp500_price == 0.0:
try:
from app.services.korea_investment_api import korea_investment_api
spy_data = await korea_investment_api.get_overseas_price("SPY", "NASD")
output = spy_data.get("output", {})
result.sp500_price = float(output.get("last", 0))
prev = float(output.get("base", 0))
if prev > 0:
result.sp500_change_pct = round((result.sp500_price - prev) / prev * 100, 2)
result.data_source = "kis_fallback"
logger.info("[US_SENTIMENT] KIS 폴백으로 SPY 조회: %.2f", result.sp500_price)
except Exception as e:
logger.warning("[US_SENTIMENT] KIS 폴백도 실패: %s", e)
# 4. 종합 심리 판단
result.sentiment, result.regime_override, result.description = _analyze_sentiment(result)
return result
def _analyze_sentiment(data: USMarketSentiment) -> tuple:
"""미장 데이터 기반 심리 판단 + 레짐 오버라이드 결정.
Returns:
(sentiment, regime_override, description)
"""
vix = data.vix_price
sp500_chg = data.sp500_change_pct
nasdaq_chg = data.nasdaq_change_pct
# PANIC: VIX >= 35 또는 S&P500 -3% 이상 하락
if vix >= 35 or sp500_chg <= -3.0:
return (
"PANIC",
"crisis",
f"미장 패닉 (VIX={vix:.1f}, S&P500={sp500_chg:+.1f}%, NASDAQ={nasdaq_chg:+.1f}%)",
)
# RISK_OFF: VIX >= 30 또는 S&P500 -2% 이상 하락
if vix >= 30 or sp500_chg <= -2.0:
return (
"RISK_OFF",
"defensive",
f"미장 리스크오프 (VIX={vix:.1f}, S&P500={sp500_chg:+.1f}%, NASDAQ={nasdaq_chg:+.1f}%)",
)
# RISK_ON: VIX < 18 이고 S&P500 +1% 이상 상승
if vix < 18 and sp500_chg >= 1.0:
return (
"RISK_ON",
"", # 오버라이드 없음 (공격적 전환은 국내 데이터 기반으로)
f"미장 리스크온 (VIX={vix:.1f}, S&P500={sp500_chg:+.1f}%, NASDAQ={nasdaq_chg:+.1f}%)",
)
# NEUTRAL: 기본
return (
"NEUTRAL",
"",
f"미장 중립 (VIX={vix:.1f}, S&P500={sp500_chg:+.1f}%, NASDAQ={nasdaq_chg:+.1f}%)",
)
async def run_pre_market_check() -> Dict:
"""07:00 KST 프리마켓 배치 메인 함수.
1. 미장 데이터 수집
2. 심리 분석
3. Redis 캐시 저장
4. 필요 시 적응형 전략 모드 오버라이드
5. Telegram 알림
"""
logger.info("[PRE_MARKET] 미장 심리 분석 시작...")
sentiment = await fetch_us_market_data()
sentiment_dict = asdict(sentiment)
# Redis 캐시 저장 (TTL 4시간 = 14400초)
try:
from app.core.redis import setex_json
await setex_json("us_market_sentiment:latest", 14400, sentiment_dict)
logger.info("[PRE_MARKET] Redis 캐시 저장 완료")
except Exception as e:
logger.warning("[PRE_MARKET] Redis 저장 실패: %s", e)
# 적응형 전략 모드 오버라이드
if sentiment.regime_override:
try:
from app.services.adaptive_strategy import force_mode_override
force_mode_override(sentiment.regime_override, reason=sentiment.description)
logger.warning(
"[PRE_MARKET] 적응형 전략 모드 오버라이드: %s → %s",
sentiment.sentiment, sentiment.regime_override,
)
except Exception as e:
logger.warning("[PRE_MARKET] 모드 오버라이드 실패: %s", e)
# Telegram 알림
try:
from app.services.telegram_notifier import telegram_notifier
msg = (
f"🌍 미장 프리마켓 리포트\n"
f"━━━━━━━━━━━━━━━\n"
f"S&P500: {sentiment.sp500_price:,.1f} ({sentiment.sp500_change_pct:+.1f}%)\n"
f"NASDAQ: {sentiment.nasdaq_price:,.1f} ({sentiment.nasdaq_change_pct:+.1f}%)\n"
f"DOW: {sentiment.dow_price:,.1f} ({sentiment.dow_change_pct:+.1f}%)\n"
f"VIX: {sentiment.vix_price:.1f} ({sentiment.vix_change_pct:+.1f}%)\n"
f"━━━━━━━━━━━━━━━\n"
f"판단: {sentiment.sentiment}\n"
)
if sentiment.regime_override:
msg += f"⚠️ 전략 오버라이드: {sentiment.regime_override}\n"
msg += f"📝 {sentiment.description}"
asyncio.ensure_future(telegram_notifier.send_message(msg))
except Exception as _swallowed:
logger.warning("[US_SENTIMENT] 텔레그램 알림 실패: %s", _swallowed)
logger.info(
"[PRE_MARKET] 완료 — %s (VIX=%.1f, S&P500=%+.1f%%, override=%s)",
sentiment.sentiment, sentiment.vix_price, sentiment.sp500_change_pct,
sentiment.regime_override or "없음",
)
return sentiment_dict