trade_executor - 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room) - 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning - 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed - 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지) - 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡 korea_investment_api - get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning daytrading - 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제) - 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler) - legacy _record_trade strategy_type=daytrading 명시 db_worker - update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지) alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함) CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014 998 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
901 lines
40 KiB
Python
901 lines
40 KiB
Python
"""데이트레이딩 청산 서비스
|
||
|
||
당일 포지션의 청산 조건 체크 및 실행:
|
||
- 5분봉 ATR 기반 손절/익절 (day_trading_strategy.check_day_trade_exit 재활용)
|
||
- 15:20 EOD 강제 청산 (CLAUDE.md 7.3: 당일 청산 원칙)
|
||
- 일일 손실 한도(-3%) 초과 시 즉시 전체 청산
|
||
- 분할 매도 지원: 목표가 달성 시 50% 매도, 잔량 트레일링스탑
|
||
|
||
청산 우선순위:
|
||
1. 일일 손실 한도 초과 → 전체 강제 청산
|
||
2. EOD 15:20 도달 → 전체 강제 청산
|
||
3. 개별 포지션 체크:
|
||
- 손절: ATR 기반 또는 하드스탑
|
||
- 시간 손절: 진입 30분 경과 후 언제든 -0.3% 미만
|
||
- 과매수 청산: RSI >= 70
|
||
- 분할 익절: 목표가 +2R 달성 시 50% 매도
|
||
|
||
아키텍처 규칙:
|
||
- CLAUDE.md 3.1: DB I/O 금지 — 인메모리 상태만 수정, 체결 후 db_task_queue 경유
|
||
- loss_tracker 연동: 전량 매도 시 record_trade_result_safe() + save_to_db() 호출
|
||
"""
|
||
import asyncio
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
|
||
import pytz
|
||
|
||
from app.core.config import settings
|
||
from app.core.market_schedule import is_eod_flat_time, is_market_open
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
from app.services.day_trading_strategy import (
|
||
parse_minute_candles, aggregate_to_5min,
|
||
check_day_trade_exit, is_daily_loss_exceeded,
|
||
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
|
||
|
||
|
||
def get_stale_realized_pnl() -> float:
|
||
"""전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액)."""
|
||
return _stale_realized_pnl
|
||
|
||
|
||
def _dt_sell_tax_rate(pos: dict) -> float:
|
||
"""DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일)."""
|
||
market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper()
|
||
if market == "KOSDAQ":
|
||
return float(settings.KR_SELL_TAX_RATE_KOSDAQ)
|
||
return float(settings.KR_SELL_TAX_RATE_KOSPI)
|
||
|
||
|
||
def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) -> dict:
|
||
"""[2026-10-01] DT 실현손익(비용 차감). 스윙 집행 경로와 같은 비용 정의(trade_cost_core).
|
||
|
||
매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(KR_SELL_TAX_RATE_*)를 뺀다.
|
||
반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate
|
||
"""
|
||
return net_realized_pnl(
|
||
entry_price, exit_price, quantity,
|
||
float(settings.KR_COMMISSION_RATE), _dt_sell_tax_rate(pos),
|
||
)
|
||
|
||
|
||
def _fallback_stop_pct() -> float:
|
||
"""[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율.
|
||
|
||
기존 하드코딩 0.06(-6%)은 전략 하드캡(ORB 3%, VWAP/RSI_BB 3.5%)의 2배로 느슨했다.
|
||
가장 보수적(타이트)한 하드캡을 사용해 폴백이 실제 전략보다 헐거워지지 않게 한다.
|
||
position_manager.restore_from_db() 의 폴백(DAY_TRADE_STOP_LOSS_PCT)과도 일치.
|
||
"""
|
||
return min(
|
||
settings.DAY_TRADE_STOP_LOSS_PCT,
|
||
settings.DAY_TRADE_ORB_STOP_LOSS_PCT,
|
||
settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT,
|
||
)
|
||
|
||
|
||
def _fallback_stop_price(entry_price: float) -> float:
|
||
"""진입가 기준 폴백 손절가."""
|
||
return entry_price * (1 - _fallback_stop_pct())
|
||
|
||
|
||
def _fallback_target_price(entry_price: float, stop_loss: float) -> float:
|
||
"""진입가·손절가 기준 폴백 목표가 (DAY_TRADE_TARGET_MULTIPLIER 정합)."""
|
||
risk = entry_price - stop_loss
|
||
if risk <= 0:
|
||
risk = entry_price * _fallback_stop_pct()
|
||
return entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
|
||
|
||
|
||
def _kst_now() -> datetime:
|
||
"""현재 KST 시간을 timezone-naive datetime으로 반환.
|
||
|
||
entry_time과 비교 시 timezone 불일치 방지를 위해 tzinfo 제거.
|
||
|
||
Returns:
|
||
datetime: 현재 KST 시간 (timezone-naive)
|
||
"""
|
||
return datetime.now(_KST).replace(tzinfo=None)
|
||
|
||
|
||
# [2026-10-01] 진입 시각 신뢰 구간 (KST 시각). DT 진입은 정규장 안에서만 일어나므로
|
||
# 이 밖의 entry_time 은 기준 불일치(예: 과거 UTC 저장 행 — KST 09:05 → UTC 00:05)로 본다.
|
||
_ENTRY_TIME_VALID_FROM = (9, 0)
|
||
_ENTRY_TIME_VALID_TO = (15, 30)
|
||
|
||
# 1분봉 라벨(stck_cntg_hour)이 "분 시작"인지 "분 종료"인지 응답 규약이 확정되지 않았다.
|
||
# 라벨 >= 진입시각 + 60초 인 봉만 쓰면 두 해석 모두에서 봉 전체가 진입 이후 체결이다.
|
||
_POST_ENTRY_CANDLE_LAG_SEC = 60
|
||
|
||
|
||
def _normalize_entry_time(entry_time) -> datetime | None:
|
||
"""entry_time 을 KST naive datetime 으로 정규화. 신뢰할 수 없으면 None.
|
||
|
||
- 인메모리 진입(entry_service): `datetime.now(KST).replace(tzinfo=None)` (KST naive)
|
||
- DB 복원(position_manager.restore_from_db): `ActiveTrade.created_at` (모델 기본값 now_kst
|
||
= KST naive). 단 2026-09-23 이전 행은 UTC 로 저장된 것이 섞여 있다(timeutil 참조).
|
||
"""
|
||
if entry_time is None or not isinstance(entry_time, datetime):
|
||
return None
|
||
if entry_time.tzinfo is not None:
|
||
entry_time = entry_time.astimezone(_KST).replace(tzinfo=None)
|
||
hm = (entry_time.hour, entry_time.minute)
|
||
if hm < _ENTRY_TIME_VALID_FROM or hm > _ENTRY_TIME_VALID_TO:
|
||
return None
|
||
if entry_time > _kst_now():
|
||
return None
|
||
return entry_time
|
||
|
||
|
||
def _candle_datetime(candle) -> datetime | None:
|
||
"""MinuteCandle(date=YYYYMMDD, time=HHMMSS, KST) → naive datetime. 형식 불량이면 None."""
|
||
d = getattr(candle, "date", "") or ""
|
||
t = getattr(candle, "time", "") or ""
|
||
if len(d) != 8 or len(t) < 4:
|
||
return None
|
||
try:
|
||
return datetime(
|
||
int(d[:4]), int(d[4:6]), int(d[6:8]),
|
||
int(t[:2]), int(t[2:4]), int(t[4:6]) if len(t) >= 6 else 0,
|
||
)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _post_entry_candle_high(candles, entry_time) -> float | None:
|
||
"""진입 **이후에 시작한** 분봉들의 최고가. 산출 불가/해당 봉 없음이면 None (순수 함수).
|
||
|
||
[2026-10-01 버그 수정] 예전에는 `max(c.high for c in candles_5m[-3:])` 로 최근 5분봉 3개의
|
||
고가를 그대로 트레일링 기준 고점에 넣었다. 진입 후 15분 이내에는 **진입 전 봉의 고가**가
|
||
섞여, 실제로 오르지 않았는데 +1% 트레일링 트리거가 켜지거나 `고점 − 1.5×ATR` 손절선이
|
||
부풀려졌다(운영 실측: 트레일링 6건이 "고점 +1.1~2.2%" 기록 후 −0.13~−0.81% 로 종료).
|
||
|
||
보수적 처리:
|
||
- **진입이 속한 봉은 제외한다.** 봉 고가가 진입 전/후 어느 쪽 체결인지 알 수 없다.
|
||
진입 직후 구간의 고점은 30초 주기의 현재가 관측과 진입 시 초기값(=진입가)으로만 잡는다
|
||
(고점을 과소 추정 → 트레일링이 늦게 켜질 뿐, 하드 손절·시간손절은 영향 없음).
|
||
- 5분봉 대신 1분봉을 쓰고, 라벨 >= 진입시각+60초 인 봉만 인정한다(라벨 규약 무관 안전).
|
||
- 날짜까지 포함한 datetime 으로 비교해 전일 캔들 혼입(CLAUDE.md §7)을 차단한다.
|
||
날짜·시각이 비어 있거나 형식 불량인 봉은 위치를 알 수 없으므로 제외한다.
|
||
- entry_time 이 없거나 신뢰할 수 없으면 None → 호출부는 현재가만 쓴다.
|
||
"""
|
||
entry = _normalize_entry_time(entry_time)
|
||
if entry is None:
|
||
return None
|
||
threshold = entry + timedelta(seconds=_POST_ENTRY_CANDLE_LAG_SEC)
|
||
best: float | None = None
|
||
for c in candles or []:
|
||
cdt = _candle_datetime(c)
|
||
if cdt is None or cdt < threshold:
|
||
continue
|
||
h = float(getattr(c, "high", 0) or 0)
|
||
if h > 0 and (best is None or h > best):
|
||
best = h
|
||
return best
|
||
|
||
|
||
async def check_and_exit_positions() -> list[dict]:
|
||
"""활성 데이트레이딩 포지션의 청산 조건 체크 및 실행.
|
||
|
||
인메모리 positions 순회 → 5분봉 ATR 기반 청산 판단.
|
||
스케줄러에서 30초 주기로 호출.
|
||
|
||
체크 순서:
|
||
1. 일일 손실 한도 초과 → 전체 강제 청산
|
||
2. EOD 15:20 → 전체 강제 청산
|
||
3. 개별 포지션별 check_day_trade_exit() 호출
|
||
|
||
Returns:
|
||
list[dict]: 청산 실행 결과 목록
|
||
"""
|
||
day_trading_state.reset_if_new_day()
|
||
exits = []
|
||
|
||
if not day_trading_state.positions:
|
||
# 인메모리가 비어도 DB에 전일 미청산 포지션이 남아 있을 수 있다(자정 reset 이후)
|
||
return await close_stale_positions()
|
||
|
||
# 일일 손실 한도 초과 시 전체 강제 청산
|
||
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
|
||
unrealized_pnl = 0.0
|
||
for _code, _pos in day_trading_state.positions.items():
|
||
_entry = _pos.get("entry_price", 0)
|
||
_qty = _pos.get("quantity", 0)
|
||
_cur = _pos.get("current_price", _entry)
|
||
if _entry > 0 and _qty > 0:
|
||
unrealized_pnl += (_cur - _entry) * _qty
|
||
total_daily_pnl = day_trading_state.daily_pnl + unrealized_pnl
|
||
if is_daily_loss_exceeded(day_trading_state.last_total_assets, total_daily_pnl):
|
||
logger.warning(
|
||
f"[DayExit] 일일 손실 한도 초과 → 전체 강제 청산 "
|
||
f"(실현={day_trading_state.daily_pnl:.0f}, 미실현={unrealized_pnl:.0f})"
|
||
)
|
||
return await force_close_all(reason="일일 손실 한도 초과")
|
||
|
||
# EOD 강제 청산 (15:20)
|
||
if is_eod_flat_time():
|
||
logger.info("[DayExit] EOD (15:20) → 전체 강제 청산")
|
||
return await force_close_all(reason="EOD 강제 청산")
|
||
|
||
# [안전장치 2026-09-23] 전일 미청산 포지션 즉시 청산.
|
||
# 데이트레이딩은 당일 청산이 원칙인데 15:20 EOD가 실패/누락되면(2026-09-22 LG씨엔에스)
|
||
# 포지션이 하룻밤 갭 리스크에 노출된 채 다음 날 일반 청산 로직을 타게 된다.
|
||
stale_exits = await close_stale_positions()
|
||
if stale_exits:
|
||
exits.extend(stale_exits)
|
||
if not day_trading_state.positions:
|
||
return exits
|
||
|
||
# 개별 포지션 청산 체크
|
||
codes_snapshot = list(day_trading_state.positions.keys())
|
||
for stock_code in codes_snapshot:
|
||
try:
|
||
exit_result = await _check_single_position(stock_code)
|
||
if exit_result:
|
||
exits.append(exit_result)
|
||
except Exception as e:
|
||
logger.error(f"[DayExit] 포지션 체크 오류 ({stock_code}): {e}", exc_info=True)
|
||
|
||
return exits
|
||
|
||
|
||
async def _check_single_position(stock_code: str) -> dict | None:
|
||
"""단일 포지션의 청산 조건 체크.
|
||
|
||
5분봉 데이터를 조회하여 check_day_trade_exit()로 청산 신호를 판단.
|
||
최고가(highest_price)를 갱신하여 트레일링스탑 정확도 향상.
|
||
|
||
Args:
|
||
stock_code: 종목 코드
|
||
|
||
Returns:
|
||
dict | None: 청산 실행 결과 또는 None (청산 조건 미충족)
|
||
"""
|
||
pos = day_trading_state.get_position(stock_code)
|
||
if not pos:
|
||
return None
|
||
|
||
entry_price = pos.get("entry_price", 0)
|
||
# [M7] stop_loss / target_price 는 None 뿐 아니라 0 으로 저장될 수 있다.
|
||
# `or` 패턴은 0 도 폴백으로 넘기지만 의도가 모호하므로 명시적으로 None/비정상값을 검사한다.
|
||
# (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨)
|
||
_sl_raw = pos.get("stop_loss")
|
||
stop_loss = float(_sl_raw) if (_sl_raw is not None and float(_sl_raw) > 0) else _fallback_stop_price(entry_price)
|
||
_tp_raw = pos.get("target_price")
|
||
target_price = (
|
||
float(_tp_raw) if (_tp_raw is not None and float(_tp_raw) > 0)
|
||
else _fallback_target_price(entry_price, stop_loss)
|
||
)
|
||
highest_price = pos.get("highest_price", entry_price)
|
||
entry_time = pos.get("entry_time")
|
||
|
||
# [H4] try 범위를 "분봉 조회 + 지표/신호 계산"까지로 한정한다.
|
||
# 예전 코드는 _execute_exit 까지 하나의 try 안에 있고 로그가 debug 였기 때문에
|
||
# 분봉 조회 실패 시 손절·트레일링·시간손절이 전부 미적용된 채 15:20 까지 방치되고,
|
||
# 주문 실패조차 로그에 보이지 않았다.
|
||
exit_signal = None
|
||
current_price = 0.0
|
||
try:
|
||
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
|
||
candles = parse_minute_candles(raw)
|
||
candles_5m = aggregate_to_5min(candles)
|
||
except Exception as e:
|
||
logger.warning(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e} — 폴백 손절만 판정")
|
||
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
|
||
|
||
if not candles_5m or len(candles_5m) < 5:
|
||
# 분봉이 부족하면 ATR/RSI 기반 판정이 불가 → 손절선만 지킨다(방치 금지)
|
||
logger.warning(
|
||
f"[DayExit] 5분봉 부족 ({stock_code}): {len(candles_5m or [])}개 — 폴백 손절만 판정"
|
||
)
|
||
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
|
||
|
||
try:
|
||
current_price = float(candles_5m[-1].close)
|
||
|
||
# highest_price 갱신 — 진입 이후에 시작한 1분봉 고가만 반영 (30초 주기 보완).
|
||
# [2026-10-01] 예전 `candles_5m[-3:]` 는 진입 전 봉 고가가 섞여 트레일링 고점을 오염시켰다.
|
||
# entry_time 없음/불신 → None → 현재가만 사용(고점 과대 추정보다 과소 추정이 안전).
|
||
# 기존 highest_price 와 max → 단조 증가 유지.
|
||
candle_high = _post_entry_candle_high(candles, entry_time)
|
||
if candle_high is None and entry_time is not None and _normalize_entry_time(entry_time) is None:
|
||
logger.warning(
|
||
f"[DayExit] entry_time 불신 ({stock_code}, entry_time={entry_time}) "
|
||
f"— 트레일링 고점은 현재가만으로 갱신"
|
||
)
|
||
new_highest = max(current_price, candle_high or 0.0, highest_price)
|
||
async with day_trading_state.trade_lock:
|
||
if stock_code in day_trading_state.positions:
|
||
if new_highest > highest_price:
|
||
day_trading_state.positions[stock_code]["highest_price"] = new_highest
|
||
# [S5] 미실현 손익 계산용 현재가 갱신
|
||
day_trading_state.positions[stock_code]["current_price"] = current_price
|
||
if new_highest > highest_price:
|
||
highest_price = new_highest
|
||
|
||
# 청산 신호 체크
|
||
current_time = _kst_now().time()
|
||
exit_signal = check_day_trade_exit(
|
||
entry_price=entry_price,
|
||
current_price=current_price,
|
||
stop_loss=stop_loss,
|
||
target_price=target_price,
|
||
highest_since_entry=highest_price,
|
||
candles_5min=candles_5m,
|
||
current_time=current_time,
|
||
entry_time=entry_time,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[DayExit] 청산 지표 계산 실패 ({stock_code}): {e} — 폴백 손절만 판정")
|
||
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
|
||
|
||
if not exit_signal or current_price <= 0:
|
||
return None
|
||
|
||
# 주문 실행은 try 밖(별도 try) — 실패를 조회 실패와 구분해 error 로 남긴다.
|
||
try:
|
||
return await _execute_exit(
|
||
stock_code, pos, current_price, exit_signal.reason,
|
||
sell_pct=exit_signal.sell_pct,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"[DayExit] 청산 주문 실행 실패 ({stock_code}): {e}", exc_info=True)
|
||
return None
|
||
|
||
|
||
async def _check_fallback_stop_loss(
|
||
stock_code: str, pos: dict, stop_loss: float,
|
||
) -> dict | None:
|
||
"""[H4] 분봉 미확보 시 최소 안전장치 — 손절선 이탈만 판정해 전량 청산.
|
||
|
||
swing/exit_service.py 의 고정비율 폴백 손절과 같은 취지. 분봉이 없으면
|
||
트레일링/시간손절/RSI 청산은 판정할 수 없으므로 "손절선"만 지킨다.
|
||
현재가는 KIS 현재가 조회 → 인메모리 최신가(current_price) 순으로 확보하고,
|
||
둘 다 실패하면 logger.error 로 남긴다(손절 미적용을 침묵시키지 않는다).
|
||
|
||
Args:
|
||
stock_code: 종목 코드
|
||
pos: 포지션 정보
|
||
stop_loss: 적용할 손절가 (0 이하이면 진입가 기준 폴백 산출)
|
||
|
||
Returns:
|
||
dict | None: 청산 결과 또는 None (손절 미도달·판정 불가)
|
||
"""
|
||
entry_price = float(pos.get("entry_price", 0) or 0)
|
||
if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0:
|
||
return None
|
||
if stop_loss <= 0:
|
||
stop_loss = _fallback_stop_price(entry_price)
|
||
|
||
current_price = 0.0
|
||
try:
|
||
resp = await korea_investment_api.get_current_price(stock_code)
|
||
current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0
|
||
except Exception as e:
|
||
logger.warning(f"[DayExit] 폴백 손절 현재가 조회 실패 ({stock_code}): {e}")
|
||
if current_price <= 0:
|
||
current_price = float(pos.get("current_price", 0) or 0)
|
||
if current_price <= 0:
|
||
logger.error(
|
||
f"[DayExit] 폴백 손절 현재가 확보 실패 ({stock_code}) — 손절 미적용 상태, 수동 확인 필요"
|
||
)
|
||
return None
|
||
|
||
async with day_trading_state.trade_lock:
|
||
if stock_code in day_trading_state.positions:
|
||
day_trading_state.positions[stock_code]["current_price"] = current_price
|
||
|
||
if current_price > stop_loss:
|
||
return None
|
||
|
||
loss_rate = (current_price - entry_price) / entry_price * 100
|
||
logger.warning(
|
||
f"[DayExit] ⚠️ 폴백 손절 발동 ({stock_code}): 현재가={current_price:.0f} "
|
||
f"<= 손절={stop_loss:.0f} ({loss_rate:.2f}%) — 분봉 미확보"
|
||
)
|
||
try:
|
||
return await _execute_exit(
|
||
stock_code, pos, current_price,
|
||
reason=f"폴백 손절(분봉 미확보) {loss_rate:.1f}%",
|
||
sell_pct=1.0,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"[DayExit] 폴백 손절 주문 실패 ({stock_code}): {e}", exc_info=True)
|
||
return None
|
||
|
||
|
||
async def _execute_exit(
|
||
stock_code: str, pos: dict, price: float, reason: str,
|
||
sell_pct: float = 1.0,
|
||
) -> dict | None:
|
||
"""실제 매도 주문 실행 및 상태 업데이트.
|
||
|
||
처리 흐름:
|
||
1. 매도 수량 계산 (분할 매도 시 최소 1주 보장)
|
||
2. safe_place_order()로 시장가 매도 (세마포어+분산락 보호)
|
||
3. 인메모리 상태 업데이트 (trade_lock 보호)
|
||
4. 전량 매도 시 loss_tracker 연동
|
||
5. DB 기록 (db_task_queue 경유)
|
||
|
||
Args:
|
||
stock_code: 종목 코드
|
||
pos: 포지션 정보 딕셔너리
|
||
price: 현재가 (매도 기준가)
|
||
reason: 청산 사유 (로그 및 DB 기록용)
|
||
sell_pct: 매도 비율 (1.0=전량, 0.5=50% 분할 매도)
|
||
|
||
Returns:
|
||
dict | None: 청산 결과 (stock_code, price, quantity, profit_loss 등)
|
||
"""
|
||
return await _execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale=False)
|
||
|
||
|
||
async def _execute_exit_stale(
|
||
stock_code: str, pos: dict, price: float, reason: str,
|
||
) -> dict | None:
|
||
"""[M1] 전일 미청산(stale) 포지션 전용 청산 경로.
|
||
|
||
_execute_exit 과 동일하게 주문·상태반영·DB 기록을 수행하지만,
|
||
실현손익을 **당일** daily_pnl / loss_streak_today 에 가산하지 않는다.
|
||
(전일 손실이 당일 손실 한도(-3%)를 오발동시켜 정상 포지션까지 전량 청산되고
|
||
신규 진입이 종일 차단되던 문제 — 2026-09-27 수정)
|
||
"""
|
||
return await _execute_exit_impl(stock_code, pos, price, reason, 1.0, is_stale=True)
|
||
|
||
|
||
async def _execute_exit_impl(
|
||
stock_code: str, pos: dict, price: float, reason: str,
|
||
sell_pct: float, is_stale: bool,
|
||
) -> dict | None:
|
||
"""매도 주문 실행 + 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변).
|
||
|
||
is_stale=True 이면 당일 손익 집계(update_pnl)를 건너뛴다.
|
||
"""
|
||
total_qty = pos.get("quantity", 0)
|
||
|
||
if total_qty <= 0 or price <= 0:
|
||
return None
|
||
|
||
# [P1] 분할 매도 수량 계산
|
||
if sell_pct >= 1.0:
|
||
quantity = total_qty
|
||
else:
|
||
quantity = max(1, int(total_qty * sell_pct))
|
||
if quantity >= total_qty:
|
||
quantity = total_qty
|
||
|
||
is_full_exit = (quantity >= total_qty)
|
||
|
||
# KIS API 매도 주문 (시장가) — 분산락+세마포어 보호
|
||
from app.services.trade_executor import safe_place_order
|
||
order_resp = await safe_place_order(
|
||
stock_code=stock_code,
|
||
order_type="SELL",
|
||
quantity=quantity,
|
||
price=None, # 시장가
|
||
)
|
||
|
||
if not order_resp or order_resp.get("rt_cd") != "0":
|
||
err = order_resp.get("msg1", "unknown") if order_resp else "no response"
|
||
logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}")
|
||
return None
|
||
|
||
# [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계
|
||
# 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가
|
||
# 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다.
|
||
if is_stale:
|
||
return await asyncio.shield(_finalize_exit_stale(
|
||
stock_code, pos, price, reason, order_resp, quantity, total_qty,
|
||
))
|
||
return await asyncio.shield(_finalize_exit(
|
||
stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit
|
||
))
|
||
|
||
|
||
async def _finalize_exit(
|
||
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
|
||
order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool,
|
||
) -> dict | None:
|
||
"""체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간.
|
||
|
||
당일 포지션 전용 — 실현손익을 당일 daily_pnl / loss_streak_today 에 반영한다.
|
||
(전일 포지션은 _finalize_exit_stale 사용 — M1)
|
||
"""
|
||
return await _finalize_exit_impl(
|
||
stock_code, pos, price, reason, sell_pct, order_resp,
|
||
quantity, total_qty, is_full_exit, count_daily_pnl=True,
|
||
)
|
||
|
||
|
||
async def _finalize_exit_stale(
|
||
stock_code: str, pos: dict, price: float, reason: str,
|
||
order_resp: dict, quantity: int, total_qty: int,
|
||
) -> dict | None:
|
||
"""[M1] 전일(stale) 포지션 체결 이후 상태 반영.
|
||
|
||
당일 손익 집계(daily_pnl·loss_streak_today)에는 가산하지 않고,
|
||
별도 누적값(_stale_realized_pnl)과 로그로만 추적한다.
|
||
DB TradeHistory 기록은 동일하게 수행되므로 실제 손익 추적은 유지된다.
|
||
"""
|
||
return await _finalize_exit_impl(
|
||
stock_code, pos, price, reason, 1.0, order_resp,
|
||
quantity, total_qty, True, count_daily_pnl=False,
|
||
)
|
||
|
||
|
||
async def _finalize_exit_impl(
|
||
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
|
||
order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool,
|
||
count_daily_pnl: bool = True,
|
||
) -> dict | None:
|
||
"""체결 이후 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변).
|
||
|
||
count_daily_pnl=False 이면 당일 손익/연속손실 집계를 건너뛰고
|
||
전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로).
|
||
"""
|
||
global _stale_realized_pnl
|
||
entry_price = pos.get("entry_price", 0)
|
||
stock_name = pos.get("stock_name", stock_code)
|
||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
||
# [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다.
|
||
# 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다
|
||
# → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0)
|
||
# 청산도 순손익으로는 손실이다.
|
||
_pnl = dt_net_pnl(entry_price, price, quantity, pos)
|
||
gross_profit_loss = _pnl["gross_profit_loss"]
|
||
trade_cost = _pnl["total_cost"]
|
||
profit_loss = _pnl["net_profit_loss"]
|
||
profit_loss_rate = _pnl["net_profit_loss_rate"] if entry_price > 0 else 0.0
|
||
|
||
# 인메모리 상태 업데이트 (DB 직접 접근 금지)
|
||
async with day_trading_state.trade_lock:
|
||
if is_full_exit:
|
||
day_trading_state.remove_position(stock_code)
|
||
if not count_daily_pnl:
|
||
# [M10] DB에서만 발견된 전일 포지션은 인메모리에 없어 remove_position()의
|
||
# closed_today 추가가 일어나지 않는다 → 강제청산한 종목이 같은 날
|
||
# scan_and_enter 후보로 되살아난다. 명시적으로 재진입 금지 집합에 넣는다.
|
||
day_trading_state.closed_today.add(stock_code)
|
||
else:
|
||
# 분할 매도: 잔량 유지, target_price 제거(트레일링만 적용)
|
||
remaining = total_qty - quantity
|
||
day_trading_state.positions[stock_code]["quantity"] = remaining
|
||
day_trading_state.positions[stock_code]["target_price"] = float("inf")
|
||
day_trading_state.positions[stock_code]["partial_sold"] = True
|
||
if count_daily_pnl:
|
||
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
|
||
else:
|
||
_stale_realized_pnl += profit_loss
|
||
logger.warning(
|
||
f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} "
|
||
f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)"
|
||
)
|
||
|
||
# [F9 2026-09-28] 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화.
|
||
# 값이 바뀐 직후에 저장한다 — 재기동해도 일일 -3% 한도·3연패 쿨다운·당일
|
||
# 재진입 차단이 풀리지 않게 한다(락 밖에서 호출: Redis I/O 로 락을 잡지 않는다).
|
||
await day_trading_state.save_daily_state()
|
||
|
||
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
|
||
# [2026-10-01] 시간손절은 **중립**(손실로도 이익으로도 세지 않음 → 기록 자체를 생략).
|
||
# 기회비용 청산까지 3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지에 넣으면 그 밴이
|
||
# 스윙 진입(swing/entry_service)까지 막는다. 하드/폴백 손절·일일한도·EOD·트레일링 등
|
||
# 나머지 청산은 기존대로 기록한다. (당일 연패 쿨다운 loss_streak_today 는 위 update_pnl 이
|
||
# 그대로 센다 — DT 전용·당일 한정 스로틀이라 스윙에 번지지 않는다.)
|
||
if is_full_exit:
|
||
if is_time_stop_exit_reason(reason):
|
||
logger.info(
|
||
f"[DayExit] 시간손절은 연속손실 집계 제외(중립): {stock_code} "
|
||
f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/"
|
||
f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음"
|
||
)
|
||
else:
|
||
is_loss = profit_loss < 0
|
||
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
|
||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||
|
||
# DB 기록 (큐 경유)
|
||
await enqueue_trade_history({
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"order_type": "SELL",
|
||
"order_price": price,
|
||
"order_quantity": quantity,
|
||
"executed_price": price,
|
||
"executed_quantity": quantity,
|
||
"total_amount": price * quantity,
|
||
"status": "COMPLETED",
|
||
"order_id": order_id,
|
||
"exit_reason": reason,
|
||
"commission": trade_cost,
|
||
"net_profit_loss": profit_loss,
|
||
"profit_loss_rate": profit_loss_rate,
|
||
"strategy_type": "daytrading",
|
||
})
|
||
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정.
|
||
_schedule_sell_fill_reconcile(
|
||
stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price,
|
||
quantity=quantity, entry_price=entry_price, recorded_net=profit_loss,
|
||
count_daily_pnl=count_daily_pnl,
|
||
)
|
||
if is_full_exit:
|
||
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
|
||
else:
|
||
# 분할 매도 후 ActiveTrade 잔량 갱신 — 누락 시 재기동 복원에서 원래 수량이 되살아나
|
||
# 다음 청산이 보유량보다 많이 매도한다(2026-09-23 LG씨엔에스 4주 보유 중 7주 매도).
|
||
from app.services.db_worker import enqueue_active_trade_upsert
|
||
await enqueue_active_trade_upsert({
|
||
"stock_code": stock_code,
|
||
"strategy_name": "day_trade",
|
||
"quantity": total_qty - quantity,
|
||
})
|
||
# 포트폴리오 화면에서 즉시 제거/차감
|
||
from app.services.portfolio_cache_bridge import reflect_sell
|
||
await reflect_sell(stock_code, quantity, price, remaining_qty=max(0, int(total_qty) - int(quantity)))
|
||
|
||
sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량"
|
||
logger.info(
|
||
f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} "
|
||
f"수량={quantity}/{total_qty} 순수익률={profit_loss_rate:.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,
|
||
}
|
||
|
||
|
||
def _schedule_sell_fill_reconcile(
|
||
stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int,
|
||
entry_price: float, recorded_net: float, count_daily_pnl: bool,
|
||
):
|
||
"""매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
|
||
|
||
확인되면:
|
||
- TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신
|
||
- 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl —
|
||
§7 당일 미반영 규칙 유지). 당일 연패·loss_tracker 판정은 청산 시점 값 그대로 둔다.
|
||
확인 실패면 신호가 기록이 그대로 남는다(fill_reconciler 가 warning).
|
||
"""
|
||
from app.core.timeutil import now_kst as _now_kst
|
||
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
|
||
|
||
async def _on_fill(filled_qty: int, avg_price: float) -> None:
|
||
global _stale_realized_pnl
|
||
if filled_qty < quantity:
|
||
logger.warning(
|
||
f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)"
|
||
)
|
||
new = dt_net_pnl(entry_price, avg_price, quantity, pos)
|
||
delta = new["net_profit_loss"] - recorded_net
|
||
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
|
||
from decimal import Decimal
|
||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||
await enqueue_update_trade_history_by_order_id(order_id, {
|
||
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
|
||
"order_date": _order_date,
|
||
"order_type": "SELL",
|
||
"strategy_type": "daytrading",
|
||
"executed_price": Decimal(str(round(avg_price, 2))),
|
||
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
|
||
"commission": Decimal(str(new["total_cost"])),
|
||
"net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))),
|
||
"profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None,
|
||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||
})
|
||
if delta:
|
||
if count_daily_pnl:
|
||
async with day_trading_state.trade_lock:
|
||
day_trading_state.adjust_realized_pnl(delta)
|
||
await day_trading_state.save_daily_state()
|
||
else:
|
||
_stale_realized_pnl += delta
|
||
logger.info(
|
||
f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
|
||
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) "
|
||
f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 "
|
||
f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)"
|
||
)
|
||
|
||
return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill)
|
||
|
||
|
||
async def close_stale_positions() -> list[dict]:
|
||
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.
|
||
|
||
EOD(15:20) 강제청산이 실패하거나 스케줄러가 그 구간을 놓친 경우의 안전장치.
|
||
장중에만 동작하며(시장가 주문 필요), 청산 실패 시 다음 사이클에서 재시도된다.
|
||
|
||
Returns:
|
||
list[dict]: 청산 실행 결과 목록
|
||
"""
|
||
if not is_market_open():
|
||
return []
|
||
|
||
today = _kst_now().date()
|
||
results: list[dict] = []
|
||
|
||
# [2026-09-23] 인메모리 positions 만 순회하면 자정 reset_if_new_day() 이후에는
|
||
# 대상이 사라져 안전장치가 무력해진다 → DB(ActiveTrade)에서도 전일 포지션을 찾는다.
|
||
stale: dict[str, dict] = {}
|
||
for stock_code in list(day_trading_state.positions.keys()):
|
||
pos = day_trading_state.get_position(stock_code)
|
||
entry_time = (pos or {}).get("entry_time")
|
||
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
|
||
if pos and entry_date is not None and entry_date < today:
|
||
stale[stock_code] = pos
|
||
|
||
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
|
||
# SELECT 1건만 수행하며 commit()/flush()/add() 호출이 없다. 세션 수명은 아래
|
||
# async with 블록(쿼리 1회)로 최소화하고, 실패는 warning 으로 운영에 노출한다.
|
||
# 실제 청산 기록은 아래 _execute_exit_stale → db_task_queue 경유로만 이뤄진다.
|
||
try:
|
||
from app.core.database import AsyncSessionLocal
|
||
from app.models.models import ActiveTrade
|
||
from sqlalchemy import select as _select
|
||
|
||
async with AsyncSessionLocal() as _db:
|
||
_rows = (await _db.execute(
|
||
_select(ActiveTrade).where(
|
||
ActiveTrade.strategy_name == "day_trade",
|
||
ActiveTrade.status == "active",
|
||
)
|
||
)).scalars().all()
|
||
for _row in _rows:
|
||
_created = _row.created_at.date() if _row.created_at else None
|
||
if _created is None or _created >= today or _row.stock_code in stale:
|
||
continue
|
||
_entry = float(_row.entry_price or 0)
|
||
stale[_row.stock_code] = {
|
||
"stock_name": _row.stock_name or _row.stock_code,
|
||
"strategy": "day_trade",
|
||
"entry_price": _entry,
|
||
"quantity": int(_row.quantity or 0),
|
||
"highest_price": float(_row.highest_since_entry or _entry),
|
||
"entry_time": _row.created_at,
|
||
"reason": "",
|
||
"order_id": _row.order_id or "",
|
||
}
|
||
except Exception as _db_err:
|
||
logger.warning(f"[DayExit] 전일 포지션 DB 조회 실패(인메모리만 처리): {_db_err}")
|
||
|
||
for stock_code, pos in stale.items():
|
||
if int(pos.get("quantity", 0) or 0) <= 0:
|
||
continue
|
||
entry_time = pos.get("entry_time")
|
||
entry_date = entry_time.date() if hasattr(entry_time, "date") else None
|
||
|
||
price = 0.0
|
||
try:
|
||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||
price = float(price_resp.get("output", {}).get("stck_prpr", 0) or 0) if price_resp else 0.0
|
||
except Exception as e:
|
||
logger.warning(f"[DayExit] 전일 포지션 현재가 조회 실패 ({stock_code}): {e}")
|
||
if price <= 0:
|
||
price = float(pos.get("entry_price", 0) or 0)
|
||
if price <= 0:
|
||
continue
|
||
|
||
logger.warning(
|
||
f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} "
|
||
f"(진입일={entry_date}, 수량={pos.get('quantity')})"
|
||
)
|
||
# [M10] 청산 성공/실패와 무관하게 당일 재진입은 금지한다. 주문이 실패해도
|
||
# 다음 사이클에서 재시도되며, 그 사이 scan_and_enter 가 같은 종목을 다시
|
||
# 매수하면 전일 포지션이 되살아난다.
|
||
async with day_trading_state.trade_lock:
|
||
day_trading_state.closed_today.add(stock_code)
|
||
# [F9] closed_today 변경분 영속화 — 재기동 후에도 당일 재진입을 막는다
|
||
await day_trading_state.save_daily_state()
|
||
|
||
# [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다.
|
||
result = await _execute_exit_stale(
|
||
stock_code, pos, price,
|
||
reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)",
|
||
)
|
||
if result:
|
||
results.append(result)
|
||
await asyncio.sleep(0.3) # KIS 레이트 리밋
|
||
|
||
return results
|
||
|
||
|
||
async def force_close_all(reason: str = "강제 청산") -> list[dict]:
|
||
"""모든 데이트레이딩 포지션 강제 청산.
|
||
|
||
호출 시점:
|
||
- EOD 15:20 (CLAUDE.md 7.3)
|
||
- 일일 손실 한도(-3%) 초과
|
||
- 수동 API 호출
|
||
|
||
현재가 조회 실패 시 진입가를 대체가로 사용.
|
||
KIS API 레이트 리밋 방지를 위해 종목 간 0.3초 대기.
|
||
|
||
Args:
|
||
reason: 강제 청산 사유 (로그 및 DB 기록용)
|
||
|
||
Returns:
|
||
list[dict]: 청산 실행 결과 목록
|
||
"""
|
||
results = []
|
||
failed_codes: list[str] = []
|
||
codes = list(day_trading_state.positions.keys())
|
||
|
||
for stock_code in codes:
|
||
pos = day_trading_state.get_position(stock_code)
|
||
if not pos:
|
||
continue
|
||
try:
|
||
# 현재가 조회
|
||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||
price = float(
|
||
price_resp.get("output", {}).get("stck_prpr", 0) or 0
|
||
) if price_resp else 0.0
|
||
|
||
if price <= 0:
|
||
price = float(pos.get("entry_price", 0))
|
||
|
||
result = await _execute_exit(stock_code, pos, price, reason)
|
||
if result:
|
||
results.append(result)
|
||
|
||
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
||
|
||
except Exception as e:
|
||
logger.error(f"[DayExit] 강제 청산 오류 ({stock_code}): {e}", exc_info=True)
|
||
failed_codes.append(stock_code)
|
||
|
||
# S4 fix: 실패 종목 1회 재시도
|
||
if failed_codes:
|
||
logger.warning(f"[DayExit] 강제 청산 실패 {len(failed_codes)}건 — 2초 후 재시도: {failed_codes}")
|
||
await asyncio.sleep(2.0)
|
||
still_failed: list[str] = []
|
||
for stock_code in failed_codes:
|
||
pos = day_trading_state.get_position(stock_code)
|
||
if not pos:
|
||
continue
|
||
try:
|
||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||
price = float(
|
||
price_resp.get("output", {}).get("stck_prpr", 0) or 0
|
||
) if price_resp else 0.0
|
||
if price <= 0:
|
||
price = float(pos.get("entry_price", 0))
|
||
result = await _execute_exit(stock_code, pos, price, reason)
|
||
if result:
|
||
results.append(result)
|
||
await asyncio.sleep(0.3)
|
||
except Exception as e:
|
||
logger.critical(f"[DayExit] 강제 청산 재시도 실패 ({stock_code}): {e} — 포지션 미청산 위험!", exc_info=True)
|
||
still_failed.append(stock_code)
|
||
if still_failed:
|
||
logger.critical(f"[DayExit] 최종 미청산 종목: {still_failed} — 수동 청산 필요!")
|
||
|
||
logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})")
|
||
return results
|