## CRITICAL/HIGH (S1-S8) - S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지) - S2: ML scaler train-only fit (데이터 누수 제거) - S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소) - S4: EOD 청산 실패 시 재시도 + CRITICAL 로그 - S5: 일일 손실 한도에 미실현 P&L 포함 - S6: trailing stop 고점 매 사이클 갱신 - S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용) - S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간 ## MEDIUM (M1-M7) - M1: 스캐너 최소 거래량 100만→10만주 - M2: ADX 히스테리시스 18/22 (국면 플리핑 방지) - M3: 분할매도 최소 수익 1.0%→0.5% - M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소 - M5: 쿨다운 스트릭 부분매도 시 미변경 - M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지) - M7: RSI Wilder 스무딩으로 통일 ## 인프라/보안 - 인증 누락 엔드포인트 수정 (analysis, journals) - nginx 보안 헤더 location 블록 누락 수정 - Alembic 마이그레이션 IF NOT EXISTS 가드 추가 - SellRequest/UsOrderRequest 입력 검증 강화 - 차트 네이버 폴백 추가 (DB 데이터 없을 때) - 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집 - CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%) - MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수) - mdd_manager failed_count에서 skip 종목 제외 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""데이트레이딩 청산 서비스
|
|
|
|
당일 포지션의 청산 조건 체크 및 실행:
|
|
- 5분봉 ATR 기반 손절/익절 (day_trading_strategy.check_day_trade_exit 재활용)
|
|
- 15:20 EOD 강제 청산 (CLAUDE.md 7.3: 당일 청산 원칙)
|
|
- 일일 손실 한도(-3%) 초과 시 즉시 전체 청산
|
|
- 분할 매도 지원: 목표가 달성 시 50% 매도, 잔량 트레일링스탑
|
|
|
|
청산 우선순위:
|
|
1. 일일 손실 한도 초과 → 전체 강제 청산
|
|
2. EOD 15:20 도달 → 전체 강제 청산
|
|
3. 개별 포지션 체크:
|
|
- 손절: ATR 기반 또는 하드스탑
|
|
- 시간 손절: 30분(6봉) +0.3% 미달
|
|
- 과매수 청산: RSI >= 70
|
|
- 분할 익절: 목표가 +2R 달성 시 50% 매도
|
|
|
|
아키텍처 규칙:
|
|
- CLAUDE.md 3.1: DB I/O 금지 — 인메모리 상태만 수정, 체결 후 db_task_queue 경유
|
|
- loss_tracker 연동: 전량 매도 시 record_trade_result_safe() + save_to_db() 호출
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
import pytz
|
|
|
|
from app.core.market_schedule import is_eod_flat_time
|
|
from app.services.korea_investment_api import korea_investment_api
|
|
from app.services.day_trading_strategy import (
|
|
parse_minute_candles, aggregate_to_5min,
|
|
check_day_trade_exit, is_daily_loss_exceeded,
|
|
)
|
|
from app.services.daytrading.position_manager import day_trading_state
|
|
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
|
|
from app.services.loss_tracker import loss_tracker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 한국 표준시(KST) 타임존
|
|
_KST = pytz.timezone("Asia/Seoul")
|
|
|
|
|
|
def _kst_now() -> datetime:
|
|
"""현재 KST 시간을 timezone-naive datetime으로 반환.
|
|
|
|
entry_time과 비교 시 timezone 불일치 방지를 위해 tzinfo 제거.
|
|
|
|
Returns:
|
|
datetime: 현재 KST 시간 (timezone-naive)
|
|
"""
|
|
return datetime.now(_KST).replace(tzinfo=None)
|
|
|
|
|
|
async def check_and_exit_positions() -> list[dict]:
|
|
"""활성 데이트레이딩 포지션의 청산 조건 체크 및 실행.
|
|
|
|
인메모리 positions 순회 → 5분봉 ATR 기반 청산 판단.
|
|
스케줄러에서 30초 주기로 호출.
|
|
|
|
체크 순서:
|
|
1. 일일 손실 한도 초과 → 전체 강제 청산
|
|
2. EOD 15:20 → 전체 강제 청산
|
|
3. 개별 포지션별 check_day_trade_exit() 호출
|
|
|
|
Returns:
|
|
list[dict]: 청산 실행 결과 목록
|
|
"""
|
|
day_trading_state.reset_if_new_day()
|
|
exits = []
|
|
|
|
if not day_trading_state.positions:
|
|
return exits
|
|
|
|
# 일일 손실 한도 초과 시 전체 강제 청산
|
|
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
|
|
unrealized_pnl = 0.0
|
|
for _code, _pos in day_trading_state.positions.items():
|
|
_entry = _pos.get("entry_price", 0)
|
|
_qty = _pos.get("quantity", 0)
|
|
_cur = _pos.get("current_price", _entry)
|
|
if _entry > 0 and _qty > 0:
|
|
unrealized_pnl += (_cur - _entry) * _qty
|
|
total_daily_pnl = day_trading_state.daily_pnl + unrealized_pnl
|
|
if is_daily_loss_exceeded(day_trading_state.last_total_assets, total_daily_pnl):
|
|
logger.warning(
|
|
f"[DayExit] 일일 손실 한도 초과 → 전체 강제 청산 "
|
|
f"(실현={day_trading_state.daily_pnl:.0f}, 미실현={unrealized_pnl:.0f})"
|
|
)
|
|
return await force_close_all(reason="일일 손실 한도 초과")
|
|
|
|
# EOD 강제 청산 (15:20)
|
|
if is_eod_flat_time():
|
|
logger.info("[DayExit] EOD (15:20) → 전체 강제 청산")
|
|
return await force_close_all(reason="EOD 강제 청산")
|
|
|
|
# 개별 포지션 청산 체크
|
|
codes_snapshot = list(day_trading_state.positions.keys())
|
|
for stock_code in codes_snapshot:
|
|
try:
|
|
exit_result = await _check_single_position(stock_code)
|
|
if exit_result:
|
|
exits.append(exit_result)
|
|
except Exception as e:
|
|
logger.error(f"[DayExit] 포지션 체크 오류 ({stock_code}): {e}", exc_info=True)
|
|
|
|
return exits
|
|
|
|
|
|
async def _check_single_position(stock_code: str) -> dict | None:
|
|
"""단일 포지션의 청산 조건 체크.
|
|
|
|
5분봉 데이터를 조회하여 check_day_trade_exit()로 청산 신호를 판단.
|
|
최고가(highest_price)를 갱신하여 트레일링스탑 정확도 향상.
|
|
|
|
Args:
|
|
stock_code: 종목 코드
|
|
|
|
Returns:
|
|
dict | None: 청산 실행 결과 또는 None (청산 조건 미충족)
|
|
"""
|
|
pos = day_trading_state.get_position(stock_code)
|
|
if not pos:
|
|
return None
|
|
|
|
entry_price = pos.get("entry_price", 0)
|
|
stop_loss = pos.get("stop_loss", entry_price * 0.94)
|
|
target_price = pos.get("target_price", entry_price * 1.06)
|
|
quantity = pos.get("quantity", 0)
|
|
highest_price = pos.get("highest_price", entry_price)
|
|
entry_time = pos.get("entry_time")
|
|
|
|
# 5분봉 데이터 조회
|
|
try:
|
|
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
|
|
candles = parse_minute_candles(raw)
|
|
candles_5m = aggregate_to_5min(candles)
|
|
if len(candles_5m) < 5:
|
|
return None
|
|
|
|
current_price = float(candles_5m[-1].close) if candles_5m else 0.0
|
|
|
|
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완)
|
|
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price
|
|
new_highest = max(current_price, candle_high, highest_price)
|
|
async with day_trading_state.trade_lock:
|
|
if stock_code in day_trading_state.positions:
|
|
if new_highest > highest_price:
|
|
day_trading_state.positions[stock_code]["highest_price"] = new_highest
|
|
# [S5] 미실현 손익 계산용 현재가 갱신
|
|
day_trading_state.positions[stock_code]["current_price"] = current_price
|
|
if new_highest > highest_price:
|
|
highest_price = new_highest
|
|
|
|
# 청산 신호 체크
|
|
current_time = _kst_now().time()
|
|
exit_signal = check_day_trade_exit(
|
|
entry_price=entry_price,
|
|
current_price=current_price,
|
|
stop_loss=stop_loss,
|
|
target_price=target_price,
|
|
highest_since_entry=highest_price,
|
|
candles_5min=candles_5m,
|
|
current_time=current_time,
|
|
entry_time=entry_time,
|
|
)
|
|
|
|
if exit_signal:
|
|
sell_pct = exit_signal.sell_pct
|
|
return await _execute_exit(
|
|
stock_code, pos, current_price, exit_signal.reason,
|
|
sell_pct=sell_pct,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e}")
|
|
|
|
return None
|
|
|
|
|
|
async def _execute_exit(
|
|
stock_code: str, pos: dict, price: float, reason: str,
|
|
sell_pct: float = 1.0,
|
|
) -> dict | None:
|
|
"""실제 매도 주문 실행 및 상태 업데이트.
|
|
|
|
처리 흐름:
|
|
1. 매도 수량 계산 (분할 매도 시 최소 1주 보장)
|
|
2. safe_place_order()로 시장가 매도 (세마포어+분산락 보호)
|
|
3. 인메모리 상태 업데이트 (trade_lock 보호)
|
|
4. 전량 매도 시 loss_tracker 연동
|
|
5. DB 기록 (db_task_queue 경유)
|
|
|
|
Args:
|
|
stock_code: 종목 코드
|
|
pos: 포지션 정보 딕셔너리
|
|
price: 현재가 (매도 기준가)
|
|
reason: 청산 사유 (로그 및 DB 기록용)
|
|
sell_pct: 매도 비율 (1.0=전량, 0.5=50% 분할 매도)
|
|
|
|
Returns:
|
|
dict | None: 청산 결과 (stock_code, price, quantity, profit_loss 등)
|
|
"""
|
|
total_qty = pos.get("quantity", 0)
|
|
entry_price = pos.get("entry_price", 0)
|
|
stock_name = pos.get("stock_name", stock_code)
|
|
|
|
if total_qty <= 0 or price <= 0:
|
|
return None
|
|
|
|
# [P1] 분할 매도 수량 계산
|
|
if sell_pct >= 1.0:
|
|
quantity = total_qty
|
|
else:
|
|
quantity = max(1, int(total_qty * sell_pct))
|
|
if quantity >= total_qty:
|
|
quantity = total_qty
|
|
|
|
is_full_exit = (quantity >= total_qty)
|
|
|
|
# KIS API 매도 주문 (시장가) — 분산락+세마포어 보호
|
|
from app.services.trade_executor import safe_place_order
|
|
order_resp = await safe_place_order(
|
|
stock_code=stock_code,
|
|
order_type="SELL",
|
|
quantity=quantity,
|
|
price=None, # 시장가
|
|
)
|
|
|
|
if not order_resp or order_resp.get("rt_cd") != "0":
|
|
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
|
|
logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}")
|
|
return None
|
|
|
|
order_id = order_resp.get("output", {}).get("ODNO", "")
|
|
profit_loss = (price - entry_price) * quantity
|
|
profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
|
|
|
|
# 인메모리 상태 업데이트 (DB 직접 접근 금지)
|
|
async with day_trading_state.trade_lock:
|
|
if is_full_exit:
|
|
day_trading_state.remove_position(stock_code)
|
|
else:
|
|
# 분할 매도: 잔량 유지, target_price 제거(트레일링만 적용)
|
|
remaining = total_qty - quantity
|
|
day_trading_state.positions[stock_code]["quantity"] = remaining
|
|
day_trading_state.positions[stock_code]["target_price"] = float("inf")
|
|
day_trading_state.positions[stock_code]["partial_sold"] = True
|
|
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
|
|
|
|
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
|
|
if is_full_exit:
|
|
is_loss = profit_loss < 0
|
|
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
|
|
await loss_tracker.save_to_db(stock_code=stock_code)
|
|
|
|
# DB 기록 (큐 경유)
|
|
await enqueue_trade_history({
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"order_type": "SELL",
|
|
"order_price": price,
|
|
"order_quantity": quantity,
|
|
"executed_price": price,
|
|
"executed_quantity": quantity,
|
|
"total_amount": price * quantity,
|
|
"status": "COMPLETED",
|
|
"order_id": order_id,
|
|
"exit_reason": reason,
|
|
"net_profit_loss": profit_loss,
|
|
"profit_loss_rate": profit_loss_rate,
|
|
"strategy_type": "daytrading",
|
|
})
|
|
if is_full_exit:
|
|
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
|
|
|
|
sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량"
|
|
logger.info(
|
|
f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} "
|
|
f"수량={quantity}/{total_qty} 수익률={profit_loss_rate:.1f}% 이유={reason}"
|
|
)
|
|
|
|
return {
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"price": price,
|
|
"quantity": quantity,
|
|
"profit_loss": profit_loss,
|
|
"profit_loss_rate": profit_loss_rate,
|
|
"reason": reason,
|
|
"sell_pct": sell_pct,
|
|
}
|
|
|
|
|
|
async def force_close_all(reason: str = "강제 청산") -> list[dict]:
|
|
"""모든 데이트레이딩 포지션 강제 청산.
|
|
|
|
호출 시점:
|
|
- EOD 15:20 (CLAUDE.md 7.3)
|
|
- 일일 손실 한도(-3%) 초과
|
|
- 수동 API 호출
|
|
|
|
현재가 조회 실패 시 진입가를 대체가로 사용.
|
|
KIS API 레이트 리밋 방지를 위해 종목 간 0.3초 대기.
|
|
|
|
Args:
|
|
reason: 강제 청산 사유 (로그 및 DB 기록용)
|
|
|
|
Returns:
|
|
list[dict]: 청산 실행 결과 목록
|
|
"""
|
|
results = []
|
|
failed_codes: list[str] = []
|
|
codes = list(day_trading_state.positions.keys())
|
|
|
|
for stock_code in codes:
|
|
pos = day_trading_state.get_position(stock_code)
|
|
if not pos:
|
|
continue
|
|
try:
|
|
# 현재가 조회
|
|
price_resp = await korea_investment_api.get_current_price(stock_code)
|
|
price = float(
|
|
price_resp.get("output", {}).get("stck_prpr", 0) or 0
|
|
) if price_resp else 0.0
|
|
|
|
if price <= 0:
|
|
price = float(pos.get("entry_price", 0))
|
|
|
|
result = await _execute_exit(stock_code, pos, price, reason)
|
|
if result:
|
|
results.append(result)
|
|
|
|
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
|
|
|
except Exception as e:
|
|
logger.error(f"[DayExit] 강제 청산 오류 ({stock_code}): {e}", exc_info=True)
|
|
failed_codes.append(stock_code)
|
|
|
|
# S4 fix: 실패 종목 1회 재시도
|
|
if failed_codes:
|
|
logger.warning(f"[DayExit] 강제 청산 실패 {len(failed_codes)}건 — 2초 후 재시도: {failed_codes}")
|
|
await asyncio.sleep(2.0)
|
|
still_failed: list[str] = []
|
|
for stock_code in failed_codes:
|
|
pos = day_trading_state.get_position(stock_code)
|
|
if not pos:
|
|
continue
|
|
try:
|
|
price_resp = await korea_investment_api.get_current_price(stock_code)
|
|
price = float(
|
|
price_resp.get("output", {}).get("stck_prpr", 0) or 0
|
|
) if price_resp else 0.0
|
|
if price <= 0:
|
|
price = float(pos.get("entry_price", 0))
|
|
result = await _execute_exit(stock_code, pos, price, reason)
|
|
if result:
|
|
results.append(result)
|
|
await asyncio.sleep(0.3)
|
|
except Exception as e:
|
|
logger.critical(f"[DayExit] 강제 청산 재시도 실패 ({stock_code}): {e} — 포지션 미청산 위험!", exc_info=True)
|
|
still_failed.append(stock_code)
|
|
if still_failed:
|
|
logger.critical(f"[DayExit] 최종 미청산 종목: {still_failed} — 수동 청산 필요!")
|
|
|
|
logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})")
|
|
return results
|