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

@@ -0,0 +1,379 @@
"""Helper functions for trade_executor.py — Buy/Sell logic decomposition.
This module extracts complex logic from _execute_buy() and _execute_sell()
into reusable helper functions, keeping the executor focused on orchestration.
"""
import logging
from decimal import Decimal
from datetime import datetime as _dt_utc
from app.core.config import settings
from app.services.korea_investment_api import korea_investment_api
from app.services.mean_reversion_strategy import (
calculate_rsi, calculate_bollinger, calculate_atr, MarketRegime
)
from app.services.technical_indicators import (
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema,
)
from app.core.market_schedule import is_opening_range_period
from app.services.market_analyzer import _get_kst_now
from app.services.exchange_detector import get_market_info
logger = logging.getLogger(__name__)
# ────────────────────────────────────────────────────────────────────────────
# BUY HELPERS
# ────────────────────────────────────────────────────────────────────────────
async def check_opening_range_volatility() -> bool:
"""Check if we're in opening range period (09:00~09:30) — volatility avoidance.
Returns:
bool: True if in opening range (SKIP BUY), False otherwise.
"""
return is_opening_range_period()
async def check_surge_prevention(
stock_code: str,
is_domestic: bool,
exchange_code: str,
current_price: float,
) -> dict:
"""Check 3-day surge (ATR-based dynamic threshold) — avoid chasing rallies.
[동작] 3일 전 종가 대비 현재가 상승률 > threshold(최소 10%, ATR% 기반 동적) 이면
최근 급등(chase_buy_prevention) 판정 → HOLD 반환.
Args:
stock_code: Stock code (KRX code or ticker)
is_domestic: True if domestic stock, False if overseas
exchange_code: Exchange code for overseas stocks
current_price: Current price
Returns:
dict: {"skip": False} (통과) or {"skip": True, "reason": "chase_buy_prevention"}
"""
try:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10)
chart_data = chart_resp.get("output2", [])
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(
stock_code, exchange_code, "D"
)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 4:
closes = (
[float(item.get("stck_clpr", 0)) for item in chart_data[-4:]]
if is_domestic
else [float(item.get("clos", 0)) for item in chart_data[-4:]]
)
if len(closes) >= 4:
price_3days_ago = closes[0]
recent_gain = (
((current_price - price_3days_ago) / price_3days_ago) * 100
if price_3days_ago > 0 else 0
)
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 {"skip": True, "reason": "chase_buy_prevention"}
except Exception as e:
logger.debug(f"[SURGE_CHECK] {stock_code} chart fetch failed, bypassed: {type(e).__name__}: {e}")
return {"skip": False}
async def check_trend_filter(
stock_code: str,
stock_name: str,
is_domestic: bool,
exchange_code: str,
current_price: float,
stock: dict,
) -> dict:
"""Apply trend filter (MA + EMA) — skip if downtrend, except MEAN_REVERSION.
[동작] MEAN_REVERSION 전략은 과매도 진입이므로 필터 제외.
MA20 >= MA60 AND current >= MA20 (TREND_TOLERANCE_PCT 범위 내) 통과 조건.
Args:
stock_code: Stock code
stock_name: Stock name
is_domestic: True if domestic
exchange_code: Exchange code (for overseas)
current_price: Current price
stock: Stock dict with pre-calculated MA values
Returns:
dict: {"skip": False} (통과) or {"skip": True, "reason": "trend_filter"}
"""
_rule_name = stock.get("rule_name", "") or ""
_rule_type = stock.get("rule_type", "") or ""
_is_mean_reversion = (
"MEAN_REVERSION" in _rule_type
or "평균회귀" in _rule_name
or "mean_reversion" in _rule_name.lower()
)
if not TREND_FILTER_ENABLED or _is_mean_reversion:
return {"skip": False} # 필터 미적용
try:
ma20 = stock.get("ma20")
ma60 = stock.get("ma60")
ema20 = ema60 = None
closes_local = []
if ma20 is None or ma60 is None:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])]
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])]
if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG):
ma20 = _ma(closes_local, TREND_MA_SHORT)
ma60 = _ma(closes_local, TREND_MA_LONG)
ema20 = _ema(closes_local, TREND_MA_SHORT)
ema60 = _ema(closes_local, TREND_MA_LONG)
ma_ok = bool(
ma20 and ma60
and float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT)
and current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT)
)
ema_ok = bool(
ema20 and ema60
and ema20 >= ema60 * (1 - TREND_TOLERANCE_PCT)
and current_price >= ema20 * (1 - TREND_TOLERANCE_PCT)
) if ema20 and ema60 else False
if not (ma_ok or ema_ok):
return {"skip": True, "reason": "trend_filter"}
return {"skip": False}
except Exception as e:
logger.debug(f"[TREND_FILTER] {stock_code}: {type(e).__name__}: {e}")
return {"skip": True, "reason": "trend_filter_error"}
def calculate_cooldown_minutes(stock: dict, current_price: float) -> int:
"""Calculate dynamic cooldown minutes based on ATR (higher volatility → shorter cooldown).
[동작] ATR%가 높을수록(고변동) 쿨다운 단축 → max(15, 30 - atr_pct×3)분
예: ATR% = 5% → 쿨다운 = 15분
ATR% = 1% → 쿨다운 = 27분
Args:
stock: Stock dict with "atr" field
current_price: Current price (for ATR% calculation)
Returns:
int: Cooldown minutes
"""
atr_val = float(stock.get("atr", 0) or 0)
if atr_val > 0 and current_price > 0:
atr_pct = atr_val / current_price * 100
return max(15, int(30 - atr_pct * 3))
return int(stock.get("add_buy_cooldown_min", 30) or 30)
def get_daily_cutoff_utc() -> _dt_utc:
"""Get daily cutoff (00:00:00 UTC) for same-day order filtering.
[이슈1 수정] TradeHistory.created_at은 UTC 저장이므로 daily_cutoff도 UTC 기준 계산.
Returns:
datetime: Today's 00:00:00 UTC
"""
return _dt_utc.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
def get_leverage_check_keywords() -> set:
"""Get keywords for leverage/inverse ETF detection.
Returns:
set: Leverage/inverse ETF keywords
"""
return {
"레버리지", "2X", "3X", "인버스", "곱버스", "TIGER 200선물", "KODEX 인버스"
}
def is_leverage_etf(stock_name: str, keywords: set = None) -> bool:
"""Check if stock is leverage/inverse ETF.
Args:
stock_name: Stock name
keywords: Leverage keywords (optional, uses default if None)
Returns:
bool: True if leverage/inverse ETF
"""
if keywords is None:
keywords = get_leverage_check_keywords()
_name_upper = (stock_name or "").upper()
return (
any(kw in (stock_name or "") for kw in keywords)
or "LEVERAGE" in _name_upper
or "INVERSE" in _name_upper
)
# ────────────────────────────────────────────────────────────────────────────
# SELL HELPERS
# ────────────────────────────────────────────────────────────────────────────
def calculate_trade_costs(
entry_price: float,
exit_price: float,
quantity: int,
is_domestic: bool = True,
market: str = "KOSPI",
) -> dict:
"""매매 수수료 + 세금 계산 (net PnL 산출용).
Returns:
dict: {"buy_commission", "sell_commission", "sell_tax", "total_cost"}
"""
buy_commission = entry_price * quantity * settings.KR_COMMISSION_RATE
sell_commission = exit_price * quantity * settings.KR_COMMISSION_RATE
if not is_domestic:
sell_tax = 0.0
elif market in ("KOSDAQ", "kosdaq"):
sell_tax = exit_price * quantity * settings.KR_SELL_TAX_RATE_KOSDAQ
else:
sell_tax = exit_price * quantity * settings.KR_SELL_TAX_RATE_KOSPI
total_cost = buy_commission + sell_commission + sell_tax
return {
"buy_commission": round(buy_commission, 0),
"sell_commission": round(sell_commission, 0),
"sell_tax": round(sell_tax, 0),
"total_cost": round(total_cost, 0),
}
def calculate_profit_loss_rate(
current_price: float,
average_price: float,
holding_profit_loss_rate: float = None,
) -> float:
"""Calculate realized profit/loss rate as percentage.
Args:
current_price: Current price
average_price: Average entry price
holding_profit_loss_rate: Fallback value if calculation fails
Returns:
float: Profit/loss rate (%)
"""
if average_price > 0:
return ((current_price - average_price) / average_price) * 100
return float(holding_profit_loss_rate or 0)
def calculate_atr_based_stops(
average_price: float,
current_price: float,
atr: float,
stock_dict: dict,
base_stop_loss: float = -6.0,
) -> dict:
"""Calculate ATR-based stop loss and trailing stop.
[CLAUDE.md 5.2]
- ATR Stop: average_price - 2.5×ATR → TIGHTER(더 작은) 값 적용
- Trailing Stop: high_price - 1.5×ATR (수익 구간에서만)
Args:
average_price: Average entry price
current_price: Current price
atr: ATR value
stock_dict: Stock dict with optional "high_price"
base_stop_loss: Base stop loss percentage (default -6%)
Returns:
dict: {
"stop_loss": float (%),
"trailing_stop_price": float,
"exit_reason_detail": str
}
"""
stop_loss = base_stop_loss
trailing_stop_price = None
exit_reason_detail = ""
if atr > 0 and average_price > 0:
atr_stop_price = average_price - 2.5 * atr
atr_stop_pct = ((atr_stop_price / average_price) - 1) * 100
stop_loss = max(stop_loss, atr_stop_pct) # TIGHTER = 0에 가까운 쪽 (음수이므로 max)
high_price = float(stock_dict.get("high_price") or current_price)
trailing_stop_price = high_price - 1.5 * atr
profit_loss_rate = calculate_profit_loss_rate(current_price, average_price)
if trailing_stop_price and current_price <= trailing_stop_price and profit_loss_rate > 0:
exit_reason_detail = f"ATR 트레일링 스탑: {current_price:.0f} ≤ {trailing_stop_price:.0f}"
return {
"stop_loss": stop_loss,
"trailing_stop_price": trailing_stop_price,
"exit_reason_detail": exit_reason_detail,
}
def build_kis_order_id(order_result: dict) -> str:
"""Extract KIS order ID from API response.
[이슈3 수정] KIS response structure: output.ODNO preferred, fallback to top-level odno.
Args:
order_result: Order API response dict
Returns:
str: Order ID or empty string if not found
"""
return (
order_result.get("output", {}).get("ODNO", "")
or order_result.get("odno", "")
)
def get_kst_now() -> _dt_utc:
"""Get current time in KST (convenience wrapper for compatibility).
Returns:
datetime: Current time in KST
"""
return _get_kst_now()