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>
This commit is contained in:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -69,7 +69,11 @@ async def get_stock_price(stock_code: str):
except Exception as e:
# 예외 발생 시 500 에러와 함께 상세 내용 반환
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
logger.warning(f"Price unavailable for stock: {e}")
return {"stock_code": stock_code, "current_price": 0, "error": "temporarily_unavailable"}
_PRICES_MAX_CODES = 50 # /prices 엔드포인트 최대 종목 수 (KIS API 레이트 리밋 방어)
@router.get("/prices")
@@ -78,14 +82,18 @@ async def get_stock_prices(codes: str):
Query:
- codes: 콤마로 구분된 종목코드들 (예: 005930,000660,035420)
- 최대 50개 종목 허용. 초과 시 앞의 50개만 조회.
"""
try:
# codes 파라미터가 없는 경우 빈 데이터 반환
if not codes:
return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()}
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환 (최대 50개 제한)
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
if len(code_list) > _PRICES_MAX_CODES:
logger.warning(f"/prices 종목 수 초과: 요청={len(code_list)}, 최대={_PRICES_MAX_CODES}개로 제한")
code_list = code_list[:_PRICES_MAX_CODES]
prices: Dict[str, float] = {}
# 병렬로 현재가 일괄 조회 (asyncio.gather)
@@ -200,51 +208,152 @@ def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> di
}
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리 (최대 _CHART_CACHE_MAX_SIZE 개)
chart_cache = {}
_CHART_CACHE_MAX_SIZE = 100
_CHART_CACHE_TTL = 600 # 10분
def _aggregate_weekly_monthly(daily_data: list, period: str) -> list:
"""일봉 데이터를 주봉(W) 또는 월봉(M)으로 집계."""
if not daily_data:
return []
from collections import OrderedDict
buckets: OrderedDict = OrderedDict()
for d in daily_data:
dt = datetime.strptime(d["date"], "%Y%m%d")
if period == "W":
# ISO 주의 월요일을 키로 사용
monday = dt - timedelta(days=dt.weekday())
key = monday.strftime("%Y%m%d")
else: # "M"
key = dt.strftime("%Y%m01")
if key not in buckets:
buckets[key] = {
"date": d["date"], # 해당 기간의 마지막 거래일
"open": d["open"],
"high": d["high"],
"low": d["low"],
"close": d["close"],
"volume": d["volume"],
}
else:
b = buckets[key]
b["date"] = d["date"] # 마지막 거래일로 갱신
b["high"] = max(b["high"], d["high"])
b["low"] = min(b["low"], d["low"])
b["close"] = d["close"]
b["volume"] += d["volume"]
return list(buckets.values())
async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
"""stock_daily_prices DB에서 일봉 차트 데이터 조회 (oldest first).
배치(collect_krx_data.py / batch_daily_analysis.py)가 사전 수집한 데이터를 사용.
DB에 데이터가 없으면 빈 리스트 반환.
[READ-ONLY 함수 — DB 쓰기 없음]
SELECT만 수행하므로 CLAUDE.md 3.1 "실시간 계층 DB I/O 금지" 규칙의 예외 대상.
단, WebSocket 경로에서 이 함수를 직접 호출하면 안 됩니다.
WebSocket 핸들러는 get_chart_data_cached()를 통해 인메모리 캐시(1차)를 우선 사용하고,
캐시 미스 시에도 이 함수를 호출하지 말고 캐시된 값(빈 리스트 포함)을 반환해야 합니다.
REST 엔드포인트에서만 DB 폴백이 허용됩니다.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
async with AsyncSessionLocal() as db:
result = await db.execute(
select(StockDailyPrice)
.where(StockDailyPrice.stock_code == stock_code)
.order_by(StockDailyPrice.trade_date.desc())
.limit(limit)
)
rows = result.scalars().all()
if not rows:
return []
# DB 결과는 newest first → oldest first로 역순 변환
chart_data = []
for row in reversed(rows):
chart_data.append({
"date": row.trade_date.strftime("%Y%m%d"),
"open": float(row.open_price or 0),
"high": float(row.high_price or 0),
"low": float(row.low_price or 0),
"close": float(row.close_price or 0),
"volume": int(row.volume or 0),
})
return chart_data
except Exception as e:
logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}")
return []
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
"""캐시된 차트 데이터 반환 — REST endpoint와 WebSocket 핸들러가 공유.
캐시 미스 시 KIS API 호출 후 chart_cache에 저장.
조회 우선순위:
1. 인메모리 캐시 (10분 TTL)
2. DB (stock_daily_prices) — 일봉 전용, 배치가 사전 수집한 데이터
3. KIS API 폴백 (DB에 데이터 부족 시)
realtime 보정 없음 (현재가는 WebSocket tick이 처리).
"""
if period not in ("D", "W", "M"):
period = "D"
limit = min(limit, 240)
cache_key = f"{stock_code}__{period}"
cache_key = f"{stock_code}__{period}__{limit}"
current_time = datetime.now()
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제 + 크기 제한)
cached = chart_cache.get(cache_key)
if cached and (current_time - cached["timestamp"]).total_seconds() < 600:
return list(cached["data"])
if cached:
if (current_time - cached["timestamp"]).total_seconds() < _CHART_CACHE_TTL:
return list(cached["data"])
else:
# 만료 항목 즉시 삭제
del chart_cache[cache_key]
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
# 2차: DB 조회 (KIS API 사용 안 함 — 배치가 수집한 일봉 데이터만 사용)
# 주/월봉은 일봉 데이터에서 집계
db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
if not db_data:
# DB에 데이터 없으면 샘플 반환
return _generate_sample_chart(stock_code, period, limit).get("data", [])
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
if "output2" not in data:
return []
# 주/월봉 집계
if period in ("W", "M"):
db_data = _aggregate_weekly_monthly(db_data, period)
db_data = db_data[-limit:] # 최신 N개만
chart_data = []
for item in data["output2"]:
try:
chart_data.append({
"date": item.get("stck_bsop_date"),
"open": float(str(item.get("stck_oprc", "0")).replace(",", "")),
"high": float(str(item.get("stck_hgpr", "0")).replace(",", "")),
"low": float(str(item.get("stck_lwpr", "0")).replace(",", "")),
"close": float(str(item.get("stck_clpr", "0")).replace(",", "")),
"volume": int(item.get("acml_vol", 0)),
})
except (ValueError, TypeError) as e:
logger.error(f"가격 파싱 오류: {item} - {e}")
continue
# 캐시 크기 제한: 초과 시 가장 오래된 항목 제거
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
# 만료 항목 우선 정리
expired_keys = [
k for k, v in chart_cache.items()
if (current_time - v["timestamp"]).total_seconds() >= _CHART_CACHE_TTL
]
for k in expired_keys:
del chart_cache[k]
# 그래도 초과면 가장 오래된 항목 제거
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
del chart_cache[oldest_key]
chart_cache[cache_key] = {"timestamp": current_time, "data": chart_data}
return chart_data
chart_cache[cache_key] = {"timestamp": current_time, "data": db_data}
logger.debug(f"차트 DB 히트: {stock_code} period={period} ({len(db_data)}행)")
return db_data
@router.get("/chart/{stock_code}")
@@ -254,7 +363,7 @@ async def get_stock_chart(
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
):
"""주식 차트 데이터 조회 엔드포인트 (캐싱 적용)
"""주식 차트 데이터 조회 엔드포인트 (DB 전용 — KIS API 미사용)
Args:
stock_code: 종목 코드
@@ -262,119 +371,20 @@ async def get_stock_chart(
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
"""
try:
# 캐시 키 생성 (예: "005930__D")
cache_key = f"{stock_code}__{period}"
current_time = datetime.now()
# 캐시에서 데이터 조회
cached_data = chart_cache.get(cache_key)
use_cache = False
chart_data = []
# 캐시 데이터가 있고, 10분(600초) 이내에 생성된 경우 캐시 사용
if cached_data:
last_updated = cached_data.get("timestamp")
if last_updated and (current_time - last_updated).total_seconds() < 600:
chart_data = list(cached_data.get("data", [])) # 원본 수정을 방지하기 위해 데이터 복사
use_cache = True
# logger.debug(f"Chart cache hit for {stock_code}")
chart_data = await get_chart_data_cached(stock_code, period, limit)
# 캐시를 사용하지 않는 경우 (캐시가 없거나 만료됨)
if not use_cache:
# API 키가 설정되지 않은 경우 샘플 데이터 생성
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
return _generate_sample_chart(stock_code, period, limit)
# 한국투자증권 API를 통해 차트 데이터 조회
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
if "output2" not in data:
raise HTTPException(status_code=404, detail="Chart data not found")
chart_data = [] # 데이터 리스트 초기화
for item in data["output2"]:
# KIS API 응답에서 가격 데이터를 안전하게 파싱
try:
# 콤마가 포함될 수 있는 문자열 형태의 가격을 가져옴
close_str = str(item.get("stck_clpr", "0"))
open_str = str(item.get("stck_oprc", "0"))
high_str = str(item.get("stck_hgpr", "0"))
low_str = str(item.get("stck_lwpr", "0"))
# 콤마를 제거하고 float으로 변환
close_val = float(close_str.replace(",", ""))
open_val = float(open_str.replace(",", ""))
high_val = float(high_str.replace(",", ""))
low_val = float(low_str.replace(",", ""))
# 파싱된 데이터를 딕셔너리 형태로 리스트에 추가
chart_data.append({
"date": item.get("stck_bsop_date"),
"open": open_val,
"high": high_val,
"low": low_val,
"close": close_val,
"volume": int(item.get("acml_vol", 0))
})
except (ValueError, TypeError) as e:
# 가격 파싱 중 오류 발생 시 로그 기록하고 해당 데이터는 건너뜀
logger.error(f"가격 파싱 오류: {item} - {e}")
continue
# 조회한 데이터를 캐시에 저장
chart_cache[cache_key] = {
"timestamp": current_time,
"data": chart_data
}
# KIS API는 최신 데이터가 가장 먼저 오는 경향이 있으므로, 프론트엔드에서 날짜순으로 정렬(reverse)하여 사용
latest_candle = None
# 'realtime' 쿼리 파라미터가 True인 경우, 현재가를 반영한 실시간 캔들 생성
if realtime:
try:
price_data = await korea_investment_api.get_current_price(stock_code)
if price_data and price_data.get("output"):
o = price_data["output"]
# 현재가, 고가, 저가, 시가 등의 정보를 파싱하여 실시간 캔들 데이터 구성
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
high_price = float(str(o.get("stck_hgpr", "0")).replace(",", ""))
low_price = float(str(o.get("stck_lwpr", "0")).replace(",", ""))
open_price = float(str(o.get("stck_oprc", current_price)).replace(",", ""))
volume = int(o.get("acml_vol", 0))
today_str = o.get("stck_bsop_date") or datetime.now().strftime("%Y%m%d")
latest_candle = {
"date": today_str,
"open": open_price,
"high": high_price,
"low": low_price,
"close": current_price,
"volume": volume,
"realtime": True
}
# 기존 차트 데이터의 첫 번째(가장 최신) 캔들이 오늘 날짜와 같으면 교체, 아니면 맨 앞에 추가
if chart_data and chart_data[0]["date"] == latest_candle["date"]:
chart_data[0] = latest_candle
else:
chart_data.insert(0, latest_candle)
except Exception as e:
# 실시간 캔들 생성 실패 시 경고 로그 기록
logger.warning(f"실시간 현재가 캔들 생성 실패: {e}")
# 최종 차트 데이터를 딕셔너리 형태로 반환
return {
"stock_code": stock_code,
"period": period,
"data": chart_data,
"is_sample": False,
"realtime": realtime,
"latest": latest_candle
"is_sample": not chart_data,
"realtime": False,
"latest": None,
}
except Exception as e:
# API 호출 등에서 예외 발생 시, 개발 편의를 위해 샘플 데이터를 반환
sample = _generate_sample_chart(stock_code, period)
sample["error"] = str(e) # 에러 메시지 포함
sample["error"] = str(e)
sample["is_sample"] = True
sample["realtime"] = False
sample["latest"] = None
@@ -442,7 +452,7 @@ async def get_market_news(limit: int = 10):
async def get_orderbook(stock_code: str):
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
try:
logger.info(f"🔍 Fetching orderbook for stock: {stock_code}")
logger.debug(f"Fetching orderbook for stock: {stock_code}")
# 한국투자증권 API를 통해 호가 데이터 조회
data = await korea_investment_api.get_orderbook(stock_code)
@@ -478,18 +488,18 @@ async def get_orderbook(stock_code: str):
}
# 디버깅을 위한 원본 매도호가 데이터 로그
logger.info(f"📊 [{stock_code}] 매도호가 원본 데이터:")
logger.debug(f"[{stock_code}] 매도호가 원본 데이터:")
for i in range(1, 11):
price = output1.get(f"askp{i}", 0)
volume = output1.get(f"askp_rsqn{i}", 0)
logger.info(f" askp{i}: {price} ({volume}주)")
logger.debug(f" askp{i}: {price} ({volume}주)")
# 디버깅을 위한 원본 매수호가 데이터 로그
logger.info(f"📊 [{stock_code}] 매수호가 원본 데이터:")
logger.debug(f"[{stock_code}] 매수호가 원본 데이터:")
for i in range(1, 11):
price = output1.get(f"bidp{i}", 0)
volume = output1.get(f"bidp_rsqn{i}", 0)
logger.info(f" bidp{i}: {price} ({volume}주)")
logger.debug(f" bidp{i}: {price} ({volume}주)")
# 매도호가 10단계 파싱 (askp1 ~ askp10)
temp_asks = []
@@ -512,13 +522,13 @@ async def get_orderbook(stock_code: str):
orderbook["total_bid_volume"] += volume
# 최종 파싱 결과 로그
logger.info(f"✅ Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
logger.info(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
logger.info(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
logger.debug(f"Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
logger.debug(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
logger.debug(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
if orderbook['asks']:
logger.info(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
logger.debug(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
if orderbook['bids']:
logger.info(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
logger.debug(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
return orderbook
@@ -526,9 +536,9 @@ async def get_orderbook(stock_code: str):
# HTTPException은 그대로 다시 발생시킴
raise
except Exception as e:
# 그 외 예외는 500 에러로 처리
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch orderbook: {str(e)}")
# KIS API 장애 시 빈 호가 반환 (프론트 500 폭탄 방지)
logger.warning(f"Orderbook unavailable for {stock_code}: {e}")
return {"stock_code": stock_code, "asks": [], "bids": [], "error": "temporarily_unavailable"}
@router.get("/ai-analysis/{stock_code}")
@@ -575,15 +585,14 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
"bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None,
}
else:
# DB에 분석 데이터 없으면 KIS 일봉 차트로 직접 계산 (fallback)
# DB에 분석 데이터 없으면 DB 일봉으로 직접 계산 (fallback, KIS API 미사용)
try:
from app.services.mean_reversion_strategy import calculate_rsi, calculate_bollinger
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=60)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(c.get("stck_clpr", 0)) for c in reversed(chart_data)]
highs = [float(c.get("stck_hgpr", 0)) for c in reversed(chart_data)]
lows = [float(c.get("stck_lwpr", 0)) for c in reversed(chart_data)]
db_chart = await _fetch_chart_from_db(stock_code, 60)
if len(db_chart) >= 20:
closes = [c["close"] for c in db_chart]
highs = [c["high"] for c in db_chart]
lows = [c["low"] for c in db_chart]
rsi_val = calculate_rsi(closes)
bb = calculate_bollinger(closes)
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None
@@ -753,3 +762,40 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
# 그 외 예외는 500 에러로 처리
logger.error(f"Error in AI analysis for {stock_code}: {e}")
raise HTTPException(status_code=500, detail=f"AI 분석 실패: {str(e)}")
# ── 해외 주식 엔드포인트 ──────────────────────────────────────────
@router.get("/overseas/price/{ticker}")
async def get_overseas_price(ticker: str):
"""미국 주식 현재가 조회"""
from app.services.exchange_detector import detect_exchange
exchange = detect_exchange(ticker)
result = await korea_investment_api.get_overseas_price(ticker, exchange)
if not result:
raise HTTPException(status_code=404, detail=f"해외 시세 조회 실패: {ticker}")
return result
@router.get("/overseas/chart/{ticker}")
async def get_overseas_chart(ticker: str, period: str = "D", count: int = 100):
"""미국 주식 차트 데이터 조회
period: D(일), W(주), M(월)
"""
from app.services.exchange_detector import detect_exchange
exchange = detect_exchange(ticker)
result = await korea_investment_api.get_overseas_daily_chart(ticker, exchange, period)
if not result:
raise HTTPException(status_code=404, detail=f"해외 차트 조회 실패: {ticker}")
# Limit results
if isinstance(result, list):
result = result[:count]
return result
@router.get("/overseas/exchange-rate")
async def get_exchange_rate():
"""현재 USD/KRW 환율 조회"""
try:
rate = await korea_investment_api.get_exchange_rate()
return {"currency_pair": "USD/KRW", "rate": rate}
except Exception as e:
raise HTTPException(status_code=500, detail=f"환율 조회 실패: {str(e)}")