Files
auto-stock/backend/app/services/daytrading/exit_service.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

3834 lines
200 KiB
Python
Raw Permalink 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 contextvars
import logging
import time
from datetime import datetime, timedelta, time as _dtime
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 에서 제외된 금액).
하위호환용 원시 누적값. 날짜 경계 처리는 가산 시점(_add_stale_realized_pnl)에만 하므로,
날짜가 바뀐 뒤 첫 가산 전에는 전일 값이 남아 보일 수 있다 — 표시용은 _v2 를 쓸 것.
"""
return _stale_realized_pnl
# [R7 2026-10-10] _stale_realized_pnl 이 귀속된 KST 날짜. 날짜가 바뀌면 0 으로 되돌린다
# (종전에는 프로세스 수명 동안 무한 누적돼 '전일 누적'·/close-all 응답이 여러 날의 합이 됐다).
# 매매 판정에는 쓰이지 않는 추적·표시 값이다.
_stale_realized_pnl_date: str | None = None
def _roll_stale_realized_pnl_day() -> None:
"""날짜가 바뀌었으면 stale 누적값을 0 으로 초기화하고 날짜를 오늘로 맞춘다."""
global _stale_realized_pnl, _stale_realized_pnl_date
today = _today_key()
if _stale_realized_pnl_date != today:
if _stale_realized_pnl_date is not None and _stale_realized_pnl != 0.0:
logger.info(
f"[DayExit] 전일·고아 청산 손익 누적 일자 변경 {_stale_realized_pnl_date}→{today}: "
f"{_stale_realized_pnl:+.0f}원 → 0 초기화"
)
if _stale_realized_pnl_date is not None:
_stale_realized_pnl = 0.0
_stale_realized_pnl_date = today
def _add_stale_realized_pnl(delta: float) -> float:
"""당일(KST) stale 누적값에 delta 를 더하고 새 누적값을 반환한다."""
global _stale_realized_pnl
_roll_stale_realized_pnl_day()
_stale_realized_pnl += delta
return _stale_realized_pnl
def get_stale_realized_pnl_v2() -> float:
"""당일(KST) 전일·고아 청산 실현손익 누적값. 날짜가 바뀌면 0 에서 다시 센다."""
_roll_stale_realized_pnl_day()
return _stale_realized_pnl
# [R2-3 2026-10-09] 국내 ETF/ETN 매도에는 증권거래세가 없다. DT 포지션에는 상품 구분이 없어
# 종목명으로 판별한다(운용사 브랜드 + 공백으로 시작, 또는 'ETN' 포함). 판별 실패(이름 없음·
# 신규 브랜드)는 주식 세율을 그대로 쓴다 — 순손익을 **낮게**(손실 쪽으로) 보는 기존 동작.
# ⚠️ 해외·채권형 ETF 의 매매차익 배당소득세(15.4%)는 원천징수라 여기서 모델링하지 않는다.
_ETF_BRAND_PREFIXES = (
"KODEX", "TIGER", "PLUS", "KIWOOM", "RISE", "ACE", "SOL", "HANARO", "KOSEF",
"ARIRANG", "KBSTAR", "TIMEFOLIO", "WOORI", "1Q", "BNK", "FOCUS", "TRUSTON",
"KTOP", "UNICORN", "VITA", "WON", "TREX", "DAISHIN343", "마이티", "에셋플러스",
"파워", "히어로즈", "마이다스", "KCGI", "HK",
)
def _is_etf_or_etn_name_impl(stock_name) -> bool:
"""종목명으로 국내 ETF/ETN 여부 판별 (브랜드 + 공백 접두 또는 'ETN' 토큰)."""
name = str(stock_name or "").strip()
if not name:
return False
upper = name.upper()
if "ETN" in upper.split() or upper.endswith(" ETN") or " ETN(" in upper:
return True
head = upper.split(" ", 1)
if len(head) < 2:
return False
# 브랜드 토큰 + 공백일 때만 인정(주식명은 대개 공백이 없다). ETN 발행사(삼성·신한·TRUE 등)는
# 이름에 'ETN' 이 붙어 위에서 걸린다.
return head[0] in _ETF_BRAND_PREFIXES
def _dt_sell_tax_rate(pos: dict) -> float:
"""DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일).
[R2-3] 국내 ETF/ETN(종목명 판별)은 증권거래세 0.
"""
if _is_etf_or_etn_name_impl((pos or {}).get("stock_name")):
return 0.0
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 _sell_fill_pnl_impl(
entry_price: float, avg_price: float, signal_price: float,
filled_qty: int, quantity: int, pos: dict,
) -> dict:
"""[H-R1-2 2026-10-09] 매도 체결 확인 결과 → 손익·거래대금 (순수 계산).
- 전량 체결(filled ≥ quantity): 종전과 동일 — 주문 수량 전체를 체결가로.
- 미종결 부분체결(0 < filled < quantity): 체결분은 실제 체결가, 미체결분은 **신호가 기준 추정**
(청산 접수 시 recorded_net 과 같은 근거)으로 합산한다. 예전에는 미체결분까지 체결가로 계상했다.
미체결분을 0 으로 빼면 일일 손실한도(daily_pnl)가 그만큼 덜 잡히므로(손실 과소) 추정치를 남긴다.
거래대금·체결수량은 실제 체결분만.
두 구간을 수량 가중 혼합 청산가 하나로 묶어 dt_net_pnl 을 1회 호출한다(비용 반올림이 구간별로
두 번 일어나지 않게 — 전량 체결 시 종전 값과 비트 동일).
"""
q = max(0, int(quantity))
f = max(0, min(int(filled_qty), q))
if f >= q or f <= 0:
r = dt_net_pnl(entry_price, avg_price, q, pos)
return {
"net_profit_loss": r["net_profit_loss"], "total_cost": r["total_cost"],
"net_profit_loss_rate": r["net_profit_loss_rate"],
"total_amount": float(avg_price) * q, "executed_quantity": q, "is_partial": False,
}
_sig = float(signal_price) if signal_price and float(signal_price) > 0 else float(avg_price)
blended_exit = (float(avg_price) * f + _sig * (q - f)) / q
r = dt_net_pnl(entry_price, blended_exit, q, pos)
return {
"net_profit_loss": r["net_profit_loss"], "total_cost": r["total_cost"],
"net_profit_loss_rate": r["net_profit_loss_rate"],
"total_amount": float(avg_price) * f, "executed_quantity": f, "is_partial": True,
}
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)
# [R2-1 2026-10-09] KRX 종가 단일가 매매 구간(시장 구조 상수 — 전략 파라미터 아님).
# 이 구간에 낸 시장가 주문은 15:30 에 일괄 체결된다.
_KRX_CLOSING_AUCTION_START = _dtime(15, 20)
_KRX_MARKET_CLOSE = _dtime(15, 30)
# 종가 단일가 재확인 백그라운드 태스크 (GC 방지·테스트 대기용)
_auction_fill_tasks: set = set()
def _in_closing_auction_impl(now: datetime) -> bool:
"""now(KST naive) 가 종가 단일가 구간(15:20 ≤ t < 15:30)인가."""
t = now.time()
return _KRX_CLOSING_AUCTION_START <= t < _KRX_MARKET_CLOSE
def _auction_recheck_params_impl() -> tuple[float, int, float]:
"""(장 마감 후 대기 초, 재확인 횟수, 재확인 간격 초).
settings 에 DAY_TRADE_FILL_AUCTION_RECHECK_{DELAY_SEC,ATTEMPTS,INTERVAL_SEC} 가 있으면 그 값,
없으면 기본 30초/3회/30초(config.py 미편입 — 같은 라운드에서 config 를 고치지 않았다).
"""
_delay = max(0.0, float(getattr(settings, "DAY_TRADE_FILL_AUCTION_RECHECK_DELAY_SEC", 30.0)))
_attempts = max(1, int(getattr(settings, "DAY_TRADE_FILL_AUCTION_RECHECK_ATTEMPTS", 3)))
_interval = max(0.0, float(getattr(settings, "DAY_TRADE_FILL_AUCTION_RECHECK_INTERVAL_SEC", 30.0)))
return _delay, _attempts, _interval
def _seconds_until_auction_recheck_impl(now: datetime) -> float:
"""장 마감(15:30) + 재확인 대기(_auction_recheck_params_impl) 까지 남은 초 (지났으면 0)."""
_buf = _auction_recheck_params_impl()[0]
target = datetime.combine(now.date(), _KRX_MARKET_CLOSE) + timedelta(seconds=_buf)
return max(0.0, (target - now).total_seconds())
def _apply_round_trip_streak_v2(round_trip_net: float) -> None:
"""[R2-4] 전량 청산 시 왕복 순손익으로 당일 연패 판정 (trade_lock 안에서 호출).
position_manager.update_pnl(is_full_close=True) 의 판정 규칙과 동일:
손실 → +1, 이익 → 0 리셋, 0 → 중립. daily_pnl 은 호출부가 update_pnl(..., False) 로 더한다.
"""
if round_trip_net < 0:
day_trading_state.loss_streak_today += 1
elif round_trip_net > 0:
day_trading_state.loss_streak_today = 0
# [HL2-2 2026-10-09] 매도 0체결 복원 포지션의 '이미 기록된 승패' 표식.
# 전량 청산 접수 시 _finalize_exit_impl 이 당일 연패(update_pnl)·loss_tracker 를 기록하고,
# 그 매도가 0체결·부분 종결로 복원되면 기록은 되돌리지 않는다(§8.5-27). 복원 포지션이 다시
# 전량 청산될 때 같은 왕복을 또 세면 거래 1건이 3연패/42일 밴 카운트를 2 올린다.
# 값: True(손실로 기록됨) / False(이익·비손실로 기록됨) / 키 없음(기록 안 됨).
# ⚠️ 인메모리 전용 — 재기동하면 사라져 재청산 시 다시 기록된다(보수적 방향).
_RESTORED_STREAK_MARK_KEY = "restored_streak_is_loss"
_RESTORED_LOSS_TRACKER_MARK_KEY = "restored_loss_tracker_is_loss"
# _finalize_exit_impl → _on_unfilled 로 넘기는 스냅샷 전용 키(포지션에는 남기지 않는다)
_FULL_EXIT_RECORD_KEY = "_full_exit_record"
# [R6-1 2026-10-10] 당일 매수분 고아 표식. _detect_dt_orphans_impl 이 당일 창의 DT 원장 순체결이
# 양수인 고아에 붙인다. 이 포지션은 실보유·타전략 차감·재시도 관리는 전일·고아(stale) 경로를
# 그대로 타되, 실현손익은 **당일** daily_pnl·loss_streak_today 에 넣는다(§7 이 당일 집계 제외를
# 허용하는 것은 '전일' 포지션뿐 — 당일 사고 판 손실이 -3% 한도·3연패 쿨다운을 우회하던 결함).
_ORPHAN_TODAY_KEY = "orphan_today"
# 당일 고아 청산 사유 접두사 — position_manager._STALE_EXIT_REASON_PREFIXES("DT 고아 잔고 청산")와
# **겹치지 않아야** 재기동 원장 재구성(_rebuild_daily_state_from_rows)이 당일 손익에 포함한다.
_ORPHAN_TODAY_EXIT_REASON_PREFIX = "DT 당일 고아 잔고 청산"
def _is_orphan_today_position(pos) -> bool:
"""[R6-1] 당일 매수분 고아(당일 손익 집계 대상)인가."""
return bool((pos or {}).get(_ORPHAN_TODAY_KEY))
def _should_record_outcome_impl(prev_mark, is_loss: bool) -> bool:
"""같은 왕복에 대해 승패를 (다시) 기록해야 하는가.
- 이전 기록 없음(None) → 기록
- 이전과 같은 결과 → 이미 셌으므로 기록 안 함(이중 집계 방지)
- 이전=비손실, 이번=손실 → 기록(손실을 놓치지 않는 보수적 방향)
- 이전=손실, 이번=비손실 → 기록 안 함(이미 센 손실을 유지 — 보수적 방향)
"""
if prev_mark is None:
return True
return bool(is_loss) and prev_mark is False
def _read_outcome_mark_impl(src: dict | None, key: str):
"""포지션 dict 의 승패 표식(True/False/None). bool 이 아니면 None(기록 안 됨으로 간주)."""
if not isinstance(src, dict):
return None
v = src.get(key)
return v if isinstance(v, bool) else 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():
# [R1-4 2026-10-09] 전일(stale) 포지션의 미실현(오버나이트 갭 포함)은 당일 한도에 넣지 않는다.
# 실현손익도 당일에 넣지 않는 규칙(§7, count_daily_pnl=False)과 같은 기준이다. 넣으면
# 갭 하락만으로 -3% 가 오발동해 당일 정상 포지션까지 force_close_all 로 청산된다.
if _is_stale_position(_pos):
continue
_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, _fast_failed = await _run_exit_checks_bounded_v2_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, _full_failed = await _run_exit_checks_bounded_v2_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])
# [R3-3] 2단계 실패·판정 불가 종목은 연속 실패로 센다.
# [R4-3 2026-10-09] 1단계에서 '처리됨'으로 빠진 종목도 그 처리가 실패(손절 주문 KIS 거부·예외)
# 였으면 실패로 센다 — 예전에는 handled 종목이 _full_failed 에 없어 카운터가 0 으로 리셋됐다.
# (1단계 현재가 미확보 등 handled 가 아닌 종목은 2단계 결과가 우선한다.)
_update_exit_check_health_impl(
codes_snapshot, set(_full_failed) | (set(_fast_failed) & handled), "청산 판정",
)
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, _fast_failed = await _run_exit_checks_bounded_v2_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])
# [R3-3] ORB 구간은 선판정이 유일한 손절 판정 — 현재가 미확보도 실패로 센다.
_update_exit_check_health_impl(codes, _fast_failed, "ORB 구간 손절 판정")
return exits
async def _run_exit_checks_bounded_impl(codes, fn, timeout: float, label: str) -> dict:
"""[H4] 포지션별 청산 판정을 제한 동시성 + 포지션별 상한으로 실행. {code: 결과}.
주문~상태 반영은 _execute_exit_impl 이 shield 하므로, 상한 초과로 판정 코루틴이
취소돼도 이미 전송된 주문의 반영은 끝까지 진행된다.
"""
out, _failed = await _run_exit_checks_bounded_v2_impl(codes, fn, timeout, label)
return out
# [R3-3 2026-10-09] 판정 실패 수집기 — 판정 단계마다 새 set 을 ContextVar 로 내려보낸다.
# gather 가 만든 자식 태스크는 컨텍스트를 복사하므로 같은 set 을 본다(동시 사이클 간 섞이지 않음).
_exit_failed_cv: contextvars.ContextVar = contextvars.ContextVar("dt_exit_failed_codes", default=None)
# [R3-3] 종목별 '판정 실패' 연속 사이클 수 / 이번 연속 구간에서 경보를 이미 보낸 종목
_exit_check_failures: dict[str, int] = {}
_exit_stall_alerted: set[str] = set()
# [R3-3] 경보 기준 연속 사이클 수 기본값(config 미편입 — settings.DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES 우선)
_EXIT_CHECK_FAIL_ALERT_CYCLES_DEFAULT = 4
def _mark_exit_check_failed_impl(stock_code: str) -> None:
"""[R3-3] 현재 판정 단계의 수집기에 '판정 실패(손절 미판정)'를 기록. 수집기 없으면 무시."""
_c = _exit_failed_cv.get()
if _c is not None:
_c.add(stock_code)
async def _run_exit_checks_bounded_v2_impl(codes, fn, timeout: float, label: str) -> tuple[dict, set]:
"""[R3-3] _run_exit_checks_bounded_impl + 판정 실패 종목 집합. ({code: 결과}, {실패 code}).
실패 = 포지션별 상한 초과·예외, 또는 판정 함수가 _mark_exit_check_failed_impl 로 알린
'판정 불가'(폴백 손절 현재가 미확보 등). 예전에는 전부 삼키고 정상 반환해 스케줄러가
'청산 체크 성공'으로 기록했다 — 손절이 멈춰도 경보가 없었다.
"""
out: dict = {}
failed: set = set()
if not codes:
return out, failed
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:
failed.add(code)
logger.warning(
f"[DayExit] ⏱️ {label} {timeout:.0f}초 초과 ({code}) — 이번 사이클 스킵, "
f"다음 사이클 재판정"
)
except Exception as e:
failed.add(code)
logger.error(f"[DayExit] {label} 오류 ({code}): {e}", exc_info=True)
_tok = _exit_failed_cv.set(failed)
try:
await asyncio.gather(*(_one(c) for c in codes))
finally:
_exit_failed_cv.reset(_tok)
return out, failed
def _exit_check_fail_alert_cycles_impl() -> int:
try:
return max(1, int(getattr(settings, "DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES",
_EXIT_CHECK_FAIL_ALERT_CYCLES_DEFAULT)))
except (TypeError, ValueError):
return _EXIT_CHECK_FAIL_ALERT_CYCLES_DEFAULT
# [R4-3 2026-10-09] 종목별 청산 매도 주문 '거부'(rejected) 연속 횟수 / 이번 연속 구간 경보 보낸 종목.
# 접수(accepted) 1회면 리셋. 전일(stale) 경로는 _stale_sell_failures 가 따로 센다(여기서 제외).
_exit_reject_streak: dict[str, int] = {}
_exit_reject_alerted: set[str] = set()
def _note_exit_order_rejected_impl(
stock_code: str, quantity: int, msg_cd, msg1, reason: str,
) -> None:
"""[R4-3] DT 청산 주문 거부 1건 기록 — 판정 실패 수집기(헬스)에 넣고, N 회 연속이면
텔레그램(DT_EXIT_REJECT). 예전에는 logger.error 만 남고 헬스는 '성공'으로 리셋돼,
손절선에 닿은 포지션의 시장가 매도가 계속 거부돼도 15:20 EOD 재시도 실패 전까지 경보가 없었다.
매매 로직(재시도 주기·수량)은 바꾸지 않는다."""
_mark_exit_check_failed_impl(stock_code)
n = _exit_reject_streak.get(stock_code, 0) + 1
_exit_reject_streak[stock_code] = n
_limit = _exit_check_fail_alert_cycles_impl()
if n >= _limit and stock_code not in _exit_reject_alerted:
_exit_reject_alerted.add(stock_code)
_alert_once_v2(
"DT_EXIT_REJECT", f"{stock_code}:{time.monotonic():.0f}",
f"[데이트레이딩] {stock_code} 청산 매도 주문이 {n}회 연속 거부됨 — 손절·청산이 집행되지 "
f"않고 있습니다. 수량={int(quantity or 0)}주, msg_cd={msg_cd}, msg1={msg1}, 사유={reason}. "
f"계좌(주문가능수량·계좌 거부 코드)를 확인하고 필요하면 수동 청산하세요.",
)
def _note_exit_order_accepted_impl(stock_code: str) -> None:
"""[R4-3] 청산 주문 접수 → 거부 연속 횟수·경보 상태 해제."""
_exit_reject_streak.pop(stock_code, None)
_exit_reject_alerted.discard(stock_code)
def _update_exit_check_health_impl(codes, failed: set, label: str) -> None:
"""[R3-3] 이번 사이클 판정 결과로 종목별 연속 실패 수 갱신 + N 사이클 연속이면 텔레그램(DT_EXIT_STALL).
청산된(인메모리에 없는) 종목은 카운터를 지운다. 성공 1회면 0 으로 되돌리고 경보 상태도 푼다
(다시 N 사이클 연속 실패하면 다시 경보).
"""
_limit = _exit_check_fail_alert_cycles_impl()
for code in list(codes or []):
if code not in day_trading_state.positions:
_exit_check_failures.pop(code, None)
_exit_stall_alerted.discard(code)
continue
if code not in failed:
_exit_check_failures.pop(code, None)
_exit_stall_alerted.discard(code)
continue
n = _exit_check_failures.get(code, 0) + 1
_exit_check_failures[code] = n
if n >= _limit and code not in _exit_stall_alerted:
_exit_stall_alerted.add(code)
_qty = int((day_trading_state.positions.get(code) or {}).get("quantity", 0) or 0)
_alert_once_v2(
"DT_EXIT_STALL", f"{code}:{time.monotonic():.0f}",
f"[데이트레이딩] {code}({_qty}주) 청산 판정({label})이 {n}사이클 연속 실패 — 손절·트레일링·"
f"시간손절이 멈춘 상태일 수 있습니다(KIS 시세·분봉 조회 장애 추정). 계좌를 확인하고 "
f"필요하면 수동 청산하세요.",
)
for code in [c for c in _exit_check_failures if c not in day_trading_state.positions]:
_exit_check_failures.pop(code, None)
_exit_stall_alerted.discard(code)
# [R4-3] 청산돼 인메모리에서 사라진 종목의 거부 카운터 정리
for code in [c for c in _exit_reject_streak if c not in day_trading_state.positions]:
_exit_reject_streak.pop(code, None)
_exit_reject_alerted.discard(code)
def get_exit_check_health_v2() -> dict:
"""[R3-3] 청산 판정 헬스 — {"failing": {code: 연속 실패 사이클}, "stalled": [경보 기준 이상 code],
"alert_cycles": N}. 스케줄러가 _record_dt_exit_result_v2(False) 판단에 쓸 수 있다."""
_limit = _exit_check_fail_alert_cycles_impl()
failing = {c: n for c, n in _exit_check_failures.items() if c in day_trading_state.positions}
return {
"failing": failing,
"stalled": sorted(c for c, n in failing.items() if n >= _limit),
"alert_cycles": _limit,
# [R4-3] 종목별 청산 주문 거부 연속 횟수
"order_rejects": {
c: n for c, n in _exit_reject_streak.items() if c in day_trading_state.positions
},
}
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
# [R1-4] 전일 포지션은 close_stale_positions 전용 — 일반 경로는 실보유·타전략 차감 없이
# 행 수량 전량을 팔고 전일 손익을 당일 daily_pnl·연패에 넣는다(§7 금지). '처리됨'으로 넘긴다.
if _is_stale_position(pos):
_warn_stale_in_general_path(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"— 분봉 판정으로 넘김"
)
_mark_exit_check_failed_impl(stock_code) # [R3-3] (2단계가 있으면 그쪽 결과가 우선)
return False, None
except Exception as e:
logger.warning(f"[DayExit] 손절 선판정 현재가 조회 실패 ({stock_code}): {e} — 분봉 판정으로 넘김")
_mark_exit_check_failed_impl(stock_code)
return False, None
if current_price <= 0:
_mark_exit_check_failed_impl(stock_code)
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
_remember_price_impl(stock_code, current_price) # [R3-4]
_rest_price_seen[stock_code] = (current_price, _kst_now()) # [R1-9] 2단계 재사용
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)
_mark_exit_check_failed_impl(stock_code) # [R4-3] 손절 미집행 → 헬스에 실패로
result = None
# 주문 실패여도 이번 사이클 분봉 판정에서 같은 손절을 재주문하지 않는다(다음 사이클 재시도).
return True, result
# [R1-9 2026-10-09] 손절 선판정(1단계)이 REST 로 받은 현재가 {code: (가격, 관측 KST naive)}.
_rest_price_seen: dict[str, tuple[float, datetime]] = {}
# [R3-4 2026-10-09] KIS 로 실제 관측한 현재가 {code: (가격, time.monotonic())} — 폴백 손절 대체가의
# 나이 판정용. pos['current_price'] 는 영속화·복원되는 값이라 시각을 싣지 않고 여기 따로 둔다
# (재기동 후엔 비어 있어 대체가 없음 = 판정 불가로 처리된다).
_price_seen_mono: dict[str, tuple[float, float]] = {}
# 대체가 최대 나이 기본값(초, config 미편입 — settings.DAY_TRADE_FALLBACK_PRICE_MAX_AGE_SEC 우선)
_DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT = 180.0
def _remember_price_impl(stock_code: str, price: float) -> None:
"""[R3-4] KIS 실관측 현재가를 monotonic 시각과 함께 기록."""
try:
p = float(price)
except (TypeError, ValueError):
return
if p > 0:
_price_seen_mono[stock_code] = (p, time.monotonic())
def _dt_fallback_price_max_age_impl() -> float:
try:
v = float(getattr(settings, "DAY_TRADE_FALLBACK_PRICE_MAX_AGE_SEC", _DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT))
except (TypeError, ValueError):
v = _DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT
return v if v > 0 else _DT_FALLBACK_PRICE_MAX_AGE_SEC_DEFAULT
def _fresher_rest_price_impl(stock_code: str, candles) -> float | None:
"""[R1-9] 2단계 판정가로 1단계 REST 현재가를 쓸지. 쓸 값 또는 None(분봉 종가 사용).
조건: (a) 같은 사이클 관측(나이 ≤ 선판정 상한 + 분봉 판정 상한) (b) 마지막 1분봉 라벨 + 60초
이후의 관측 — 라벨이 분 시작/종료 어느 규약이어도 그 봉이 끝난 뒤의 가격이라 분봉 종가보다
신선하다. 라벨을 알 수 없으면 (a) 만으로 판단한다.
"""
_rp = _rest_price_seen.get(stock_code)
if not _rp or _rp[0] <= 0:
return None
price, seen_at = _rp
_max_age = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC)
if (_kst_now() - seen_at).total_seconds() > _max_age:
return None
last_dt = None
for c in candles or []:
cdt = _candle_datetime(c)
if cdt is not None and (last_dt is None or cdt > last_dt):
last_dt = cdt
if last_dt is not None and seen_at < last_dt + timedelta(seconds=_POST_ENTRY_CANDLE_LAG_SEC):
return None
return float(price)
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
# [R3-1 2026-10-09] 청산 RSI 워밍업 — 진입 판정과 같은 시계열 기준으로 맞춘다.
# 진입(entry_service._fetch_candles_impl)은 전일 봉을 붙인 시계열로 RSI(14)를 계산하는데
# 청산은 당일 봉만 써서, 당일 5분봉 15개 미만(약 10:10 이전)에는 rsi_wilder 가 None 이라
# '과매수 청산'이 구조적으로 발동하지 않았고 이후에도 진입/청산 RSI 시드가 달랐다.
# 전일 봉은 장중에 바뀌지 않으므로 종목별로 하루 1회만 확보해 재사용한다(KIS 호출 최소화).
# 확보 실패·빈 결과면 _RSI_WARMUP_RETRY_SEC 마다 재시도하고, 그동안은 종전대로 당일 봉만 쓴다.
# 값 {종목: (당일 영업일자, 확보 시각 monotonic, 전일 1분봉 리스트)}
_rsi_warmup_prev_cache: dict[str, tuple[str, float, list]] = {}
_RSI_WARMUP_RETRY_SEC: float = 300.0 # 운영 상수(전략 파라미터 아님) — 빈 결과 재시도 간격
_RSI_WARMUP_FETCH_TIMEOUT_SEC: float = 15.0 # 워밍업 조회 상한 — 포지션별 판정 상한(45s) 안에서 끝나게
def _prev_session_candles_impl(candles: list, today_date: str) -> list:
"""`today_date` 보다 이전 영업일자의 1분봉만 남긴다(영업일자 빈 봉 제외)."""
out = []
for c in candles or []:
d = getattr(c, "date", "") or ""
if d and d < today_date:
out.append(c)
return out
async def _rsi_warmup_prev_candles_impl(stock_code: str, today_date: str) -> list:
"""[R3-1] 청산 RSI 워밍업용 전일 1분봉. 실패해도 예외를 올리지 않고 [] 를 돌려준다.
순서: 모듈 캐시(당일 1회) → 진입 스캔 캐시(`entry_service._candle_cache`, 같은 시계열)
→ `get_minute_chart_full_day_v2(include_prev_day=True)` 직접 조회.
"""
if not today_date:
return []
now = time.monotonic()
cached = _rsi_warmup_prev_cache.get(stock_code)
if cached and cached[0] == today_date:
if cached[2]:
return cached[2]
if (now - cached[1]) < _RSI_WARMUP_RETRY_SEC:
return []
prev: list = []
# 1) 진입 스캔 캐시 재사용 — 네트워크 없음
try:
from app.services.daytrading import entry_service as _ens
ent = _ens._candle_cache.get(stock_code)
if ent:
prev = _prev_session_candles_impl(ent[1], today_date)
except Exception as e:
logger.warning(f"[DayExit] 진입 분봉 캐시 참조 실패 ({stock_code}): {e}")
prev = []
# 2) 직접 조회 (진입과 같은 API·같은 옵션)
if not prev:
try:
raw = await asyncio.wait_for(
korea_investment_api.get_minute_chart_full_day_v2(
stock_code, include_prev_day=True
),
timeout=_RSI_WARMUP_FETCH_TIMEOUT_SEC,
)
prev = _prev_session_candles_impl(parse_minute_candles(raw or []), today_date)
except Exception as e:
warn_throttled(
logger, f"dt_exit:rsi_warmup:{stock_code}",
f"[DayExit] 청산 RSI 워밍업 조회 실패 ({stock_code}): {e!r} "
f"— 당일 봉만으로 RSI 판정({_RSI_WARMUP_RETRY_SEC:.0f}초 후 재시도)",
)
prev = []
# 당일 외 항목 정리(무한 증식 방지)
for k in [k for k, v in _rsi_warmup_prev_cache.items() if v[0] != today_date]:
_rsi_warmup_prev_cache.pop(k, None)
_rsi_warmup_prev_cache[stock_code] = (today_date, now, prev)
if not prev:
warn_throttled(
logger, f"dt_exit:rsi_warmup_empty:{stock_code}",
f"[DayExit] 청산 RSI 워밍업 전일 봉 0개 ({stock_code}) — 당일 5분봉 15개 미만 구간은 "
f"과매수 청산(RSI≥{float(settings.DAY_TRADE_RSI_EXIT_OVERBOUGHT):g}) 판정 불가",
)
return prev
def _exit_signal_candles_5m_impl(today_candles: list, prev_candles: list) -> list:
"""청산 판정용 5분봉 = 전일 워밍업 + 당일 봉 (진입 판정 시계열과 같은 구성).
`check_day_trade_exit` 에서 이 시계열이 쓰이는 곳은 `_rsi`(워밍업 의도)와
`_atr`(내부에서 `_last_session_candles` 로 당일 세션만 사용 — 갭 TR 미포함)뿐이므로
ATR·트레일링은 당일 봉 기준 그대로다. 현재가·고점은 호출부가 당일 봉으로 따로 구한다.
"""
if not prev_candles:
return aggregate_to_5min(today_candles)
return aggregate_to_5min(list(prev_candles) + list(today_candles))
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
# [R1-4] 전일 포지션은 close_stale_positions 전용(실보유·타전략 차감·당일 손익 제외)
if _is_stale_position(pos):
_warn_stale_in_general_path(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)
# [R1-9] 1단계 REST 현재가가 마지막 1분봉보다 확실히 뒤의 관측이면 그 값을 쓴다
# (분봉은 1분 버킷 캐시라 최대 1분 묵을 수 있다 — 낡은 종가로 신선한 현재가를 덮어쓰지 않게).
_rest_px = _fresher_rest_price_impl(stock_code, candles)
if _rest_px is not None:
current_price = _rest_px
# 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
_remember_price_impl(stock_code, current_price) # [R3-4]
if new_highest > highest_price:
highest_price = new_highest
# [R3-1] RSI 판정 시계열만 진입과 같은 워밍업 구성으로 맞춘다(임계값 불변).
# 현재가·고점은 위에서 당일 봉으로 이미 확정했고, ATR 은 _atr 이 당일 세션만 쓴다.
_today_date = (getattr(candles[-1], "date", "") or "") if candles else ""
_prev_warm = await _rsi_warmup_prev_candles_impl(stock_code, _today_date)
candles_5m_signal = _exit_signal_candles_5m_impl(candles, _prev_warm)
# 청산 신호 체크
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_signal,
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}")
_fresh_from_kis = current_price > 0
if not _fresh_from_kis:
# [R3-4] 대체가는 나이 상한 안의 최근 관측가만 쓴다. 예전에는 시각 없는 pos['current_price']
# 를 써서, 수십 분 전 가격이 손절가보다 높으면 '미도달'로 조용히 통과했다.
_max_age = _dt_fallback_price_max_age_impl()
_cached = _price_seen_mono.get(stock_code)
_age = (time.monotonic() - _cached[1]) if _cached else None
if _cached and _cached[0] > 0 and _age is not None and 0 <= _age <= _max_age:
current_price = float(_cached[0])
logger.warning(
f"[DayExit] 폴백 손절 대체가 사용 ({stock_code}): {current_price:.0f} (나이 {_age:.0f}초 ≤ {_max_age:.0f}초)"
)
else:
# 판정 불가 — '손절 미도달'과 구분한다(error + 연속 실패 집계 → N 사이클이면 텔레그램).
_mark_exit_check_failed_impl(stock_code)
_error_throttled(
f"dt_exit:fallback_no_price:{stock_code}",
f"[DayExit] 🚨 폴백 손절 판정 불가 ({stock_code}) — KIS 현재가 실패, 대체가 "
+ (f"나이 {_age:.0f}초 > {_max_age:.0f}초" if _age is not None else "없음")
+ " — 손절 미적용 상태, 수동 확인 필요",
)
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 _fresh_from_kis:
_remember_price_impl(stock_code, 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
elif _is_stale_position(pos):
# [R1-4] 공통 종단 가드 — 전일 포지션은 is_stale=True 경로(close_stale_positions)만 판다.
# 일반 경로는 실보유·타전략 차감 없이 행 수량 전량을 팔고 전일 손익을 당일에 넣는다.
_warn_stale_in_general_path(stock_code, f"일반 청산({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 _warn_stale_in_general_path(stock_code: str, label: str) -> None:
"""[R1-4] 일반 청산 경로가 전일 포지션을 만났다 — 건너뛰고 stale 경로에 맡긴다(로그 스로틀)."""
warn_throttled(
logger, f"dt_exit:stale_in_general:{stock_code}",
f"[DayExit] {stock_code} 전일 미청산 포지션 — {label}·EOD·일일한도 경로에서 제외, "
f"close_stale_positions(실보유·타전략 차감, 당일 손익 제외)만 청산한다",
)
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 보유,
# "reason"/"price"/"sell_pct": 원 주문의 청산 사유·판정가·매도비율 (R4-2)}}
# [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, _complete = await _fetch_kis_holdings_v3_impl()
if h is not None and _holding_unknown_v2(h, _complete, stock_code):
# [R3-1] 응답에 없는데 첫 페이지가 가득 참 → 0주(=접수)로 확정하지 않는다(판단 불가·보류).
logger.warning(
f"[DayExit] {stock_code} 결과 불명 매도 판정 보류 — KIS 잔고 첫 페이지 가득 참·종목 미발견"
f"(다음 페이지에 있을 수 있음)"
)
return "lookup_failed", None, 0
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))
# [C-1 r9] v3 가 '보유 기준 DT 몫 온전 여부'를 보도록 판정 근거를 남긴다(KIS 재조회 없이)
_last_unknown_resolve_snapshot[stock_code] = (hldg, sellable, 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 _fetch_dt_ledger_net_qty_impl(stock_code: str) -> int | None:
"""[C-1 r9] DT 원장(TradeHistory strategy_type='daytrading') 순체결수량. 실패면 None.
창은 DAY_TRADE_ORPHAN_LOOKBACK_DAYS(전일 포지션 경로도 덮도록 당일만이 아니다).
집계는 _dt_net_qty_from_history — BUY 는 COMPLETED 만, SELL 은 FAILED/REJECTED 외 전부
(PENDING 포함)를 차감한다. 결과 불명 매도의 재매도 상한에서만 호출되는 드문 경로다.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import TradeHistory
from sqlalchemy import select as _select
_since = datetime.combine(
_kst_now().date() - timedelta(days=int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS)),
datetime.min.time(),
)
# DB-READ-EXCEPTION: §3.2 ③-c 와 같은 읽기 전용 SELECT 1건, commit/flush/add 없음.
# 쓰기는 db_worker 경유. 결과 불명 매도의 재매도 상한 확인에서만 호출된다.
async with AsyncSessionLocal() as _db:
_rows = (await _db.execute(
_select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.strategy_type == "daytrading",
TradeHistory.created_at >= _since,
)
)).scalars().all()
return int(_dt_net_qty_from_history(_rows).get(stock_code, 0))
except Exception as e:
logger.error(f"[DayExit] DT 원장 순체결 조회 실패({stock_code}): {e}")
return None
# [C-1 r9] v2 판정 근거 스냅샷 {code: (hldg, sellable, 비DT)} — v3 가 즉시 pop 한다
_last_unknown_resolve_snapshot: dict[str, tuple[int, int, int]] = {}
# [C-1 r9] 재매도 보류 상태(판정 불가) — lookup_failed 와 같은 처리(보류·N회 경보)
_UNKNOWN_HOLD_STATUSES = ("lookup_failed", "ambiguous", "ledger_failed", "orders_failed", "ledger_nonpositive")
async def _lookup_todays_orders_v2_impl(stock_code: str) -> list | None:
"""[C-1 r9 2차] 당일 주문내역 — **조회 실패·시간초과면 None**(get_todays_orders_v2 계약).
v1(_lookup_todays_orders_impl)은 실패에도 [] 를 받아 "주문 없음"과 구분할 수 없었다.
호출부는 None 을 판정 보류(재매도 금지)로 취급한다.
"""
try:
return await asyncio.wait_for(
korea_investment_api.get_todays_orders_v2(stock_code),
timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC),
)
except asyncio.TimeoutError:
logger.warning(f"[DayExit] 당일 주문내역(v2) 조회 시간초과 ({stock_code}) — 판정 보류")
except Exception as e:
logger.warning(f"[DayExit] 당일 주문내역(v2) 조회 실패 ({stock_code}): {e} — 판정 보류")
return None
def _find_accepted_sell_row_strict_v2(rows, stock_code: str, qty: int, since) -> dict | None:
"""[C-1 r9 2차] 불명 주문 등록 시각 이후·수량 일치·유효(체결>0 또는 접수 상태) 매도 행.
_find_accepted_sell_row 와 달리 **주문시각(ord_tmd)이 없거나 since 가 없으면 매칭하지 않는다**
(시각 근거 없는 같은 수량 행 — 스윙 매도 등 — 을 DT 접수로 오인하지 않게). since 에서
DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC 만큼만 앞당겨 시계 오차를 허용한다.
"""
if since is None:
return 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
try:
if int(float(r.get("ord_qty", 0) or 0)) != int(qty):
continue
except (TypeError, ValueError):
continue
t = str(r.get("ord_tmd", "") or "")
if not t or 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 _resolve_unknown_exit_v3_impl(
stock_code: str, qty: int, since, total_qty: int,
) -> tuple[str, dict | None, int]:
"""[C-1 r9, 2차 2026-10-12] v2 판정 + HTS·수동 보유분(ActiveTrade 없음) 재매도 방지.
v2 는 "DT 몫 = min(주문가능, 보유) − 비-DT active" 로 본다. ActiveTrade 없는 수동 보유분 M 이
있으면 원 주문이 체결됐어도 not_accepted 가 되고 재시도가 수동 물량을 판다. 결과 불명 SELL 은
TradeHistory 행을 남기지 않아 원장도 매수 수량 그대로다(원장만으로는 못 막는다).
v2 가 not_accepted 일 때 순서대로:
1) 당일 주문내역 v2(실패면 None). None → "orders_failed"(보류). 등록 시각 이후·수량 일치·유효
매도 행이 있으면 **온전 여부와 무관하게** accepted.
2) 행 없음 + 보유 기준 DT 몫(보유 − 비DT) < 주문 전 수량 → "ambiguous"(보류).
3) 원장 순체결 실패 → "ledger_failed", ≤ 0 → "ledger_nonpositive"(둘 다 보류 + error·경보).
4) 그 외(주문내역 [] = KIS 미접수 실증, 원장 > 0) → 재매도, 상한 min(DT 매도가능, 원장, q).
⚠️ 잔여: 주문내역이 첫 페이지에 안 보이면(당일 주문 다수) 4) 로 떨어져 수동 물량을 최대 q 주
팔 수 있다. 같은 수량·등록 이후 스윙 매도는 1) 에서 DT 접수로 오인될 수 있다(과소매도 쪽).
상한 계산은 try 밖. 보류 상태들은 _UNKNOWN_HOLD_STATUSES.
"""
st, resp, dt_sellable = await _resolve_unknown_exit_v2_impl(stock_code, qty, since, total_qty)
_snap = _last_unknown_resolve_snapshot.pop(stock_code, None)
if st != "not_accepted":
return st, resp, dt_sellable
# 1) 주문내역(v2) — 조회 실패(None)면 보류. 행이 있으면 온전 판정과 무관하게 접수 확정.
rows = await _lookup_todays_orders_v2_impl(stock_code)
if rows is None:
return "orders_failed", None, 0
row = _find_accepted_sell_row_strict_v2(rows, stock_code, qty, since)
if row is not None:
odno = str(row.get("odno", "") or "")
logger.warning(
f"[DayExit] {stock_code} 결과 불명 매도 → 당일 주문내역 일치 행(등록 이후·{qty}주, "
f"ODNO={odno or '미확인'})으로 접수 판정 (잔고상 DT 매도가능 {dt_sellable}주)"
)
return "accepted", {"rt_cd": "0", "msg1": "resolved_by_orders", "output": {"ODNO": odno}}, dt_sellable
# 2) 행 없음 + 보유 기준 DT 몫이 온전하지 않음 → 판정 불가(보류)
intact = _snap is None or (int(_snap[0]) - int(_snap[2])) >= int(total_qty)
if not intact:
logger.error(
f"[DayExit] 🚨 {stock_code} 결과 불명 매도 판정 불가 — 보유 {_snap[0]}주 − 비DT {_snap[2]}주가 "
f"주문 전 DT {total_qty}주보다 적은데 DT 매도가능 {dt_sellable}주가 주문 후 기대치"
f"({max(0, int(total_qty) - int(qty))}주)보다 많고 주문내역 일치 행도 없음. "
f"ActiveTrade 없는 수동·HTS 보유분일 수 있어 재매도 보류"
)
return "ambiguous", None, 0
# 3) 원장 — 실패·순체결 ≤ 0 이면 보류(과다매도보다 보류, §3.5)
ledger = await _fetch_dt_ledger_net_qty_impl(stock_code)
if ledger is None:
return "ledger_failed", None, 0
if ledger <= 0:
logger.error(
f"[DayExit] 🚨 {stock_code} 결과 불명 매도 — DT 원장 순체결 {ledger}주(≤0)라 DT 몫을 확인할 수 "
f"없음. 재매도 보류(원장 반영 지연이면 다음 확인에서 해소)"
)
return "ledger_nonpositive", None, 0
# 4) 주문내역 [] 실증(KIS 가 접수한 적 없음) + 원장 > 0 → 재매도 허용, 상한 min(DT 매도가능, 원장, q)
capped = min(int(dt_sellable), int(ledger), int(qty))
if capped < dt_sellable:
logger.warning(
f"[DayExit] {stock_code} 결과 불명 재매도 상한 축소: DT 매도가능 {dt_sellable} → "
f"min(원장 {ledger}, 원 주문 {qty}) = {capped}주"
)
return "not_accepted", None, capped
def _note_unknown_hold_v2(stock_code: str, status: str) -> None:
"""[C-1 r9] 보류 사유별 로그. 조회 실패 계열은 기존 N회 연속 텔레그램 경보를 공유한다."""
if status == "orders_failed":
_error_throttled(
f"dt_exit:unknown_orders_failed:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 당일 주문내역 조회 실패 — 재매도 보류 중",
)
if status == "ledger_nonpositive":
_error_throttled(
f"dt_exit:unknown_ledger_nonpos:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + DT 원장 순체결 ≤ 0 — 재매도 보류 중. 계좌 확인 필요",
)
if status == "ambiguous":
_error_throttled(
f"dt_exit:unknown_ambiguous:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 접수 여부 판정 불가(수동 보유분 혼재 의심) — "
f"재매도 보류 중. 계좌 확인 필요",
)
_note_unknown_lookup_failure(stock_code)
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:
# [C-1 r9] v3 — 수동·HTS 보유분 혼재 시 not_accepted 오판 방지 + DT 원장 순체결 상한
_st, _resp, _dt_sellable = await _resolve_unknown_exit_v3_impl(
stock_code, int(_prev["qty"]), _prev["since"],
int(_prev.get("total_qty", total_qty)),
)
if _st in _UNKNOWN_HOLD_STATUSES:
# 판단 불가 — 재매도하지 않고 보류를 이어간다(guarded finally 가 HOLD 뒤 해제)
_last_exit_order_outcome[stock_code] = "unknown"
_note_unknown_hold_v2(stock_code, _st)
return None
_unknown_lookup_failures.pop(_ukey, None)
if _st == "accepted":
_unresolved_exit_unknown.pop(_ukey, None)
_last_exit_order_outcome[stock_code] = "accepted"
if not is_stale:
_note_exit_order_accepted_impl(stock_code) # [R4-3]
_pq = min(int(_prev["qty"]), int(total_qty))
# [R4-2 2026-10-09] 확정되는 것은 **원 주문**이다 — 이번 사이클의 판정(사유·가격)이
# 아니라 등록 때 저장한 원 사유·가격·매도비율로 반영한다. 이번 사유로 확정하면
# 손절이 시간손절로 기록돼 loss_tracker 에서 빠지거나(§5.1) 그 반대가 되고,
# TradeHistory exit_reason·(ODNO 미발견 시) 순손익도 틀린다.
# 옛 엔트리(키 없음)는 이번 호출 값으로 폴백(구 동작).
_o_reason = _prev.get("reason") or reason
try:
_o_price = float(_prev.get("price") or 0) or price
except (TypeError, ValueError):
logger.warning(f"[DayExit] {stock_code} 불명 주문 원 가격 파싱 실패 — 이번 판정가 사용")
_o_price = price
_o_sell_pct = _prev.get("sell_pct", sell_pct)
if _o_reason != reason:
logger.warning(
f"[DayExit] {stock_code} 직전 결과 불명 매도 접수 확정 — 원 사유 '{_o_reason}'·"
f"가격 {_o_price:,.0f} 로 반영(이번 판정 '{reason}'·{price:,.0f} 아님)"
)
return await _finalize_after_accept_impl(
stock_code, pos, _o_price, _o_reason, _o_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 수동 확인)",
)
if not is_stale:
# [R4-3] 매도 불가 상태도 청산 미집행 — 헬스·연속 거부 경보에 반영
_note_exit_order_rejected_impl(
stock_code, quantity, "DT_SELLABLE_ZERO", "직전 결과 불명 + DT 매도가능 0", reason,
)
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,
# [R4-2] 접수 확정 시 원 주문 기준으로 반영하기 위한 원 사유·가격·매도비율
"reason": reason, "price": price, "sell_pct": sell_pct,
}
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')}"
)
if not is_stale:
# [R4-3] 거부 연속 횟수 → 헬스 실패 + N 회째 텔레그램. 전일 경로는 _stale_sell_failures 담당.
_note_exit_order_rejected_impl(
stock_code, quantity, _r.get("msg_cd"), _r.get("msg1"), reason,
)
return None
_last_exit_order_outcome[stock_code] = "accepted"
if not is_stale:
_note_exit_order_accepted_impl(stock_code) # [R4-3]
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 이면 당일 손익/연속손실 집계를 건너뛰고
전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로).
"""
entry_price = pos.get("entry_price", 0)
stock_name = pos.get("stock_name", stock_code)
order_id = order_resp.get("output", {}).get("ODNO", "")
# [R6-1] 당일 매수분 고아는 stale 경로(count_daily_pnl=False)로 들어와도 당일 손익·연패에 넣는다.
# 인메모리·closed_today·체결 0 복원 처리는 원래 플래그(count_daily_pnl)를 그대로 따른다.
_count_today = bool(count_daily_pnl) or _is_orphan_today_position(pos)
# [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 직접 접근 금지)
_recent_dt_sell_finalized[stock_code] = time.monotonic()
# [R2-4 2026-10-09] 왕복(분할 익절 포함) 순손익. 분할 매도 레그의 순손익을 포지션에
# 누적(realized_net)해 두고, 전량 청산 때 마지막 레그와 합쳐 승패(당일 연패·loss_tracker)를
# 판정한다. 예전에는 마지막 레그만 봐 +1.5R 분할 후 잔량 -0.1% 청산이 '손실'로 셌다.
# ⚠️ 인메모리 전용 — 재기동하면 누적이 사라져 마지막 레그 기준(구 동작)으로 돌아간다.
_prior_realized = 0.0
# [R4-1 2026-10-09] 분할 레그가 0체결로 종결되면 _on_unfilled 가 되돌릴 수 있도록
# 분할 전 목표가·partial_sold 를 보관한다(아래에서 positions[code] 를 inf/True 로 바꾸기 전).
_pre_partial_state: dict | None = None
async with day_trading_state.trade_lock:
_mem_pos = day_trading_state.positions.get(stock_code)
if not is_full_exit and _mem_pos is not None:
_pre_partial_state = {
"target_price": _mem_pos.get("target_price"),
"partial_sold": bool(_mem_pos.get("partial_sold", False)),
}
_src = _mem_pos if _mem_pos is not None else (pos or {})
try:
_prior_realized = float(_src.get("realized_net", 0.0) or 0.0)
except (TypeError, ValueError):
logger.warning(f"[DayExit] {stock_code} realized_net 파싱 실패 — 마지막 레그 기준 판정")
_prior_realized = 0.0
# [HL2-2] 0체결 복원 포지션이면 같은 왕복의 승패가 이미 기록돼 있다(표식).
_prev_streak_mark = _read_outcome_mark_impl(_src, _RESTORED_STREAK_MARK_KEY)
_prev_lt_mark = _read_outcome_mark_impl(_src, _RESTORED_LOSS_TRACKER_MARK_KEY)
_streak_mark = _prev_streak_mark
if is_full_exit:
day_trading_state.remove_position(stock_code)
# [2026-10-08] DT 전용 청산 집계(엔진 카드 '청산 N') — 인메모리에 없던 전일·고아 포지션 포함
day_trading_state.dt_exited_today.add(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
day_trading_state.positions[stock_code]["realized_net"] = _prior_realized + profit_loss
if _count_today:
if not count_daily_pnl:
logger.warning(
f"[DayExit] 당일 매수분 고아 청산 실현손익은 당일 집계에 포함: {stock_code} "
f"{profit_loss:+.0f}원 (일일 손실한도·당일 연패 반영)"
)
if is_full_exit and (_prior_realized != 0.0 or _prev_streak_mark is not None):
# daily_pnl 은 이 레그만 더하고, 연패는 왕복 순손익으로 판정한다.
day_trading_state.update_pnl(profit_loss, is_full_close=False)
_rt_streak = _prior_realized + profit_loss
if _rt_streak != 0.0 and _should_record_outcome_impl(_prev_streak_mark, _rt_streak < 0):
_apply_round_trip_streak_v2(_rt_streak)
_streak_mark = _rt_streak < 0
elif _rt_streak != 0.0:
logger.info(
f"[DayExit] {stock_code} 매도 미체결 복원 포지션 재청산 — 당일 연패는 이미 "
f"기록됨(이전 손실={_prev_streak_mark}) → 왕복 {_rt_streak:+.0f}원 재집계 생략"
)
else:
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
if is_full_exit and profit_loss != 0.0:
_streak_mark = profit_loss < 0
else:
_stale_now = _add_stale_realized_pnl(profit_loss)
logger.warning(
f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} "
f"{profit_loss:+.0f}원 (오늘 처리한 전일·고아 누적={_stale_now:+.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 큐잉")
# [R2-1 2026-10-09] loss_tracker 기록 시점 결정.
# 15:20~15:30 은 KRX 종가 단일가 구간이라 그때 낸 시장가 매도는 15:30 에 체결된다.
# 주문 직후 체결 확인(수 초)은 체결 0·미종결만 보고 끝나므로, 이 구간 매도는 15:30 이후
# 재확인한 **실제 체결가 기준 왕복 순손익**으로 loss_tracker 승패를 기록한다(_on_settled).
# 재확인이 실패하면 지금 값(신호가 기준)으로 기록한다 — 구 동작으로 폴백.
_record_loss_now = False
_on_settled = None
_auction = False
if is_full_exit and not is_time_stop_exit_reason(reason):
_auction = bool(
settings.DAY_TRADE_FILL_CHECK_ENABLED and order_id
and _in_closing_auction_impl(_kst_now())
)
if _auction:
async def _on_settled(leg_net):
if leg_net is None:
logger.warning(
f"[DayExit] {stock_code} 종가 단일가 매도 전량 체결 미확인(체결 0·부분) — "
f"loss_tracker 기록 보류(잔량이 청산될 때 기록)"
)
return
_rt = _prior_realized + float(leg_net)
if not _should_record_outcome_impl(_prev_lt_mark, _rt < 0):
logger.info(
f"[DayExit] {stock_code} 매도 미체결 복원 포지션 재청산 — loss_tracker 는 이미 "
f"기록됨(이전 손실={_prev_lt_mark}) → 왕복 {_rt:+.0f}원 재기록 생략"
)
return
logger.info(
f"[DayExit] {stock_code} 종가 단일가 체결 확인 후 연속손실 기록: "
f"왕복 순손익={_rt:+.0f}원 (신호가 기준 {_prior_realized + profit_loss:+.0f}원)"
)
async def _rec():
await loss_tracker.record_trade_result_safe(stock_code, _rt < 0)
await loss_tracker.save_to_db(stock_code=stock_code)
await _bounded_finalize_step(_rec(), stock_code, "loss_tracker 기록(체결 확인 후)")
else:
_record_loss_now = True
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정.
_scheduled = None
_snap_pos = dict(pos)
if _pre_partial_state is not None:
_snap_pos[_PRE_PARTIAL_STATE_KEY] = _pre_partial_state
# [HL2-2] 전량 청산 접수 때 기록한(할) 승패와 그 시점 왕복 누적을 스냅샷에 싣는다.
# 0체결·부분 종결로 복원되면 _on_unfilled 가 이 값으로 복원 포지션에 표식·realized_net 을 남긴다.
_lt_will_record = False
_lt_mark = _prev_lt_mark
if _record_loss_now:
_lt_is_loss = (_prior_realized + profit_loss) < 0
_lt_will_record = _should_record_outcome_impl(_prev_lt_mark, _lt_is_loss)
if _lt_will_record:
_lt_mark = _lt_is_loss
if is_full_exit:
_snap_pos[_FULL_EXIT_RECORD_KEY] = {
"streak": _streak_mark if _count_today else None,
"loss_tracker": _lt_mark,
"prior_realized": _prior_realized,
}
try:
_scheduled = _schedule_sell_fill_reconcile_v2(
stock_code=stock_code, pos=_snap_pos, order_id=order_id, signal_price=price,
quantity=quantity, entry_price=entry_price, recorded_net=profit_loss,
count_daily_pnl=count_daily_pnl, is_partial=not is_full_exit,
on_settled=_on_settled, closing_auction=_auction,
)
except Exception as e:
logger.warning(f"[DayExit] {stock_code} 체결가 확인 예약 실패: {e}")
if _on_settled is not None and _scheduled is None:
# 재확인 예약 실패 → 지연 기록 주체가 없다. 지금 값으로 기록(구 동작).
logger.warning(f"[DayExit] {stock_code} 종가 단일가 재확인 예약 실패 — 신호가 기준으로 연속손실 기록")
_record_loss_now = True
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}일 밴 카운트 변화 없음"
)
elif _record_loss_now:
# [R2-4] 왕복 순손익(분할 익절 레그 포함)으로 승패 판정
_round_trip = _prior_realized + profit_loss
is_loss = _round_trip < 0
if _prior_realized != 0.0:
logger.info(
f"[DayExit] {stock_code} 왕복 순손익 {_round_trip:+.0f}원 "
f"(분할 {_prior_realized:+.0f} + 잔량 {profit_loss:+.0f}) 기준 승패 판정"
)
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)
# [HL2-2] 0체결 복원 포지션 재청산이면 같은 왕복을 다시 세지 않는다.
if _should_record_outcome_impl(_prev_lt_mark, is_loss):
await _bounded_finalize_step(_record_loss(), stock_code, "loss_tracker 기록")
else:
logger.info(
f"[DayExit] {stock_code} 매도 미체결 복원 포지션 재청산 — loss_tracker 는 이미 "
f"기록됨(이전 손실={_prev_lt_mark}) → 왕복 {_round_trip:+.0f}원 재기록 생략"
)
# 포트폴리오 화면에서 즉시 제거/차감
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,
):
"""매도 실제 체결가 확인 예약 — 구 시그니처 유지용 위임(_schedule_sell_fill_reconcile_v2)."""
return _schedule_sell_fill_reconcile_v2(
stock_code=stock_code, pos=pos, order_id=order_id, signal_price=signal_price,
quantity=quantity, entry_price=entry_price, recorded_net=recorded_net,
count_daily_pnl=count_daily_pnl,
)
def _schedule_sell_fill_reconcile_v2(
stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int,
entry_price: float, recorded_net: float, count_daily_pnl: bool,
is_partial: bool = False, on_settled=None, closing_auction: bool = False,
):
"""매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
[R2-1/R2-4 2026-10-09] v2 추가 인자:
- is_partial: 분할 매도 레그면 체결가 보정 차이(delta)를 잔량 포지션의 realized_net
(왕복 순손익 누적)에도 반영한다.
- on_settled(leg_net | None): 이 레그의 **확정** 순손익을 한 번 넘긴다(전량 체결 확인 →
실제 체결가 기준, 확인 불가 → 신호가 기준 recorded_net, 체결 0·부분 → None).
- closing_auction: 15:20~15:30 종가 단일가 주문. 즉시 확인이 종결이 아니면 15:30 이후
(DAY_TRADE_FILL_AUCTION_RECHECK_*) 다시 확인한다.
확인되면:
- 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) — 확인이 늦어져도 이 날짜로 조회
_settled = [False]
# [R6-1] 당일 매수분 고아: stale 경로(count_daily_pnl=False — 인메모리 복원 없이 재청산 대기)를
# 유지하되 손익 보정은 당일 daily_pnl 로 보낸다(_finalize_exit_impl 의 당일 집계와 대칭).
_orphan_today = (not count_daily_pnl) and _is_orphan_today_position(pos)
async def _settle(leg_net) -> None:
"""on_settled 를 최대 1회 호출 (예외는 warning — 흐름 비차단)."""
if _settled[0] or on_settled is None:
_settled[0] = True
return
_settled[0] = True
try:
await on_settled(leg_net)
except Exception as e:
logger.warning(f"[DayExit] {stock_code} 매도 확정 콜백 실패: {type(e).__name__}: {e}", exc_info=True)
def _adjust_partial_realized_impl(delta: float) -> None:
"""분할 레그 체결가 보정분을 잔량 포지션의 왕복 누적(realized_net)에 반영 (trade_lock 안)."""
if not is_partial or not delta:
return
_cur = day_trading_state.positions.get(stock_code)
if _cur is not None and _cur.get("partial_sold"):
_cur["realized_net"] = float(_cur.get("realized_net", 0.0) or 0.0) + float(delta)
async def _on_fill(filled_qty: int, avg_price: float) -> None:
if filled_qty < quantity:
logger.warning(
f"[DayExit] 매도 부분체결 확인(미종결): {stock_code} {filled_qty}/{quantity}주 "
f"order_id={order_id} — 체결분은 체결가, 미체결 {int(quantity) - int(filled_qty)}주는 "
f"신호가 기준 추정으로 손익 반영. 인메모리 수량은 주문 수량 기준(수동 확인 필요)"
)
# [H-R1-2 2026-10-09] 미종결 부분체결이면 미체결분을 체결가로 계상하지 않는다
# (_sell_fill_pnl_impl — 체결분 실측 + 미체결분 신호가 추정, 거래대금·체결수량은 체결분만).
new = _sell_fill_pnl_impl(entry_price, avg_price, signal_price, filled_qty, 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
_th_upd = {
# 식별 키(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(new["total_amount"], 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 new["is_partial"]:
_th_upd["executed_quantity"] = int(new["executed_quantity"])
await enqueue_update_trade_history_by_order_id(order_id, _th_upd)
if delta:
if count_daily_pnl:
async with day_trading_state.trade_lock:
day_trading_state.adjust_realized_pnl(delta)
_adjust_partial_realized_impl(delta)
await day_trading_state.save_daily_state()
elif _orphan_today:
async with day_trading_state.trade_lock:
day_trading_state.adjust_realized_pnl(delta)
await day_trading_state.save_daily_state()
else:
_add_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 or _orphan_today) else '전일'} 손익 보정 {delta:+.0f}원)"
)
await _settle(new["net_profit_loss"] if filled_qty >= quantity else None)
async def _on_unfilled(filled_qty: int, avg_price: float, unfilled: int) -> None:
"""[2026-10-08] 접수됐던 매도가 **종결(취소·거부·잔량 0)됐는데 미체결분이 있다**.
_finalize_exit_impl 은 접수 시점에 인메모리 제거·ActiveTrade close·TradeHistory COMPLETED
를 이미 반영했다(10/06 KB금융 고아의 원인). 여기서 되돌릴 수 있는 것만 되돌린다:
- 당일 포지션: 미체결 수량을 인메모리에 되살려 손절·EOD 관리 재개(재시도)
- 전일·고아 포지션: 재주문 차단 표식을 지우고 [R8-1] 미체결 수량으로 ActiveTrade(day_trade,
active)를 되살려 close_stale_positions 가 다시 잡게 한다. 접수 때 행이 닫히고 reflect_sell 이
Portfolio 수량을 0 으로 깎아, 표식만 지우면 전일 경로(active 행)·고아 경로(Portfolio 수량>0)
어느 쪽에도 걸리지 않았고 다음 sync 가 'daytrading' 태그를 풀어 스윙/external 로 넘겼다.
- 당일/전일 손익을 실제 체결분 기준으로 보정, TradeHistory 수량·상태 보정
loss_tracker·당일 연패 기록은 되돌리지 않는다(§8.5-27, 보수적 방향).
"""
_recent_dt_sell_finalized.pop(stock_code, None)
_today = _kst_now().date().isoformat()
_stale_sell_attempted.discard((_today, stock_code))
_stale_cleaned.discard((_today, stock_code))
if filled_qty > 0 and avg_price > 0:
new_net = dt_net_pnl(entry_price, avg_price, filled_qty, pos)
_net, _cost = new_net["net_profit_loss"], new_net["total_cost"]
else:
_net, _cost = 0.0, 0.0
delta = _net - recorded_net
restored_mem = False
_restored_snapshot: dict | None = None
async with day_trading_state.trade_lock:
if count_daily_pnl:
day_trading_state.adjust_realized_pnl(delta)
_adjust_partial_realized_impl(delta)
# [R4-1] 분할 레그가 체결 0으로 종결 → 분할 자체가 없었던 것이므로 목표가·partial_sold
# 도 분할 전으로 되돌린다(수량만 되살리면 원 수량 전체가 트레일링 전용이 된다).
# 부분체결(filled_qty>0)이면 분할이 일어난 것이므로 현행(inf/True) 유지.
_pre = (pos or {}).get(_PRE_PARTIAL_STATE_KEY) if (is_partial and filled_qty <= 0) else None
_cur = day_trading_state.positions.get(stock_code)
# [HL2-2] 전량 청산 레그의 기록 정보(승패 표식·접수 시점 왕복 누적)
_full_rec = None if is_partial else (pos or {}).get(_FULL_EXIT_RECORD_KEY)
if _cur is not None:
_cur["quantity"] = int(_cur.get("quantity", 0) or 0) + int(unfilled)
_restore_pre_partial_state_impl(stock_code, _cur, _pre)
_apply_full_exit_record_impl(stock_code, _cur, _full_rec, _net, use_prior=False)
restored_mem = True
_restored_snapshot = dict(_cur)
elif not day_trading_state.is_exit_in_progress(stock_code):
_np = dict(pos)
_np.pop(_PRE_PARTIAL_STATE_KEY, None)
_np.pop(_FULL_EXIT_RECORD_KEY, None)
_np["quantity"] = int(unfilled)
_np["sell_unfilled_restored"] = True
_restore_pre_partial_state_impl(stock_code, _np, _pre)
_apply_full_exit_record_impl(stock_code, _np, _full_rec, _net, use_prior=True)
day_trading_state.add_position(stock_code, _np)
restored_mem = True
_restored_snapshot = dict(_np)
if filled_qty <= 0:
day_trading_state.dt_exited_today.discard(stock_code)
elif _orphan_today:
# [R6-1] 인메모리로 직접 되살리지 않는다(손절가 없는 근거 행 사본). 손익만 당일 기준으로
# 되돌린다. [R8-1] 대신 아래에서 ActiveTrade(day_trade, active)를 되살린다(당일 생성 →
# close_stale_positions 의 당일 행 복원 경로가 KIS 실보유 − 타전략 확인 후 인메모리로 복원).
day_trading_state.adjust_realized_pnl(delta)
_restored_snapshot = _stale_unfilled_restore_snapshot_impl(
stock_code, pos, unfilled, orphan_today=True,
)
if _restored_snapshot is not None:
# 당일 DT 전량 청산 기록(dt_exited_today) 뒤 보유 재개 — 복원 보류(restore_block)를 푼다.
# add_position 의 표식과 같은 의미(재기동 원장 재구성도 순체결>0 이면 reheld 로 본다).
day_trading_state.dt_reheld_today.add(stock_code)
else:
_add_stale_realized_pnl(delta)
# [R8-1] 전일·고아 잔량 — ActiveTrade 를 원 진입일(오늘 이전)로 되살려 다음 사이클
# close_stale_positions 의 전일 경로가 다시 잡게 한다.
_restored_snapshot = _stale_unfilled_restore_snapshot_impl(
stock_code, pos, unfilled, orphan_today=False,
)
if _restored_snapshot is not None:
# [R1-5 2026-10-09] ActiveTrade(day_trade)도 되살린다. 전량 매도 접수 때
# _finalize_exit_impl 이 행을 닫았고(분할이면 잔량으로 줄였고), 인메모리만 복원하면
# 재기동 시 restore_from_db 가 이 포지션을 찾지 못해 고아·관리 공백이 된다.
await _bounded_finalize_step(
_enqueue_restored_active_trade_impl(stock_code, _restored_snapshot),
stock_code, "미체결 복원 ActiveTrade 큐잉",
)
from decimal import Decimal
from app.services.db_worker import enqueue_update_trade_history_by_order_id
_upd = {
"order_date": _order_date,
"order_type": "SELL",
"strategy_type": "daytrading",
"executed_quantity": int(filled_qty),
"commission": Decimal(str(_cost)),
"net_profit_loss": Decimal(str(round(_net, 2))),
"status": "CANCELLED" if filled_qty <= 0 else "COMPLETED",
}
if filled_qty > 0 and avg_price > 0:
_upd["executed_price"] = Decimal(str(round(avg_price, 2)))
_upd["total_amount"] = Decimal(str(round(avg_price * filled_qty, 2)))
await enqueue_update_trade_history_by_order_id(order_id, _upd)
if count_daily_pnl or _orphan_today:
await day_trading_state.save_daily_state()
if _kst_now().time() >= _KRX_MARKET_CLOSE:
# [R3-2] 장 마감 후 복원 = 더 이상 장중 재시도가 없다 → 오버나이트 확정. 잔존 점검이 먼저
# '청산 확인'을 남겼을 수 있으므로 여기서 직접 텔레그램(DT_EOD)으로 알린다.
_alert_once_v2(
"DT_EOD", f"post_close_unfilled:{stock_code}:{order_id}",
f"[데이트레이딩] 장 마감 후 매도 미체결 확인: {stock_code} 체결 {filled_qty}/{quantity}주, "
f"미체결 {unfilled}주(order_id={order_id}) — 오버나이트 보유. 다음 영업일 장중 첫 사이클에 "
f"전일 포지션 청산이 시도됩니다. 계좌를 확인하세요.",
)
logger.error(
f"[DayExit] 🚨 매도 미체결 종결: {stock_code} 체결 {filled_qty}/{quantity}주 "
f"(미체결 {unfilled}주) order_id={order_id} — "
+ (f"인메모리 포지션 {'복원' if restored_mem else '복원 실패'}(손절·EOD 재시도), "
f"당일 손익 {delta:+.0f}원 보정"
if count_daily_pnl else
f"전일/고아 포지션 재청산 대기(close_stale_positions, ActiveTrade "
f"{'복원' if _restored_snapshot is not None else '복원 실패 — 수동 확인'}), "
f"{'당일' if _orphan_today else '전일'} 손익 {delta:+.0f}원 보정")
+ ". loss_tracker·연패 기록은 되돌리지 않음"
)
await _settle(None)
async def _on_result(info: dict) -> None:
filled = int(info.get("filled_qty", 0) or 0)
avg = float(info.get("avg_price", 0) or 0)
if info.get("terminal") and filled < quantity:
await _on_unfilled(filled, avg, int(quantity) - filled)
return
if filled > 0 and avg > 0:
await _on_fill(filled, avg)
if on_settled is None and not closing_auction:
# [2026-10-08] v2 — 체결 0·미체결 종결도 돌려받는다(v1 은 체결>0 일 때만 콜백).
return fill_reconciler.schedule_v2(stock_code, order_id, "SELL", int(quantity), _on_result)
if not settings.DAY_TRADE_FILL_CHECK_ENABLED:
return None
def _is_final(info: dict | None) -> bool:
if info is None:
return False
return bool(info.get("terminal")) or int(info.get("filled_qty", 0) or 0) >= int(quantity)
async def _run() -> None:
info = None
try:
info = await fill_reconciler.fetch_fill_v2(stock_code, order_id, "SELL", int(quantity))
if closing_auction and not _is_final(info):
_wait = _seconds_until_auction_recheck_impl(_kst_now())
_, _attempts, _interval = _auction_recheck_params_impl()
logger.info(
f"[DayExit] {stock_code} 종가 단일가 매도(order_id={order_id}) 체결 미종결 — "
f"{_wait:.0f}초 뒤(장 마감 후) 재확인 최대 {_attempts}회"
)
await asyncio.sleep(_wait)
for i in range(_attempts):
_i = await fill_reconciler.fetch_fill_v2(stock_code, order_id, "SELL", int(quantity))
if _i is not None:
info = _i
if _is_final(info):
break
if i < _attempts - 1:
await asyncio.sleep(_interval)
if info is not None:
await _on_result(info)
except Exception as e:
logger.warning(
f"[DayExit] {stock_code} 매도 체결 확인·반영 실패 order_id={order_id}: "
f"{type(e).__name__}: {e}", exc_info=True,
)
finally:
if not _settled[0]:
# 체결 확인 불가 → 신호가 기준 값으로 확정(구 동작 폴백)
logger.warning(
f"[DayExit] {stock_code} 매도 체결 확인 불가(order_id={order_id}) — "
f"신호가 기준 순손익 {recorded_net:+.0f}원으로 확정"
)
await _settle(recorded_net)
try:
task = asyncio.get_running_loop().create_task(_run())
except RuntimeError as e:
logger.warning(f"[DayExit] {stock_code} 매도 체결 확인 예약 실패: {e}")
return None
_auction_fill_tasks.add(task)
task.add_done_callback(_auction_fill_tasks.discard)
return task
async def _enqueue_restored_active_trade_impl(stock_code: str, pos: dict) -> None:
"""[R1-5] 매도 미체결로 인메모리에 되살린 포지션을 ActiveTrade(day_trade, active)로 영속화.
db_worker upsert 는 active day_trade 행이 있으면 갱신(분할 미체결 → 수량을 복원 후 수량으로),
없으면 새로 만든다(전량 미체결 → 접수 때 닫힌 행 대신 새 active 행). 쓰기는 큐 경유(§3.1).
target_price 가 inf(분할 후 트레일링 전용)면 take_profit 을 넣지 않고 remaining_pct<1 로 남겨
restore_from_db 가 목표가 분할을 재발동하지 않게 한다.
"""
from app.services.db_worker import enqueue_active_trade_upsert
qty = int(pos.get("quantity", 0) or 0)
entry = float(pos.get("entry_price", 0) or 0)
if qty <= 0 or entry <= 0:
logger.error(
f"[DayExit] 🚨 {stock_code} 미체결 복원 ActiveTrade 생략(수량 {qty}·진입가 {entry}) — 수동 확인 필요"
)
return
payload: dict = {
"stock_code": stock_code,
"stock_name": pos.get("stock_name") or stock_code,
"strategy_name": "day_trade",
"quantity": qty,
"entry_price": entry,
"status": "active",
}
_sl = pos.get("stop_loss")
if _sl is not None and 0 < float(_sl) < float("inf"):
payload["stop_loss"] = float(_sl)
_tp = pos.get("target_price")
if _tp is not None and 0 < float(_tp) < float("inf"):
payload["take_profit"] = float(_tp)
_hi = pos.get("highest_price")
if _hi is not None and 0 < float(_hi) < float("inf"):
payload["highest_since_entry"] = float(_hi)
if pos.get("partial_sold"):
payload["remaining_pct"] = 0.99
else:
# [R4-1] 분할 레그 0체결 복원 — 분할 때 기록한 remaining_pct(<1.0)를 1.0 으로 되돌려야
# restore_from_db 가 목표가 분할을 막지 않는다(분할 이력 없음 = 원 수량 전체).
payload["remaining_pct"] = 1.0
if pos.get("reason"):
payload["entry_reason"] = str(pos.get("reason"))
# [HL2-1 2026-10-09] 원 진입 시각·매수 ODNO 를 싣는다(둘 다 기존 ORM 컬럼 — 선택 키).
# 전량 미체결 복원은 새 active 행을 만드는데, 없으면 created_at=복원 시각(now_kst 기본값)이
# 되어 재기동 후 entry_time(=created_at)이 리셋 → 30분 시간손절·트레일링 최소 보유가 늦게
# 발동하고, order_id 가 NULL 이라 R3 의 ODNO 영속화가 이 경로에서 다시 빠진다.
_ct = _restored_created_at_impl(pos.get("entry_time"))
if _ct is not None:
payload["created_at"] = _ct
else:
logger.warning(
f"[DayExit] {stock_code} 미체결 복원 ActiveTrade — 원 진입 시각 없음/형식 불량"
f"({pos.get('entry_time')!r}) → created_at 은 복원 시각(시간손절 시계 리셋 가능)"
)
_oid = str(pos.get("order_id") or "").strip()
if _oid:
payload["order_id"] = _oid
await enqueue_active_trade_upsert(payload)
def _restored_created_at_impl(entry_time) -> datetime | None:
"""복원 ActiveTrade.created_at 값 — 원 진입 시각을 KST naive 로(모델 기본값 now_kst 와 같은 기준).
datetime 이 아니거나 미래 시각이면 None(호출부가 키를 넣지 않아 모델 기본값을 쓴다).
정규장 밖 시각도 그대로 둔다 — restore 쪽 _elapsed_minutes/_normalize_entry_time 이 판정한다.
"""
if not isinstance(entry_time, datetime):
return None
if entry_time.tzinfo is not None:
entry_time = entry_time.astimezone(_KST).replace(tzinfo=None)
if entry_time > _kst_now():
return None
return entry_time
def _stale_unfilled_restore_snapshot_impl(
stock_code: str, pos: dict, unfilled: int, orphan_today: bool,
) -> dict | None:
"""[R8-1 2026-10-10] 전일·고아 매도 0체결/부분 종결 → ActiveTrade 복원용 포지션 사본(없으면 None).
_enqueue_restored_active_trade_impl 에 넘길 dict 를 만든다. 수량은 **미체결분만**이다
(접수 때 sell_qty 는 이미 KIS 실보유 − 타전략(§3.5)·주문가능 상한으로 잘렸다 — 그 이상은 DT 몫이
아니다). 다음 close_stale_positions 가 매도 시점에 다시 KIS 실보유 − 타전략으로 상한을 건다.
created_at(=entry_time) 규칙:
- 전일·과거 고아(orphan_today=False): 원 진입 시각. 반드시 **오늘 이전**이어야 전일 경로에
잡힌다 — 시각이 없거나 오늘 이후면 전일 15:30(KST)으로 대체(warning). 오늘 날짜로 들어가면
당일 행 복원 경로가 손절가 없는 사본을 '당일 진입'으로 되살린다.
- 당일 고아(orphan_today=True): 원 진입 시각(당일) → 당일 행 복원 경로(인메모리 관리 재개).
"""
try:
qty = int(unfilled or 0)
except (TypeError, ValueError):
qty = 0
if qty <= 0:
return None
snap = dict(pos or {})
for _k in (_PRE_PARTIAL_STATE_KEY, _FULL_EXIT_RECORD_KEY, "orphan", _ORPHAN_TODAY_KEY):
snap.pop(_k, None)
snap["quantity"] = qty
snap["strategy"] = "day_trade"
snap["sell_unfilled_restored"] = True
# 분할 이력 없음(전일·고아 매도는 항상 전량 레그) — remaining_pct=1.0 로 복원된다.
snap.pop("partial_sold", None)
if not orphan_today:
today = _kst_now().date()
_ct = _restored_created_at_impl(snap.get("entry_time"))
if _ct is None or _ct.date() >= today:
_fallback = datetime.combine(today - timedelta(days=1), _KRX_MARKET_CLOSE)
logger.warning(
f"[DayExit] {stock_code} 전일·고아 미체결 복원 — 원 진입 시각 "
f"{snap.get('entry_time')!r} 이 오늘 이전이 아니거나 없음 → created_at={_fallback} 로 "
f"기록(전일 경로 재청산 보장, 실제 진입일 아님)"
)
snap["entry_time"] = _fallback
else:
snap["entry_time"] = _ct
return snap
# ── [2026-10-08] DT 고아 잔고 감지 ────────────────────────────────────────────────
# 동기: 10/06 KB금융(105560) EOD 시장가 매도가 **접수(rt_cd=0)** 되자 _finalize_exit_impl 이
# 체결 확인 없이 인메모리 제거 + ActiveTrade close + TradeHistory COMPLETED 를 남겼다. 이 구조에서는
# 실제 0체결(취소/미체결)이면 잔고가 남는다.
# [정정 2026-10-09] 105560 의 잔여 3주가 "10/06 EOD 0체결"이라는 서술은 **운영 DB 와 맞지 않는다**:
# - ActiveTrade id=178 'synced' 3주(평단 171,058.33)가 10/01 08:55 — 그날 DT 매수(13:44) **이전** 에 생성.
# - ActiveTrade id=177(9/30 day_trade)이 수량 6 으로 기록됐는데 9/30 DT 매수 TradeHistory 는 3주
# → 9/30 이전부터 DT 외 3주가 있었고 DT 행에 합산된 것으로 보인다(추정).
# - TradeHistory(daytrading) BUY 3+3+3 / SELL 3+3+3 = 순 0. 10/06 SELL(order_id 0000031910)은
# slippage NULL = 체결 확인이 안 된 채 COMPLETED(체결 여부는 KIS 주문내역 미확인 — 추정 불가).
# → 잔여 3주는 DT 0체결분이 아니라 기존 외부/동기화 보유분일 가능성이 높다(추정). 그래서 아래
# 감지는 TradeHistory 순보유 0 인 105560 을 **팔지 않고** 수동 확인 경보만 낸다(R1-1).
# Portfolio 행은 sync 가 'daytrading' 태그를 유지한 채 수량만 갱신했고, 이후
# - restore_from_db / close_stale_positions 는 **active** ActiveTrade 만 보고(행이 closed),
# - 스윙 경로(execute_auto_trade)는 daytrading 소유 행을 매도하지 않으며(§6.4),
# - sync_portfolio 는 'daytrading' 행에 ActiveTrade 를 시딩하지 않는다
# → 아무도 손절을 걸지 않는 고아가 됐다. 아래 감지는 close_stale_positions 의 읽기 전용
# 세션(§3.2 ③) 안에서만 수행하고, 매도는 기존 stale 경로(실보유·타전략 차감·재시도·
# count_daily_pnl=False·closed_today)를 그대로 탄다.
# [R4-1 2026-10-09] 분할 레그 체결 확인용 포지션 스냅샷에 싣는 "분할 전 상태" 키
# ({"target_price": 분할 전 목표가, "partial_sold": 분할 전 값}). 체결 0 종결 시 복원에 쓴다.
_PRE_PARTIAL_STATE_KEY = "_pre_partial_state"
def _apply_full_exit_record_impl(
stock_code: str, target: dict, rec, filled_net: float, use_prior: bool,
) -> None:
"""[HL2-2] 전량 청산 레그가 0체결·부분 종결로 복원될 때 복원 포지션에 남길 상태.
- 승패 표식: 접수 시점에 당일 연패·loss_tracker 에 기록한 결과(재청산 시 이중 집계 방지)
- realized_net: 체결된 레그의 순손익(filled_net)을 왕복 누적에 더한다 — 빠지면 다음 전량
청산의 왕복 판정(_prior_realized + 마지막 레그)에서 그 레그가 누락된다.
use_prior=True 면 접수 시점 누적(prior_realized)을 기준으로, False 면 대상의 현재값에 더한다.
rec 가 없으면(분할 레그·구 스냅샷) 아무것도 하지 않는다.
"""
if not isinstance(rec, dict):
return
for _key, _src_key in (
(_RESTORED_STREAK_MARK_KEY, "streak"),
(_RESTORED_LOSS_TRACKER_MARK_KEY, "loss_tracker"),
):
_v = rec.get(_src_key)
if isinstance(_v, bool):
target[_key] = _v
elif use_prior:
target.pop(_key, None)
if use_prior:
try:
_base = float(rec.get("prior_realized", 0.0) or 0.0)
except (TypeError, ValueError):
logger.warning(f"[DayExit] {stock_code} 복원 prior_realized 파싱 실패 — 0 기준")
_base = 0.0
else:
try:
_base = float(target.get("realized_net", 0.0) or 0.0)
except (TypeError, ValueError):
logger.warning(f"[DayExit] {stock_code} 복원 realized_net 파싱 실패 — 0 기준")
_base = 0.0
target["realized_net"] = _base + float(filled_net or 0.0)
if filled_net:
logger.warning(
f"[DayExit] {stock_code} 매도 부분체결 복원 — 체결 레그 순손익 {filled_net:+.0f}원을 "
f"왕복 누적에 반영(realized_net={target['realized_net']:+.0f}원)"
)
def _restore_pre_partial_state_impl(stock_code: str, target: dict, pre) -> None:
"""[R4-1] 체결 0 으로 종결된 분할 레그 — 목표가·partial_sold 를 분할 전 값으로 되돌린다."""
if not isinstance(pre, dict):
return
_tp = pre.get("target_price")
if _tp is not None:
target["target_price"] = _tp
target["partial_sold"] = bool(pre.get("partial_sold", False))
logger.warning(
f"[DayExit] {stock_code} 분할 매도 체결 0 종결 — 목표가({_tp})·partial_sold"
f"({target['partial_sold']}) 분할 전 상태로 복원(목표가 분할 재시도 가능)"
)
# 최근 DT 매도 반영 시각 {code: monotonic} — 반영 직후 Portfolio/KIS 가 아직 옛 수량일 때
# 고아로 오판하지 않게 DAY_TRADE_ORPHAN_GRACE_SEC 동안 보류한다. 0체결 확인 시 해제.
_recent_dt_sell_finalized: dict[str, float] = {}
def _orphan_blocked_reason(stock_code: str, today_key: str) -> str | None:
"""고아/복원 판정을 보류해야 하면 사유, 아니면 None."""
return _orphan_blocked_reason_v2(stock_code, today_key, block_unresolved_unknown=True)
def _orphan_blocked_reason_v2(
stock_code: str, today_key: str, block_unresolved_unknown: bool,
) -> str | None:
"""[R1-2 2026-10-09] 고아/복원 판정 보류 사유.
block_unresolved_unknown=False(고아 후보 전용): 직전 고아 매도가 **결과 불명**이어도 후보에서
빼지 않는다. 빼면 `_place_and_finalize_exit_impl` 의 불명 재판정(잔고 기준)에 다시 도달하지
못해 `_unresolved_exit_unknown` 이 당일 내내 풀리지 않고 고아가 오버나이트로 남았다.
재판정·재매도 상한은 stale 경로(_stale_sell_failures 백오프 → _resolve_unknown_exit_v2_impl)가
그대로 맡는다. 진행 중 표식(HOLD) 동안은 아래 '청산 주문 진행 중'이 계속 막는다.
"""
if stock_code in day_trading_state.positions:
return "인메모리 보유"
if day_trading_state.is_exit_in_progress(stock_code):
return "청산 주문 진행 중"
if block_unresolved_unknown and (today_key, stock_code) in _unresolved_exit_unknown:
return "결과 불명 매도 미해결"
_t = _recent_dt_sell_finalized.get(stock_code)
if _t is not None and time.monotonic() - _t < float(settings.DAY_TRADE_ORPHAN_GRACE_SEC):
return "최근 DT 매도 반영(유예)"
return None
# [R1-3/6/7] 수동 개입 경보를 보낸 (날짜, key) — 하루 1회만 텔레그램(로그는 _error_throttled)
_manual_alerted: set[tuple[str, str]] = set()
def _alert_manual_intervention_impl(key: str, msg: str) -> None:
"""[R1-3/6/7] 자동 처리 주체가 없는 잔고 → logger.error(스로틀) + 텔레그램(하루 1회).
warn_throttled 경고는 놓치기 쉬웠다(KB금융 105560 3주가 10/01부터 DT·스윙 어느 쪽도
관리하지 않는 상태로 남았다). 발송 실패는 error 로그만 남기고 흐름을 막지 않는다.
"""
_error_throttled(f"dt_exit:manual:{key}", msg)
_k = (_today_key(), key)
for k in [k for k in _manual_alerted if k[0] != _k[0]]:
_manual_alerted.discard(k)
if _k in _manual_alerted:
return
_manual_alerted.add(_k)
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_MANUAL_CHECK", msg), timeout=10.0,
))
_alert_tasks.add(_t)
_t.add_done_callback(_alert_tasks.discard)
except Exception as e:
logger.error(f"[DayExit] 수동 확인 경보 발송 실패({key}): {e}")
def _dt_net_qty_from_history(rows) -> dict[str, int]:
"""[R1-1] TradeHistory(daytrading) 행 → 종목별 DT 순보유 추정(BUY 체결 − SELL 체결). 순수 함수.
보수적 집계(과다매도 방지 쪽으로 기운다):
- BUY 는 status=='COMPLETED' 만, 체결수량(없으면 주문수량)으로 센다.
- SELL 은 FAILED/REJECTED 를 뺀 전부를 센다 — PENDING(결과 불명)도 팔린 것으로 본다.
0체결 종결은 fill_reconciler 가 CANCELLED·executed_quantity=0 으로 바꿔 두므로 0 으로 들어간다.
창 밖에서 산 물량을 창 안에서 판 경우 음수가 될 수 있다 → 호출부가 0 하한으로 쓴다(매도 안 함).
"""
out: dict[str, int] = {}
for r in rows or []:
code = getattr(r, "stock_code", None)
if not code:
continue
ot = str(getattr(r, "order_type", "") or "").upper()
st = str(getattr(r, "status", "") or "").upper()
eq = getattr(r, "executed_quantity", None)
q = int(eq if eq is not None else (getattr(r, "order_quantity", 0) or 0))
if ot == "BUY":
if st == "COMPLETED":
out[code] = out.get(code, 0) + q
elif ot == "SELL":
if st not in ("FAILED", "REJECTED"):
out[code] = out.get(code, 0) - q
return out
def _row_created_date_v2(row):
"""[R6-1] 행의 created_at(KST naive 저장) 날짜. 없거나 파싱 불가면 None."""
_c = getattr(row, "created_at", None)
if isinstance(_c, datetime):
return _c.date()
return None
def _last_completed_dt_buy_at_v2(rows, today=None) -> dict:
"""[R6-1] {(code, is_today): 마지막 COMPLETED DT BUY created_at}. 순수 함수.
today 를 주지 않으면 KST 오늘을 쓴다. 고아 청산 사유·entry_time 의 '진입일'을 근거
ActiveTrade 행(과거 행일 수 있음)이 아니라 실제 매수 체결 기준으로 쓰기 위한 것이다.
"""
_today = today or _kst_now().date()
out: dict = {}
for r in rows or []:
code = getattr(r, "stock_code", None)
if not code:
continue
if str(getattr(r, "order_type", "") or "").upper() != "BUY":
continue
if str(getattr(r, "status", "") or "").upper() != "COMPLETED":
continue
_c = getattr(r, "created_at", None)
if not isinstance(_c, datetime):
continue
key = (code, _c.date() == _today)
if key not in out or _c > out[key]:
out[key] = _c
return out
async def _detect_dt_orphans_impl(db, active_dt_rows, stale: dict, today) -> tuple[dict, list]:
"""(고아 후보 {code: pos}, 복원 대상 ActiveTrade 행 목록).
호출부(close_stale_positions)의 읽기 전용 세션을 그대로 쓴다 — SELECT 만, commit 없음.
- 복원: 당일 생성 active day_trade 행인데 인메모리에 없고 당일 DT 전량 청산 기록도 없는 종목
(restore_block_reason_v2 — [R2 하드닝] closed_today 는 재진입 차단 집합이라 근거로 쓰지 않는다).
- 고아: Portfolio.strategy_type ∈ DT 이고 수량>0 인데 인메모리·active DT 행이 없는 종목.
근거로 DAY_TRADE_ORPHAN_LOOKBACK_DAYS 이내 DT ActiveTrade 행(상태 무관)이 있어야 하며,
매도 상한 = min(Portfolio 수량, 그 행 수량, TradeHistory DT 순보유).
[R1-1 2026-10-09] **정상 체결로 닫힌 DT 행은 근거가 아니다.** Portfolio 'daytrading' 태그는
같은 종목의 수동·HTS 매수분이 합산돼도 해제되지 않는다(§3.5 — 행이 stock_code UNIQUE).
예전에는 최근 DT 행이 있기만 하면 그 수량만큼 팔아, DT 3주 정상 청산 후 남은 수동 10주에서
3주를 매일 시장가 매도했다. 이제 같은 창의 TradeHistory(strategy_type='daytrading')
BUY−SELL 순체결수량(_dt_net_qty_from_history)이 양수여야만 고아로 본다 — 0체결 종결된 매도는
fill_reconciler 가 CANCELLED·체결 0 으로 바꾸므로 그 몫만 양수로 남는다.
근거가 없거나 순보유 0 이면 팔지 않고 **logger.error + 텔레그램**(수동 확인, R1-3/6/7).
"""
from app.models.models import ActiveTrade, Portfolio, TradeHistory
from app.services.db_worker import DT_STRATEGY_NAMES
from sqlalchemy import select as _select
_tk = today.isoformat()
restores: list = []
active_codes = set()
for r in active_dt_rows or []:
active_codes.add(r.stock_code)
_c = r.created_at.date() if getattr(r, "created_at", None) else None
if _c is None or _c < today or r.stock_code in stale:
continue
# [R2 하드닝 2026-10-09] closed_today(재진입 차단)가 아니라 DT 전량 청산 기록으로 판단한다.
# 불명 매수 접수 확정·매도 0체결 종결 복원 종목은 closed_today 에 남아 있어도 보유 중이다.
_rb = day_trading_state.restore_block_reason_v2(r.stock_code)
if _rb is not None:
warn_throttled(
logger, f"dt_exit:restore_block:{r.stock_code}",
f"[DayExit] ⚠️ {r.stock_code}: 당일 active DT 행({int(r.quantity or 0)}주)이 인메모리에 없으나 "
f"{_rb} — 복원 보류(close 큐 반영 지연이면 정상, 실보유가 남아 있으면 수동 확인)",
)
continue
if _orphan_blocked_reason(r.stock_code, _tk) is not None:
continue
if int(r.quantity or 0) > 0:
restores.append(r)
pf_rows = (await db.execute(
_select(Portfolio).where(
Portfolio.strategy_type.in_(DT_STRATEGY_NAMES),
Portfolio.quantity > 0,
)
)).scalars().all()
cands: dict[str, object] = {}
for p in pf_rows or []:
code = getattr(p, "stock_code", None)
if not code or code in stale or code in active_codes:
continue
if int(getattr(p, "quantity", 0) or 0) <= 0:
continue
if _orphan_blocked_reason_v2(code, _tk, block_unresolved_unknown=False) is not None:
continue
cands[code] = p
if not cands:
return {}, restores
_since = datetime.combine(
today - timedelta(days=int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS)), datetime.min.time(),
)
ev_rows = (await db.execute(
_select(ActiveTrade).where(
ActiveTrade.stock_code.in_(list(cands)),
ActiveTrade.strategy_name.in_(DT_STRATEGY_NAMES),
ActiveTrade.created_at >= _since,
).order_by(ActiveTrade.created_at.desc())
)).scalars().all()
latest: dict[str, object] = {}
for r in ev_rows or []:
if r.stock_code not in latest:
latest[r.stock_code] = r
# [R1-1] 같은 창의 DT 원장 — 순체결수량이 양수인 종목만 DT 몫이 남아 있다고 본다.
th_rows = (await db.execute(
_select(TradeHistory).where(
TradeHistory.stock_code.in_(list(cands)),
TradeHistory.strategy_type == "daytrading",
TradeHistory.created_at >= _since,
)
)).scalars().all()
net_by_code = _dt_net_qty_from_history(th_rows)
# [R6-1 2026-10-10] 당일 창(KST 오늘 생성 행)만의 DT 순체결 — 양수면 '당일 매수분' 고아다.
# 그 손익은 전일 포지션 예외(§7 count_daily_pnl=False)가 아니라 당일 집계 대상이다.
# created_at 이 없는 행은 당일로 보지 않는다(근거 불명 → 기존 동작).
today_rows = [r for r in th_rows or [] if _row_created_date_v2(r) == today]
today_net_by_code = _dt_net_qty_from_history(today_rows)
last_buy_at = _last_completed_dt_buy_at_v2(th_rows, today)
_lb = int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS)
orphans: dict[str, dict] = {}
for code, p in cands.items():
pf_qty = int(getattr(p, "quantity", 0) or 0)
ev = latest.get(code)
ev_qty = int(getattr(ev, "quantity", 0) or 0) if ev is not None else 0
th_net = max(0, int(net_by_code.get(code, 0)))
if ev is None or ev_qty <= 0 or th_net <= 0:
_why = (
f"최근 {_lb}일 DT 진입 근거(ActiveTrade) 없음" if ev is None or ev_qty <= 0
else f"최근 {_lb}일 DT 원장 순체결 {int(net_by_code.get(code, 0))}주(매수=매도 — DT 몫 없음)"
)
_alert_manual_intervention_impl(
f"orphan_unowned:{code}",
f"[DayExit] 🚨 {code}: Portfolio 'daytrading' {pf_qty}주인데 인메모리·active DT 행이 없고 "
f"{_why} — DT 는 자동 청산하지 않는다. 스윙 청산도 'daytrading' 태그 행은 건너뛰므로 "
f"손절 주체가 없는 잔고일 수 있다(수동 매수 합산·sync 'synced' 행 공존 포함). "
f"수동 청산 또는 Portfolio 태그 재지정 필요",
)
continue
_cap = min(pf_qty, ev_qty, th_net)
_entry = float(getattr(ev, "entry_price", 0) or 0) or float(getattr(p, "average_price", 0) or 0)
# [R6-1] 당일 DT 순매수가 남아 있으면 당일 포지션 — 청산 손익을 당일 한도·연패에 넣는다.
_today_net = int(today_net_by_code.get(code, 0))
_is_today = _today_net > 0
# 진입 시각은 근거 ActiveTrade 생성시각(과거 행일 수 있다)이 아니라 실제 DT 매수 체결 행 기준.
# 당일 고아면 당일 마지막 매수, 아니면 오늘 이전 마지막 매수 → 없으면 근거 행 시각(구 동작).
_buy_at = last_buy_at.get((code, True)) if _is_today else last_buy_at.get((code, False))
_entry_time = _buy_at or getattr(ev, "created_at", None)
orphans[code] = {
"stock_name": getattr(p, "stock_name", None) or getattr(ev, "stock_name", None) or code,
"strategy": "day_trade",
"entry_price": _entry,
"quantity": _cap,
"highest_price": float(getattr(ev, "highest_since_entry", 0) or 0) or _entry,
"entry_time": _entry_time,
"reason": "",
"order_id": "",
"orphan": True,
_ORPHAN_TODAY_KEY: _is_today,
}
logger.warning(
f"[DayExit] ⚠️ DT 고아 잔고 감지: {code} Portfolio 'daytrading' {pf_qty}주, 최근 DT 행 "
f"(status={getattr(ev, 'status', '?')}, 수량 {ev_qty}, 생성 {getattr(ev, 'created_at', None)}), "
f"DT 원장 순체결 {th_net}주(당일 {_today_net}주) — 인메모리·active 행 없음 → 전량 청산 대상"
f"(상한 {_cap}주, {'당일 매수분 — 당일 손익 집계' if _is_today else '전일 이전 — 당일 손익 제외'})"
)
return orphans, restores
async def _restore_lost_today_positions_v2_impl(rows, kis_holdings: dict, other_qty: dict) -> int:
"""[C-2 r9] 판정 수량을 min(주문가능, 보유) 로 — 접수·미체결 DT 매도 물량을 복원해 재매도하지 않게."""
from app.services.daytrading.position_manager import _cap_hldg_by_sellable_v2
_capped = {c: _cap_hldg_by_sellable_v2(v) for c, v in (kis_holdings or {}).items()}
return await _restore_lost_today_positions_impl(rows, _capped, other_qty)
async def _restore_lost_today_positions_impl(rows, kis_holdings: dict, other_qty: dict) -> int:
"""당일 active 행 → 인메모리 복원 (restore_from_db 와 같은 변환). 복원 수 반환."""
from app.services.daytrading.position_manager import _active_trade_row_to_position
n = 0
for r in rows:
code = r.stock_code
qty = int(r.quantity or 0)
_h = kis_holdings.get(code) or {}
hldg = int(_h.get("hldg", 0) or 0)
dt_avail = hldg - int(other_qty.get(code, 0) or 0)
if qty <= 0 or dt_avail < qty:
warn_throttled(
logger, f"dt_exit:restore_skip:{code}",
f"[DayExit] ⚠️ {code}: 당일 active DT 행({qty}주)이 인메모리에 없으나 실보유 {hldg}주 − "
f"타전략 {int(other_qty.get(code, 0) or 0)}주 = {dt_avail}주 < 행 수량 — 복원 보류(수동 확인)",
)
continue
async with day_trading_state.trade_lock:
_rb = None if code in day_trading_state.positions else day_trading_state.restore_block_reason_v2(code)
if code in day_trading_state.positions or _rb is not None:
if _rb is not None:
warn_throttled(
logger, f"dt_exit:restore_block:{code}",
f"[DayExit] ⚠️ {code}: 당일 active DT 행 복원 직전 {_rb} — 복원 보류",
)
continue
day_trading_state.add_position(code, _active_trade_row_to_position(r))
n += 1
logger.warning(
f"[DayExit] ⚠️ 당일 DT 포지션 인메모리 유실 → ActiveTrade 에서 복원: {code} {qty}주 "
f"(실보유 {hldg}주) — 손절·트레일링·EOD 관리 재개"
)
return n
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
restore_rows: list = []
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)
)
# [2026-10-08] DT 고아 잔고 / 당일 active 행 유실 감지 — 같은 읽기 전용 세션.
# 위 쿼리 순서(①DT active ②비-DT)는 바꾸지 않고 뒤에 덧붙인다.
if settings.DAY_TRADE_ORPHAN_DETECT_ENABLED:
_orphans, _restores = await _detect_dt_orphans_impl(_db, _rows, stale, today)
stale.update(_orphans)
restore_rows = _restores
_extra = [c for c in list(_orphans) + [r.stock_code for r in _restores]
if c not in other_strategy_qty]
if _extra:
_other_rows2 = (await _db.execute(
_select(ActiveTrade).where(
ActiveTrade.stock_code.in_(_extra),
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)).scalars().all()
for _o in _other_rows2:
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 and not restore_rows:
return results
# [H3] KIS 실보유 조회 — 실패하면 None → 매도하지 않는다(warning).
kis_holdings, _holdings_complete = await _fetch_kis_holdings_v3_impl()
# [2026-10-08] 당일 active 행이 있는데 인메모리에서 사라진 포지션 → 복원(정상 관리 재개).
# 실보유에서 타 전략 몫을 뺀 DT 가용이 행 수량 이상일 때만(이미 팔린 행을 되살려 스윙
# 물량을 파는 것을 막는다). 판정 불가(조회 실패)면 복원하지 않는다 — 다음 사이클 재시도.
if restore_rows and kis_holdings is not None and other_strategy_qty is not None:
# [R3-1] 첫 페이지가 가득 차 응답에 없는 종목은 '보유 0'이 아니라 판정 불가 → 이번 회차 보류.
_judgeable = []
for _r in restore_rows:
if _holding_unknown_v2(kis_holdings, _holdings_complete, _r.stock_code):
_error_throttled(
f"dt_exit:restore_page_full:{_r.stock_code}",
f"[DayExit] 🚨 {_r.stock_code}: 당일 active DT 행이 인메모리에 없으나 KIS 잔고 첫 페이지가 "
f"가득 차 실보유를 확인할 수 없음 — 복원 보류(손절 공백, 수동 확인 필요)",
)
continue
_judgeable.append(_r)
if _judgeable:
await _restore_lost_today_positions_v2_impl(_judgeable, kis_holdings, other_strategy_qty)
if not stale:
return results
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
if _holding_unknown_v2(kis_holdings, _holdings_complete, stock_code):
# [R3-1] 응답에 없는데 첫 페이지가 가득 참 → '보유 0'(=매도 없이 ActiveTrade 정리)으로
# 해석하면 실보유가 남은 채 행이 닫혀 손절 주체가 없는 고아가 된다. 매도·정리 모두 보류.
_alert_manual_intervention_impl(
f"stale_page_full:{stock_code}",
f"[DayExit] 🚨 전일 DT 포지션 {stock_code}({row_qty}주): KIS 잔고 첫 페이지가 가득 차"
f"(≥{int(getattr(settings, 'SWING_BALANCE_FULL_PAGE_ROWS', 20) or 0)}행) 실보유를 확인할 수 "
f"없음 — 자동 매도·정리 보류(ActiveTrade 유지). get_balance 연속조회 미구현(§8.5-37), "
f"계좌 확인 후 수동 청산 필요",
)
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)
_is_orphan = bool(pos.get("orphan"))
if _is_orphan and (hldg_qty <= 0 or (sell_qty <= 0 and not _already_accepted)):
# 고아 후보는 닫을 ActiveTrade 가 없다 — 실보유 0(이미 팔림·Portfolio 미동기화)이거나
# DT 가용 0(타 전략 몫)이면 아무것도 하지 않는다.
if hldg_qty <= 0:
warn_throttled(
logger, f"dt_exit:orphan_nothing:{stock_code}",
f"[DayExit] DT 고아 후보 {stock_code}: KIS 보유 0주 — 매도 대상 없음"
f"(Portfolio 미동기화 추정)",
)
else:
# [R1-3/7] 실보유가 있는데 DT 가용 0 = 'daytrading' 태그 행을 DT 는 타 전략 몫으로,
# 스윙은 DT 소유로 보고 서로 미루는 관리 공백일 수 있다 → 수동 개입 경보.
_alert_manual_intervention_impl(
f"orphan_no_dt_available:{stock_code}",
f"[DayExit] 🚨 DT 고아 후보 {stock_code}: KIS 보유 {hldg_qty}주(주문가능 "
f"{sellable_qty}주) − 비-DT active 행 {other_qty}주 → DT 가용 {dt_available}주 — "
f"DT 는 팔지 않는다. Portfolio 태그가 'daytrading' 이면 스윙 청산도 건너뛰므로 "
f"손절 주체가 없을 수 있다. 수동 청산 또는 Portfolio 태그 재지정 필요",
)
continue
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
if _is_orphan and _is_orphan_today_position(pos):
# [R6-1] 당일 매수분 — 사유 접두사를 분리해 재기동 원장 재구성이 당일 손익에 포함하게 한다
# (position_manager._STALE_EXIT_REASON_PREFIXES 와 겹치지 않음). 손익은 당일 집계.
_exit_reason = (
f"{_ORPHAN_TODAY_EXIT_REASON_PREFIX} (진입일 {entry_date or '미상'}, 인메모리·active 행 "
f"없음 — 당일 매수 후 포지션 기록 유실 추정, 당일 손익 집계)"
)
logger.warning(
f"[DayExit] ⚠️ DT 당일 고아 잔고 즉시 청산: {stock_code} "
f"(진입일={entry_date or '미상'}, 수량={sell_qty}/{row_qty}, KIS 보유 {hldg_qty}·"
f"주문가능 {sellable_qty} − 타전략 {other_qty}) — 당일 손익·연패 반영"
)
elif _is_orphan:
_exit_reason = (
f"DT 고아 잔고 청산 (진입일 {entry_date or '미상'}, 인메모리·active 행 없음 — "
f"EOD/청산 매도 미체결 추정)"
)
logger.warning(
f"[DayExit] ⚠️ DT 고아 잔고 즉시 청산: {stock_code} "
f"(진입일={entry_date or '미상'}, 수량={sell_qty}/{row_qty}, KIS 보유 {hldg_qty}·"
f"주문가능 {sellable_qty} − 타전략 {other_qty})"
)
else:
_exit_reason = f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)"
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 에 가산하지 않는다.
# [R6-1] 단 당일 매수분 고아(orphan_today)는 _finalize_exit_impl 이 당일 집계에 넣는다.
_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=_exit_reason,
)
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
def _balance_rows_page_full_v2(n_rows: int) -> bool:
"""[R3-1] get_balance 첫 페이지가 가득 찼는가 — 응답 밖(다음 페이지)에 종목이 있을 수 있다.
get_balance 는 연속조회 없이 첫 페이지만 읽는다(모의 20행/실전 50행, CLAUDE.md §8.5-37).
판정 기준은 스윙(swing/exit_service)·MDD(mdd_manager._balance_page_full_v2)와 같은
SWING_BALANCE_FULL_PAGE_ROWS 다(경로마다 기준이 다르면 같은 계좌를 다르게 해석한다).
"""
rows = int(getattr(settings, "SWING_BALANCE_FULL_PAGE_ROWS", 20) or 0)
return rows > 0 and int(n_rows) >= rows
def _holding_unknown_v2(holdings: dict | None, complete: bool, stock_code: str) -> bool:
"""[R3-1] 이 종목의 실보유를 '판정 불가'로 봐야 하는가.
조회 실패(None)이거나, 응답에 없는데 페이지가 가득 찬 경우(다음 페이지에 있을 수 있음).
응답에 있는 종목은 페이지가 가득 차도 수량을 신뢰한다(같은 종목 행은 연속돼 나온다는 전제 —
추정, KIS 응답 정렬 규약 미확인).
"""
if holdings is None:
return True
return (not complete) and (stock_code not in holdings)
async def _fetch_kis_holdings_v2_impl() -> dict[str, dict] | None:
"""[H3/M-A] KIS 계좌 실보유 {종목코드: {"hldg": 보유수량, "sellable": 주문가능수량}}.
조회 실패면 None (warning). 빈 목록은 "보유 0"(정상 응답)이다. `ord_psbl_qty` 가 없으면
주문가능수량 = 보유수량으로 본다. 주문가능수량에는 대기 중인 매도 주문분이 이미 빠져 있다.
[R3-1 2026-10-09] 첫 페이지가 가득 차(응답 밖 종목 누락 가능) 완전성을 보장할 수 없으면
**None(실보유 미확인)** 을 돌려준다. 호출부가 '응답에 없음 = 0주'로 해석해 고아 정리·불명
매도 접수 오판·불명 매수 미접수 오판을 하던 결함을 막는 보수적 계약이다. 종목별로 판정할 수
있는 exit_service 내부 호출부는 _fetch_kis_holdings_v3_impl 을 쓴다.
"""
holdings, complete = await _fetch_kis_holdings_v3_impl()
if holdings is not None and not complete:
_error_throttled(
"dt_exit:holdings_page_full",
f"[DayExit] KIS 잔고 첫 페이지가 가득 참(≥{int(getattr(settings, 'SWING_BALANCE_FULL_PAGE_ROWS', 20) or 0)}행) "
f"— 응답 밖 종목 누락 가능, 실보유 미확인으로 처리(get_balance 연속조회 미구현, CLAUDE.md §8.5-37)",
)
return None
return holdings
async def _fetch_kis_holdings_v3_impl() -> tuple[dict[str, dict] | None, bool]:
"""[R3-1] (_fetch_kis_holdings_v2_impl 과 같은 dict | None, 완전성).
완전성 False = 첫 페이지가 가득 차 응답 밖 종목이 있을 수 있다. 이때 응답에 **없는** 종목은
0주가 아니라 '판정 불가'로 다뤄야 한다(_holding_unknown_v2).
"""
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, False
except Exception as e:
logger.warning(f"[DayExit] KIS 잔고 조회 실패 — 실보유 미확인: {e}")
return None, False
if not isinstance(bal, dict):
logger.warning(f"[DayExit] KIS 잔고 응답 형식 이상({type(bal).__name__}) — 실보유 미확인")
return None, False
if bal.get("rt_cd") not in (None, "0"):
logger.warning(f"[DayExit] KIS 잔고 조회 오류 응답: {bal.get('msg1')} — 실보유 미확인")
return None, False
rows = bal.get("output1")
if not isinstance(rows, list):
logger.warning("[DayExit] KIS 잔고 응답에 output1 없음 — 실보유 미확인")
return None, False
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, False
cur = out.setdefault(code, {"hldg": 0, "sellable": 0})
cur["hldg"] += hq
cur["sellable"] += sq
return out, not _balance_rows_page_full_v2(len(rows))
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())
stale_skipped = 0
for stock_code in codes:
pos = day_trading_state.get_position(stock_code)
if not pos:
continue
# [R1-4] 전일 포지션은 일반 경로(_execute_exit, count_daily_pnl=True)로 팔지 않는다.
# 아래에서 close_stale_positions 로 넘긴다(실보유·타전략 차감, 당일 손익 제외).
if _is_stale_position(pos):
_warn_stale_in_general_path(stock_code, f"강제 청산({reason})")
stale_skipped += 1
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)
else:
# [R2-5] 예외 없이 None(KIS 거부·결과 불명 보류 등)도 실패로 집계해 재시도·경보한다.
logger.error(f"[DayExit] 강제 청산 미체결 반환 ({stock_code}) — 재시도 대상")
failed_codes.append(stock_code)
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
if 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)
elif day_trading_state.get_position(stock_code):
logger.critical(f"[DayExit] 강제 청산 재시도 미체결 반환 ({stock_code}) — 포지션 미청산 위험!")
still_failed.append(stock_code)
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} — 수동 청산 필요!")
# [R2-5] critical 로그만으로는 15:30 이후 오버나이트 보유를 아무도 모른다 → 텔레그램.
_desc = ", ".join(
f"{c}({int((day_trading_state.get_position(c) or {}).get('quantity', 0) or 0)}주)"
for c in still_failed
)
_alert_once_v2(
"DT_EOD", f"force_close:{reason}:{','.join(sorted(still_failed))}",
f"[데이트레이딩] '{reason}' 재시도 후에도 미청산: {_desc} — 다음 사이클에 재시도하지만 "
f"장 마감(15:30) 전에 계좌를 확인하고 필요하면 수동 청산하세요.",
)
# [R2-5] EOD 구간이면 장 마감 직후 잔존 DT 포지션 점검을 하루 1회 예약한다.
if is_eod_flat_time():
_schedule_post_close_residual_check_impl()
if stale_skipped:
try:
results.extend(await close_stale_positions() or [])
except Exception as e:
logger.error(f"[DayExit] 강제 청산 중 전일 포지션 청산 실패: {e} — 수동 확인 필요", exc_info=True)
logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})")
return results
# ─────────────────────────────────────────────────────────────────────────────
# [R2-5 2026-10-09] EOD 미청산 경보
# ─────────────────────────────────────────────────────────────────────────────
# (날짜, tag:key) — 하루 1회만 텔레그램
_once_alerted: set[tuple[str, str]] = set()
# 장 마감 후 잔존 점검을 예약한 날짜
_post_close_check_scheduled: set[str] = set()
_post_close_tasks: set = set()
def _alert_once_v2(tag: str, key: str, msg: str) -> None:
"""logger.critical + 텔레그램 notify_system_warning(tag)(하루 1회/키). 발송 실패는 error 로그만."""
logger.critical(f"[DayExit] 🚨 {msg}")
_k = (_today_key(), f"{tag}:{key}")
for k in [k for k in _once_alerted if k[0] != _k[0]]:
_once_alerted.discard(k)
if _k in _once_alerted:
return
_once_alerted.add(_k)
try:
from app.services.telegram_notifier import telegram_notifier
_t = asyncio.get_running_loop().create_task(asyncio.wait_for(
telegram_notifier.notify_system_warning(tag, msg), timeout=10.0,
))
_alert_tasks.add(_t)
_t.add_done_callback(_alert_tasks.discard)
except Exception as e:
logger.error(f"[DayExit] {tag} 경보 발송 실패({key}): {e}")
def check_post_close_residual_v2() -> list[str]:
"""장 마감 후 인메모리 DT 포지션이 남아 있으면 텔레그램 경보(DT_EOD). 잔존 종목 코드 반환.
§7 'EOD 전량 청산' 이 지켜지지 않은 상태 — 다음 영업일 close_stale_positions 까지 손절 없이
오버나이트로 남는다. 전일(stale) 포지션도 포함해 센다.
"""
residual = sorted(day_trading_state.positions.keys())
if not residual:
logger.info("[DayExit] 장 마감 후 잔존 DT 포지션 없음 — EOD 전량 청산 확인")
return []
_desc = ", ".join(
f"{c}({int((day_trading_state.positions.get(c) or {}).get('quantity', 0) or 0)}주"
f"{', 청산 진행/불명' if day_trading_state.is_exit_in_progress(c) else ''})"
for c in residual
)
_alert_once_v2(
"DT_EOD", f"post_close:{','.join(residual)}",
f"[데이트레이딩] 장 마감 후 미청산 DT 포지션 {len(residual)}종목: {_desc} — 오버나이트 보유 중. "
f"다음 영업일 장중 첫 사이클에 전일 포지션 청산이 시도됩니다. 계좌를 확인하세요.",
)
return residual
# [R3-2] 종가 재확인 1회 조회의 여유 시간(초) — fetch_fill_v2 왕복 상한 추정(config 미편입)
_AUCTION_RECHECK_FETCH_SLACK_SEC = 30.0
def _auction_recheck_wait_cap_impl() -> float:
"""[R3-2] 진행 중인 종가 재확인 태스크를 기다릴 최대 초 — 재확인 횟수×(간격+조회 여유)+여유."""
_, _attempts, _interval = _auction_recheck_params_impl()
return float(_attempts) * (float(_interval) + _AUCTION_RECHECK_FETCH_SLACK_SEC) + _AUCTION_RECHECK_FETCH_SLACK_SEC
async def _await_auction_rechecks_impl() -> None:
"""[R3-2] 진행 중인 종가 단일가 매도 재확인 태스크가 끝날 때까지 대기(상한 있음).
상한을 넘기면 error 를 남기고 점검을 진행한다(남은 재확인이 나중에 복원하면 _on_unfilled 가
장 마감 후 복원 경보를 직접 보낸다 — 이중 안전).
"""
pending = [t for t in list(_auction_fill_tasks) if not t.done()]
if not pending:
return
_cap = _auction_recheck_wait_cap_impl()
_done, _not_done = await asyncio.wait(pending, timeout=_cap)
if _not_done:
logger.error(
f"[DayExit] 장 마감 후 잔존 점검: 종가 매도 재확인 {len(_not_done)}건이 {_cap:.0f}초 안에 끝나지 않음 "
f"— 점검을 진행한다(이후 0체결 복원 시 별도 경보)"
)
def _schedule_post_close_residual_check_impl() -> None:
"""EOD 구간 첫 호출에서 '장 마감 + DAY_TRADE_FILL_AUCTION_RECHECK_DELAY_SEC' 뒤 잔존 점검 1회 예약."""
_day = _today_key()
if _day in _post_close_check_scheduled:
return
for d in [d for d in _post_close_check_scheduled if d != _day]:
_post_close_check_scheduled.discard(d)
async def _run() -> None:
await asyncio.sleep(_seconds_until_auction_recheck_impl(_kst_now()))
# [R3-2] 종가 단일가 매도 재확인(_auction_fill_tasks)도 같은 시각에 깨어나 KIS 조회를 마친 뒤에야
# 0체결·부분체결 포지션을 되살린다. 그보다 먼저 점검하면 '잔존 없음'을 확정해 버린다 →
# 진행 중인 재확인이 끝날 때까지(상한: 재확인 횟수×(간격+조회 여유)+여유) 기다린 뒤 점검한다.
await _await_auction_rechecks_impl()
try:
check_post_close_residual_v2()
except Exception as e:
logger.error(f"[DayExit] 장 마감 후 잔존 점검 실패: {e} — 수동 확인 필요", exc_info=True)
try:
task = asyncio.get_running_loop().create_task(_run())
except RuntimeError as e:
logger.error(f"[DayExit] 장 마감 후 잔존 점검 예약 실패: {e}")
return
_post_close_check_scheduled.add(_day)
_post_close_tasks.add(task)
task.add_done_callback(_post_close_tasks.discard)