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

@@ -7,14 +7,25 @@ ARCHITECTURE (CLAUDE.md 3.2):
Supported task types:
- insert_trade_history : dict → TradeHistory INSERT
- update_trade_history : dict → TradeHistory UPDATE by id (체결 확인 후 슬리피지/수량 반영)
- update_trade_history_by_order_id : dict → TradeHistory UPDATE by order_id (KIS 주문번호 기준)
- upsert_stock_analysis : dict → StockAnalysis single UPSERT
- bulk_upsert_stock_analysis : list → StockAnalysis batch UPSERT
- upsert_stock_ban : dict → StockBan UPSERT (loss_tracker)
- insert_news : dict → News INSERT (뉴스 감성 분석 결과 저장)
- log_mcp_deny : dict → MCP 거부 이벤트 로그 (DB 저장 없이 로그만)
- upsert_portfolio : dict → Portfolio INSERT/UPDATE (strategy_type 포함)
- upsert_active_trade : dict → ActiveTrade UPSERT (strategy_name 포함)
- close_active_trade : dict → ActiveTrade 상태를 'closed'로 변경
"""
import asyncio
import json
import logging
from datetime import date
import uuid
from datetime import date, datetime
from pathlib import Path
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.models import TradeHistory
@@ -22,9 +33,94 @@ from app.models.models import TradeHistory
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# 전역 큐 — 앱 어디서나 import 하여 put() 호출
# DLQ (Dead Letter Queue) — 3회 재시도 실패 시 파일로 백업
# ─────────────────────────────────────────────────────────────────────────────
db_task_queue: asyncio.Queue = asyncio.Queue()
DLQ_DIR = Path.home() / ".cache" / "auto-stock" / "dlq"
async def _save_to_dlq(task: dict, error: str) -> None:
"""3회 재시도 실패한 task를 DLQ 파일로 저장.
저장 위치: ~/.cache/auto-stock/dlq/{timestamp}_{task_type}_{uuid}.json
파일 I/O는 asyncio.to_thread로 감싸 이벤트 루프 블로킹을 방지한다.
"""
try:
task_type = task.get("type", "unknown")
filename = (
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}"
f"_{task_type}"
f"_{uuid.uuid4().hex[:8]}.json"
)
dlq_entry = {
"task": task,
"error": str(error),
"failed_at": datetime.now().isoformat(),
"retry_count": task.get("_retry_count", 0),
}
def _write():
DLQ_DIR.mkdir(parents=True, exist_ok=True)
(DLQ_DIR / filename).write_text(
json.dumps(dlq_entry, default=str, ensure_ascii=False),
encoding="utf-8",
)
await asyncio.to_thread(_write)
logger.critical(
"[DLQ] Task saved to DLQ: %s | type=%s | error=%s",
filename, task_type, error,
)
except Exception as e:
logger.critical(
"[DLQ] Failed to save to DLQ: %s | task_type=%s",
e, task.get("type", "unknown"),
)
# ─────────────────────────────────────────────────────────────────────────────
# 전역 큐 — 앱 어디서나 import 하여 put() 호출
# [이슈8 수정] asyncio.Queue()를 모듈 로드 시점에 생성하면 이벤트 루프가 없는
# 환경(pytest, 스크립트 임포트 등)에서 RuntimeError가 발생할 수 있음.
# _db_task_queue를 None으로 두고, 첫 접근 시 lazy 초기화하는 패턴으로 변경.
# 외부 코드는 기존처럼 db_task_queue 이름으로 접근 가능 (get_db_task_queue() 내부 참조).
# ─────────────────────────────────────────────────────────────────────────────
_db_task_queue: asyncio.Queue | None = None
def get_db_task_queue() -> asyncio.Queue:
"""이벤트 루프 기동 후 최초 호출 시 Queue를 생성(lazy init)하여 반환."""
global _db_task_queue
if _db_task_queue is None:
_db_task_queue = asyncio.Queue()
return _db_task_queue
# 하위 호환: 기존 코드가 `db_task_queue.put()` 형태로 직접 참조하는 경우를 위해
# 프록시 객체 대신 get_db_task_queue()를 통해 접근하도록 모듈 수준 alias 제공.
# 단, 모듈 임포트 시점에 이벤트 루프가 없어도 오류가 발생하지 않음.
class _QueueProxy:
"""asyncio.Queue를 lazy init으로 감싸는 투명 프록시."""
def __getattr__(self, name):
return getattr(get_db_task_queue(), name)
async def put(self, item):
await get_db_task_queue().put(item)
async def get(self):
return await get_db_task_queue().get()
def task_done(self):
get_db_task_queue().task_done()
def qsize(self):
return get_db_task_queue().qsize()
def empty(self):
return get_db_task_queue().empty()
db_task_queue: asyncio.Queue = _QueueProxy() # type: ignore[assignment]
# ─────────────────────────────────────────────────────────────────────────────
@@ -35,6 +131,21 @@ async def enqueue_trade_history(data: dict) -> None:
await db_task_queue.put({"type": "insert_trade_history", "data": data})
async def enqueue_update_trade_history(trade_id: int, updates: dict) -> None:
"""기존 TradeHistory 레코드 UPDATE 를 큐에 등록.
주로 체결 확인(verify_and_sync) 후 executed_price/quantity/status/slippage 반영 시 사용.
Args:
trade_id: 업데이트할 TradeHistory.id
updates: 업데이트할 필드 dict (예: {"executed_quantity": 10, "status": "COMPLETED"})
"""
await db_task_queue.put({
"type": "update_trade_history",
"data": {"id": trade_id, **updates},
})
async def enqueue_upsert_analysis(row: dict) -> None:
"""단건 StockAnalysis UPSERT 를 큐에 등록."""
await db_task_queue.put({"type": "upsert_stock_analysis", "data": row})
@@ -55,6 +166,11 @@ async def enqueue_db_write(task: dict) -> None:
await db_task_queue.put(task)
async def enqueue_trade_journal_raw(data: dict) -> None:
"""TradeJournal INSERT 를 큐에 등록 (순수 dict 전달용)."""
await db_task_queue.put({"type": "insert_trade_journal", "data": data})
async def enqueue_stock_ban_upsert(stock_code: str, consecutive_losses: int, ban_until) -> None:
"""StockBan UPSERT 를 큐에 등록 (loss_tracker에서 호출)."""
await db_task_queue.put({
@@ -67,6 +183,81 @@ async def enqueue_stock_ban_upsert(stock_code: str, consecutive_losses: int, ban
})
async def enqueue_portfolio_upsert(data: dict) -> None:
"""Portfolio INSERT/UPDATE 를 큐에 등록.
data 필수 키: stock_code, quantity, average_price
data 선택 키: stock_name, current_price, profit_loss, profit_loss_rate,
market, strategy_type ('swing' | 'daytrading')
"""
await db_task_queue.put({"type": "upsert_portfolio", "data": data})
async def enqueue_active_trade_upsert(data: dict) -> None:
"""ActiveTrade UPSERT 를 큐에 등록.
data 필수 키: stock_code, quantity, entry_price
data 선택 키: strategy_name ('swing' | 'day_trade'), stop_loss,
take_profit, entry_reason, highest_since_entry
"""
await db_task_queue.put({"type": "upsert_active_trade", "data": data})
async def enqueue_close_active_trade(stock_code: str, strategy_name: str | None = None) -> None:
"""ActiveTrade 를 'closed' 상태로 변경하는 태스크를 큐에 등록."""
await db_task_queue.put({
"type": "close_active_trade",
"data": {"stock_code": stock_code, "strategy_name": strategy_name},
})
async def enqueue_update_trade_history_by_order_id(order_id: str, updates: dict) -> None:
"""KIS 주문번호(order_id)로 TradeHistory UPDATE 를 큐에 등록.
trade_id 대신 order_id 로 레코드를 조회하여 업데이트한다.
enqueue_trade_history() 로 INSERT 한 레코드의 id 를 모르는 경우 사용.
Args:
order_id: KIS 주문번호 (TradeHistory.order_id)
updates: 업데이트할 필드 dict (예: {"executed_quantity": 10, "status": "COMPLETED"})
"""
await db_task_queue.put({
"type": "update_trade_history_by_order_id",
"data": {"order_id": order_id, **updates},
})
async def enqueue_trade_journal(trade, content: str) -> None:
"""TradeJournal INSERT 를 큐에 등록 (main.py post_market_scheduler 용).
CLAUDE.md 3.2: 스케줄러(실시간 계층)에서 직접 DB 커밋 금지 → 큐 경유.
"""
from datetime import datetime
from app.services.trade_analyzer import _to_float, _parse_strategy, _parse_exit_type
pl_rate = _to_float(trade.profit_loss_rate)
strategy = _parse_strategy(trade.entry_reason)
exit_type = _parse_exit_type(trade.exit_reason)
mood = "positive" if pl_rate > 0 else ("neutral" if pl_rate > -3.0 else "negative")
tags = [strategy, exit_type]
if abs(pl_rate) > 5.0:
tags.append("significant")
await db_task_queue.put({
"type": "insert_trade_journal",
"data": {
"trade_id": trade.id,
"stock_code": trade.stock_code,
"stock_name": trade.stock_name,
"journal_date": trade.executed_at or trade.created_at or datetime.utcnow(),
"content": content,
"strategy_name": strategy,
"mood": mood,
"tags": tags,
},
})
# ─────────────────────────────────────────────────────────────────────────────
# 워커 본체
# ─────────────────────────────────────────────────────────────────────────────
@@ -77,8 +268,9 @@ async def db_writer_worker() -> None:
commit / rollback 을 자동 관리한다.
"""
logger.info("🛠️ Background DB Writer Worker started")
queue = get_db_task_queue() # 워커 진입 시 큐 인스턴스 고정
while True:
task = await db_task_queue.get()
task = await queue.get()
try:
task_type = task.get("type")
data = task.get("data")
@@ -93,7 +285,50 @@ async def db_writer_worker() -> None:
data.get("stock_code", "?"),
)
# ── 2. 단건 StockAnalysis UPSERT ──────────────────────────
# ── 2. TradeHistory UPDATE (체결 확인 후 슬리피지/수량 반영) ──
elif task_type == "update_trade_history":
trade_id = data.get("id")
if trade_id:
row = await session.execute(
select(TradeHistory).where(TradeHistory.id == trade_id)
)
trade = row.scalar_one_or_none()
if trade:
for key, val in data.items():
if key != "id" and val is not None and hasattr(trade, key):
setattr(trade, key, val)
await session.commit()
logger.info(
"✅ [Worker] TradeHistory updated: id=%s status=%s",
trade_id,
data.get("status", "?"),
)
# ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ──
elif task_type == "update_trade_history_by_order_id":
order_id = data.get("order_id")
if order_id:
row = await session.execute(
select(TradeHistory).where(TradeHistory.order_id == order_id)
)
trade = row.scalar_one_or_none()
if trade:
for key, val in data.items():
if key != "order_id" and val is not None and hasattr(trade, key):
setattr(trade, key, val)
await session.commit()
logger.info(
"✅ [Worker] TradeHistory updated by order_id=%s status=%s",
order_id,
data.get("status", "?"),
)
else:
logger.warning(
"⚠️ [Worker] TradeHistory not found for order_id=%s",
order_id,
)
# ── 3. 단건 StockAnalysis UPSERT ──────────────────────────
elif task_type == "upsert_stock_analysis":
from app.services.analysis_db_service import _execute_upsert
await _execute_upsert(session, [data])
@@ -115,7 +350,6 @@ async def db_writer_worker() -> None:
# ── 4. StockBan UPSERT ────────────────────────────────────────
elif task_type == "upsert_stock_ban":
from app.models.models import StockBan
from sqlalchemy import select
stock_code = data.get("stock_code")
consecutive_losses = data.get("consecutive_losses")
@@ -143,6 +377,32 @@ async def db_writer_worker() -> None:
ban_until.isoformat() if ban_until else "None",
)
# ── 4b. News INSERT (중복 URL 무시) ────────────────────────────
elif task_type == "insert_news":
from app.models.models import News
news_url = data.get("url")
is_dup = False
# URL 기반 중복 방지 (동일 URL은 skip)
if news_url:
dup_result = await session.execute(
select(News.id).where(News.url == news_url).limit(1)
)
if dup_result.scalar_one_or_none() is not None:
is_dup = True
logger.debug(
"⏭️ [Worker] News skip (dup URL): %s",
(data.get("title") or "?")[:40],
)
if not is_dup:
session.add(News(**data))
await session.commit()
logger.debug(
"✅ [Worker] News saved: %s",
(data.get("title") or "?")[:40],
)
# ── 5. MCP 거부 이벤트 로그 ───────────────────────────────────
elif task_type == "log_mcp_deny":
# DB 저장 없이 로그만 기록 (필요 시 별도 테이블 확장 가능)
@@ -152,6 +412,102 @@ async def db_writer_worker() -> None:
task.get("trade_plan"),
)
# ── 6. Portfolio UPSERT ────────────────────────────────────────
elif task_type == "upsert_portfolio":
from app.models.models import Portfolio
stock_code = data.get("stock_code")
result = await session.execute(
select(Portfolio).where(Portfolio.stock_code == stock_code)
)
record = result.scalar_one_or_none()
if record is None:
record = Portfolio(**data)
session.add(record)
else:
for key, val in data.items():
if key != "stock_code" and val is not None:
setattr(record, key, val)
await session.commit()
logger.debug(
"✅ [Worker] Portfolio upsert: %s (strategy=%s)",
stock_code,
data.get("strategy_type", "swing"),
)
# ── 7. ActiveTrade UPSERT ──────────────────────────────────────
elif task_type == "upsert_active_trade":
from app.models.models import ActiveTrade
stock_code = data.get("stock_code")
strategy_name = data.get("strategy_name")
query = select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
)
if strategy_name:
query = query.where(ActiveTrade.strategy_name == strategy_name)
result = await session.execute(query)
record = result.scalar_one_or_none()
if record is None:
record = ActiveTrade(**data)
session.add(record)
else:
for key, val in data.items():
if key != "stock_code" and val is not None:
setattr(record, key, val)
await session.commit()
logger.debug(
"✅ [Worker] ActiveTrade upsert: %s (strategy=%s)",
stock_code,
strategy_name,
)
# ── 8. ActiveTrade close ───────────────────────────────────────
elif task_type == "close_active_trade":
from app.models.models import ActiveTrade
from datetime import datetime as _dt
stock_code = data.get("stock_code")
strategy_name = data.get("strategy_name")
query = select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
)
if strategy_name:
query = query.where(ActiveTrade.strategy_name == strategy_name)
result = await session.execute(query)
record = result.scalar_one_or_none()
if record:
record.status = "closed"
record.updated_at = _dt.utcnow()
await session.commit()
logger.debug(
"✅ [Worker] ActiveTrade closed: %s (strategy=%s)",
stock_code,
strategy_name,
)
# ── 9. TradeJournal INSERT ────────────────────────────────────
elif task_type == "insert_trade_journal":
from app.models.models import TradeJournal
session.add(TradeJournal(**data))
await session.commit()
logger.debug(
"✅ [Worker] TradeJournal saved: %s",
data.get("stock_code", "?"),
)
else:
logger.warning("⚠️ [Worker] Unknown task type: %s", task_type)
@@ -159,7 +515,25 @@ async def db_writer_worker() -> None:
logger.info("⏹️ DB Writer Worker stopped")
break
except Exception as exc:
logger.error("❌ [Worker] DB task failed: %s", exc, exc_info=True)
# 실패한 task 는 드롭 — 재시도가 필요하면 retry_count 추가 가능
retry_count = task.get("_retry_count", 0)
max_retries = 3
task_type = task.get("type", "unknown")
if retry_count < max_retries:
task["_retry_count"] = retry_count + 1
delay = min(2 ** retry_count, 8) # 1, 2, 4초 지수 백오프
logger.warning(
"⚠️ [Worker] DB task retry %d/%d (type=%s): %s — %ds 후 재시도",
retry_count + 1, max_retries, task_type, exc, delay,
)
await asyncio.sleep(delay)
await queue.put(task) # 큐에 재등록
else:
logger.error(
"❌ [Worker] DB task DROPPED after %d retries (type=%s): %s",
max_retries, task_type, exc, exc_info=True,
)
# DLQ: 최종 실패 task를 파일로 백업하여 데이터 손실 방지
await _save_to_dlq(task, str(exc))
finally:
db_task_queue.task_done()
queue.task_done()