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

@@ -99,7 +99,7 @@ async def get_recommendations(
use_volatility_breakout: bool = Query(False, description="변동성 돌파 전략 사용"),
min_volume_ratio: float = Query(1.0, description="최소 거래량 비율 (평균 대비)"),
max_disparity: float = Query(200.0, description="최대 이격도 (20일선 대비 %)"),
max_stocks: int = Query(100, description="분석할 최대 종목 수 (기본 100개)"),
max_stocks: int = Query(100, ge=1, le=200, description="분석할 최대 종목 수 (기본 100개, 최대 200개)"),
):
market_regime = await check_market_regime()
logger.info(f"📊 시장 상태: {market_regime['message']}")
@@ -154,7 +154,7 @@ async def get_recommendations(
r for r in all_results
if r is not None
and not isinstance(r, Exception)
and r.current_price >= 3000
and r.current_price > 5000
and r.score >= regime_min_score
]
recommendations.sort(key=lambda x: x.score, reverse=True)
@@ -181,8 +181,11 @@ async def get_rule_based_recommendations(
if auto_trade and result.get("recommendations_by_rule"):
rec_snapshot = result
async def _bg_trade():
async with AsyncSessionLocal() as bg_db:
await execute_auto_trade_background(rec_snapshot, bg_db)
try:
async with AsyncSessionLocal() as bg_db:
await execute_auto_trade_background(rec_snapshot, bg_db)
except Exception as e:
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
asyncio.create_task(_bg_trade())
return result
@@ -228,7 +231,7 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
Rule(
id=1, name="RSI+볼린저 평균회귀", description="기본 평균회귀 규칙",
rule_type="MEAN_REVERSION", priority=1,
conditions={"rsi_bull_max": 25, "rsi_bear_max": 35,
conditions={"rsi_bull_max": 40, "rsi_bear_max": 30,
"bb_lower_pct": 3.0, "bb_upper_pct": 1.5},
actions={"take_profit": 3.0, "stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
@@ -252,21 +255,43 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str)
regime = MarketRegime.BULL if market_regime_str == "bull" else MarketRegime.BEAR
adx_val = float(stock.adx_14) if getattr(stock, "adx_14", None) else None
# ADX는 0~100 범위. 100 초과 시 계산 오류이므로 무시
if adx_val is not None and adx_val > 100:
adx_val = None
adx_strong_trend = adx_val is not None and adx_val > 30
# %B 계산: 밴드 내 상대 위치 (0=하단, 1=상단)
bb_width = bb_upper - bb_lower
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
if regime == MarketRegime.BULL:
rsi_threshold = conditions.get("rsi_bull_max", 25)
bb_lower_pct = conditions.get("bb_lower_pct", 3.0) / 100
bb_upper_pct = conditions.get("bb_upper_pct", 1.5) / 100
in_bb_zone = bb_lower * (1 - bb_lower_pct) <= close <= bb_lower * (1 + bb_upper_pct)
rsi_threshold = conditions.get("rsi_bull_max", 40)
if adx_strong_trend:
return False
return rsi_val <= rsi_threshold and in_bb_zone
if rsi_val > rsi_threshold:
return False
# RSI 깊이에 따른 동적 %B 임계값
if rsi_val < 25:
pct_b_max = 0.35 # 깊은 과매도 → BB 조건 완화
elif rsi_val < 30:
pct_b_max = 0.30
else:
pct_b_max = 0.20 # RSI 30~40 → BB 하단 근처 요구
return pct_b <= pct_b_max
else:
rsi_threshold = conditions.get("rsi_bear_max", 35)
rsi_threshold = conditions.get("rsi_bear_max", 30)
if adx_strong_trend:
return False
return rsi_val <= rsi_threshold and close <= bb_lower
if rsi_val > rsi_threshold:
return False
# BEAR: RSI 깊이에 따른 동적 %B (BULL보다 엄격)
if rsi_val < 20:
pct_b_max = 0.30
elif rsi_val < 25:
pct_b_max = 0.25
else:
pct_b_max = 0.20
return pct_b <= pct_b_max
async def get_rule_based_recommendations_from_db(db: AsyncSession):
active_rules = await _load_active_rules_from_db(db)
@@ -283,8 +308,8 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
all_analyzed_stocks = []
for s in raw_stocks:
if s.stock_code not in seen:
# 전일 거래량 필터: 100만주 미만 종목 제외
if s.volume is not None and s.volume < 1_000_000:
# 전일 거래량 필터: 20만주 미만 종목 제외 (유동성 확보)
if s.volume is not None and s.volume < 200_000:
continue
seen.add(s.stock_code)
all_analyzed_stocks.append(s)
@@ -398,7 +423,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"recommendations_by_rule": recommendations_by_rule,
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
max((s.analysis_date for s in all_analyzed_stocks), default=date.today())
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
).isoformat(),
}
@@ -449,7 +474,7 @@ async def get_rule_based_recommendations_realtime():
"rule_priority": 1,
"position_ratio": 0.15,
"take_profit": 5.0,
"stop_loss": 3.0
"stop_loss": -6.0
}
default_stocks.append(stock_dict)
@@ -500,16 +525,16 @@ async def get_rule_based_recommendations_realtime():
min_volume_ratio=0, # intraday acml_vol << avg_vol_20 → 장중엔 항상 0으로 고정
)
batch_size = 1
delay_between_batches = 1.5
batch_size = 5 # 네이버 일봉 사용으로 병렬 처리 가능
delay_between_batches = 0.3 # 네이버 서버 부하 방지 최소 딜레이
all_analyzed_stocks = []
for i in range(0, len(stocks_to_analyze), batch_size):
batch = stocks_to_analyze[i:i + batch_size]
tasks = [analyze_stock(code, name, criteria) for code, name in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_analyzed_stocks.extend(results)
if i + batch_size < len(stocks_to_analyze):
await asyncio.sleep(delay_between_batches)
@@ -578,33 +603,15 @@ async def get_rule_based_recommendations_realtime():
rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"]))
from app.services.korea_investment_api import korea_investment_api as kis_api
# 현재가는 DB 분석 데이터 그대로 사용 (KIS API 미호출)
top_recommendations = rule_matches[:20]
for rec in top_recommendations:
try:
await asyncio.sleep(0.2)
price_data = await kis_api.get_current_price(rec["stock_code"])
if price_data and price_data.get("output"):
output = price_data["output"]
rec["current_price"] = int(output.get("stck_prpr", rec["current_price"]))
rec["change_rate"] = float(output.get("prdy_ctrt", rec.get("change_rate", 0)))
except Exception as e:
logger.warning(f"Failed to get real-time price for {rec['stock_code']}: {e}")
for rule_group in recommendations_by_rule:
for stock in rule_group["stocks"]:
updated = next((r for r in top_recommendations if r["stock_code"] == stock["stock_code"]), None)
if updated:
stock["current_price"] = updated["current_price"]
stock["change_rate"] = updated["change_rate"]
from app.services.analysis_db_service import bulk_upsert_analysis as _bulk_upsert
# KST 기준 오늘 날짜 사용 (UTC 기준 date.today() 사용 시 KST와 날짜 불일치 → UPSERT 대신 INSERT 반복 버그 수정)
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
_today = _today_kst()
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
async with AsyncSessionLocal() as _db:
saved = await _bulk_upsert(_db, _items, _today)
await _db.commit()
await _queue_bulk(_items, _today)
return {
"total": len(rule_matches),