Files
auto-stock/backend/app/services/daytrading/exit_service.py
shuim 599de3b52e fix(daytrading): 트레일링 고점이 진입 전 고가로 오염되던 버그
- highest_price 를 max(candles_5m[-3:].high) 대신 진입+60초 이후 시작한 1분봉 고가 + 현재가로만 갱신
  (진입 전 스파이크가 +1% 트리거를 오발동시키고 고점-1.5×ATR 손절선을 부풀리던 문제)
- 날짜+시각 KST 비교로 전일 캔들 제외, entry_time 부재·UTC 저장 추정 시 현재가만 사용
- 파라미터 변경 없음. 하드손절·시간손절·EOD·폴백 손절 경로 불변
- CLAUDE.md §7 시간손절 서술을 실제 동작(30분 경과 후 상한 없이 -0.3% 미만)으로 정정
- 회귀 테스트 13건 (전체 909 passed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:04:07 +09:00

792 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""데이트레이딩 청산 서비스
당일 포지션의 청산 조건 체크 및 실행:
- 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분 경과 후 언제든 -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, timedelta
import pytz
from app.core.config import settings
from app.core.market_schedule import is_eod_flat_time, is_market_open
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")
# [M1] 전일(stale) 포지션 강제청산으로 발생한 실현손익 누적 (당일 집계에서 제외된 금액).
# 당일 손실 한도 판단에는 쓰지 않고 추적/로그 용도로만 사용한다.
_stale_realized_pnl: float = 0.0
def get_stale_realized_pnl() -> float:
"""전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액)."""
return _stale_realized_pnl
def _fallback_stop_pct() -> float:
"""[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율.
기존 하드코딩 0.06(-6%)은 전략 하드캡(ORB 3%, VWAP/RSI_BB 3.5%)의 2배로 느슨했다.
가장 보수적(타이트)한 하드캡을 사용해 폴백이 실제 전략보다 헐거워지지 않게 한다.
position_manager.restore_from_db() 의 폴백(DAY_TRADE_STOP_LOSS_PCT)과도 일치.
"""
return min(
settings.DAY_TRADE_STOP_LOSS_PCT,
settings.DAY_TRADE_ORB_STOP_LOSS_PCT,
settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT,
)
def _fallback_stop_price(entry_price: float) -> float:
"""진입가 기준 폴백 손절가."""
return entry_price * (1 - _fallback_stop_pct())
def _fallback_target_price(entry_price: float, stop_loss: float) -> float:
"""진입가·손절가 기준 폴백 목표가 (DAY_TRADE_TARGET_MULTIPLIER 정합)."""
risk = entry_price - stop_loss
if risk <= 0:
risk = entry_price * _fallback_stop_pct()
return entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
def _kst_now() -> datetime:
"""현재 KST 시간을 timezone-naive datetime으로 반환.
entry_time과 비교 시 timezone 불일치 방지를 위해 tzinfo 제거.
Returns:
datetime: 현재 KST 시간 (timezone-naive)
"""
return datetime.now(_KST).replace(tzinfo=None)
# [2026-10-01] 진입 시각 신뢰 구간 (KST 시각). DT 진입은 정규장 안에서만 일어나므로
# 이 밖의 entry_time 은 기준 불일치(예: 과거 UTC 저장 행 — KST 09:05 → UTC 00:05)로 본다.
_ENTRY_TIME_VALID_FROM = (9, 0)
_ENTRY_TIME_VALID_TO = (15, 30)
# 1분봉 라벨(stck_cntg_hour)이 "분 시작"인지 "분 종료"인지 응답 규약이 확정되지 않았다.
# 라벨 >= 진입시각 + 60초 인 봉만 쓰면 두 해석 모두에서 봉 전체가 진입 이후 체결이다.
_POST_ENTRY_CANDLE_LAG_SEC = 60
def _normalize_entry_time(entry_time) -> datetime | None:
"""entry_time 을 KST naive datetime 으로 정규화. 신뢰할 수 없으면 None.
- 인메모리 진입(entry_service): `datetime.now(KST).replace(tzinfo=None)` (KST naive)
- DB 복원(position_manager.restore_from_db): `ActiveTrade.created_at` (모델 기본값 now_kst
= KST naive). 단 2026-09-23 이전 행은 UTC 로 저장된 것이 섞여 있다(timeutil 참조).
"""
if entry_time is None or not isinstance(entry_time, datetime):
return None
if entry_time.tzinfo is not None:
entry_time = entry_time.astimezone(_KST).replace(tzinfo=None)
hm = (entry_time.hour, entry_time.minute)
if hm < _ENTRY_TIME_VALID_FROM or hm > _ENTRY_TIME_VALID_TO:
return None
if entry_time > _kst_now():
return None
return entry_time
def _candle_datetime(candle) -> datetime | None:
"""MinuteCandle(date=YYYYMMDD, time=HHMMSS, KST) → naive datetime. 형식 불량이면 None."""
d = getattr(candle, "date", "") or ""
t = getattr(candle, "time", "") or ""
if len(d) != 8 or len(t) < 4:
return None
try:
return datetime(
int(d[:4]), int(d[4:6]), int(d[6:8]),
int(t[:2]), int(t[2:4]), int(t[4:6]) if len(t) >= 6 else 0,
)
except (ValueError, TypeError):
return None
def _post_entry_candle_high(candles, entry_time) -> float | None:
"""진입 **이후에 시작한** 분봉들의 최고가. 산출 불가/해당 봉 없음이면 None (순수 함수).
[2026-10-01 버그 수정] 예전에는 `max(c.high for c in candles_5m[-3:])` 로 최근 5분봉 3개의
고가를 그대로 트레일링 기준 고점에 넣었다. 진입 후 15분 이내에는 **진입 전 봉의 고가**가
섞여, 실제로 오르지 않았는데 +1% 트레일링 트리거가 켜지거나 `고점 − 1.5×ATR` 손절선이
부풀려졌다(운영 실측: 트레일링 6건이 "고점 +1.1~2.2%" 기록 후 −0.13~−0.81% 로 종료).
보수적 처리:
- **진입이 속한 봉은 제외한다.** 봉 고가가 진입 전/후 어느 쪽 체결인지 알 수 없다.
진입 직후 구간의 고점은 30초 주기의 현재가 관측과 진입 시 초기값(=진입가)으로만 잡는다
(고점을 과소 추정 → 트레일링이 늦게 켜질 뿐, 하드 손절·시간손절은 영향 없음).
- 5분봉 대신 1분봉을 쓰고, 라벨 >= 진입시각+60초 인 봉만 인정한다(라벨 규약 무관 안전).
- 날짜까지 포함한 datetime 으로 비교해 전일 캔들 혼입(CLAUDE.md §7)을 차단한다.
날짜·시각이 비어 있거나 형식 불량인 봉은 위치를 알 수 없으므로 제외한다.
- entry_time 이 없거나 신뢰할 수 없으면 None → 호출부는 현재가만 쓴다.
"""
entry = _normalize_entry_time(entry_time)
if entry is None:
return None
threshold = entry + timedelta(seconds=_POST_ENTRY_CANDLE_LAG_SEC)
best: float | None = None
for c in candles or []:
cdt = _candle_datetime(c)
if cdt is None or cdt < threshold:
continue
h = float(getattr(c, "high", 0) or 0)
if h > 0 and (best is None or h > best):
best = h
return best
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:
# 인메모리가 비어도 DB에 전일 미청산 포지션이 남아 있을 수 있다(자정 reset 이후)
return await close_stale_positions()
# 일일 손실 한도 초과 시 전체 강제 청산
# [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 강제 청산")
# [안전장치 2026-09-23] 전일 미청산 포지션 즉시 청산.
# 데이트레이딩은 당일 청산이 원칙인데 15:20 EOD가 실패/누락되면(2026-09-22 LG씨엔에스)
# 포지션이 하룻밤 갭 리스크에 노출된 채 다음 날 일반 청산 로직을 타게 된다.
stale_exits = await close_stale_positions()
if stale_exits:
exits.extend(stale_exits)
if not day_trading_state.positions:
return exits
# 개별 포지션 청산 체크
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)
# [M7] stop_loss / target_price 는 None 뿐 아니라 0 으로 저장될 수 있다.
# `or` 패턴은 0 도 폴백으로 넘기지만 의도가 모호하므로 명시적으로 None/비정상값을 검사한다.
# (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨)
_sl_raw = pos.get("stop_loss")
stop_loss = float(_sl_raw) if (_sl_raw is not None and float(_sl_raw) > 0) else _fallback_stop_price(entry_price)
_tp_raw = pos.get("target_price")
target_price = (
float(_tp_raw) if (_tp_raw is not None and float(_tp_raw) > 0)
else _fallback_target_price(entry_price, stop_loss)
)
highest_price = pos.get("highest_price", entry_price)
entry_time = pos.get("entry_time")
# [H4] try 범위를 "분봉 조회 + 지표/신호 계산"까지로 한정한다.
# 예전 코드는 _execute_exit 까지 하나의 try 안에 있고 로그가 debug 였기 때문에
# 분봉 조회 실패 시 손절·트레일링·시간손절이 전부 미적용된 채 15:20 까지 방치되고,
# 주문 실패조차 로그에 보이지 않았다.
exit_signal = None
current_price = 0.0
try:
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = parse_minute_candles(raw)
candles_5m = aggregate_to_5min(candles)
except Exception as e:
logger.warning(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e} — 폴백 손절만 판정")
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
if not candles_5m or len(candles_5m) < 5:
# 분봉이 부족하면 ATR/RSI 기반 판정이 불가 → 손절선만 지킨다(방치 금지)
logger.warning(
f"[DayExit] 5분봉 부족 ({stock_code}): {len(candles_5m or [])}개 — 폴백 손절만 판정"
)
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
try:
current_price = float(candles_5m[-1].close)
# highest_price 갱신 — 진입 이후에 시작한 1분봉 고가만 반영 (30초 주기 보완).
# [2026-10-01] 예전 `candles_5m[-3:]` 는 진입 전 봉 고가가 섞여 트레일링 고점을 오염시켰다.
# entry_time 없음/불신 → None → 현재가만 사용(고점 과대 추정보다 과소 추정이 안전).
# 기존 highest_price 와 max → 단조 증가 유지.
candle_high = _post_entry_candle_high(candles, entry_time)
if candle_high is None and entry_time is not None and _normalize_entry_time(entry_time) is None:
logger.warning(
f"[DayExit] entry_time 불신 ({stock_code}, entry_time={entry_time}) "
f"— 트레일링 고점은 현재가만으로 갱신"
)
new_highest = max(current_price, candle_high or 0.0, 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,
)
except Exception as e:
logger.warning(f"[DayExit] 청산 지표 계산 실패 ({stock_code}): {e} — 폴백 손절만 판정")
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
if not exit_signal or current_price <= 0:
return None
# 주문 실행은 try 밖(별도 try) — 실패를 조회 실패와 구분해 error 로 남긴다.
try:
return await _execute_exit(
stock_code, pos, current_price, exit_signal.reason,
sell_pct=exit_signal.sell_pct,
)
except Exception as e:
logger.error(f"[DayExit] 청산 주문 실행 실패 ({stock_code}): {e}", exc_info=True)
return None
async def _check_fallback_stop_loss(
stock_code: str, pos: dict, stop_loss: float,
) -> dict | None:
"""[H4] 분봉 미확보 시 최소 안전장치 — 손절선 이탈만 판정해 전량 청산.
swing/exit_service.py 의 고정비율 폴백 손절과 같은 취지. 분봉이 없으면
트레일링/시간손절/RSI 청산은 판정할 수 없으므로 "손절선"만 지킨다.
현재가는 KIS 현재가 조회 → 인메모리 최신가(current_price) 순으로 확보하고,
둘 다 실패하면 logger.error 로 남긴다(손절 미적용을 침묵시키지 않는다).
Args:
stock_code: 종목 코드
pos: 포지션 정보
stop_loss: 적용할 손절가 (0 이하이면 진입가 기준 폴백 산출)
Returns:
dict | None: 청산 결과 또는 None (손절 미도달·판정 불가)
"""
entry_price = float(pos.get("entry_price", 0) or 0)
if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0:
return None
if stop_loss <= 0:
stop_loss = _fallback_stop_price(entry_price)
current_price = 0.0
try:
resp = await korea_investment_api.get_current_price(stock_code)
current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0
except Exception as e:
logger.warning(f"[DayExit] 폴백 손절 현재가 조회 실패 ({stock_code}): {e}")
if current_price <= 0:
current_price = float(pos.get("current_price", 0) or 0)
if current_price <= 0:
logger.error(
f"[DayExit] 폴백 손절 현재가 확보 실패 ({stock_code}) — 손절 미적용 상태, 수동 확인 필요"
)
return None
async with day_trading_state.trade_lock:
if stock_code in day_trading_state.positions:
day_trading_state.positions[stock_code]["current_price"] = current_price
if current_price > stop_loss:
return None
loss_rate = (current_price - entry_price) / entry_price * 100
logger.warning(
f"[DayExit] ⚠️ 폴백 손절 발동 ({stock_code}): 현재가={current_price:.0f} "
f"<= 손절={stop_loss:.0f} ({loss_rate:.2f}%) — 분봉 미확보"
)
try:
return await _execute_exit(
stock_code, pos, current_price,
reason=f"폴백 손절(분봉 미확보) {loss_rate:.1f}%",
sell_pct=1.0,
)
except Exception as e:
logger.error(f"[DayExit] 폴백 손절 주문 실패 ({stock_code}): {e}", exc_info=True)
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 등)
"""
return await _execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale=False)
async def _execute_exit_stale(
stock_code: str, pos: dict, price: float, reason: str,
) -> dict | None:
"""[M1] 전일 미청산(stale) 포지션 전용 청산 경로.
_execute_exit 과 동일하게 주문·상태반영·DB 기록을 수행하지만,
실현손익을 **당일** daily_pnl / loss_streak_today 에 가산하지 않는다.
(전일 손실이 당일 손실 한도(-3%)를 오발동시켜 정상 포지션까지 전량 청산되고
신규 진입이 종일 차단되던 문제 — 2026-09-27 수정)
"""
return await _execute_exit_impl(stock_code, pos, price, reason, 1.0, is_stale=True)
async def _execute_exit_impl(
stock_code: str, pos: dict, price: float, reason: str,
sell_pct: float, is_stale: bool,
) -> dict | None:
"""매도 주문 실행 + 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변).
is_stale=True 이면 당일 손익 집계(update_pnl)를 건너뛴다.
"""
total_qty = pos.get("quantity", 0)
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
# [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계
# 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가
# 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다.
if is_stale:
return await asyncio.shield(_finalize_exit_stale(
stock_code, pos, price, reason, order_resp, quantity, total_qty,
))
return await asyncio.shield(_finalize_exit(
stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit
))
async def _finalize_exit(
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool,
) -> dict | None:
"""체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간.
당일 포지션 전용 — 실현손익을 당일 daily_pnl / loss_streak_today 에 반영한다.
(전일 포지션은 _finalize_exit_stale 사용 — M1)
"""
return await _finalize_exit_impl(
stock_code, pos, price, reason, sell_pct, order_resp,
quantity, total_qty, is_full_exit, count_daily_pnl=True,
)
async def _finalize_exit_stale(
stock_code: str, pos: dict, price: float, reason: str,
order_resp: dict, quantity: int, total_qty: int,
) -> dict | None:
"""[M1] 전일(stale) 포지션 체결 이후 상태 반영.
당일 손익 집계(daily_pnl·loss_streak_today)에는 가산하지 않고,
별도 누적값(_stale_realized_pnl)과 로그로만 추적한다.
DB TradeHistory 기록은 동일하게 수행되므로 실제 손익 추적은 유지된다.
"""
return await _finalize_exit_impl(
stock_code, pos, price, reason, 1.0, order_resp,
quantity, total_qty, True, count_daily_pnl=False,
)
async def _finalize_exit_impl(
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool,
count_daily_pnl: bool = True,
) -> dict | None:
"""체결 이후 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변).
count_daily_pnl=False 이면 당일 손익/연속손실 집계를 건너뛰고
전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로).
"""
global _stale_realized_pnl
entry_price = pos.get("entry_price", 0)
stock_name = pos.get("stock_name", stock_code)
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)
if not count_daily_pnl:
# [M10] DB에서만 발견된 전일 포지션은 인메모리에 없어 remove_position()의
# closed_today 추가가 일어나지 않는다 → 강제청산한 종목이 같은 날
# scan_and_enter 후보로 되살아난다. 명시적으로 재진입 금지 집합에 넣는다.
day_trading_state.closed_today.add(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
if count_daily_pnl:
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
else:
_stale_realized_pnl += profit_loss
logger.warning(
f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} "
f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)"
)
# [F9 2026-09-28] 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화.
# 값이 바뀐 직후에 저장한다 — 재기동해도 일일 -3% 한도·3연패 쿨다운·당일
# 재진입 차단이 풀리지 않게 한다(락 밖에서 호출: Redis I/O 로 락을 잡지 않는다).
await day_trading_state.save_daily_state()
# [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")
else:
# 분할 매도 후 ActiveTrade 잔량 갱신 — 누락 시 재기동 복원에서 원래 수량이 되살아나
# 다음 청산이 보유량보다 많이 매도한다(2026-09-23 LG씨엔에스 4주 보유 중 7주 매도).
from app.services.db_worker import enqueue_active_trade_upsert
await enqueue_active_trade_upsert({
"stock_code": stock_code,
"strategy_name": "day_trade",
"quantity": total_qty - quantity,
})
# 포트폴리오 화면에서 즉시 제거/차감
from app.services.portfolio_cache_bridge import reflect_sell
await reflect_sell(stock_code, quantity, price, remaining_qty=max(0, int(total_qty) - int(quantity)))
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 close_stale_positions() -> list[dict]:
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.
EOD(15:20) 강제청산이 실패하거나 스케줄러가 그 구간을 놓친 경우의 안전장치.
장중에만 동작하며(시장가 주문 필요), 청산 실패 시 다음 사이클에서 재시도된다.
Returns:
list[dict]: 청산 실행 결과 목록
"""
if not is_market_open():
return []
today = _kst_now().date()
results: list[dict] = []
# [2026-09-23] 인메모리 positions 만 순회하면 자정 reset_if_new_day() 이후에는
# 대상이 사라져 안전장치가 무력해진다 → DB(ActiveTrade)에서도 전일 포지션을 찾는다.
stale: dict[str, dict] = {}
for stock_code in list(day_trading_state.positions.keys()):
pos = day_trading_state.get_position(stock_code)
entry_time = (pos or {}).get("entry_time")
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
if pos and entry_date is not None and entry_date < today:
stale[stock_code] = pos
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
# SELECT 1건만 수행하며 commit()/flush()/add() 호출이 없다. 세션 수명은 아래
# async with 블록(쿼리 1회)로 최소화하고, 실패는 warning 으로 운영에 노출한다.
# 실제 청산 기록은 아래 _execute_exit_stale → db_task_queue 경유로만 이뤄진다.
try:
from app.core.database import AsyncSessionLocal
from app.models.models import ActiveTrade
from sqlalchemy import select as _select
async with AsyncSessionLocal() as _db:
_rows = (await _db.execute(
_select(ActiveTrade).where(
ActiveTrade.strategy_name == "day_trade",
ActiveTrade.status == "active",
)
)).scalars().all()
for _row in _rows:
_created = _row.created_at.date() if _row.created_at else None
if _created is None or _created >= today or _row.stock_code in stale:
continue
_entry = float(_row.entry_price or 0)
stale[_row.stock_code] = {
"stock_name": _row.stock_name or _row.stock_code,
"strategy": "day_trade",
"entry_price": _entry,
"quantity": int(_row.quantity or 0),
"highest_price": float(_row.highest_since_entry or _entry),
"entry_time": _row.created_at,
"reason": "",
"order_id": _row.order_id or "",
}
except Exception as _db_err:
logger.warning(f"[DayExit] 전일 포지션 DB 조회 실패(인메모리만 처리): {_db_err}")
for stock_code, pos in stale.items():
if int(pos.get("quantity", 0) or 0) <= 0:
continue
entry_time = pos.get("entry_time")
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
price = 0.0
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
except Exception as e:
logger.warning(f"[DayExit] 전일 포지션 현재가 조회 실패 ({stock_code}): {e}")
if price <= 0:
price = float(pos.get("entry_price", 0) or 0)
if price <= 0:
continue
logger.warning(
f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} "
f"(진입일={entry_date}, 수량={pos.get('quantity')})"
)
# [M10] 청산 성공/실패와 무관하게 당일 재진입은 금지한다. 주문이 실패해도
# 다음 사이클에서 재시도되며, 그 사이 scan_and_enter 가 같은 종목을 다시
# 매수하면 전일 포지션이 되살아난다.
async with day_trading_state.trade_lock:
day_trading_state.closed_today.add(stock_code)
# [F9] closed_today 변경분 영속화 — 재기동 후에도 당일 재진입을 막는다
await day_trading_state.save_daily_state()
# [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다.
result = await _execute_exit_stale(
stock_code, pos, price,
reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)",
)
if result:
results.append(result)
await asyncio.sleep(0.3) # KIS 레이트 리밋
return results
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