fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -37,6 +37,18 @@ logger = logging.getLogger(__name__)
# 한국 표준시(KST) 타임존 객체 — 날짜 비교 및 시간 계산에 사용
_KST = pytz.timezone("Asia/Seoul")
# [R2 2026-10-09] 일일 안전장치 원장 재구성 (Redis 복원 실패 시) — 운영 상수(전략 파라미터 아님)
_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC = 15.0
_DAILY_STATE_REBUILD_RETRY_SEC = 120.0
_DAILY_STATE_NOTIFY_TIMEOUT_SEC = 10.0
# 전일·고아 청산의 exit_reason 접두사(daytrading/exit_service.close_stale_positions).
# 이 청산 손익은 당일 실현손익·연패에 넣지 않는다(§7, count_daily_pnl=False) — 재구성도 동일.
# 문자열이 바뀌면 test_dt_daily_state_rebuild_r2_20261009 의 동기화 테스트가 깨진다.
_STALE_EXIT_REASON_PREFIXES = ("전일 미청산 포지션 강제 청산", "DT 고아 잔고 청산")
_DT_STRATEGY_TYPE = "daytrading"
_VOID_STATUSES = frozenset({"CANCELLED", "FAILED", "REJECTED"})
_daily_state_retry_task: "asyncio.Task | None" = None
def _today_kst() -> date:
"""현재 KST 기준 날짜 반환.
@@ -77,7 +89,21 @@ class DayTradingState:
# 마지막으로 조회된 총자산
self.last_total_assets: float = 0.0
# 당일 청산 종목 (재진입 방지)
# ⚠️ 스윙 손절(swing/exit_service)·MDD 등 **타 전략 청산도** 전략 간 당일 쿨다운으로
# 여기에 들어온다 → "DT 청산 수"로 세면 안 된다(아래 dt_exited_today 사용).
self.closed_today: set[str] = set()
# [2026-10-08] 당일 **DT 포지션이 전량 청산된** 종목 (표시·집계 전용, 재진입 판정에 쓰지 않음)
# DT 엔진 카드 '청산 N' 에 스윙 손절 종목이 섞이던 문제(10/08: DT 6 + 스윙 2 = 8).
self.dt_exited_today: set[str] = set()
# [R2 하드닝 2026-10-09] 당일 DT 전량 청산(dt_exited_today) 뒤 **보유가 재개된** 종목.
# 전량 매도가 0체결·부분체결로 종결돼 exit_service._on_unfilled 가 포지션을 되살린 경우
# (add_position 이 dt_exited_today 종목을 받는 유일한 경로 — DT 는 당일 재진입 금지)에
# 표시한다. 기동 복원 검증·장중 복원이 이 종목을 '청산 완료'로 보지 않게 한다.
# remove_position(다시 전량 청산)에서 해제된다. 재진입 차단(closed_today)과 무관.
self.dt_reheld_today: set[str] = set()
# [R2 하드닝] 구버전 Redis 페이로드(dt_exited_today 키 없음)로 복원된 closed_today —
# DT 청산과 재진입 차단을 구분할 수 없으므로 복원 판정에서만 '청산 완료'로 간주(보수적).
self.restore_block_legacy: set[str] = set()
# 당일 연속 손실 횟수 (쿨다운 트리거)
self.loss_streak_today: int = 0
# 마지막 초기화 날짜
@@ -96,6 +122,14 @@ class DayTradingState:
# — 주문은 진행 중인데 인메모리 수량이 아직 그대로라 같은 물량을 다시 파는 것을 막는다.
# 날짜 초기화(reset_if_new_day)로 지우지 않는다(진행 중 주문의 보호가 풀린다).
self.exits_in_progress: set[str] = set()
# [R2 2026-10-09] 일일 안전장치 **미검증** 상태 — Redis 복원 실패 + TradeHistory 재구성
# 실패(또는 손실 상태인데 총자산 분모 미확보)면 True. is_cooldown_active() 가 True 를
# 반환해 **DT 신규 진입만** 막는다(청산 경로는 이 값을 보지 않는다 — §8.1-11).
# 재구성 재시도가 성공하거나 날짜가 바뀌면(reset_if_new_day) 해제된다.
self.daily_state_unverified: bool = False
self.daily_state_unverified_reason: str = ""
# 일일 안전장치의 출처: "" | "redis" | "trade_history" (표시·진단용)
self.daily_state_source: str = ""
def reset_if_new_day(self) -> bool:
"""날짜가 바뀌면 일일 상태 초기화.
@@ -113,8 +147,15 @@ class DayTradingState:
self.daily_pnl = 0.0
self.last_total_assets = 0.0
self.closed_today = set()
self.dt_exited_today = set()
self.dt_reheld_today = set()
self.restore_block_legacy = set()
self.loss_streak_today = 0
self.last_reset_date = today
# [R2] 미검증 차단은 **그날의** 상태에 대한 것 — 새 날은 0 에서 시작하는 것이 정상
self.daily_state_unverified = False
self.daily_state_unverified_reason = ""
self.daily_state_source = ""
self.portfolio_summary_cache = {}
self.portfolio_summary_ts = None
self.portfolio_cache_dirty = True
@@ -165,6 +206,11 @@ class DayTradingState:
"""
self.positions[stock_code] = data
self.portfolio_cache_dirty = True
# [R2 하드닝] 당일 DT 전량 청산 기록이 있는 종목이 다시 들어오면 = 매도 미체결 종결 복원
# (closed_today 로 신규 진입은 막혀 있다). 재기동 복원 검증이 '청산 완료'로 오판하지 않게 표시.
if stock_code in self.dt_exited_today or stock_code in self.restore_block_legacy:
self.dt_reheld_today.add(stock_code)
self.restore_block_legacy.discard(stock_code)
def remove_position(self, stock_code: str) -> dict | None:
"""포지션 제거 및 반환.
@@ -181,9 +227,31 @@ class DayTradingState:
pos = self.positions.pop(stock_code, None)
if pos is not None:
self.closed_today.add(stock_code)
self.dt_exited_today.add(stock_code)
self.dt_reheld_today.discard(stock_code)
self.portfolio_cache_dirty = True
return pos
def restore_block_reason_v2(self, stock_code: str) -> str | None:
"""[R2 하드닝 2026-10-09] 당일 DT active 행을 인메모리로 **되살리면 안 되는** 사유(없으면 None).
closed_today 는 **재진입 차단** 집합이다 — 불명 매수 등록(entry_service)·스윙 손절(전략 간
쿨다운)·매도 0체결 종결 뒤에도 남아 있어 '청산 완료'의 근거가 될 수 없다. 예전에는
closed_today 만으로 복원을 막아 실제 보유 중인 당일 DT 포지션이 손절·EOD 관리 밖으로
빠졌다(오버나이트). 이제 '청산 완료'는 DT 전량 청산(dt_exited_today) 기준이고, 그 뒤
보유가 재개된 종목(dt_reheld_today)은 제외한다. 나머지는 호출부가 KIS 실보유 − 타전략
수량(§3.5) 규칙으로 판정한다(이 메서드는 그 판정을 대신하지 않는다).
"""
if stock_code in self.exits_in_progress:
return "청산 주문 진행/결과 불명 보류 중(exits_in_progress)"
if stock_code in self.dt_reheld_today:
return None
if stock_code in self.dt_exited_today:
return "당일 DT 전량 청산 기록(dt_exited_today) 있음"
if stock_code in self.restore_block_legacy:
return "구버전 일일상태(dt_exited_today 없음) — closed_today 를 청산 근거로 간주"
return None
def update_pnl(self, realized: float, is_full_close: bool = True) -> None:
"""실현 손익 누적 및 연속 손실 카운트 업데이트.
@@ -258,9 +326,17 @@ class DayTradingState:
CLAUDE.md 7.3: 당일 3연패 시 신규 진입 차단.
[R2 2026-10-09] 일일 안전장치가 미검증(daily_state_unverified)이어도 True —
Redis·TradeHistory 어느 쪽으로도 당일 손익·연패·재진입 차단을 복원하지 못한 상태에서
0 부터 매매를 재개하면 -3% 한도·3연패 쿨다운이 풀린다(fail-open). 진입 게이트
(entry_service.scan_and_enter / scheduler._entry_blocked_by_exit_results)가 이
메서드를 쓰므로 시그니처를 바꾸지 않고 여기서 함께 막는다. 청산 경로는 무관.
Returns:
bool: 쿨다운 활성화 여부
"""
if self.daily_state_unverified:
return True
return self.loss_streak_today >= 3
def has_orb(self, stock_code: str) -> bool:
@@ -349,6 +425,11 @@ class DayTradingState:
"daily_pnl": float(self.daily_pnl),
"loss_streak_today": int(self.loss_streak_today),
"closed_today": sorted(self.closed_today),
# [2026-10-08] DT 전용 청산 집합(표시용) — 없으면 구버전 페이로드
"dt_exited_today": sorted(self.dt_exited_today),
# [R2 하드닝] 청산 뒤 보유 재개(매도 미체결 종결 복원) — 재기동 복원 검증용
"dt_reheld_today": sorted(self.dt_reheld_today),
"restore_block_legacy": sorted(self.restore_block_legacy),
# [C2 2026-09-28] 손실률 분모. 이 값이 0 이면
# is_daily_loss_exceeded() 가 total_assets<=0 에서 **무조건 False** 라
# 재기동 직후 첫 scan_and_enter() 가 일일 -3% 한도를 통과해버린다
@@ -400,6 +481,21 @@ class DayTradingState:
self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl)
self.loss_streak_today = max(self.loss_streak_today, _streak)
self.closed_today |= _closed
# [2026-10-08] 구버전 페이로드(키 없음)면 표시용 집합은 비워 둔다 — 스윙 쿨다운
# 종목이 섞인 closed_today 로 대체하면 같은 오집계가 되살아난다.
self.dt_exited_today |= {
str(c) for c in (data.get("dt_exited_today") or []) if c
}
self.dt_reheld_today |= {
str(c) for c in (data.get("dt_reheld_today") or []) if c
}
self.restore_block_legacy |= {
str(c) for c in (data.get("restore_block_legacy") or []) if c
}
if "dt_exited_today" not in data:
# [R2 하드닝] 구버전 페이로드 — DT 청산과 재진입 차단을 구분할 수 없다. 복원 판정에서만
# closed_today 를 청산 근거로 쓴다(재매도 방지 쪽, 구 동작과 동일).
self.restore_block_legacy |= _closed
# [C2] 분모는 **작은 쪽이 보수적**이다: pnl<0 일 때 (pnl/assets) 가 더 음수가
# 되어 -3% 한도가 더 빨리 걸린다. 단 0/음수는 게이트를 통째로 무력화하므로
# 후보에서 제외하고, 양수가 하나도 없으면 현재값(0.0)을 그대로 둔다.
@@ -420,6 +516,152 @@ class DayTradingState:
)
return False
async def rebuild_daily_state_after_redis_miss_v2(self, session_factory=None) -> bool:
"""[R2 2026-10-09] Redis 복원 실패 시 당일 DT 원장(TradeHistory)으로 일일 안전장치 재구성.
``load_daily_state()`` 는 Redis 단일 소스라, 장중에 Redis 재기동(RDB 간격 내 유실)·
``volatile-lru`` 축출(이 키는 setex TTL 을 가진다)·저장 실패 뒤 백엔드를 재기동하면
daily_pnl=0 / 연패 0 / closed_today=∅ 로 시작해 **이미 -3% 한도로 중단된 날에 매매가
재개**됐다(fail-open, warning 만). 당일 DT SELL 원장(net_profit_loss)은 DB 에 있으므로
그것으로 재구성한다.
- 성공(행 0건 포함 — 당일 매매가 없었다는 검증된 사실): 보수적 병합 후 Redis 재저장, True.
- 실패: ``daily_state_unverified=True`` → is_cooldown_active() 가 DT **신규 진입만**
차단, error 로그 + 텔레그램, 백그라운드 재시도 예약. False.
- 손실 상태인데 총자산 분모(last_total_assets)를 확보하지 못해도 미검증으로 둔다 —
분모 0 이면 is_daily_loss_exceeded() 가 무조건 False 라 -3% 한도가 무력화된다.
[§3.1 / §3.2 ③ — 기동 시 1회(+실패 시 재시도), 읽기 전용] SELECT 만 수행하고
commit()/add() 가 없다. 이 메서드는 쓰기를 하지 않는다 — 추가하지 말 것.
이 메서드는 예외를 던지지 않는다(차단 판정은 try 밖에서 한다, §4).
Args:
session_factory: 테스트 주입용 세션 팩토리(기본 AsyncSessionLocal).
"""
ok, err = await self._rebuild_daily_state_attempt_impl(session_factory)
if ok:
return True
await self._mark_daily_state_unverified_impl(err)
self._schedule_daily_state_rebuild_retry_impl(session_factory)
return False
async def _rebuild_daily_state_attempt_impl(self, session_factory=None) -> tuple[bool, str]:
"""재구성 1회 시도. (검증완료 여부, 실패 사유) — 예외를 던지지 않는다."""
today = _today_kst()
rows = None
err = ""
try:
rows = await asyncio.wait_for(
_fetch_today_trade_rows_impl(today, session_factory),
timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
err = f"TradeHistory 조회 {_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC:.0f}초 초과"
except Exception as e:
err = f"TradeHistory 조회 실패: {type(e).__name__}: {e}"
if rows is None:
logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 원장 재구성 실패 — {err}")
return False, err or "TradeHistory 조회 결과 없음"
rebuilt = _rebuild_daily_state_from_rows(rows)
async with self.trade_lock:
# 같은 날 재시도(진입 차단 중에도 청산은 돌아 인메모리 손익이 쌓인다) 시 이중 집계를
# 피하려고 합산하지 않는다 — 원장 합계가 이미 그 청산을 포함한다. 둘 중 **더 보수적인**
# 쪽(손익 min, 연패 max, 집합 합집합)을 취한다(load_daily_state 와 같은 규칙).
_pnl = float(rebuilt["daily_pnl"])
self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl)
self.loss_streak_today = max(self.loss_streak_today, int(rebuilt["loss_streak_today"]))
self.closed_today |= set(rebuilt["closed_today"])
self.dt_exited_today |= set(rebuilt["dt_exited_today"])
self.dt_reheld_today |= set(rebuilt.get("dt_reheld_today") or ())
_need_assets = self.daily_pnl < 0 and float(self.last_total_assets or 0) <= 0
if _need_assets:
# 분모 확보(청산 경로와 같은 함수 — KIS 잔고 → 마지막 값). 실패해도 예외를 던지지 않는다.
try:
from app.services.daytrading.exit_service import _ensure_total_assets_for_exit_impl
await asyncio.wait_for(
_ensure_total_assets_for_exit_impl(), timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 일일 안전장치 재구성 후 총자산 조회 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 일일 안전장치 재구성 후 총자산 조회 실패: {e}")
# 판정은 try 밖 (§4)
if self.daily_pnl < 0 and float(self.last_total_assets or 0) <= 0:
err = (
f"당일 실현손익 {self.daily_pnl:+,.0f}원(손실)인데 총자산 분모 미확보 — "
f"일일 손실한도 판정 불가"
)
logger.warning(f"[DayTrading] ⚠️ {err}")
return False, err
self.daily_state_source = "trade_history"
_was_blocked = self.daily_state_unverified
self.daily_state_unverified = False
self.daily_state_unverified_reason = ""
logger.warning(
f"[DayTrading] 일일 안전장치 원장(TradeHistory) 재구성: 실현손익={self.daily_pnl:+,.0f}원 "
f"(전일·고아 청산 {rebuilt['stale_excluded']}건 제외), 연패={self.loss_streak_today}, "
f"당일청산={len(self.closed_today)}종목, 원장 {rebuilt['dt_sell_rows']}건"
+ (" — 신규 진입 차단 해제" if _was_blocked else "")
)
try:
await self.save_daily_state()
except Exception as e:
logger.warning(f"[DayTrading] 재구성 상태 Redis 재저장 실패: {e}")
if _was_blocked:
await _notify_daily_state_impl(
"DT 일일 안전장치 원장 재구성 성공 — 신규 진입 차단 해제 "
f"(실현손익 {self.daily_pnl:+,.0f}원, 연패 {self.loss_streak_today})"
)
return True, ""
async def _mark_daily_state_unverified_impl(self, reason: str) -> None:
"""미검증 상태 표시 + error 로그 + 텔레그램 (예외를 던지지 않는다)."""
_first = not self.daily_state_unverified
self.daily_state_unverified = True
self.daily_state_unverified_reason = reason
logger.error(
f"[DayTrading] 🚨 일일 안전장치 복원 불가(Redis·TradeHistory) — DT 신규 진입 차단 "
f"(청산은 계속). 사유: {reason}"
)
if _first:
await _notify_daily_state_impl(
"DT 일일 안전장치(일일 손실한도·연패·재진입 차단) 복원 불가 — 당일 DT 신규 진입 차단. "
f"사유: {reason}. 원장 재구성을 {_DAILY_STATE_REBUILD_RETRY_SEC:.0f}초 간격으로 재시도한다."
)
def _schedule_daily_state_rebuild_retry_impl(self, session_factory=None) -> None:
"""미검증 상태에서 재구성을 주기적으로 재시도(같은 날 한정). 중복 예약하지 않는다."""
global _daily_state_retry_task
if _daily_state_retry_task is not None and not _daily_state_retry_task.done():
return
try:
_daily_state_retry_task = asyncio.get_running_loop().create_task(
self._daily_state_rebuild_retry_loop_impl(_today_kst(), session_factory)
)
except RuntimeError as e:
logger.warning(f"[DayTrading] 일일 안전장치 재구성 재시도 예약 실패: {e}")
async def _daily_state_rebuild_retry_loop_impl(self, day: date, session_factory=None) -> None:
attempt = 0
while self.daily_state_unverified:
await asyncio.sleep(_DAILY_STATE_REBUILD_RETRY_SEC)
if _today_kst() != day or self.last_reset_date not in (None, day):
# 날짜가 바뀌면 reset_if_new_day 가 플래그를 내린다 — 전일 재구성은 의미 없음
return
if not self.daily_state_unverified:
return
attempt += 1
ok, err = await self._rebuild_daily_state_attempt_impl(session_factory)
if ok:
return
self.daily_state_unverified_reason = err
logger.warning(
f"[DayTrading] 일일 안전장치 재구성 재시도 {attempt}회 실패 — 신규 진입 차단 유지: {err}"
)
def set_orb(self, stock_code: str, orb: Any) -> None:
"""ORB(시초가 레인지) 데이터 저장.
@@ -484,54 +726,7 @@ class DayTradingState:
if trade.stock_code in self.positions:
continue
entry_price = float(trade.entry_price) if trade.entry_price else 0
stop_loss = float(trade.stop_loss) if trade.stop_loss else 0
target_price = float(trade.take_profit) if trade.take_profit else 0
# [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반
# 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지
if entry_price > 0 and stop_loss <= 0:
stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT)
logger.warning(
f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → "
f"기본값 적용: {stop_loss:.0f}"
)
if entry_price > 0 and target_price <= 0:
risk = entry_price - stop_loss
target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
logger.warning(
f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → "
f"기본값 적용: {target_price:.0f}"
)
_restored = {
"stock_name": trade.stock_name or trade.stock_code,
"strategy": trade.strategy_name or "day_trade",
"entry_price": entry_price,
"stop_loss": stop_loss,
"target_price": target_price,
"quantity": trade.quantity or 0,
"highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else (
float(trade.entry_price) if trade.entry_price else 0
),
"entry_time": trade.created_at,
"reason": trade.entry_reason or "",
"order_id": trade.order_id or "",
"restored_from_db": True,
}
# [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가
# remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면
# target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이
# 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게
# 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용.
if _is_partial_sold_row(trade):
_restored["target_price"] = float("inf")
_restored["partial_sold"] = True
logger.warning(
f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 "
f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, "
f"트레일링 고점={_restored['highest_price']:.0f}"
)
_restored = _active_trade_row_to_position(trade)
self.positions[trade.stock_code] = _restored
restored += 1
logger.info(
@@ -550,6 +745,211 @@ class DayTradingState:
return restored
async def verify_restored_today_positions_v2(
self, session_factory=None, holdings_fetcher=None,
) -> dict:
"""[R3 2026-10-09] 기동 시 restore_from_db 가 되살린 **당일** DT 포지션 검증.
restore_from_db 는 closed_today·KIS 실보유를 보지 않고 active 행 수량으로 포지션을
되살린다. 전량 청산 직후 close_active_trade 큐 작업이 반영되지 못한 채(DLQ·drain 초과)
재기동하거나 분할 매도 뒤 잔량 upsert 가 유실되면, 이미 판 물량을 일반 청산 경로가
다시 시장가로 판다(같은 종목 스윙 보유분 매도 또는 반복 거부 — §3.5).
장중 복원 경로(exit_service._restore_lost_today_positions_impl)와 같은 규칙을 적용한다:
- 당일 DT 전량 청산 종목(restore_block_reason_v2: dt_exited_today − dt_reheld_today) → 복원 보류
[R2 하드닝] closed_today(재진입 차단)만으로는 보류하지 않는다 — 불명 매수 접수 확정·
매도 0체결 종결 복원·스윙 손절 쿨다운 종목도 들어 있어 실제 보유 포지션이 관리에서 빠졌다.
- KIS 보유 − 비DT active 수량 < 행 수량(또는 수량 ≤ 0) → 복원 보류
- 판정 불가(잔고·DB 조회 실패) → DAY_TRADE_ORPHAN_DETECT_ENABLED 면 보류
(close_stale_positions 가 매 사이클 같은 규칙으로 재검증·복원한다), 꺼져 있으면
복원 유지(아무도 되살리지 않아 손절 공백이 되므로) + error.
보류는 인메모리에서만 뺀다(closed_today 에 넣지 않음). 수량·판정불가 보류는 잔고가 맞으면 장중
복원이 다시 잡는다. 청산 기록 보류는 장중 복원도 같은 사유로 막으므로 수동 확인 대상이다.
전일 진입분은 대상이 아니다(close_stale_positions 가 실보유·타전략 차감으로 처리).
Returns: {"checked": n, "held": [codes], "unverified": bool}
"""
from app.core.config import settings
today = _today_kst()
held: list[str] = []
reasons: list[str] = []
mismatch = False # 실제 불일치(청산 기록·수량 부족) — 판정 불가와 구분해 경보 수준을 정한다
exit_held = False # 청산 기록으로 보류 — 장중 재복원 경로도 같은 사유로 막는다
async with self.trade_lock:
cands = {
c: p for c, p in self.positions.items()
if p.get("restored_from_db") and _entry_date(p.get("entry_time")) == today
}
for c in list(cands):
_why = self.restore_block_reason_v2(c)
if _why is not None:
if self.positions.get(c) is cands[c]:
self.positions.pop(c, None)
held.append(c)
reasons.append(f"{c}: {_why}")
mismatch = True
exit_held = True
cands.pop(c)
out = {"checked": len(cands) + len(held), "held": held, "unverified": False}
if cands:
other_qty = None
try:
other_qty = await asyncio.wait_for(
_fetch_non_dt_active_qty_by_code_impl(list(cands), session_factory),
timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 복원 검증: 비DT 보유 조회 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 복원 검증: 비DT 보유 조회 실패: {type(e).__name__}: {e}")
holdings = None
if other_qty is not None:
try:
if holdings_fetcher is None:
from app.services.daytrading.exit_service import (
_fetch_kis_holdings_v2_impl as holdings_fetcher,
)
holdings = await holdings_fetcher()
except Exception as e:
logger.warning(f"[DayTrading] 복원 검증: KIS 잔고 조회 실패: {type(e).__name__}: {e}")
holdings = None
# 판정·보류는 try 밖 (§4 — 가드를 try 안에 두지 않는다)
unverified = other_qty is None or holdings is None
out["unverified"] = unverified
if unverified and not settings.DAY_TRADE_ORPHAN_DETECT_ENABLED:
logger.error(
f"[DayTrading] 🚨 기동 복원 검증 불가(실보유·타전략 미확인) — 장중 재검증 경로"
f"(DAY_TRADE_ORPHAN_DETECT_ENABLED=False)가 없어 복원 유지: {sorted(cands)}. "
f"이미 매도된 물량이면 재매도 위험 — 수동 확인 필요"
)
else:
async with self.trade_lock:
for c, p in cands.items():
qty = int(p.get("quantity", 0) or 0)
if unverified:
why = "실보유·타전략 수량 판정 불가 → 장중 close_stale_positions 재검증 대기"
else:
hldg = int((holdings.get(c) or {}).get("hldg", 0) or 0)
oth = int(other_qty.get(c, 0) or 0)
if qty > 0 and hldg - oth >= qty:
continue
why = f"실보유 {hldg}주 − 타전략 {oth}주 = {hldg - oth}주 < 행 수량 {qty}주"
if self.positions.get(c) is p:
self.positions.pop(c, None)
held.append(c)
reasons.append(f"{c}: {why}")
mismatch = mismatch or not unverified
if held:
msg = (
f"[DayTrading] 🚨 기동 시 당일 DT 포지션 복원 보류 {len(held)}건 — "
+ "; ".join(reasons)
+ " (이미 매도된 물량 재매도 방지. 수량·판정불가 보류는 잔고가 맞으면 장중 "
"close_stale_positions 가 재복원"
+ ("; 청산 기록 보류는 장중에도 재복원하지 않음 — 실보유가 남아 있으면 수동 청산 필요)"
if exit_held else ")")
)
if mismatch:
logger.error(msg)
await _notify_daily_state_impl(msg)
else:
logger.warning(msg)
return out
def _active_trade_row_to_position(trade) -> dict:
"""ActiveTrade(day_trade) 행 → 인메모리 포지션 dict (restore_from_db 와 고아 복원 공용).
[2026-10-08] restore_from_db 의 행 변환을 그대로 옮겼다(동작 무변경).
"""
from app.core.config import settings
entry_price = float(trade.entry_price) if trade.entry_price else 0
stop_loss = float(trade.stop_loss) if trade.stop_loss else 0
target_price = float(trade.take_profit) if trade.take_profit else 0
# [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반
# 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지
if entry_price > 0 and stop_loss <= 0:
stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT)
logger.warning(
f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → "
f"기본값 적용: {stop_loss:.0f}"
)
if entry_price > 0 and target_price <= 0:
risk = entry_price - stop_loss
target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
logger.warning(
f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → "
f"기본값 적용: {target_price:.0f}"
)
_restored = {
"stock_name": trade.stock_name or trade.stock_code,
"strategy": trade.strategy_name or "day_trade",
"entry_price": entry_price,
"stop_loss": stop_loss,
"target_price": target_price,
"quantity": trade.quantity or 0,
"highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else (
float(trade.entry_price) if trade.entry_price else 0
),
"entry_time": trade.created_at,
"reason": trade.entry_reason or "",
"order_id": trade.order_id or "",
"restored_from_db": True,
}
# [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가
# remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면
# target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이
# 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게
# 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용.
if _is_partial_sold_row(trade):
_restored["target_price"] = float("inf")
_restored["partial_sold"] = True
logger.warning(
f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 "
f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, "
f"트레일링 고점={_restored['highest_price']:.0f}"
)
return _restored
def _entry_date(entry_time) -> date | None:
"""entry_time(datetime|None) → date. restore_from_db 의 created_at.date() 비교와 같은 기준."""
if isinstance(entry_time, datetime):
return entry_time.date()
return None
async def _fetch_non_dt_active_qty_by_code_impl(codes: list, session_factory=None) -> dict[str, int]:
"""비-DT(스윙·유입·legacy NULL) active ActiveTrade 종목별 수량 합 — SELECT 전용(§3.2 ③, commit 없음).
close_stale_positions 와 같은 필터(db_worker.not_dt_strategy_clause). 실패는 예외로 전파한다.
"""
from app.models.models import ActiveTrade
from app.services.db_worker import not_dt_strategy_clause
if session_factory is None:
from app.core.database import AsyncSessionLocal as session_factory # noqa: N813
out: dict[str, int] = {}
if not codes:
return out
# DB-READ-EXCEPTION: 기동 시 DT 복원 검증(§3.5 타전략 차감), SELECT 전용·commit 없음
async with session_factory() as db:
rows = (await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code.in_(list(codes)),
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)).scalars().all()
for r in rows or []:
out[r.stock_code] = out.get(r.stock_code, 0) + int(r.quantity or 0)
return out
def _is_partial_sold_row(trade) -> bool:
"""[M1] ActiveTrade 행이 분할매도 이후 상태인지 (remaining_pct < 1.0)."""
@@ -560,6 +960,144 @@ def _is_partial_sold_row(trade) -> bool:
return False
async def _fetch_today_trade_rows_impl(today: date, session_factory=None) -> list:
"""당일(KST) TradeHistory 행 조회 — SELECT 전용(§3.2 ③, commit 없음)."""
from datetime import timedelta
from app.models.models import TradeHistory
if session_factory is None:
from app.core.database import AsyncSessionLocal as session_factory # noqa: N813
start = datetime.combine(today, datetime.min.time())
end = start + timedelta(days=1)
# DB-READ-EXCEPTION: DT 일일 안전장치 원장 재구성(기동 시/미검증 재시도), SELECT 전용·commit 없음
async with session_factory() as db:
result = await db.execute(
select(TradeHistory).where(
TradeHistory.created_at >= start,
TradeHistory.created_at < end,
TradeHistory.order_type.in_(["BUY", "SELL"]),
).order_by(TradeHistory.created_at, TradeHistory.id)
)
return list(result.scalars().all())
def _rebuild_daily_state_from_rows(rows) -> dict:
"""당일 TradeHistory 행 → 일일 안전장치 값 (순수 함수, 입력은 created_at·id 순 정렬 가정).
규칙 (exit_service._finalize_exit_impl 과 동일 기준):
- daily_pnl: DT SELL 의 net_profit_loss(비용 차감 순손익) 합. 취소·실패 행 제외.
**전일·고아 청산은 제외** — exit_reason 접두사, 또는 당일 DT BUY 가 없는 종목의 SELL.
- loss_streak_today: 종목별 **마지막** 당일 SELL 시점을 전량 청산으로 보고, 그 종목의
당일 SELL net 합(분할 레그 포함 = 왕복 순손익, exit_service._apply_round_trip_streak_v2)으로
시간순 판정한다. 손실 +1, 이익 0 리셋, 0 중립. 시간손절도 센다(§5.1 — loss_tracker 와 달리 당일 연패는 시간손절 포함).
- closed_today: DT SELL 종목 전부(취소 포함 — 전일 청산은 주문 실패해도 재진입 금지) +
타 전략 체결 SELL 종목(전략 간 당일 쿨다운, swing/exit_service). 보수적 상위집합.
- dt_exited_today: 취소·실패가 아닌 DT SELL 종목(표시용).
- dt_reheld_today: [R2 하드닝] dt_exited_today 중 당일 DT 원장 순체결(BUY COMPLETED 체결 −
SELL 체결, exit_service._dt_net_qty_from_history 와 같은 규칙)이 양수인 종목 — 전량 매도가
부분체결로 종결돼 보유가 남은 경우. 기동 복원 검증이 '청산 완료'로 오판하지 않게 한다.
"""
dt_rows = [r for r in rows if str(getattr(r, "strategy_type", "") or "") == _DT_STRATEGY_TYPE]
buy_codes = {
str(r.stock_code) for r in dt_rows
if str(getattr(r, "order_type", "")).upper() == "BUY"
and str(getattr(r, "status", "") or "").upper() not in _VOID_STATUSES
}
closed: set[str] = set()
dt_exited: set[str] = set()
counted: list = []
stale_excluded = 0
dt_sell_rows = 0
for r in rows:
if str(getattr(r, "order_type", "")).upper() != "SELL":
continue
code = str(r.stock_code)
status = str(getattr(r, "status", "") or "").upper()
is_dt = str(getattr(r, "strategy_type", "") or "") == _DT_STRATEGY_TYPE
if not is_dt:
if status not in _VOID_STATUSES:
closed.add(code)
continue
dt_sell_rows += 1
closed.add(code)
if status in _VOID_STATUSES:
continue
dt_exited.add(code)
reason = str(getattr(r, "exit_reason", "") or "")
if reason.startswith(_STALE_EXIT_REASON_PREFIXES) or code not in buy_codes:
stale_excluded += 1
continue
counted.append(r)
pnl = 0.0
last_idx: dict[str, int] = {}
for i, r in enumerate(counted):
net = getattr(r, "net_profit_loss", None)
if net is None:
logger.warning(
f"[DayTrading] 원장 재구성: {r.stock_code} SELL(id={getattr(r, 'id', '?')}) "
f"net_profit_loss 없음 — 0 으로 간주"
)
pnl += float(net or 0)
last_idx[str(r.stock_code)] = i
# [R3 2026-10-09] 연패는 **왕복 순손익**(분할 레그 포함 종목별 당일 SELL net 합)으로 판정한다.
# 실시간 경로(exit_service._apply_round_trip_streak_v2, R2-4)와 같은 기준. 예전에는 마지막
# 레그만 봐 +1.5R 분할 익절(+3,000) 뒤 잔량 -300 청산을 손실로 셌다 → Redis 미스 재기동만으로
# 3연패 쿨다운이 걸릴 수 있었다. DT 는 당일 재진입이 금지(closed_today)라 종목당 왕복은 1회다.
streak = 0
round_trip: dict[str, float] = {}
for i, r in enumerate(counted):
code = str(r.stock_code)
round_trip[code] = round_trip.get(code, 0.0) + float(getattr(r, "net_profit_loss", None) or 0)
if last_idx.get(code) != i:
continue # 분할 매도 — 왕복 합에만 누적
net = round_trip[code]
if net < 0:
streak += 1
elif net > 0:
streak = 0
# [R2 하드닝] 당일 DT 순체결 수량 — 양수면 전량 매도가 일부만 체결돼 보유가 남았다.
net_qty: dict[str, int] = {}
for r in dt_rows:
code = str(r.stock_code)
ot = str(getattr(r, "order_type", "") or "").upper()
st = str(getattr(r, "status", "") or "").upper()
eq = getattr(r, "executed_quantity", None)
try:
q = int(eq if eq is not None else (getattr(r, "order_quantity", 0) or 0))
except (TypeError, ValueError):
logger.warning(f"[DayTrading] 원장 재구성: {code} 수량 파싱 실패 — 순체결 판정 제외")
continue
if ot == "BUY" and st == "COMPLETED":
net_qty[code] = net_qty.get(code, 0) + q
elif ot == "SELL" and st not in ("FAILED", "REJECTED"):
net_qty[code] = net_qty.get(code, 0) - q
reheld = {c for c in dt_exited if net_qty.get(c, 0) > 0}
return {
"daily_pnl": pnl,
"loss_streak_today": streak,
"closed_today": closed,
"dt_exited_today": dt_exited,
"dt_reheld_today": reheld,
"stale_excluded": stale_excluded,
"dt_sell_rows": dt_sell_rows,
}
async def _notify_daily_state_impl(message: str) -> None:
"""텔레그램 시스템 경고 (실패해도 예외를 던지지 않는다)."""
try:
from app.services.telegram_notifier import telegram_notifier
await asyncio.wait_for(
telegram_notifier.notify_system_warning("DAYTRADING", message),
timeout=_DAILY_STATE_NOTIFY_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 일일 안전장치 텔레그램 알림 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 일일 안전장치 텔레그램 알림 실패: {e}")
# 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유
# entry_service, exit_service, scheduler에서 동일 인스턴스 참조
day_trading_state = DayTradingState()