From f92479f3b0d4075e1685731c9ac3c6c8a197511c Mon Sep 17 00:00:00 2001 From: shuim Date: Thu, 26 Mar 2026 20:24:59 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A0=84=EC=B2=B4=20=ED=9A=8C=EC=9D=98?= =?UTF-8?q?=20CRITICAL=206=EA=B1=B4=20=EC=88=98=EC=A0=95=20=E2=80=94=20?= =?UTF-8?q?=EC=86=90=EC=A0=88/=EB=A6=AC=EC=8A=A4=ED=81=AC/=EC=84=B1?= =?UTF-8?q?=EA=B3=BC/=EC=9D=B8=ED=94=84=EB=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. trade_executor: 손절값 -6.0 하드코딩 → -settings.STOP_LOSS_PERCENT (7%) 2. mdd_manager: MDD 강제매도 시 loss_tracker 호출 추가 3. risk_validator, day_trading: 가격필터 < → <= (5,000원 정확히 차단) 4. daytrading/entry_service: 글로벌 자본 사용률(95%) 가드 추가 5. trade_analyzer: 수익률 0%를 손실에서 제외 (< 0으로 변경) 6. db_worker: 종료 시 큐 drain + DLQ 백업으로 데이터 유실 방지 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/day_trading.py | 2 +- .../app/services/daytrading/entry_service.py | 15 +++++++ backend/app/services/db_worker.py | 41 +++++++++++++++++++ backend/app/services/mdd_manager.py | 15 ++++++- backend/app/services/trade_analyzer.py | 4 +- backend/app/services/trade_executor.py | 4 +- mcp-server/risk_validator.py | 2 +- 7 files changed, 76 insertions(+), 7 deletions(-) diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index 180675f..34be1b4 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -523,7 +523,7 @@ async def _scan_signals( continue candles_5m = aggregate_to_5min(candles_1m) current_price = candles_1m[-1].close if candles_1m else 0 - if current_price < settings.MIN_PRICE_KRW: + if current_price <= settings.MIN_PRICE_KRW: continue vwap = calculate_vwap(candles_1m) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 8a99558..df1e60b 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -261,6 +261,21 @@ async def scan_and_enter() -> list[dict]: if total_assets <= 0: return entries + # [BUG FIX] 글로벌 자본 사용률 가드 — 전체 포트폴리오 투자액이 MAX_TOTAL_CAPITAL_USAGE 초과 시 진입 차단 + total_invested = sum( + pos.get("entry_price", 0) * pos.get("quantity", 0) + for pos in day_trading_state.positions.values() + ) + # 스윙 포트폴리오 투자액도 합산 (잔고 조회에서 available_cash로 간접 계산) + swing_invested = total_assets - available_cash - total_invested + all_invested = max(0, total_invested + swing_invested) + if total_assets > 0 and all_invested / total_assets > settings.MAX_TOTAL_CAPITAL_USAGE: + logger.warning( + f"[DayEntry] 전체 자본 사용률 초과: {all_invested/total_assets:.1%} > " + f"{settings.MAX_TOTAL_CAPITAL_USAGE:.0%} — 신규 진입 차단" + ) + return entries + scanned = 0 skipped_held = 0 skipped_closed = 0 diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index f17f12b..0ffefc5 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -512,6 +512,47 @@ async def db_writer_worker() -> None: logger.warning("⚠️ [Worker] Unknown task type: %s", task_type) except asyncio.CancelledError: + # 큐 drain: 종료 전 남은 태스크 처리하여 데이터 손실 방지 + remaining = 0 + while not queue.empty(): + try: + drain_task = queue.get_nowait() + drain_type = drain_task.get("type", "unknown") + drain_data = drain_task.get("data") + async with AsyncSessionLocal() as drain_session: + if drain_type == "insert_trade_history": + drain_session.add(TradeHistory(**drain_data)) + await drain_session.commit() + elif drain_type in ( + "update_trade_history", + "update_trade_history_by_order_id", + "upsert_stock_analysis", + "bulk_upsert_stock_analysis", + "upsert_stock_ban", + "upsert_portfolio", + "upsert_active_trade", + "close_active_trade", + "insert_trade_journal", + ): + # 단순 재큐잉 대신, 중요 태스크만 커밋 시도 + # insert_trade_history 외에는 복잡한 로직이므로 DLQ 저장 + await _save_to_dlq(drain_task, "shutdown_drain") + else: + await _save_to_dlq(drain_task, "shutdown_drain_unknown_type") + remaining += 1 + except asyncio.QueueEmpty: + break + except Exception as drain_err: + logger.error(f"[DBWorker] Drain 실패: {drain_err}") + try: + await _save_to_dlq(drain_task, str(drain_err)) + except Exception: + pass + remaining += 1 + finally: + queue.task_done() + if remaining: + logger.info(f"[DBWorker] 종료 시 {remaining}건 drain 완료") logger.info("⏹️ DB Writer Worker stopped") break except Exception as exc: diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index 999e51b..a69b7bb 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -238,7 +238,20 @@ class MDDManager: ) if order_resp and order_resp.get("rt_cd") == "0": order_id = order_resp.get("output", {}).get("ODNO", "") - profit_loss = (current_price - float(h.get("pchs_avg_pric", current_price))) * sell_qty + avg_price = float(h.get("pchs_avg_pric", current_price)) + profit_loss = (current_price - avg_price) * sell_qty + profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0 + + # [BUG FIX] loss_tracker 기록 — MDD 강제매도도 연속 손실 추적 대상 + try: + from app.services.loss_tracker import loss_tracker + is_loss = profit_loss_rate < 0 + await loss_tracker.record_trade_result_safe( + stock_code, is_loss + ) + await loss_tracker.save_to_db(stock_code=stock_code) + except Exception as lt_err: + logger.warning(f"[MDD] loss_tracker 기록 실패 ({stock_code}): {lt_err}") await enqueue_trade_history({ "stock_code": stock_code, diff --git a/backend/app/services/trade_analyzer.py b/backend/app/services/trade_analyzer.py index 73b9196..8123593 100644 --- a/backend/app/services/trade_analyzer.py +++ b/backend/app/services/trade_analyzer.py @@ -121,7 +121,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any pl_amounts = [_to_float(t.net_profit_loss) for t in trades] wins = [r for r in pl_rates if r > 0] - losses = [r for r in pl_rates if r <= 0] + losses = [r for r in pl_rates if r < 0] win_rate = len(wins) / len(pl_rates) if pl_rates else 0.0 avg_win = sum(wins) / len(wins) if wins else 0.0 @@ -243,7 +243,7 @@ def _empty_summary() -> Dict[str, Any]: def _calc_max_consecutive_loss(pl_rates: List[float]) -> int: max_streak = current_streak = 0 for r in pl_rates: - if r <= 0: + if r < 0: current_streak += 1 max_streak = max(max_streak, current_streak) else: diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 580b880..afc40c1 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -782,7 +782,7 @@ async def _execute_buy( "quantity": quantity, "entry_price": order_price, "strategy_name": stock.get("strategy_type", "swing"), - "stop_loss": stock.get("stop_loss", -6.0), + "stop_loss": stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)), "take_profit": stock.get("take_profit", 5.0), "entry_reason": entry_reason, "highest_since_entry": order_price, @@ -901,7 +901,7 @@ async def _execute_sell( # [CLAUDE.md 5.2] 기본 손절 -6% (안정적 종목), 익절은 ATR 기반 2R take_profit = stock.get("take_profit", 5.0) - base_stop_loss = stock.get("stop_loss", -6.0) + base_stop_loss = stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)) # ── ATR 기반 동적 손절/트레일링 스탑 조정 ──────────────────────────────── atr = float(stock.get("atr", 0) or 0) diff --git a/mcp-server/risk_validator.py b/mcp-server/risk_validator.py index ef5c11a..51d52bb 100644 --- a/mcp-server/risk_validator.py +++ b/mcp-server/risk_validator.py @@ -129,7 +129,7 @@ class RiskValidator: reason=f"Failed to fetch current price for {stock_code}" ) - if current_price < MIN_DOMESTIC_PRICE: + if current_price <= MIN_DOMESTIC_PRICE: return RiskValidationResult( allowed=False, reason=f"Price filter violation: {current_price} KRW < {MIN_DOMESTIC_PRICE} KRW minimum",