fix: 전체 회의 CRITICAL 6건 수정 — 손절/리스크/성과/인프라
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) <noreply@anthropic.com>
This commit is contained in:
@@ -523,7 +523,7 @@ async def _scan_signals(
|
|||||||
continue
|
continue
|
||||||
candles_5m = aggregate_to_5min(candles_1m)
|
candles_5m = aggregate_to_5min(candles_1m)
|
||||||
current_price = candles_1m[-1].close if candles_1m else 0
|
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
|
continue
|
||||||
|
|
||||||
vwap = calculate_vwap(candles_1m)
|
vwap = calculate_vwap(candles_1m)
|
||||||
|
|||||||
@@ -261,6 +261,21 @@ async def scan_and_enter() -> list[dict]:
|
|||||||
if total_assets <= 0:
|
if total_assets <= 0:
|
||||||
return entries
|
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
|
scanned = 0
|
||||||
skipped_held = 0
|
skipped_held = 0
|
||||||
skipped_closed = 0
|
skipped_closed = 0
|
||||||
|
|||||||
@@ -512,6 +512,47 @@ async def db_writer_worker() -> None:
|
|||||||
logger.warning("⚠️ [Worker] Unknown task type: %s", task_type)
|
logger.warning("⚠️ [Worker] Unknown task type: %s", task_type)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
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")
|
logger.info("⏹️ DB Writer Worker stopped")
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -238,7 +238,20 @@ class MDDManager:
|
|||||||
)
|
)
|
||||||
if order_resp and order_resp.get("rt_cd") == "0":
|
if order_resp and order_resp.get("rt_cd") == "0":
|
||||||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
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({
|
await enqueue_trade_history({
|
||||||
"stock_code": stock_code,
|
"stock_code": stock_code,
|
||||||
|
|||||||
@@ -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]
|
pl_amounts = [_to_float(t.net_profit_loss) for t in trades]
|
||||||
|
|
||||||
wins = [r for r in pl_rates if r > 0]
|
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
|
win_rate = len(wins) / len(pl_rates) if pl_rates else 0.0
|
||||||
avg_win = sum(wins) / len(wins) if wins 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:
|
def _calc_max_consecutive_loss(pl_rates: List[float]) -> int:
|
||||||
max_streak = current_streak = 0
|
max_streak = current_streak = 0
|
||||||
for r in pl_rates:
|
for r in pl_rates:
|
||||||
if r <= 0:
|
if r < 0:
|
||||||
current_streak += 1
|
current_streak += 1
|
||||||
max_streak = max(max_streak, current_streak)
|
max_streak = max(max_streak, current_streak)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -782,7 +782,7 @@ async def _execute_buy(
|
|||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"entry_price": order_price,
|
"entry_price": order_price,
|
||||||
"strategy_name": stock.get("strategy_type", "swing"),
|
"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),
|
"take_profit": stock.get("take_profit", 5.0),
|
||||||
"entry_reason": entry_reason,
|
"entry_reason": entry_reason,
|
||||||
"highest_since_entry": order_price,
|
"highest_since_entry": order_price,
|
||||||
@@ -901,7 +901,7 @@ async def _execute_sell(
|
|||||||
|
|
||||||
# [CLAUDE.md 5.2] 기본 손절 -6% (안정적 종목), 익절은 ATR 기반 2R
|
# [CLAUDE.md 5.2] 기본 손절 -6% (안정적 종목), 익절은 ATR 기반 2R
|
||||||
take_profit = stock.get("take_profit", 5.0)
|
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 기반 동적 손절/트레일링 스탑 조정 ────────────────────────────────
|
||||||
atr = float(stock.get("atr", 0) or 0)
|
atr = float(stock.get("atr", 0) or 0)
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class RiskValidator:
|
|||||||
reason=f"Failed to fetch current price for {stock_code}"
|
reason=f"Failed to fetch current price for {stock_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if current_price < MIN_DOMESTIC_PRICE:
|
if current_price <= MIN_DOMESTIC_PRICE:
|
||||||
return RiskValidationResult(
|
return RiskValidationResult(
|
||||||
allowed=False,
|
allowed=False,
|
||||||
reason=f"Price filter violation: {current_price} KRW < {MIN_DOMESTIC_PRICE} KRW minimum",
|
reason=f"Price filter violation: {current_price} KRW < {MIN_DOMESTIC_PRICE} KRW minimum",
|
||||||
|
|||||||
Reference in New Issue
Block a user