코드리뷰 수정

This commit is contained in:
2026-03-06 15:22:40 +09:00
parent da5cef1925
commit ab75dc66f8
5 changed files with 553 additions and 140 deletions

View File

@@ -306,6 +306,43 @@ class Settings(BaseSettings):
extra = "ignore" # .env 파일의 추가 필드 무시 extra = "ignore" # .env 파일의 추가 필드 무시
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
def validate_required(self) -> list[str]:
"""필수 환경변수 누락 여부를 검사하고 경고 목록을 반환한다.
시작 시 lifespan에서 호출하여 운영자에게 조기 경보를 제공한다.
누락 시 예외를 발생시키지 않으므로 서비스 기동은 계속된다.
"""
warnings: list[str] = []
# KIS API 키: 현재 모드에 맞는 키 필수
if not self.KOREA_INVESTMENT_APP_KEY:
warnings.append(
f"KIS_{self.KIS_MODE.upper()}_APP_KEY 미설정 — "
f"{'KIS_VIRTUAL_APP_KEY' if self.is_virtual_trading else 'KIS_REAL_APP_KEY'} 확인 필요"
)
if not self.KOREA_INVESTMENT_APP_SECRET:
warnings.append(
f"KIS_{self.KIS_MODE.upper()}_APP_SECRET 미설정"
)
if not self.KOREA_INVESTMENT_ACCOUNT_NUMBER:
warnings.append(
f"KIS_{self.KIS_MODE.upper()}_ACCOUNT_NO 미설정"
)
# DB / Redis
if "localhost" in self.DATABASE_URL and os.path.exists("/.dockerenv"):
warnings.append("DATABASE_URL에 localhost 포함 — Docker 환경에서 postgres 호스트명 확인 필요")
# MCP 검증 활성화 시 AI 키 필요
if self.MCP_VALIDATION_ENABLED and not self.ANTHROPIC_API_KEY:
from pydantic_settings import BaseSettings as _BS # lazy import
# GEMINI_API_KEY는 config에 없으므로 환경변수 직접 확인
gemini_key = os.environ.get("GEMINI_API_KEY", "")
if not gemini_key:
warnings.append("MCP_VALIDATION_ENABLED=True 이지만 ANTHROPIC_API_KEY / GEMINI_API_KEY 미설정")
return warnings
@lru_cache() @lru_cache()
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -1,14 +1,36 @@
"""공통 로깅 설정 — KST(Asia/Seoul) 타임존 포매터 제공.""" """공통 로깅 설정 — KST(Asia/Seoul) 타임존 포매터 + 민감 정보 마스킹 제공."""
import logging import logging
import datetime import datetime
import re
import pytz import pytz
KST = pytz.timezone("Asia/Seoul") KST = pytz.timezone("Asia/Seoul")
# ── 민감 정보 마스킹 패턴 (API 키, 시크릿, 토큰, 계좌번호) ──────────────────────
_MASK_PATTERNS = [
# Bearer 토큰
(re.compile(r'(Bearer\s+)[A-Za-z0-9\-_.]{20,}', re.I), r'\1***'),
# access_token 값
(re.compile(r'(access_?token[":\s=]+)[A-Za-z0-9\-_.]{20,}', re.I), r'\1***'),
# appsecret / app_secret 값
(re.compile(r'(app_?secret[":\s=]+)[A-Za-z0-9+/=]{10,}', re.I), r'\1***'),
# appkey / app_key 값 (8자 이상)
(re.compile(r'(app_?key[":\s=]+)[A-Za-z0-9]{8,}', re.I), r'\1***'),
# 계좌번호 (account_no / account_number)
(re.compile(r'(account[_\s]?(?:no|number)[":\s=]+)\d{6,}', re.I), r'\1***'),
]
def _mask_sensitive(text: str) -> str:
"""로그 텍스트에서 API 키·토큰·계좌번호 등 민감 정보를 마스킹한다."""
for pattern, replacement in _MASK_PATTERNS:
text = pattern.sub(replacement, text)
return text
class KSTFormatter(logging.Formatter): class KSTFormatter(logging.Formatter):
"""%(asctime)s 를 KST(UTC+9) 기준으로 출력하는 포매터.""" """%(asctime)s 를 KST(UTC+9) 기준으로 출력하고, 민감 정보를 마스킹하는 포매터."""
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str: def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
ct = datetime.datetime.fromtimestamp(record.created, tz=KST) ct = datetime.datetime.fromtimestamp(record.created, tz=KST)
@@ -16,3 +38,8 @@ class KSTFormatter(logging.Formatter):
return ct.strftime(datefmt) return ct.strftime(datefmt)
t = ct.strftime(self.default_time_format) t = ct.strftime(self.default_time_format)
return self.default_msec_format % (t, record.msecs) return self.default_msec_format % (t, record.msecs)
def format(self, record: logging.LogRecord) -> str:
"""포맷팅 후 민감 정보 마스킹 적용."""
formatted = super().format(record)
return _mask_sensitive(formatted)

View File

@@ -50,17 +50,19 @@ logger = logging.getLogger(__name__)
def _normalize_recommendations(rec: dict) -> dict: def _normalize_recommendations(rec: dict) -> dict:
"""사로 다른 포맷의 추청 결과를 recommendations_by_rule 포맷으로 정규화. """서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화.
- get_rule_based_recommendations_realtime() → {"recommendations": [...], ...} - get_rule_based_recommendations_realtime() → {"recommendations": [...], ...}
- get_rule_based_recommendations_from_db() → {"recommendations_by_rule": {...}, ...} - get_rule_based_recommendations_from_db() → {"recommendations_by_rule": {...}, ...}
둘 다 recommendations_by_rule 키를 보장한 상태로 돌려준다. 둘 다 recommendations_by_rule 키를 보장한 상태로 돌려준다.
Redis 캐시 데이터와 DB 데이터 모두 이 함수를 통해 정규화. Redis 캐시 데이터와 DB 데이터 모두 이 함수를 통해 정규화.
변환 실패 시 원본 rec를 그대로 반환 (런타임 에러 방지).
""" """
if not rec: if not rec:
return rec return rec or {}
try:
# Case 1: Already normalized (dict) # Case 1: Already normalized (dict)
if isinstance(rec.get("recommendations_by_rule"), dict): if isinstance(rec.get("recommendations_by_rule"), dict):
return rec return rec
@@ -92,6 +94,10 @@ def _normalize_recommendations(rec: dict) -> dict:
# Return the original dict with the new/replaced 'recommendations_by_rule' # Return the original dict with the new/replaced 'recommendations_by_rule'
return {**rec, "recommendations_by_rule": by_rule} return {**rec, "recommendations_by_rule": by_rule}
except Exception as e:
logger.warning(f"⚠️ _normalize_recommendations 변환 실패, 원본 반환: {e}")
return rec
async def batch_analysis_scheduler(): async def batch_analysis_scheduler():
"""10분 간격 배치 분석 스케줄러. """10분 간격 배치 분석 스케줄러.
@@ -99,20 +105,45 @@ async def batch_analysis_scheduler():
장중 10분마다 KIS API로 종목 분석 후 stock_analysis DB에 upsert. 장중 10분마다 KIS API로 종목 분석 후 stock_analysis DB에 upsert.
분석 완료 후 Redis에 recommendations:latest 캐시를 저장해 분석 완료 후 Redis에 recommendations:latest 캐시를 저장해
auto_trade_scheduler가 DB 조회 없이 바로 매매 판단에 활용할 수 있도록 한다. auto_trade_scheduler가 DB 조회 없이 바로 매매 판단에 활용할 수 있도록 한다.
개선:
- 15:30 이후 장 종료 감지 → 익일 08:50까지 자동 중단
- 분석 실패 시 exponential backoff 재시도 (최대 3회: 10s/20s/40s)
""" """
await asyncio.sleep(15) # 부팅 안정화 (auto_trade_scheduler보다 늦게 시작) await asyncio.sleep(15) # 부팅 안정화 (auto_trade_scheduler보다 늦게 시작)
from app.api.endpoints.recommendations import get_rule_based_recommendations_realtime from app.api.endpoints.recommendations import get_rule_based_recommendations_realtime
from app.core.redis import setex_json, TTL_RECOMMENDATIONS, TTL_ANALYSIS from app.core.redis import setex_json, TTL_RECOMMENDATIONS, TTL_ANALYSIS
from datetime import timedelta as _td
interval = int(getattr(settings, "BATCH_ANALYSIS_INTERVAL", 600)) # 기본 10분 interval = int(getattr(settings, "BATCH_ANALYSIS_INTERVAL", 600)) # 기본 10분
_KST = pytz.timezone("Asia/Seoul")
logger.info(f"📊 Batch analysis scheduler started (every {interval}s)") logger.info(f"📊 Batch analysis scheduler started (every {interval}s)")
try: try:
while True: while True:
now_kst = pytz.datetime.datetime.now(_KST)
# 15:30 이후 → 장 종료, 익일 08:50까지 대기
if now_kst.hour > 15 or (now_kst.hour == 15 and now_kst.minute >= 30):
tomorrow_start = (
_KST.localize(
pytz.datetime.datetime(now_kst.year, now_kst.month, now_kst.day, 8, 50, 0)
) + _td(days=1)
)
sleep_sec = max((tomorrow_start - now_kst).total_seconds(), 3600)
logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개")
await asyncio.sleep(sleep_sec)
continue
if not is_market_open(): if not is_market_open():
await asyncio.sleep(1800) await asyncio.sleep(1800)
continue continue
# ── exponential backoff 재시도 (최대 3회) ────────────────────────
max_retries = 3
for attempt in range(max_retries):
try: try:
logger.info("🔄 [Batch] 10-min analysis cycle starting...") suffix = f" (재시도 {attempt + 1}/{max_retries})" if attempt > 0 else ""
logger.info(f"🔄 [Batch] 10-min analysis cycle starting...{suffix}")
result = await get_rule_based_recommendations_realtime() result = await get_rule_based_recommendations_realtime()
count = len(result.get("recommendations", [])) if result else 0 count = len(result.get("recommendations", [])) if result else 0
logger.info(f"✅ [Batch] Done: {count} stocks saved/updated in stock_analysis DB") logger.info(f"✅ [Batch] Done: {count} stocks saved/updated in stock_analysis DB")
@@ -147,9 +178,19 @@ async def batch_analysis_scheduler():
logger.info(f"💾 [Batch] 개별 종목 분석 데이터 캐시 갱신 완료: {len(cache_tasks)}건 (TTL: {TTL_ANALYSIS}s)") logger.info(f"💾 [Batch] 개별 종목 분석 데이터 캐시 갱신 완료: {len(cache_tasks)}건 (TTL: {TTL_ANALYSIS}s)")
# =============================================== # ===============================================
# ─────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────
break # 성공 시 재시도 루프 탈출
except Exception as e: except Exception as e:
logger.error(f"❌ Batch analysis error: {e}", exc_info=True) wait = 10 * (2 ** attempt) # 10s → 20s → 40s
if attempt < max_retries - 1:
logger.warning(
f"⚠️ [Batch] 분석 실패 ({attempt + 1}/{max_retries}), "
f"{wait}초 후 재시도: {e}"
)
await asyncio.sleep(wait)
else:
logger.error(f"❌ Batch analysis error (최대 재시도 초과): {e}", exc_info=True)
await asyncio.sleep(interval) await asyncio.sleep(interval)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("⏹️ Batch analysis scheduler stopped") logger.info("⏹️ Batch analysis scheduler stopped")
@@ -381,16 +422,26 @@ async def krx_data_collector_scheduler():
await asyncio.sleep(3600) await asyncio.sleep(3600)
continue continue
# 수집 실행 # 수집 실행 (최대 3회 재시도, 30분 간격)
max_krx_retries = 3
for krx_attempt in range(max_krx_retries):
try: try:
logger.info("📥 [KRX] 일봉 데이터 증분 수집 시작 (update mode)...") suffix = f" (재시도 {krx_attempt + 1}/{max_krx_retries})" if krx_attempt > 0 else ""
logger.info(f"📥 [KRX] 일봉 데이터 증분 수집 시작 (update mode){suffix}...")
from collect_krx_data import collect_all from collect_krx_data import collect_all
await collect_all(update_mode=True) await collect_all(update_mode=True)
_last_collected = today _last_collected = today
logger.info("✅ [KRX] 일봉 데이터 수집 완료") logger.info("✅ [KRX] 일봉 데이터 수집 완료")
break
except Exception as e: except Exception as e:
logger.error(f"❌ [KRX] 데이터 수집 실패: {e}", exc_info=True) if krx_attempt < max_krx_retries - 1:
await asyncio.sleep(1800) # 실패 시 30분 후 재시도 logger.warning(
f"⚠️ [KRX] 수집 실패 ({krx_attempt + 1}/{max_krx_retries}), "
f"30분 후 재시도: {e}"
)
await asyncio.sleep(1800)
else:
logger.error(f"❌ [KRX] 데이터 수집 최대 재시도 초과: {e}", exc_info=True)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("⏹️ KRX data collector scheduler stopped") logger.info("⏹️ KRX data collector scheduler stopped")
@@ -405,6 +456,10 @@ async def lifespan(app: FastAPI):
logger.info(f"KIS Base URL: {settings.KOREA_INVESTMENT_API_BASE_URL}") logger.info(f"KIS Base URL: {settings.KOREA_INVESTMENT_API_BASE_URL}")
logger.info(f"KIS Account: {settings.KOREA_INVESTMENT_ACCOUNT_NUMBER}") logger.info(f"KIS Account: {settings.KOREA_INVESTMENT_ACCOUNT_NUMBER}")
# ── 필수 환경변수 검증 (조기 경보) ──────────────────────────────────────────
for warn_msg in settings.validate_required():
logger.warning(f"⚠️ [Config] {warn_msg}")
# DB 테이블 자동 생성 # DB 테이블 자동 생성
# 컬럼 추가/테이블 생성 DDL 은 Alembic migration 으로 관리됨. # 컬럼 추가/테이블 생성 DDL 은 Alembic migration 으로 관리됨.
# 배포 전 `cd backend && alembic upgrade head` 를 실행할 것. # 배포 전 `cd backend && alembic upgrade head` 를 실행할 것.
@@ -595,6 +650,21 @@ async def log_requests(request: Request, call_next):
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
# ── 전역 예외 핸들러 ────────────────────────────────────────────────────────────
from fastapi import Request as _Request
from fastapi.responses import JSONResponse as _JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(_req: _Request, exc: Exception) -> _JSONResponse:
"""처리되지 않은 예외를 500 JSON으로 변환해 일관된 에러 응답을 보장한다."""
logger.error(f"Unhandled exception: {_req.method} {_req.url} — {exc}", exc_info=True)
return _JSONResponse(
status_code=500,
content={"detail": "내부 서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."},
)
@app.get("/") @app.get("/")
async def root(): async def root():
return {"message": "Auto Stock Trading System API", "version": "1.0.0", "status": "running"} return {"message": "Auto Stock Trading System API", "version": "1.0.0", "status": "running"}
@@ -602,7 +672,34 @@ async def root():
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
return {"status": "healthy"} """DB / Redis 연결 상태를 포함한 헬스체크."""
from sqlalchemy import text as _sa_text
from app.core.redis import get_redis
db_ok = False
redis_ok = False
try:
async with engine.connect() as conn:
await conn.execute(_sa_text("SELECT 1"))
db_ok = True
except Exception:
pass
try:
r = get_redis()
if r:
await r.ping()
redis_ok = True
except Exception:
pass
status = "healthy" if (db_ok and redis_ok) else "degraded"
return {
"status": status,
"db": "ok" if db_ok else "error",
"redis": "ok" if redis_ok else "error",
}
@app.websocket("/ws/{client_id}") @app.websocket("/ws/{client_id}")
@@ -621,7 +718,17 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
await websocket_manager.connect(websocket, client_id) await websocket_manager.connect(websocket, client_id)
try: try:
while True: while True:
data = await websocket.receive_text() try:
data = await asyncio.wait_for(websocket.receive_text(), timeout=60.0)
except asyncio.TimeoutError:
# 60초 동안 메시지 없음 → ping 전송으로 연결 유지 확인
try:
await websocket_manager.send_personal_message(
json.dumps({"type": "ping"}), client_id
)
except Exception:
break
continue
try: try:
msg = json.loads(data) msg = json.loads(data)
except (json.JSONDecodeError, AttributeError): except (json.JSONDecodeError, AttributeError):

View File

@@ -19,6 +19,53 @@ TTL_MINUTE_FULLDAY = 60 # 당일 전체 분봉 캐시 1분
TTL_OVERSEAS_CHART = 300 # 해외 일봉 캐시 5분 TTL_OVERSEAS_CHART = 300 # 해외 일봉 캐시 5분
class CircuitBreaker:
"""KIS API Circuit Breaker — 연속 N회 실패 시 일정 시간 요청 차단.
States:
CLOSED : 정상 운영 (요청 허용)
OPEN : 차단 (recovery_timeout 경과 전까지 요청 거부)
HALF_OPEN: 복구 시도 (1개 요청 허용 → 성공 시 CLOSED, 실패 시 OPEN)
"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 600):
self.failure_threshold = failure_threshold # 연속 실패 허용 횟수
self.recovery_timeout = recovery_timeout # 차단 후 복구 대기 시간 (초)
self.failure_count = 0
self.last_failure_time: Optional[float] = None
self.state = "CLOSED"
def can_request(self) -> bool:
"""요청 가능 여부 반환."""
if self.state == "CLOSED":
return True
if self.state == "OPEN":
if self.last_failure_time and time.time() - self.last_failure_time >= self.recovery_timeout:
self.state = "HALF_OPEN"
logger.info("⚡ Circuit breaker HALF_OPEN: 복구 시도")
return True
return False
return True # HALF_OPEN
def record_success(self) -> None:
"""성공 기록 — 차단 해제."""
if self.state != "CLOSED":
logger.info("✅ Circuit breaker CLOSED: 복구 완료")
self.failure_count = 0
self.state = "CLOSED"
def record_failure(self) -> None:
"""실패 기록 — 임계치 초과 시 차단."""
self.failure_count += 1
self.last_failure_time = time.time()
if self.state == "HALF_OPEN" or self.failure_count >= self.failure_threshold:
self.state = "OPEN"
logger.warning(
f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. "
f"{self.recovery_timeout}초 후 재시도"
)
class APICallMonitor: class APICallMonitor:
"""API 호출 모니터링 및 통계""" """API 호출 모니터링 및 통계"""
@@ -125,6 +172,7 @@ class KoreaInvestmentAPI:
instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
instance._token_lock = asyncio.Lock() instance._token_lock = asyncio.Lock()
instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
instance.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=600)
logger.info( logger.info(
"🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...", "🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...",
instance.base_url, instance.app_key[:6] instance.base_url, instance.app_key[:6]
@@ -157,6 +205,9 @@ class KoreaInvestmentAPI:
# 모니터링 # 모니터링
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
# Circuit Breaker (5회 연속 실패 → 10분 차단)
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=600)
# 시작 시 설정 로깅 (키 마스킹) # 시작 시 설정 로깅 (키 마스킹)
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, " logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
f"base_url={self.base_url}, " f"base_url={self.base_url}, "
@@ -287,7 +338,13 @@ class KoreaInvestmentAPI:
raise raise
async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any): async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
"""API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)""" """API 요청 실행 (Circuit Breaker + 세마포어 + 재시도 + 모니터링)"""
# Circuit Breaker 상태 확인
if not self.circuit_breaker.can_request():
wait_sec = self.circuit_breaker.recovery_timeout
logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}")
raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)")
# 동시 요청 수 제한 (세마포어) # 동시 요청 수 제한 (세마포어)
async with self.semaphore: async with self.semaphore:
@@ -337,6 +394,7 @@ class KoreaInvestmentAPI:
if response.status_code == 200: if response.status_code == 200:
duration = time.time() - start_time duration = time.time() - start_time
self.monitor.record_call(success=True, duration=duration, retries=retry_count) self.monitor.record_call(success=True, duration=duration, retries=retry_count)
self.circuit_breaker.record_success()
# 오류율 경고 # 오류율 경고
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD): if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
@@ -364,6 +422,7 @@ class KoreaInvestmentAPI:
await asyncio.sleep(delay) await asyncio.sleep(delay)
continue continue
else: else:
self.circuit_breaker.record_failure()
logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}") logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}")
raise last_exception raise last_exception
@@ -401,6 +460,7 @@ class KoreaInvestmentAPI:
else: else:
duration = time.time() - start_time duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count) self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self.circuit_breaker.record_failure()
logger.error(f"❌ API 타임아웃 최대 재시도 초과") logger.error(f"❌ API 타임아웃 최대 재시도 초과")
raise raise
@@ -408,12 +468,14 @@ class KoreaInvestmentAPI:
# 예상치 못한 오류 # 예상치 못한 오류
duration = time.time() - start_time duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count) self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self.circuit_breaker.record_failure()
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}") logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
raise raise
# 여기까지 왔다면 모든 재시도 실패 # 여기까지 왔다면 모든 재시도 실패
duration = time.time() - start_time duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count) self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self.circuit_breaker.record_failure()
if last_exception: if last_exception:
raise last_exception raise last_exception
raise Exception("API 요청 실패 (알 수 없는 오류)") raise Exception("API 요청 실패 (알 수 없는 오류)")

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import time
from decimal import Decimal from decimal import Decimal
from datetime import timedelta from datetime import timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -19,14 +20,124 @@ from app.services.technical_indicators import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 동시 주문 1개로 제한 — 중복 주문 및 레이스 컨디션 방지 # ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ────────────────────────────────
_order_semaphore = asyncio.Semaphore(1) _order_semaphores: dict = {}
async def _mcp_validate_buy(stock: dict) -> tuple: def _get_order_semaphore(stock_code: str) -> asyncio.Semaphore:
# 전략 엔진(RSI/BB/MA/ATR 등)이 이미 신호를 검증하므로 AI 호출 불필요. """종목별 세마포어 반환. 없으면 생성."""
# RISK_ASSESSMENT는 매매 직전 mcp.py에서 별도 수행. if stock_code not in _order_semaphores:
return True, "strategy_validated" _order_semaphores[stock_code] = asyncio.Semaphore(1)
return _order_semaphores[stock_code]
# ── Redis 분산락 — 중복 주문 완전 방지 (TTL 60초) ────────────────────────────
_ORDER_LOCK_TTL = 60 # seconds
async def _acquire_order_lock(stock_code: str, order_type: str) -> bool:
"""Redis SETNX 기반 분산락 획득. 성공 시 True."""
try:
from app.core.redis import get_redis
client = get_redis()
if not client:
return True # Redis 미연결 → 락 없이 진행
key = f"order_lock:{stock_code}:{order_type}"
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
return result is True
except Exception as e:
logger.debug(f"[order_lock] 획득 오류 (패스): {e}")
return True
async def _release_order_lock(stock_code: str, order_type: str) -> None:
"""Redis 분산락 해제."""
try:
from app.core.redis import delete_key
await delete_key(f"order_lock:{stock_code}:{order_type}")
except Exception:
pass
# ── 주문 재시도 — Exponential backoff (1→2→4초, 최대 3회) ───────────────────
async def _place_order_with_retry(
is_domestic: bool,
stock_code: str,
order_type: str,
quantity: int,
price: float,
exchange_code: str = "NASD",
max_retries: int = 3,
) -> dict:
"""주문 실행 with exponential backoff. 시스템 오류에만 재시도."""
last_result: dict = {}
for attempt in range(max_retries):
try:
if is_domestic:
result = await korea_investment_api.place_order(stock_code, order_type, quantity, price)
else:
result = await korea_investment_api.place_overseas_order(
stock_code, order_type, quantity, float(price), exchange_code
)
# rt_cd "0"=성공, "1"=PENDING → 재시도 불필요
if result.get("rt_cd") in ("0", "1"):
return result
last_result = result
if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) # 1s → 2s → 4s
logger.warning(
f"⚠️ 주문 실패 (rt_cd={result.get('rt_cd')}) "
f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries}): {stock_code}"
)
await asyncio.sleep(wait)
except Exception as e:
last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None}
if attempt < max_retries - 1:
wait = 1 * (2 ** attempt)
logger.warning(f"⚠️ 주문 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {e}")
await asyncio.sleep(wait)
else:
logger.error(f"❌ 주문 최대 재시도 초과: {stock_code} {order_type} — {e}")
return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None}
# ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
"""delay초 후에도 PENDING 상태면 자동 취소."""
await asyncio.sleep(delay)
try:
result = await korea_investment_api.cancel_order(order_id, quantity)
if result.get("rt_cd") == "0":
logger.info(f"미체결 자동 취소 완료: {stock_code} order_id={order_id}")
else:
logger.debug(f"[auto_cancel] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
except Exception as e:
logger.debug(f"[auto_cancel] {stock_code} — {e}")
# ── 계좌 정보 30초 인메모리 캐시 ──────────────────────────────────────────────
_account_cache: dict = {"data": None, "ts": 0.0}
_ACCOUNT_CACHE_TTL = 30 # seconds
def _get_cached_account() -> tuple | None:
"""(available_cash, total_assets) 30초 캐시. 만료 시 None 반환."""
if _account_cache["data"] and time.monotonic() - _account_cache["ts"] < _ACCOUNT_CACHE_TTL:
return _account_cache["data"]
return None
def _set_account_cache(available_cash: float, total_assets: float) -> None:
_account_cache["data"] = (available_cash, total_assets)
_account_cache["ts"] = time.monotonic()
# ── 지정가 ±0.5% 조정 — 체결률 향상 ──────────────────────────────────────────
def _adjust_order_price(price: float, order_type: str) -> int:
"""BUY +0.5% / SELL -0.5% 지정가 조정."""
if order_type == "BUY":
return int(price * 1.005)
return int(price * 0.995)
async def _execute_buy( async def _execute_buy(
@@ -43,7 +154,7 @@ async def _execute_buy(
) -> dict: ) -> dict:
"""BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → sync.""" """BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → sync."""
# ── 1. 3일 급등 체크 ───────────────────────────────────────────────────── # ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ───────────────────────────────
try: try:
if is_domestic: if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10) chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10)
@@ -64,7 +175,30 @@ async def _execute_buy(
((current_price - price_3days_ago) / price_3days_ago) * 100 ((current_price - price_3days_ago) / price_3days_ago) * 100
if price_3days_ago > 0 else 0 if price_3days_ago > 0 else 0
) )
if recent_gain > 10: # ATR 기반 동적 임계값: 최근 3일 평균 일간 변동폭 * 2, 최소 10%
try:
highs = (
[float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]]
if is_domestic
else [float(item.get("high", 0)) for item in chart_data[-4:]]
)
lows = (
[float(item.get("stck_lwpr", 0)) for item in chart_data[-4:]]
if is_domestic
else [float(item.get("low", 0)) for item in chart_data[-4:]]
)
daily_ranges = [
abs(highs[i] - lows[i]) for i in range(len(closes))
if closes[i] > 0 and highs[i] > 0 and lows[i] > 0
]
atr_pct = (
(sum(daily_ranges) / len(daily_ranges) / price_3days_ago * 100)
if daily_ranges and price_3days_ago > 0 else 0
)
surge_threshold = max(10.0, atr_pct * 2)
except Exception:
surge_threshold = 10.0
if recent_gain > surge_threshold:
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
except Exception as e: except Exception as e:
logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}") logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}")
@@ -95,11 +229,16 @@ async def _execute_buy(
except Exception: except Exception:
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"} return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"}
# ── 3. 현금·자산 조회 ──────────────────────────────────────────────────── # ── 3. 현금·자산 조회 (30초 캐시 활용) ──────────────────────────────────
cached = _get_cached_account()
if cached:
available_cash, total_assets = cached
else:
try: try:
from app.api.endpoints.portfolio import portfolio_cache from app.api.endpoints.portfolio import portfolio_cache
available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0 available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0
total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0 total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0
_set_account_cache(available_cash, total_assets)
except Exception: except Exception:
available_cash, total_assets = 0.0, 0.0 available_cash, total_assets = 0.0, 0.0
@@ -145,12 +284,7 @@ async def _execute_buy(
if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60:
return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
# ── 7. MCP 검증 ─────────────────────────────────────────────────────────── # ── 7. 포지션 사이징 ──────────────────────────────────────────────────────
mcp_ok, mcp_reason = await _mcp_validate_buy(stock)
if not mcp_ok:
return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason}
# ── 8. 포지션 사이징 ──────────────────────────────────────────────────────
current_value = float(holding.quantity * current_price) if holding else 0.0 current_value = float(holding.quantity * current_price) if holding else 0.0
regime_str = stock.get("_regime_str", "bear") regime_str = stock.get("_regime_str", "bear")
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
@@ -165,26 +299,32 @@ async def _execute_buy(
if invest_amount <= 0 and total_assets <= 0: if invest_amount <= 0 and total_assets <= 0:
invest_amount = available_cash * 0.04 invest_amount = available_cash * 0.04
quantity = int(invest_amount / current_price) quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price)))
if quantity <= 0: if quantity <= 0:
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name} return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
# ── 9. 주문 실행 (semaphore로 동시성 제어) ─────────────────────────────── # ── 8. Redis 분산락 획득 ──────────────────────────────────────────────────
if not await _acquire_order_lock(stock_code, "BUY"):
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
# ── 9. 주문 실행 (종목별 세마포어 + 재시도) ───────────────────────────────
try: try:
async with _order_semaphore: order_price = _adjust_order_price(current_price, "BUY")
if is_domestic: async with _get_order_semaphore(stock_code):
order_result = await korea_investment_api.place_order( order_result = await _place_order_with_retry(
stock_code, "BUY", quantity, int(current_price) is_domestic=is_domestic,
) stock_code=stock_code,
else: order_type="BUY",
order_result = await korea_investment_api.place_overseas_order( quantity=quantity,
stock_code, "BUY", quantity, float(current_price), exchange_code price=order_price,
exchange_code=exchange_code,
) )
total_amount = Decimal(str(current_price)) * quantity total_amount = Decimal(str(current_price)) * quantity
entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점" entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점"
success = order_result.get("rt_cd") == "0" success = order_result.get("rt_cd") == "0"
status = "COMPLETED" if success else ("PENDING" if order_result.get("rt_cd") == "1" else "FAILED") is_pending = order_result.get("rt_cd") == "1"
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
trade = TradeHistory( trade = TradeHistory(
rule_id=stock.get("rule_id"), rule_id=stock.get("rule_id"),
@@ -214,8 +354,12 @@ async def _execute_buy(
portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price) portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price)
except Exception: except Exception:
pass pass
# KIS 체결 확인 후 포트폴리오 DB 동기화 (3초 딜레이)
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3))
elif is_pending:
# PENDING 주문 → 1분 후 자동 취소
order_id = order_result.get("odno")
if order_id:
asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity))
return { return {
"action": "BUY", "action": "BUY",
@@ -227,6 +371,8 @@ async def _execute_buy(
except Exception as e: except Exception as e:
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
finally:
await _release_order_lock(stock_code, "BUY")
async def _execute_sell( async def _execute_sell(
@@ -251,11 +397,28 @@ async def _execute_sell(
take_profit = stock.get("take_profit", 3.0) take_profit = stock.get("take_profit", 3.0)
stop_loss = stock.get("stop_loss", -1.5) stop_loss = stock.get("stop_loss", -1.5)
if not (profit_loss_rate >= take_profit or profit_loss_rate <= stop_loss or True): # ATR 기반 동적 손절/트레일링 스탑 조정
return {"action": "HOLD", "stock": stock_name} atr = float(stock.get("atr", 0) or 0)
_avg_price = float(holding.average_price or 0)
exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%"
if atr > 0 and _avg_price > 0:
# ATR 손절: 진입가 - 2.5*ATR (CLAUDE.md 규칙)
atr_stop_price = _avg_price - 2.5 * atr
atr_stop_pct = ((atr_stop_price / _avg_price) - 1) * 100
stop_loss = max(stop_loss, atr_stop_pct) # 더 보수적인 값 사용
# 트레일링 스탑: 고점 - 1.5*ATR (CLAUDE.md 규칙)
high_price = float(stock.get("high_price") or current_price)
trailing_stop_price = high_price - 1.5 * atr
if current_price <= trailing_stop_price and profit_loss_rate > 0:
exit_reason_detail = f"ATR 트레일링 스탑: {current_price:.0f} ≤ {trailing_stop_price:.0f} | {exit_reason_detail}"
# Redis 분산락 획득
if not await _acquire_order_lock(stock_code, "SELL"):
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
try: try:
# ── 1. 중복 주문 체크 — 주문 전에 먼저 확인 ──────────────────────── # ── 1. 중복 주문 체크 (DB + Redis 락 이중 방어) ──────────────────────
recent_cutoff = _get_kst_now() - timedelta(seconds=10) recent_cutoff = _get_kst_now() - timedelta(seconds=10)
duplicate_check = await db.execute( duplicate_check = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
@@ -267,21 +430,23 @@ async def _execute_sell(
if duplicate_check.scalar_one_or_none(): if duplicate_check.scalar_one_or_none():
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# ── 2. 주문 실행 (semaphore로 동시성 제어) ────────────────────────── # ── 2. 주문 실행 (종목별 세마포어 + 재시도) ──────────────────────────
async with _order_semaphore: order_price = _adjust_order_price(current_price, "SELL")
if is_domestic: async with _get_order_semaphore(stock_code):
order_result = await korea_investment_api.place_order( order_result = await _place_order_with_retry(
stock_code, "SELL", quantity, int(current_price) is_domestic=is_domestic,
) stock_code=stock_code,
else: order_type="SELL",
order_result = await korea_investment_api.place_overseas_order( quantity=quantity,
stock_code, "SELL", quantity, float(current_price), exchange_code price=order_price,
exchange_code=exchange_code,
) )
total_amount = Decimal(str(current_price)) * quantity total_amount = Decimal(str(current_price)) * quantity
profit_loss = (current_price - float(holding.average_price or current_price)) * quantity profit_loss = (current_price - float(holding.average_price or current_price)) * quantity
success = order_result.get("rt_cd") == "0" success = order_result.get("rt_cd") == "0"
status = "COMPLETED" if success else ("PENDING" if order_result.get("rt_cd") == "1" else "FAILED") is_pending = order_result.get("rt_cd") == "1"
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
trade = TradeHistory( trade = TradeHistory(
rule_id=stock.get("rule_id"), rule_id=stock.get("rule_id"),
@@ -295,7 +460,7 @@ async def _execute_sell(
total_amount=total_amount, total_amount=total_amount,
status=status, status=status,
order_id=order_result.get("odno"), order_id=order_result.get("odno"),
message=f"자동매도: 손익률 {profit_loss_rate:.2f}%", message=f"자동매도: {exit_reason_detail}",
exit_reason=f"손익: {profit_loss:,.0f}원", exit_reason=f"손익: {profit_loss:,.0f}원",
signals=signals, signals=signals,
net_profit_loss=Decimal(str(profit_loss)) if success else None, net_profit_loss=Decimal(str(profit_loss)) if success else None,
@@ -317,8 +482,11 @@ async def _execute_sell(
] ]
except Exception: except Exception:
pass pass
# KIS 체결 확인 후 포트폴리오 DB 동기화
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3))
elif is_pending:
order_id = order_result.get("odno")
if order_id:
asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity))
return { return {
"action": "SELL", "action": "SELL",
@@ -331,6 +499,8 @@ async def _execute_sell(
except Exception as e: except Exception as e:
return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)}
finally:
await _release_order_lock(stock_code, "SELL")
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
@@ -562,6 +732,16 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
if not has_strong_signal and (matched_count < 2 or score_val < 2): if not has_strong_signal and (matched_count < 2 or score_val < 2):
should_buy = False should_buy = False
# ── ATR 기반 강제 손절 (신호 없어도 발동) ────────────────────────────
if holding and not should_sell:
atr = float(stock.get("atr", 0) or 0)
avg_price = float(holding.average_price or 0)
if atr > 0 and avg_price > 0:
atr_stop_price = avg_price - 2.5 * atr
if current_price <= atr_stop_price:
should_sell = True
signals = list(signals) + [f"ATR 손절: {current_price:.0f} ≤ {atr_stop_price:.0f}"]
# ── 매매 실행 위임 ──────────────────────────────────────────────────── # ── 매매 실행 위임 ────────────────────────────────────────────────────
if should_buy: if should_buy:
return await _execute_buy( return await _execute_buy(