Files
auto-stock/backend/app/services/daytrading/exit_service.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

1909 lines
93 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
import time
from datetime import datetime, timedelta
import pytz
from app.core.config import settings
from app.core.log_throttle import warn_throttled
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,
is_time_stop_exit_reason,
)
from app.services.daytrading.position_manager import day_trading_state
from app.services.daytrading import fill_reconciler
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
from app.services.loss_tracker import loss_tracker
from app.services.trade_cost_core import net_realized_pnl
logger = logging.getLogger(__name__)
# 한국 표준시(KST) 타임존
_KST = pytz.timezone("Asia/Seoul")
# [M1] 전일(stale) 포지션 강제청산으로 발생한 실현손익 누적 (당일 집계에서 제외된 금액).
# 당일 손실 한도 판단에는 쓰지 않고 추적/로그 용도로만 사용한다.
_stale_realized_pnl: float = 0.0
# [H3/H-A 2026-10-02] 이번 프로세스에서 stale 매도 **주문이 접수된**(rt_cd=="0") (날짜, 종목).
# 재주문 금지용. 실패는 여기 넣지 않는다 — 아래 _stale_sell_failures 로 제한 재시도한다.
_stale_sell_attempted: set[tuple[str, str]] = set()
# [H-A] stale 매도 실패 이력 {(날짜, 종목): (실패 횟수, 마지막 실패 monotonic)}
_stale_sell_failures: dict[tuple[str, str], tuple[int, float]] = {}
# [H-A/H-B] 종목별 마지막 청산 주문 결과: "accepted" | "rejected" | "unknown"
_last_exit_order_outcome: dict[str, str] = {}
# [M-4] stale 직전 시도의 주문 수량·시각 (불명 주문 매칭 범위)
_stale_last_qty: dict[tuple[str, str], int] = {}
_stale_last_since: dict[tuple[str, str], datetime] = {}
# [L-1] 보유 0 정리를 이미 큐잉한 (날짜, 종목)
_stale_cleaned: set[tuple[str, str]] = set()
# [H4 2026-10-02] 청산 체크 처리량·상한 — settings 로 이관(값 동일):
# DAY_TRADE_EXIT_CHECK_CONCURRENCY(4) / DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC(15) /
# DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC(45) / DAY_TRADE_TOTAL_ASSETS_REFRESH_SEC(300).
# 종전에는 포지션마다 당일 전체 분봉을 **순차** 조회해(오후 종목당 10~12페이지 × 약 3초)
# 포지션 몇 개만으로 스케줄러 _guard(STEP_TIMEOUT=180)를 넘겨 청산 체크 전체가 취소됐다.
# 손절선 판정은 현재가 1회 조회로 먼저 끝내고, 분봉 기반 판정은 제한 동시성 + 포지션별
# 상한으로 돌린다. 동시성은 entry_service 선조회(8)보다 낮게 잡아 KIS 세마포어(8)를
# 진입 스캔과 나눠 쓴다.
# 최악치(기본값, N=DAY_TRADE_MAX_POSITIONS=5 → ceil(5/4)=2 웨이브):
# 총자산 조회 15 + 손절 선판정 2×(15+5)=40 + 분봉 판정 2×45=90 = 145초
# + close_stale_positions(잔고 15 + 종목당 현재가·주문) — stale 이 있으면 180초에
# 근접할 수 있다. 초과 시 _guard 가 취소하지만 주문~반영은 shield 로 완주한다.
_total_assets_refreshed_at: float = 0.0
def get_stale_realized_pnl() -> float:
"""전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액)."""
return _stale_realized_pnl
def _dt_sell_tax_rate(pos: dict) -> float:
"""DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일)."""
market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper()
if market == "KOSDAQ":
return float(settings.KR_SELL_TAX_RATE_KOSDAQ)
return float(settings.KR_SELL_TAX_RATE_KOSPI)
def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) -> dict:
"""[2026-10-01] DT 실현손익(비용 차감). 스윙 집행 경로와 같은 비용 정의(trade_cost_core).
매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(KR_SELL_TAX_RATE_*)를 뺀다.
반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate
"""
return net_realized_pnl(
entry_price, exit_price, quantity,
float(settings.KR_COMMISSION_RATE), _dt_sell_tax_rate(pos),
)
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()
# [L5 2026-10-02] 일일 손실한도 분모 확보 — last_total_assets 는 scan_and_enter 에서만
# 갱신돼, 진입 스캔이 돌지 않는 구간(MDD 차단·마감 후·재기동 직후)에는 0 이었다.
# is_daily_loss_exceeded() 는 total_assets<=0 이면 무조건 False → 청산 측 -3% 한도 무력화.
await _ensure_total_assets_for_exit_impl()
# 일일 손실 한도 초과 시 전체 강제 청산
# [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
# [H4 2026-10-02] 1단계 — 손절선 선판정(현재가 1회 조회, 제한 동시성).
# 분봉 전체 조회가 느려도 손절은 먼저 집행된다(§8.1-11 청산 우선).
codes_snapshot = list(day_trading_state.positions.keys())
fast = await _run_exit_checks_bounded_impl(
codes_snapshot, _fast_stop_check_impl, float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0, "손절 선판정",
)
handled: set[str] = set()
for code, res in fast.items():
if not res:
continue
_done, _exit = res
if _done:
handled.add(code)
if _exit:
exits.append(_exit)
# 2단계 — 분봉 기반 전체 판정(트레일링·시간손절·목표가·RSI). 포지션별 상한 + 제한 동시성.
remaining_codes = [
c for c in codes_snapshot
if c not in handled and c in day_trading_state.positions
]
full = await _run_exit_checks_bounded_impl(
remaining_codes, _check_single_position, float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC), "청산 판정",
)
for code in remaining_codes:
if full.get(code):
exits.append(full[code])
return exits
async def check_stops_only_v2() -> list[dict]:
"""[M4 2026-10-02] 손절 전용 체크 — 09:00~09:30 ORB 수집 구간에서 호출.
ORB 분기가 `continue` 로 청산 체크를 건너뛰어, 그 30분 동안 전일 미청산 포지션과
재기동 복원 포지션의 손절이 전혀 판정되지 않았다. 이 함수는 진입·익절 규칙을
건드리지 않고 **전일 포지션 청산 + 손절선 선판정**만 수행한다.
"""
day_trading_state.reset_if_new_day()
exits: list[dict] = list(await close_stale_positions() or [])
codes = list(day_trading_state.positions.keys())
if not codes:
return exits
fast = await _run_exit_checks_bounded_impl(
codes, _fast_stop_check_impl, float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0, "ORB 구간 손절 판정",
)
for res in fast.values():
if res and res[1]:
exits.append(res[1])
return exits
async def _run_exit_checks_bounded_impl(codes, fn, timeout: float, label: str) -> dict:
"""[H4] 포지션별 청산 판정을 제한 동시성 + 포지션별 상한으로 실행. {code: 결과}.
주문~상태 반영은 _execute_exit_impl 이 shield 하므로, 상한 초과로 판정 코루틴이
취소돼도 이미 전송된 주문의 반영은 끝까지 진행된다.
"""
out: dict = {}
if not codes:
return out
sem = asyncio.Semaphore(max(1, int(settings.DAY_TRADE_EXIT_CHECK_CONCURRENCY)))
async def _one(code: str) -> None:
async with sem:
try:
out[code] = await asyncio.wait_for(fn(code), timeout=timeout)
except asyncio.TimeoutError:
logger.warning(
f"[DayExit] ⏱️ {label} {timeout:.0f}초 초과 ({code}) — 이번 사이클 스킵, "
f"다음 사이클 재판정"
)
except Exception as e:
logger.error(f"[DayExit] {label} 오류 ({code}): {e}", exc_info=True)
await asyncio.gather(*(_one(c) for c in codes))
return out
def _effective_stop_loss(pos: dict) -> float:
"""포지션의 실효 손절가 — _check_single_position 과 같은 규칙(없거나 0 이하면 폴백)."""
entry_price = float(pos.get("entry_price", 0) or 0)
_sl_raw = pos.get("stop_loss")
if _sl_raw is not None and float(_sl_raw) > 0:
return float(_sl_raw)
return _fallback_stop_price(entry_price)
async def _fast_stop_check_impl(stock_code: str) -> tuple[bool, dict | None]:
"""[H4] 현재가 1회 조회로 손절선(하드/폴백)만 판정. (처리됨 여부, 청산 결과).
check_day_trade_exit 의 '손절' 조건(현재가 ≤ 손절가)과 같은 판정이다 — 입력 가격만
분봉 종가 대신 현재가 시세다. 미도달·조회 실패면 (False, None) → 분봉 기반 판정으로 넘긴다.
"""
pos = day_trading_state.get_position(stock_code)
if not pos:
return True, None
# [H-B] 청산 주문 진행 중 → '처리됨'으로 보고 2단계 판정도 건너뛰게 한다
if day_trading_state.is_exit_in_progress(stock_code):
return True, 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 False, None
stop_loss = _effective_stop_loss(pos)
current_price = 0.0
try:
resp = await asyncio.wait_for(
korea_investment_api.get_current_price(stock_code), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC),
)
current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0
except asyncio.TimeoutError:
logger.warning(
f"[DayExit] 손절 선판정 현재가 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 ({stock_code}) "
f"— 분봉 판정으로 넘김"
)
return False, None
except Exception as e:
logger.warning(f"[DayExit] 손절 선판정 현재가 조회 실패 ({stock_code}): {e} — 분봉 판정으로 넘김")
return False, None
if current_price <= 0:
return False, 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 False, None
loss_pct = (current_price - entry_price) / entry_price * 100
reason = (
f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f}, 현재가 선판정)"
)
logger.warning(f"[DayExit] ⚠️ {stock_code} {reason}")
try:
result = await _execute_exit(stock_code, pos, current_price, reason, sell_pct=1.0)
except Exception as e:
logger.error(f"[DayExit] 손절 선판정 주문 실행 실패 ({stock_code}): {e}", exc_info=True)
result = None
# 주문 실패여도 이번 사이클 분봉 판정에서 같은 손절을 재주문하지 않는다(다음 사이클 재시도).
return True, result
async def _ensure_total_assets_for_exit_impl() -> float:
"""[L5] 청산 측 일일 손실한도 분모(총자산) 확보.
순서: 최근 값이 유효(>0, 갱신 주기 이내)면 그대로 → KIS 잔고 조회 → 마지막 값 →
Redis 일일 안전장치 저장분(`daytrading:daily:{오늘}`.last_total_assets) → 없으면 warning.
"""
global _total_assets_refreshed_at
now = time.monotonic()
cur = float(day_trading_state.last_total_assets or 0)
if cur > 0 and _total_assets_refreshed_at and (now - _total_assets_refreshed_at) < float(settings.DAY_TRADE_TOTAL_ASSETS_REFRESH_SEC):
return cur
fresh = 0.0
try:
bal = await asyncio.wait_for(
korea_investment_api.get_balance(), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC),
)
summary = ((bal or {}).get("output2") or [{}])[0] if isinstance(bal, dict) else {}
fresh = float((summary or {}).get("tot_evlu_amt", 0) or 0)
except asyncio.TimeoutError:
logger.warning(f"[DayExit] 총자산 조회 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 — 이전 값 사용")
except Exception as e:
logger.warning(f"[DayExit] 총자산 조회 실패 — 이전 값 사용: {e}")
# 실패해도 다음 갱신 주기까지 재조회하지 않는다(매 사이클 실패 호출 방지)
_total_assets_refreshed_at = now
if fresh > 0:
day_trading_state.last_total_assets = fresh
return fresh
if cur > 0:
return cur
restored = 0.0
try:
from app.core.redis import get_json
from app.services.daytrading.position_manager import _today_kst
data = await get_json(f"daytrading:daily:{_today_kst()}")
if data and str(data.get("date") or "") == _today_kst().isoformat():
restored = float(data.get("last_total_assets", 0) or 0)
except Exception as e:
logger.warning(f"[DayExit] 총자산 Redis 복원 실패: {e}")
if restored > 0:
day_trading_state.last_total_assets = restored
logger.warning(f"[DayExit] 총자산 KIS 미확보 → Redis 저장값 {restored:,.0f}원 사용")
return restored
# 다음 사이클에 즉시 재시도하도록 타임스탬프를 되돌린다(값이 아예 없는 상태)
_total_assets_refreshed_at = 0.0
warn_throttled(
logger, "dt_exit:total_assets_missing",
f"[DayExit] ⚠️ 총자산 미확보 — 청산 측 일일 손실한도"
f"({float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:+.1f}%) 판정 불가 상태 "
f"(KIS·이전 값·Redis 모두 없음)",
)
return 0.0
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
# [H-B] 청산 주문 진행 중이면 판정하지 않는다(인메모리 수량이 아직 반영 전)
if day_trading_state.is_exit_in_progress(stock_code):
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 day_trading_state.is_exit_in_progress(stock_code): # [H-B]
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)
# [H-B 2026-10-02] 청산 진행 중 등록 — 같은 종목에 주문이 이미 나가 있으면 내지 않는다.
# 판정 코루틴이 상한(wait_for)으로 취소돼도 shield 된 주문은 계속 진행되는데, 인메모리
# 수량은 finalize 전까지 그대로라 2단계/다음 사이클이 같은 물량을 다시 팔았다(4주 → 4+4).
# 해제는 인메모리 반영 직후(정상) 또는 shield 작업의 finally(실패·불명)가 보장한다.
async with day_trading_state.trade_lock:
if stock_code in day_trading_state.exits_in_progress:
warn_throttled(
logger, "dt_exit:exit_in_progress",
f"[DayExit] {stock_code} 청산 주문 진행 중 — 중복 매도 방지로 이번 요청 건너뜀 ({reason})",
)
return None
# [L-8] 판정 시점의 포지션 객체가 아직 그대로인지 — 그 사이 청산·교체됐으면 내지 않는다.
# 전일(stale) 경로는 DB 에만 있는 행·수량 축소 사본을 넘기므로, 인메모리가 비었거나
# 같은 날짜 경계 밖(전일) 포지션일 때만 허용한다.
_cur = day_trading_state.positions.get(stock_code)
if is_stale:
if _cur is not None and _cur is not pos and not _is_stale_position(_cur):
logger.warning(f"[DayExit] {stock_code} 전일 청산 요청 — 당일 포지션과 겹쳐 건너뜀")
return None
elif _cur is not pos:
logger.warning(
f"[DayExit] {stock_code} 판정 이후 포지션이 청산/교체됨 — 주문하지 않는다 ({reason})"
)
return None
day_trading_state.exits_in_progress.add(stock_code)
_tok = object()
_exit_tokens[stock_code] = _tok
# [H4 2026-10-02] 주문 전송 ~ 상태 반영 전체를 shield 한다.
# 예전에는 finalize 만 shield 돼 있어, 청산 체크가 포지션별 wait_for·스케줄러 _guard
# 로 취소되는 시점이 safe_place_order 대기 중이면 "브로커는 주문을 받았는데 응답을
# 잃어 인메모리는 그대로" → 다음 사이클이 같은 물량을 다시 판다(과다매도·스윙 물량 매도).
return await asyncio.shield(_place_and_finalize_exit_guarded_impl(
stock_code, pos, price, reason, sell_pct, quantity, total_qty, is_full_exit, is_stale,
_tok,
))
# 진행 중 표식의 소유 토큰 {code: token}. finalize 가 먼저 해제한 뒤 같은 종목의 새 청산이
# 표식을 다시 잡았을 때, 앞선 작업의 finally 가 새 표식을 지우지 못하게 한다.
_exit_tokens: dict[str, object] = {}
def _is_stale_position(pos: dict) -> bool:
et = (pos or {}).get("entry_time")
return hasattr(et, "date") and et.date() < _kst_now().date()
def _release_exit_in_progress(stock_code: str, delay: float = 0.0, token=None) -> None:
"""[H-B] 청산 진행 중 표식 해제. delay>0 이면 그만큼 뒤에 해제(결과 불명 보류).
token 을 주면 그 토큰이 아직 현재 소유자일 때만 해제한다(앞선 작업이 뒤 작업 표식을
지우는 경합 방지). finalize(현재 소유자 문맥)는 token 없이 즉시 해제한다.
"""
def _do() -> None:
if token is not None and _exit_tokens.get(stock_code) is not token:
return
_exit_tokens.pop(stock_code, None)
day_trading_state.exits_in_progress.discard(stock_code)
if delay <= 0:
_do()
return
try:
asyncio.get_running_loop().call_later(delay, _do)
except RuntimeError:
_do()
async def _place_and_finalize_exit_guarded_impl(
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool,
token=None,
) -> dict | None:
"""[H-B] 주문~반영 + 진행 중 표식 해제 보장 (shield 대상)."""
_hold = 0.0
try:
return await _place_and_finalize_exit_impl(
stock_code, pos, price, reason, sell_pct, quantity, total_qty, is_full_exit, is_stale,
)
finally:
if _last_exit_order_outcome.get(stock_code) == "unknown":
_hold = float(settings.DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC)
logger.error(
f"[DayExit] 🚨 {stock_code} 매도 주문 결과 불명(접수 미확인) — {_hold:.0f}초 동안 "
f"같은 종목 재매도 보류. 다음 시도 직전에 당일 주문내역·실보유를 다시 확인한다"
)
if token is None or _exit_tokens.get(stock_code) is token:
_release_exit_in_progress(stock_code, _hold, token=token)
# ── [H-1 2026-10-02] 주문 응답 분류 ─────────────────────────────────────────────
# korea_investment_api.place_order 가 예외를 msg_cd 로 구분해 준다:
# 전송 전 실패(요청이 나가지 않음) → 재매도 안전 = rejected
# 전송 후 응답 유실(ReadTimeout·연결 끊김·5xx 재시도 초과) → 접수 여부 불명 = unknown
# [L-2 r5] safe_place_order 가 붙이는 ORDER_LOCK_BUSY(분산락 미획득)·KIS_PRESEND_EXCEPTION
# (place_order 호출 전 예외)도 전송 전이다 — trade_executor.SAFE_ORDER_MSG_* 와 같은 값.
# (순환 import 를 피하려고 리터럴로 둔다. 값이 바뀌면 이 집합도 함께 바꿀 것.)
_PRESEND_MSG_CDS = frozenset({
"TOKEN_UNAVAILABLE", "KIS_PRESEND_CONNECT", "KIS_PRESEND_CIRCUIT_OPEN",
"KIS_PRESEND_EXCEPTION", "ORDER_LOCK_BUSY",
})
_TRANSPORT_UNKNOWN_MSG_CD = "KIS_TRANSPORT_UNKNOWN"
_RATE_LIMIT_MSG_CDS = frozenset({"EGW00201"})
# safe_place_order 가 분산락을 못 잡으면 주문을 보내지 않고 이 msg1 로 돌려준다(전송 전).
_ORDER_LOCK_BUSY_MSG = "order_lock_busy"
# [H-2] 결과 불명 주문이 아직 확인되지 않은 종목
# {(날짜, code): {"qty": 주문수량, "since": datetime, "total_qty": 주문 시 DT 보유}}
# [L-1 r5] 키에 날짜를 넣고 날짜가 바뀌면 정리한다(_prune_exit_state_for_today).
_unresolved_exit_unknown: dict[tuple[str, str], dict] = {}
# [M-3 r5] 불명 확인용 조회(잔고·비DT 수량) 연속 실패 횟수 {(날짜, code): n}, 경보 보낸 키
_unknown_lookup_failures: dict[tuple[str, str], int] = {}
_unknown_lookup_alerted: set[tuple[str, str]] = set()
_alert_tasks: set = set()
# 종목별 마지막 주문 응답 요약(분류·msg_cd·msg1) — stale 재시도 산정용
_last_exit_order_detail: dict[str, dict] = {}
# 이 프로세스가 접수 확인한(finalize 한) DT 매도 주문번호 {(날짜, 정규화 ODNO)} — 불명 주문
# 매칭에서 제외한다(이미 반영한 우리 주문을 다른 불명 주문으로 다시 세지 않게).
_finalized_sell_odnos: set[tuple[str, str]] = set()
def _today_key() -> str:
return _kst_now().date().isoformat()
def _norm_odno(v) -> str:
s_ = str(v or "").strip()
return s_.lstrip("0") or ("0" if s_ else "")
def _prune_exit_state_for_today() -> None:
"""[L-1 r5] 날짜가 바뀐 불명·ODNO·조회실패 상태를 버린다(전일 상태가 당일 판정에 새지 않게)."""
today = _today_key()
for d in (_unresolved_exit_unknown, _unknown_lookup_failures):
for k in [k for k in d if k[0] != today]:
d.pop(k, None)
for coll in (_finalized_sell_odnos, _unknown_lookup_alerted):
for k in [k for k in coll if k[0] != today]:
coll.discard(k)
def _classify_order_response(resp) -> str:
"""주문 응답 → "accepted" | "rejected" | "unknown".
rejected = 전송 전 실패(msg_cd ∈ _PRESEND_MSG_CDS, 분산락 busy) 또는 KIS 업무 응답(rt_cd
가 "0"/"-1" 이 아닌 값). msg_cd 없는 rt_cd "-1"(래퍼가 감싼 예외)·전송 불명은 unknown.
"""
if not isinstance(resp, dict) or resp.get("rt_cd") is None:
return "unknown"
rt = str(resp.get("rt_cd"))
mc = str(resp.get("msg_cd") or "")
if rt == "0":
return "accepted"
if mc in _PRESEND_MSG_CDS or str(resp.get("msg1") or "") == _ORDER_LOCK_BUSY_MSG:
return "rejected"
if mc == _TRANSPORT_UNKNOWN_MSG_CD or rt == "-1":
return "unknown"
return "rejected"
def _is_transient_order_failure(detail: dict | None) -> bool:
"""[M-3] 횟수에 세지 않는 일시 실패 — 레이트리밋·전송 전·전송 불명·응답 없음."""
if not detail:
return True
if detail.get("outcome") == "unknown":
return True
mc = str(detail.get("msg_cd") or "")
m1 = str(detail.get("msg1") or "")
return (
mc in _PRESEND_MSG_CDS or mc in _RATE_LIMIT_MSG_CDS or mc == _TRANSPORT_UNKNOWN_MSG_CD
or any(c in m1 for c in _RATE_LIMIT_MSG_CDS) or m1 == _ORDER_LOCK_BUSY_MSG
)
def _row_is_live_or_filled_sell(row: dict, stock_code: str) -> bool:
"""[M-4] 당일 주문내역 행이 '이 종목의 유효한 매도 접수'인지.
거부(rjct_qty>0)·취소(cncl_yn=Y)이면서 체결 0 인 행은 접수로 보지 않는다
(fill_reconciler.is_terminal_fill 과 같은 필드 기준).
"""
if not isinstance(row, dict):
return False
if str(row.get("pdno", stock_code) or stock_code) != stock_code:
return False
if str(row.get("sll_buy_dvsn_cd", "")) != "01":
return False
try:
oq = int(float(row.get("ord_qty", 0) or 0))
cq = int(float(row.get("tot_ccld_qty", 0) or 0))
rj = int(float(row.get("rjct_qty", 0) or 0))
except (TypeError, ValueError):
return False
if oq <= 0:
return False
cancelled = str(row.get("cncl_yn", "") or "").upper() == "Y"
if cq == 0 and (rj > 0 or cancelled):
return False
return True
def _find_accepted_sell_row(rows, stock_code: str, qty: int | None, since) -> dict | None:
"""[H-2/M-4] 주문 수량·시각 범위까지 맞는 유효 매도 행. since=None 이면 시각 무관."""
_since_hms = None
if since is not None:
_w = float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC)
_since_hms = (since - timedelta(seconds=_w)).strftime("%H%M%S")
for r in rows or []:
if not _row_is_live_or_filled_sell(r, stock_code):
continue
if qty is not None:
try:
if int(float(r.get("ord_qty", 0) or 0)) != int(qty):
continue
except (TypeError, ValueError):
continue
if _since_hms is not None:
t = str(r.get("ord_tmd", "") or "")
if t and t < _since_hms:
continue
odno = _norm_odno(r.get("odno", ""))
if odno and (_today_key(), odno) in _finalized_sell_odnos:
continue
return r
return None
async def _lookup_todays_orders_impl(stock_code: str) -> list | None:
try:
return await asyncio.wait_for(
korea_investment_api.get_todays_orders(stock_code),
timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC),
)
except asyncio.TimeoutError:
logger.warning(f"[DayExit] 당일 주문내역 조회 시간초과 ({stock_code}) — 접수 미확인")
except Exception as e:
logger.warning(f"[DayExit] 당일 주문내역 조회 실패 ({stock_code}): {e} — 접수 미확인")
return None
async def _fetch_non_dt_active_qty_impl(stock_code: str) -> int | None:
"""[H-2 r5] 같은 종목의 비-DT(스윙·유입·legacy NULL) active ActiveTrade 수량 합. 실패면 None.
결과 불명 매도의 재매도 상한을 정하려면 계좌 보유분 중 **DT 몫**을 알아야 하는데,
DB 없이 얻을 수 있는 소스가 없다: Portfolio 캐시(portfolio_cache_bridge)는 stock_code
UNIQUE 로 스윙+DT 가 합산된 한 행이고(§3.5), 스윙 보유 인메모리 캐시는 존재하지 않는다
(swing/position_manager 는 전부 DB 세션 함수). close_stale_positions 와 같은 소스·같은
필터(not_dt_strategy_clause)를 쓴다. 결과 불명 이후에만 호출되는 드문 경로다.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import ActiveTrade
from app.services.db_worker import not_dt_strategy_clause
from sqlalchemy import select as _select
# DB-READ-EXCEPTION: §3.2 ③ 과 같은 읽기 전용 SELECT 1건, commit/flush/add 없음.
# 쓰기는 db_worker 경유. 결과 불명 매도의 재매도 상한 확인에서만 호출된다.
async with AsyncSessionLocal() as _db:
_rows = (await _db.execute(
_select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)).scalars().all()
return sum(int(r.quantity or 0) for r in _rows)
except Exception as e:
logger.warning(f"[DayExit] 비-DT 보유 수량 조회 실패({stock_code}): {e}")
return None
async def _resolve_unknown_exit_v2_impl(
stock_code: str, qty: int, since, total_qty: int,
) -> tuple[str, dict | None, int]:
"""[H-2 r5] 결과 불명 매도 판정 → (status, 접수 응답 dict|None, DT 매도가능수량).
status:
"accepted" — 계좌의 DT 몫이 주문 수량만큼 줄었다(접수·대기·체결 모두 주문가능수량에서
빠진다). 당일 주문내역에 맞는 행이 있으면 그 ODNO 로, 없으면 ODNO 없이 반영.
"not_accepted" — DT 몫이 줄지 않았다 → 주문이 접수되지 않음. 재매도 상한 = DT 매도가능수량.
"lookup_failed" — 잔고 또는 비-DT 수량 조회 실패 → 판단 불가(재매도 금지, 보류 유지).
**판정의 근거는 잔고**다. `get_todays_orders` 는 조회 실패에도 `[]` 를 돌려줘
"실패"와 "없음"을 응답 형태로 구분할 수 없다(korea_investment_api 쪽 계약) → 주문내역은
ODNO 를 얻는 보조 소스로만 쓰고, 행이 있어도 잔고 감소가 없으면 접수로 보지 않는다
(같은 수량·시각의 스윙 매도를 DT 접수로 오인하지 않게).
DT 매도가능수량 = max(0, min(주문가능, 보유) − 비-DT active 수량)
"""
h = await _fetch_kis_holdings_v2_impl()
nd = await _fetch_non_dt_active_qty_impl(stock_code) if h is not None else None
if h is None or nd is None:
return "lookup_failed", None, 0
_hh = h.get(stock_code) or {}
hldg = int(_hh.get("hldg", 0) or 0)
sellable = int(_hh.get("sellable", 0) or 0)
dt_sellable = max(0, min(sellable, hldg) - int(nd))
if dt_sellable <= max(0, int(total_qty) - int(qty)):
rows = await _lookup_todays_orders_impl(stock_code)
row = _find_accepted_sell_row(rows, stock_code, qty, since)
odno = str((row or {}).get("odno", "") or "")
logger.warning(
f"[DayExit] {stock_code} 결과 불명 매도 → 접수로 판정: DT 매도가능 {dt_sellable}주 "
f"(보유 {hldg}·주문가능 {sellable} − 비DT {nd}) ≤ 주문 전 {total_qty} − {qty}"
f" / 주문내역 ODNO={odno or '미확인'}"
)
return "accepted", {"rt_cd": "0", "msg1": "resolved_by_holdings", "output": {"ODNO": odno}}, dt_sellable
return "not_accepted", None, dt_sellable
def _note_unknown_lookup_failure(stock_code: str) -> None:
"""[M-3 r5] 불명 확인 조회 실패 — error(레이트리밋) + N회 연속이면 텔레그램 수동 청산 경보."""
key = (_today_key(), stock_code)
n = _unknown_lookup_failures.get(key, 0) + 1
_unknown_lookup_failures[key] = n
_error_throttled(
f"dt_exit:unknown_lookup_failed:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 잔고/비DT 수량 조회 실패({n}회 연속) — "
f"재매도 보류 중(손절 미집행). 수동 확인 필요",
)
_limit = int(settings.DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT)
if n >= _limit and key not in _unknown_lookup_alerted:
_unknown_lookup_alerted.add(key)
try:
from app.services.telegram_notifier import telegram_notifier
_t = asyncio.get_running_loop().create_task(asyncio.wait_for(
telegram_notifier.notify_system_warning(
"DT_EXIT_UNKNOWN",
f"{stock_code} 데이트레이딩 매도 결과 불명 + 확인 조회 {n}회 연속 실패 — "
f"손절이 보류된 상태입니다. 계좌를 확인하고 수동 청산하세요.",
),
timeout=10.0,
))
_alert_tasks.add(_t)
_t.add_done_callback(_alert_tasks.discard)
except Exception as e:
logger.error(f"[DayExit] 수동 청산 경보 발송 실패({stock_code}): {e}")
async def _place_and_finalize_exit_impl(
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool,
) -> dict | None:
"""[H4] 매도 주문 + 체결 이후 상태 반영 (취소로부터 shield 되는 구간).
주문 결과를 `_last_exit_order_outcome[stock_code]` 에 남긴다:
accepted(rt_cd=="0" 또는 불명 후 당일 주문내역에서 접수 확인) /
rejected(전송 전 실패·KIS 업무 거부) /
unknown(전송 후 응답 유실·DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC 초과 — 접수 미확인).
불명이면 잔고(주문가능·보유 − 비-DT active)로 접수 여부를 판정하고(_resolve_unknown_exit_v2_impl),
판정이 안 되면 DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC 동안 같은 종목 재매도를 보류한 뒤 재시도
직전에 다시 판정한다. 판정 불가(조회 실패)가 이어지면 손절이 보류된 채로 남는다 →
DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT 회 연속이면 텔레그램 수동 청산 경보.
⚠️ [L-4] **장 마감 직전(대략 15:29:40 이후) 불명**이면 보류·재확인 주기가 15:30 폐장을
넘긴다. 그 경우 이 포지션은 **오버나이트로 남고**, 다음 영업일 자정 reset 이후
`close_stale_positions()`(ActiveTrade 기준 전일 포지션 청산 — 실보유·타전략 차감 확인 포함)가
장중 첫 사이클에 처리한다. 인메모리 불명 상태(_unresolved_exit_unknown)는 날짜 키라서
다음 날 판정에 새지 않는다(_prune_exit_state_for_today).
"""
from app.services.trade_executor import safe_place_order
_prune_exit_state_for_today()
_ukey = (_today_key(), stock_code)
# [H-2] 직전 불명 주문이 미해결이면 새 주문 전에 다시 확인한다(잔고 기준).
_prev = _unresolved_exit_unknown.get(_ukey)
if _prev is not None:
_st, _resp, _dt_sellable = await _resolve_unknown_exit_v2_impl(
stock_code, int(_prev["qty"]), _prev["since"],
int(_prev.get("total_qty", total_qty)),
)
if _st == "lookup_failed":
# 판단 불가 — 재매도하지 않고 보류를 이어간다(guarded finally 가 HOLD 뒤 해제)
_last_exit_order_outcome[stock_code] = "unknown"
_note_unknown_lookup_failure(stock_code)
return None
_unknown_lookup_failures.pop(_ukey, None)
if _st == "accepted":
_unresolved_exit_unknown.pop(_ukey, None)
_last_exit_order_outcome[stock_code] = "accepted"
_pq = min(int(_prev["qty"]), int(total_qty))
return await _finalize_after_accept_impl(
stock_code, pos, price, reason, sell_pct if _pq < total_qty else 1.0,
_resp, _pq, total_qty, _pq >= total_qty, is_stale,
)
# not_accepted — 재매도 수량을 DT 매도가능수량으로 상한(스윙 물량 보호)
if _dt_sellable <= 0:
_unresolved_exit_unknown.pop(_ukey, None)
_last_exit_order_outcome[stock_code] = "rejected"
_error_throttled(
f"dt_exit:unknown_no_sellable:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + DT 매도가능 0 — 재매도하지 않는다"
f"(실보유·ActiveTrade 수동 확인)",
)
return None
if _dt_sellable < quantity:
logger.warning(
f"[DayExit] {stock_code} 직전 주문 결과 불명 — DT 매도가능 {_dt_sellable}주로 "
f"매도 수량 축소({quantity}→{_dt_sellable})"
)
quantity = _dt_sellable
is_full_exit = quantity >= total_qty
_unresolved_exit_unknown.pop(_ukey, None)
_last_exit_order_outcome[stock_code] = "unknown"
_since = _kst_now()
_timeout = float(settings.DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC)
order_resp = None
try:
order_resp = await asyncio.wait_for(
safe_place_order(
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=None, # 시장가
),
timeout=_timeout,
)
except asyncio.TimeoutError:
logger.error(f"[DayExit] 매도 주문 응답 {_timeout:.0f}초 초과 ({stock_code}) — 결과 불명")
outcome = _classify_order_response(order_resp)
_r = order_resp if isinstance(order_resp, dict) else {}
_last_exit_order_detail[stock_code] = {
"outcome": outcome, "msg_cd": _r.get("msg_cd"), "msg1": _r.get("msg1"),
}
if outcome == "unknown":
logger.error(
f"[DayExit] 매도 주문 결과 불명 ({stock_code}): rt_cd={_r.get('rt_cd')} "
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 당일 주문내역 확인"
)
_st, resolved, _ = await _resolve_unknown_exit_v2_impl(
stock_code, quantity, _since, total_qty,
)
if _st != "accepted":
# 아직 반영 전일 수 있다(주문가능수량 갱신 지연) → 기록만 하고 보류. 재시도 직전에 다시 확인.
_unresolved_exit_unknown[_ukey] = {
"qty": quantity, "since": _since, "total_qty": total_qty,
}
if _st == "lookup_failed":
_note_unknown_lookup_failure(stock_code)
return None
order_resp = resolved
outcome = "accepted"
if outcome == "rejected":
_last_exit_order_outcome[stock_code] = "rejected"
logger.error(
f"[DayExit] 매도 주문 실패 ({stock_code}): msg_cd={_r.get('msg_cd')} "
f"{_r.get('msg1', 'unknown')}"
)
return None
_last_exit_order_outcome[stock_code] = "accepted"
return await _finalize_after_accept_impl(
stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty,
is_full_exit, is_stale,
)
async def _finalize_after_accept_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, is_stale: bool,
) -> dict | None:
"""접수 확인된 매도의 상태 반영. [L-2] 반영 중 예외면 인메모리 최소 반영 + 불명 처리."""
_odno = _norm_odno(((order_resp or {}).get("output") or {}).get("ODNO", ""))
if _odno:
_finalized_sell_odnos.add((_today_key(), _odno))
# [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계
# 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가
# 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다.
try:
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
))
except Exception as e:
logger.error(
f"[DayExit] 🚨 {stock_code} 매도 접수 후 상태 반영 실패: {e} — 인메모리 최소 반영, "
f"재매도 보류(결과 불명 처리)", exc_info=True,
)
async with day_trading_state.trade_lock:
_cur = day_trading_state.positions.get(stock_code)
if _cur is not None and int(_cur.get("quantity", 0) or 0) == int(total_qty):
if is_full_exit:
day_trading_state.remove_position(stock_code)
else:
_cur["quantity"] = int(total_qty) - int(quantity)
_cur["target_price"] = float("inf")
_cur["partial_sold"] = True
if is_full_exit:
day_trading_state.closed_today.add(stock_code)
_last_exit_order_outcome[stock_code] = "unknown"
return None
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", "")
# [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다.
# 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다
# → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0)
# 청산도 순손익으로는 손실이다.
_pnl = dt_net_pnl(entry_price, price, quantity, pos)
gross_profit_loss = _pnl["gross_profit_loss"]
trade_cost = _pnl["total_cost"]
profit_loss = _pnl["net_profit_loss"]
profit_loss_rate = _pnl["net_profit_loss_rate"] 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}원)"
)
# [M-2 2026-10-02] 인메모리 반영이 끝났으므로 청산 진행 중 표식을 **지금** 해제한다.
# 예전에는 아래 꼬리 단계(Redis 저장·loss_tracker·포트폴리오 캐시) 뒤에 해제돼, 그중
# 하나가 멈추면 표식이 영구 잔류 → EOD 강제청산이 잔량을 계속 건너뛰었다.
_release_exit_in_progress(stock_code)
# DB 기록 (큐 경유) — 원장·ActiveTrade 를 꼬리 단계보다 먼저 남긴다
await _bounded_finalize_step(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,
"commission": trade_cost,
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "daytrading",
}), stock_code, "TradeHistory 큐잉")
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정.
try:
_schedule_sell_fill_reconcile(
stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price,
quantity=quantity, entry_price=entry_price, recorded_net=profit_loss,
count_daily_pnl=count_daily_pnl,
)
except Exception as e:
logger.warning(f"[DayExit] {stock_code} 체결가 확인 예약 실패: {e}")
if is_full_exit:
await _bounded_finalize_step(
enqueue_close_active_trade(stock_code, strategy_name="day_trade"),
stock_code, "ActiveTrade close 큐잉",
)
else:
# 분할 매도 후 ActiveTrade 잔량 갱신 — 누락 시 재기동 복원에서 원래 수량이 되살아나
# 다음 청산이 보유량보다 많이 매도한다(2026-09-23 LG씨엔에스 4주 보유 중 7주 매도).
from app.services.db_worker import enqueue_active_trade_upsert
# [M1 2026-10-02] 분할매도 상태 영속화 — remaining_pct(<1.0)·highest_since_entry 를
# 함께 남긴다(둘 다 기존 ORM 컬럼 → db_worker payload 계약 무변경, 선택 키).
# restore_from_db 가 remaining_pct<1.0 을 보고 목표가 분할 재발동을 막고,
# 트레일링 고점을 이어받는다. Numeric(5,2) 반올림으로 1.00 이 되지 않게 0.99 상한.
_remaining = total_qty - quantity
_remaining_pct = min(0.99, round(_remaining / total_qty, 2)) if total_qty > 0 else 0.99
_highest = float(pos.get("highest_price") or 0) or None
_payload = {
"stock_code": stock_code,
"strategy_name": "day_trade",
"quantity": _remaining,
"remaining_pct": _remaining_pct,
}
if _highest:
_payload["highest_since_entry"] = _highest
await _bounded_finalize_step(
enqueue_active_trade_upsert(_payload), stock_code, "ActiveTrade 잔량 큐잉",
)
# [F9 2026-09-28] 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화.
# 값이 바뀐 직후에 저장한다 — 재기동해도 일일 -3% 한도·3연패 쿨다운·당일
# 재진입 차단이 풀리지 않게 한다(락 밖에서 호출: Redis I/O 로 락을 잡지 않는다).
# [M-2] Redis 무응답이면 상한에서 끊는다(core/redis socket_timeout 은 인프라 소유).
await _bounded_finalize_step(day_trading_state.save_daily_state(), stock_code, "일일 안전장치 저장")
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
# [2026-10-01] 시간손절은 **중립**(손실로도 이익으로도 세지 않음 → 기록 자체를 생략).
# 기회비용 청산까지 3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지에 넣으면 그 밴이
# 스윙 진입(swing/entry_service)까지 막는다. 하드/폴백 손절·일일한도·EOD·트레일링 등
# 나머지 청산은 기존대로 기록한다. (당일 연패 쿨다운 loss_streak_today 는 위 update_pnl 이
# 그대로 센다 — DT 전용·당일 한정 스로틀이라 스윙에 번지지 않는다.)
if is_full_exit:
if is_time_stop_exit_reason(reason):
logger.info(
f"[DayExit] 시간손절은 연속손실 집계 제외(중립): {stock_code} "
f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/"
f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음"
)
else:
is_loss = profit_loss < 0
async def _record_loss():
# record_trade_result() 후 반드시 save_to_db() (§5.1)
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
await loss_tracker.save_to_db(stock_code=stock_code)
await _bounded_finalize_step(_record_loss(), stock_code, "loss_tracker 기록")
# 포트폴리오 화면에서 즉시 제거/차감
from app.services.portfolio_cache_bridge import reflect_sell
await _bounded_finalize_step(
reflect_sell(stock_code, quantity, price, remaining_qty=max(0, int(total_qty) - int(quantity))),
stock_code, "포트폴리오 캐시 반영",
)
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:.2f}% "
f"(총손익={gross_profit_loss:+.0f} 비용={trade_cost:.0f} 순손익={profit_loss:+.0f}원) 이유={reason}"
)
return {
"stock_code": stock_code,
"stock_name": stock_name,
"price": price,
"quantity": quantity,
"profit_loss": profit_loss, # 순손익(비용 차감)
"profit_loss_rate": profit_loss_rate, # 순수익률(진입 원금 대비 %)
"gross_profit_loss": gross_profit_loss,
"trade_cost": trade_cost,
"reason": reason,
"sell_pct": sell_pct,
"order_id": order_id,
}
async def _bounded_finalize_step(coro, stock_code: str, label: str) -> None:
"""[M-2] 체결 후 꼬리 단계 1개를 상한(DAY_TRADE_EXIT_FINALIZE_STEP_TIMEOUT_SEC)으로 실행.
한 단계가 멈추거나 실패해도 나머지 단계와 청산 결과 반환은 계속된다(error 로그).
"""
_t = float(settings.DAY_TRADE_EXIT_FINALIZE_STEP_TIMEOUT_SEC)
try:
await asyncio.wait_for(coro, timeout=_t)
except asyncio.TimeoutError:
logger.error(f"[DayExit] 🚨 {stock_code} 청산 후 '{label}' {_t:.0f}초 초과 — 건너뜀(수동 확인)")
except Exception as e:
logger.error(f"[DayExit] 🚨 {stock_code} 청산 후 '{label}' 실패: {e}", exc_info=True)
def _schedule_sell_fill_reconcile(
stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int,
entry_price: float, recorded_net: float, count_daily_pnl: bool,
):
"""매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
확인되면:
- TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신
- 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl —
§7 당일 미반영 규칙 유지). 당일 연패·loss_tracker 판정은 청산 시점 값 그대로 둔다.
확인 실패면 신호가 기록이 그대로 남는다(fill_reconciler 가 warning).
"""
from app.core.timeutil import now_kst as _now_kst
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
async def _on_fill(filled_qty: int, avg_price: float) -> None:
global _stale_realized_pnl
if filled_qty < quantity:
logger.warning(
f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)"
)
new = dt_net_pnl(entry_price, avg_price, quantity, pos)
delta = new["net_profit_loss"] - recorded_net
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
from decimal import Decimal
from app.services.db_worker import enqueue_update_trade_history_by_order_id
await enqueue_update_trade_history_by_order_id(order_id, {
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
"order_date": _order_date,
"order_type": "SELL",
"strategy_type": "daytrading",
"executed_price": Decimal(str(round(avg_price, 2))),
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
"commission": Decimal(str(new["total_cost"])),
"net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))),
"profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None,
"slippage": Decimal(str(round(slippage_pct, 4))),
})
if delta:
if count_daily_pnl:
async with day_trading_state.trade_lock:
day_trading_state.adjust_realized_pnl(delta)
await day_trading_state.save_daily_state()
else:
_stale_realized_pnl += delta
logger.info(
f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) "
f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 "
f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)"
)
return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill)
async def close_stale_positions() -> list[dict]:
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.
EOD(15:20) 강제청산이 실패하거나 스케줄러가 그 구간을 놓친 경우의 안전장치.
장중에만 동작한다(시장가 주문 필요). 자정 reset 이후 전일 포지션은 DB 에만 남으므로
이 경로가 오버나이트 보유를 끝내는 유일한 장치다.
재시도 규칙 [H-A 2026-10-02]:
- 주문이 접수(rt_cd=="0")되면 같은 날·같은 프로세스에서 다시 주문하지 않는다.
- 명시적 거부(EGW00201 등)는 DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC 간격으로
최대 DAY_TRADE_STALE_SELL_MAX_RETRIES 회까지 재시도한다.
- 결과 불명(응답 없음·예외·시간초과)이면 재시도 전에 당일 주문내역에서 이 종목
매도 접수 여부를 확인하고, 접수돼 있으면 재주문하지 않는다.
- 이중 매도는 매 시도마다 다시 읽는 KIS 주문가능수량(ord_psbl_qty)으로도 막힌다.
- 매도 수량 = min(행 수량, 주문가능수량, 보유수량 − 타 전략 보유분).
- 주문 없이 ActiveTrade 만 닫는 정리는 **KIS 보유수량(hldg_qty)=0** 일 때만 한다.
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 만 수행하며 commit()/flush()/add() 호출이 없다. 세션 수명은 아래
# async with 블록으로 최소화하고, 실패는 warning 으로 운영에 노출한다.
# 실제 청산 기록은 아래 _execute_exit_stale → db_task_queue 경유로만 이뤄진다.
# [H3 2026-10-02] 같은 세션에서 비-DT(스윙·유입 등) active 행 수량도 읽는다(§3.5 차감용).
# 조회 실패면 None 으로 남겨 **매도하지 않는다**(과다매도 = 스윙 물량 매도 방지).
other_strategy_qty: dict[str, int] | None = None
try:
from app.core.database import AsyncSessionLocal
from app.models.models import ActiveTrade
from app.services.db_worker import not_dt_strategy_clause
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 "",
}
other_strategy_qty = {}
if stale:
_other_rows = (await _db.execute(
_select(ActiveTrade).where(
ActiveTrade.stock_code.in_(list(stale.keys())),
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)).scalars().all()
for _o in _other_rows:
other_strategy_qty[_o.stock_code] = (
other_strategy_qty.get(_o.stock_code, 0) + int(_o.quantity or 0)
)
except Exception as _db_err:
logger.warning(
f"[DayExit] 전일 포지션 DB 조회 실패(인메모리만 처리, 타 전략 보유분 미확인 → "
f"매도 보류): {_db_err}"
)
other_strategy_qty = None
stale = {
c: p for c, p in stale.items() if int((p or {}).get("quantity", 0) or 0) > 0
}
if not stale:
return results
# [H3] KIS 실보유 조회 — 실패하면 None → 매도하지 않는다(warning).
kis_holdings = await _fetch_kis_holdings_v2_impl()
for stock_code, pos in stale.items():
entry_time = pos.get("entry_time")
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
_attempt_key = (today.isoformat(), stock_code)
if day_trading_state.is_exit_in_progress(stock_code): # [H-B]
continue
_already_accepted = _attempt_key in _stale_sell_attempted
row_qty = int(pos.get("quantity", 0) or 0)
_mem_pos = day_trading_state.positions.get(stock_code)
_mem_is_other = _mem_pos is not None and _mem_pos is not pos
if _mem_is_other:
# 같은 종목에 당일 DT 포지션과 전일 DB 행이 공존 — close_active_trade 가
# '가장 최근 day_trade 행'을 닫으므로 당일 행을 잘못 닫을 수 있다. 자동 처리 보류.
_error_throttled(
"dt_exit:stale_coexist",
f"[DayExit] 🚨 {stock_code}: 당일 DT 포지션과 전일 미청산 DB 행이 공존 — "
f"자동 청산·정리를 보류한다(수동 확인 필요)",
)
continue
if kis_holdings is None:
logger.warning(
f"[DayExit] ⚠️ 전일 포지션 {stock_code} 매도 보류 — KIS 실보유 조회 실패 "
f"(실보유 미확인 상태로 팔면 스윙 물량을 팔 수 있다). 다음 사이클 재시도"
)
continue
if other_strategy_qty is None:
logger.warning(
f"[DayExit] ⚠️ 전일 포지션 {stock_code} 매도 보류 — 타 전략 보유분 조회 실패"
f"(§3.5 차감 불가). 다음 사이클 재시도"
)
continue
_h = kis_holdings.get(stock_code) or {"hldg": 0, "sellable": 0}
hldg_qty = int(_h.get("hldg", 0) or 0)
sellable_qty = int(_h.get("sellable", 0) or 0)
other_qty = int(other_strategy_qty.get(stock_code, 0) or 0)
# [M-A] 타 전략 차감은 **보유수량** 기준으로 한다. 주문가능수량(ord_psbl_qty)에는 이미
# 대기 중인 스윙 매도분이 빠져 있어 거기서 또 빼면 이중 차감된다. 주문가능수량은 상한으로만.
dt_available = max(0, min(sellable_qty, hldg_qty - other_qty))
sell_qty = min(row_qty, dt_available)
if hldg_qty <= 0 and _attempt_key not in _stale_cleaned:
# [L-1] 실보유 0 — 이미 팔렸다(접수된 매도의 체결 포함). 주문 없이 ActiveTrade 만 정리.
sell_qty = 0
elif _already_accepted:
# [H3/H-A] 이번 프로세스에서 이미 매도 주문이 **접수된** 종목은 재주문 금지.
# 예전에는 매도 성공 후 ActiveTrade close 가 큐에서 반영되기 전(또는 엉뚱한 행이
# 닫혀) 다음 사이클 DB 조회가 같은 행을 다시 stale 로 잡아 **반복 매도**했다.
warn_throttled(
logger, "dt_exit:stale_already_attempted",
f"[DayExit] 전일 포지션 {stock_code} 은 이번 프로세스에서 매도 주문이 이미 접수됨 — "
f"재주문하지 않는다(체결 후 보유 0 이 되면 ActiveTrade 를 정리)",
)
continue
elif hldg_qty <= 0:
continue # 이미 정리함
if sell_qty <= 0:
if hldg_qty <= 0:
# 실보유 0 — 이미 팔렸다. 주문 없이 ActiveTrade(day_trade)만 정리
_stale_sell_attempted.add(_attempt_key)
_stale_cleaned.add(_attempt_key)
logger.warning(
f"[DayExit] ⚠️ 전일 포지션 {stock_code} KIS 보유 0주 (행 {row_qty}주) "
f"— 매도 없이 ActiveTrade(day_trade)만 닫는다"
)
async with day_trading_state.trade_lock:
if day_trading_state.positions.get(stock_code) is pos:
day_trading_state.remove_position(stock_code)
day_trading_state.closed_today.add(stock_code)
await day_trading_state.save_daily_state()
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
else:
# 보유는 있는데 DT 가용이 0 — 대기 매도·유령 비-DT 행 등으로 판단 불가.
# 행을 닫으면 실보유가 있는데 추적이 끊기므로 유지하고 사람에게 넘긴다.
_error_throttled(
f"dt_exit:stale_no_dt_available:{stock_code}",
f"[DayExit] 🚨 전일 포지션 {stock_code}: KIS 보유 {hldg_qty}주(주문가능 "
f"{sellable_qty}주) − 타전략 {other_qty}주 → DT 가용 0, 행 {row_qty}주 — "
f"매도·정리 보류(ActiveTrade 유지). 수동 확인 필요",
)
continue
# [H-A] 실패 이력이 있으면 백오프·최대 횟수·결과 불명 확인
_fail = _stale_sell_failures.get(_attempt_key)
if _fail is not None:
_cnt, _last = _fail
_max = int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES)
_wait = float(settings.DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC)
if _cnt >= _max:
# [M-3] 업무 거부가 한도에 닿아도 당일 포기하지 않는다 — 긴 간격으로 계속 시도
_wait = float(settings.DAY_TRADE_STALE_SELL_EXHAUSTED_RETRY_SEC)
_error_throttled(
f"dt_exit:stale_retry_exhausted:{stock_code}",
f"[DayExit] 🚨 전일 포지션 {stock_code} 매도 업무 거부 {_cnt}회(한도 {_max}회) — "
f"{_wait:.0f}초 간격으로 계속 재시도한다. 수동 청산 권장(오버나이트 보유 위험)",
)
if time.monotonic() - _last < _wait:
continue
if _last_exit_order_outcome.get(stock_code) == "unknown":
_lq = int(_stale_last_qty.get(_attempt_key) or 0)
# [L-1 r5] 주문내역 행만으로는 스윙 매도와 구분이 안 된다 → 잔고 기준 DT 가용이
# 직전 주문 수량만큼 줄었을 때만 접수로 본다(줄지 않았으면 재매도 — 수량 상한은 위
# dt_available 이 이미 걸려 있다).
_reduced = dt_available <= max(0, row_qty - _lq)
_acc = _reduced and await _stale_sell_accepted_today_impl(
stock_code, qty=_lq or None,
since=_stale_last_since.get(_attempt_key),
)
if _acc:
_stale_sell_attempted.add(_attempt_key)
logger.error(
f"[DayExit] 🚨 전일 포지션 {stock_code}: 직전 주문 응답은 불명이었으나 "
f"당일 매도 접수가 확인됨 — 재주문하지 않는다(체결 후 보유 0 이면 다음 "
f"사이클이 ActiveTrade 를 정리)"
)
continue
sell_pos = pos
if sell_qty < row_qty:
logger.warning(
f"[DayExit] ⚠️ 전일 포지션 {stock_code} 수량 불일치 — 행 {row_qty}주, "
f"KIS 보유 {hldg_qty}주(주문가능 {sellable_qty}주) − 타전략 {other_qty}주 "
f"= DT 가용 {dt_available}주 → {sell_qty}주만 매도"
)
sell_pos = dict(pos)
sell_pos["quantity"] = sell_qty
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}, 수량={sell_qty}/{row_qty})"
)
# [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 에 가산하지 않는다.
_last_exit_order_outcome.pop(stock_code, None)
_last_exit_order_detail.pop(stock_code, None)
_stale_last_qty[_attempt_key] = sell_qty
_stale_last_since[_attempt_key] = _kst_now()
result = None
try:
result = await _execute_exit_stale(
stock_code, sell_pos, price,
reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)",
)
except Exception as e:
logger.error(f"[DayExit] 전일 포지션 {stock_code} 매도 예외: {e}", exc_info=True)
_outcome = _last_exit_order_outcome.get(stock_code)
if result or _outcome == "accepted":
# [H-A] 시도 기록은 **접수 성공 시에만** 남긴다
_stale_sell_attempted.add(_attempt_key)
_stale_sell_failures.pop(_attempt_key, None)
if result:
results.append(result)
else:
# [M-3] 일시 실패(레이트리밋·전송 전·전송 불명)는 횟수에 세지 않는다 — 업무 거부만 센다
_detail = _last_exit_order_detail.get(stock_code)
_prev_cnt = _stale_sell_failures.get(_attempt_key, (0, 0.0))[0]
_transient = _is_transient_order_failure(_detail)
_cnt = _prev_cnt if _transient else _prev_cnt + 1
_stale_sell_failures[_attempt_key] = (_cnt, time.monotonic())
logger.error(
f"[DayExit] 🚨 전일 포지션 {stock_code} 매도 실패 "
f"({'일시 실패 — 미산정' if _transient else '업무 거부'}, 누적 {_cnt}/"
f"{int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES)}회, 결과={_outcome or '미상'}, "
f"msg_cd={(_detail or {}).get('msg_cd')}) — 백오프 뒤 재시도"
)
await asyncio.sleep(0.3) # KIS 레이트 리밋
return results
def _error_throttled(key: str, msg: str) -> None:
"""[Low] 같은 key 의 error 로그를 레이트리밋(30초 사이클마다 반복되는 경고 억제)."""
from app.core.log_throttle import should_log
ok, suppressed = should_log(key)
if ok:
logger.error(msg + (f" (+{suppressed}건 억제)" if suppressed else ""))
async def _stale_sell_accepted_today_impl(stock_code: str, qty: int | None = None, since=None) -> bool:
"""[H-A/M-4] 당일 주문내역에 이 종목의 **유효한** 매도 접수가 있는지.
거부·취소(체결 0) 행은 접수로 보지 않고, 주문 수량(qty)·주문 시각(since − 허용오차 이후)
으로 범위를 좁힌다(같은 날 스윙 매도·거부 행을 DT 접수로 오인하던 결함). 조회 실패·없음 →
False — 그 경우 재시도 주문 수량은 다시 읽은 주문가능수량(ord_psbl_qty)이 제한한다.
"""
rows = await _lookup_todays_orders_impl(stock_code)
return _find_accepted_sell_row(rows, stock_code, qty, since) is not None
async def _fetch_kis_holdings_v2_impl() -> dict[str, dict] | None:
"""[H3/M-A] KIS 계좌 실보유 {종목코드: {"hldg": 보유수량, "sellable": 주문가능수량}}.
조회 실패면 None (warning). 빈 목록은 "보유 0"(정상 응답)이다. `ord_psbl_qty` 가 없으면
주문가능수량 = 보유수량으로 본다. 주문가능수량에는 대기 중인 매도 주문분이 이미 빠져 있다.
"""
try:
bal = await asyncio.wait_for(
korea_investment_api.get_balance(), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC),
)
except asyncio.TimeoutError:
logger.warning(f"[DayExit] KIS 잔고 조회 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 — 실보유 미확인")
return None
except Exception as e:
logger.warning(f"[DayExit] KIS 잔고 조회 실패 — 실보유 미확인: {e}")
return None
if not isinstance(bal, dict):
logger.warning(f"[DayExit] KIS 잔고 응답 형식 이상({type(bal).__name__}) — 실보유 미확인")
return None
if bal.get("rt_cd") not in (None, "0"):
logger.warning(f"[DayExit] KIS 잔고 조회 오류 응답: {bal.get('msg1')} — 실보유 미확인")
return None
rows = bal.get("output1")
if not isinstance(rows, list):
logger.warning("[DayExit] KIS 잔고 응답에 output1 없음 — 실보유 미확인")
return None
out: dict[str, dict] = {}
for h in rows:
if not isinstance(h, dict):
continue
code = str(h.get("pdno", "") or "")
if not code:
continue
try:
hq = max(0, int(float(h.get("hldg_qty", 0) or 0)))
sq = hq
if h.get("ord_psbl_qty") not in (None, ""):
sq = max(0, min(hq, int(float(h.get("ord_psbl_qty") or 0))))
except (TypeError, ValueError):
logger.warning(f"[DayExit] KIS 잔고 수량 파싱 실패 ({code}) — 실보유 미확인")
return None
cur = out.setdefault(code, {"hldg": 0, "sellable": 0})
cur["hldg"] += hq
cur["sellable"] += sq
return out
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
# [H-B] 진행 중 청산 주문이 있으면 건너뛴다. 진행 중 표식은 shield 작업의 finally 가
# 해제하므로(결과 불명이면 DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC 뒤) 다음 EOD 사이클(30초)에
# 남은 포지션을 다시 잡는다 — 영구 skip 되지 않는다.
if day_trading_state.is_exit_in_progress(stock_code):
logger.warning(f"[DayExit] 강제 청산: {stock_code} 청산 주문 진행 중 — 이번 회차 건너뜀")
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 or day_trading_state.is_exit_in_progress(stock_code):
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