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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user