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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -206,6 +206,17 @@ def is_terminal_fill(info: dict, ordered_qty: int) -> bool:
async def _poll_fill_v2(stock_code: str, order_id: str, ordered_qty: int) -> Optional[dict]:
"""종결될 때까지(또는 시도 소진까지) 폴링. 마지막으로 본 행 정보를 돌려준다."""
return await _poll_fill_v2_impl(stock_code, order_id, ordered_qty, None)
async def _poll_fill_v2_impl(
stock_code: str, order_id: str, ordered_qty: int, seen: Optional[dict],
) -> Optional[dict]:
"""`_poll_fill_v2` 본체. `seen` 이 주어지면 마지막으로 본 행을 `seen["last"]` 에도 남긴다.
[R1 2026-10-09] fetch_fill_v2 의 wait_for 가 시간초과로 이 코루틴을 취소하면 반환값이
사라져, 이미 본 부분체결 행까지 '행 미확인'과 같은 None 이 됐다. seen 으로 그 행을 살린다.
"""
attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS))
interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC))
await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC)))
@@ -215,6 +226,8 @@ async def _poll_fill_v2(stock_code: str, order_id: str, ordered_qty: int) -> Opt
info = parse_fill_row_v2(rows, order_id)
if info is not None:
last = info
if seen is not None:
seen["last"] = info
if is_terminal_fill(info, ordered_qty):
return info
if i < attempts - 1:
@@ -233,16 +246,26 @@ async def fetch_fill_v2(
logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지")
return None
timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)
_seen: dict = {}
try:
info = await asyncio.wait_for(
_poll_fill_v2(stock_code, order_id, ordered_qty), timeout=timeout,
_poll_fill_v2_impl(stock_code, order_id, ordered_qty, _seen), timeout=timeout,
)
except asyncio.TimeoutError:
_last = _seen.get("last")
if _last is None:
logger.warning(
f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) "
f"order_id={order_id} — 신호가 기록 유지"
)
return None
# [R1 2026-10-09] 시간초과 전에 본 행이 있으면 버리지 않는다(시도 소진 시와 같은 의미:
# 미종결이면 terminal=False 로 넘어가 호출부가 수량 보정을 보류한다).
logger.warning(
f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) "
f"order_id={order_id} — 신호가 기록 유지"
f"order_id={order_id} — 직전 확인 행(체결 {int(_last.get('filled_qty', 0) or 0)}주) 사용"
)
return None
info = _last
except Exception as e:
logger.warning(
f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: "
@@ -275,7 +298,9 @@ def schedule_v2(
return None
async def _run() -> None:
info = await fetch_fill_v2(stock_code, order_id, side, ordered_qty)
# [R1 2026-10-09] 즉시 확인(≈6초)에서 종결을 못 보면 지연 재확인까지 한 뒤 **1회만** 넘긴다
# (on_result 는 기록 손익 대비 차액을 더하므로 두 번 부르면 이중 보정된다).
info = await fetch_fill_with_late_recheck_v2(stock_code, order_id, side, ordered_qty)
if info is None:
return
try:
@@ -295,3 +320,92 @@ def schedule_v2(
_pending_tasks.add(task)
task.add_done_callback(_pending_tasks.discard)
return task
# ── [R1 2026-10-09] 지연 재확인 ───────────────────────────────────────────────
# 운영 실측(trade_history daytrading, 2026-10-06~08): SELL 8/18·BUY 3/19 의 slippage 가 NULL
# = 체결 미확인(신호가 기록 유지). 즉시 확인 창은 DELAY 2 + INTERVAL 2×2 ≈ 6초(+조회 왕복)로,
# 모의 서버 체결 지연·조회 반영 지연·rate-limit 으로 행이 늦게 보이면 그대로 놓친다(원인 추정 —
# 운영 로그 미확인). 놓치면 daily_pnl(-3% 한도)·연패 판정·원장이 신호가 기준으로 남는다.
# 즉시 확인이 종결을 못 보면 간격을 넓혀 몇 번 더 본다. 전략 파라미터가 아니라 조회 정책이다.
# 기본값: 시도 4회, 간격 = 즉시 확인 간격 × 5(운영 2초 → 10초), 호출당 상한 = 즉시 확인 전체 상한.
# config 키(DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS / _INTERVAL_SEC)가 있으면 그 값을 쓴다.
_LATE_RECHECK_DEFAULT_ATTEMPTS = 4
_LATE_RECHECK_INTERVAL_FACTOR = 5.0
def _late_recheck_params_impl() -> tuple[int, float, float]:
"""(시도 횟수, 간격 초, 호출당 상한 초). 음수·잘못된 값은 0 으로 막는다."""
try:
attempts = int(getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS",
_LATE_RECHECK_DEFAULT_ATTEMPTS))
except (TypeError, ValueError):
attempts = _LATE_RECHECK_DEFAULT_ATTEMPTS
_iv = getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_INTERVAL_SEC", None)
try:
interval = (float(_iv) if _iv is not None
else float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC) * _LATE_RECHECK_INTERVAL_FACTOR)
except (TypeError, ValueError):
interval = float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC) * _LATE_RECHECK_INTERVAL_FACTOR
per_call = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC))
return max(0, attempts), max(0.0, interval), per_call
async def fetch_fill_with_late_recheck_v2(
stock_code: str, order_id: str, side: str, ordered_qty: int,
) -> Optional[dict]:
"""fetch_fill_v2 + (종결 미확인이면) 지연 재확인. 반환 형식은 fetch_fill_v2 와 같다.
- 즉시 확인이 종결이면 그대로 반환(추가 조회 없음).
- 아니면 `_late_recheck_params_impl()` 만큼 더 조회한다. 조회 1회의 예외·시간초과는 그 회차만
건너뛴다(warning). 종결 행을 보면 즉시 반환, 끝까지 못 보면 가장 최근에 본 행
(terminal=False) — 없으면 None.
- 로그: 지연 재확인으로 확정되면 info, 최종 미확인이면 warning 1건
('최종 체결 미확인' — 운영 집계용. 즉시 확인 단계의 warning 은 그대로 남는다).
종가 단일가(15:20~15:30) 매도는 exit_service 가 장 마감 후 재확인을 따로 하므로 이 함수를 쓰지 않는다.
"""
info = await fetch_fill_v2(stock_code, order_id, side, ordered_qty)
if not order_id:
return info
if info is not None and info.get("terminal"):
return info
attempts, interval, per_call = _late_recheck_params_impl()
if attempts <= 0:
return info
last = info
for i in range(attempts):
await asyncio.sleep(interval)
try:
rows = await asyncio.wait_for(
korea_investment_api.get_todays_orders(stock_code), timeout=per_call,
)
except asyncio.TimeoutError:
logger.warning(
f"[DTFill] {stock_code} {side} 지연 재확인 {i + 1}/{attempts} 시간초과({per_call:.0f}초) "
f"order_id={order_id}"
)
continue
except Exception as e:
logger.warning(
f"[DTFill] {stock_code} {side} 지연 재확인 {i + 1}/{attempts} 실패 order_id={order_id}: "
f"{type(e).__name__}: {e}"
)
continue
row = parse_fill_row_v2(rows, order_id)
if row is None:
continue
last = dict(row)
last["terminal"] = is_terminal_fill(row, ordered_qty)
if last["terminal"]:
logger.info(
f"[DTFill] {stock_code} {side} 지연 재확인 {i + 1}/{attempts}회차에 종결 확인 "
f"(체결 {int(last.get('filled_qty', 0) or 0)}/{ordered_qty}주) order_id={order_id}"
)
return last
logger.warning(
f"[DTFill] {stock_code} {side} 최종 체결 미확인(즉시 확인 + 지연 재확인 {attempts}회, "
f"간격 {interval:.0f}초) order_id={order_id} — "
+ ("주문 행 없음, 신호가 기록 유지" if last is None
else f"미종결 행 사용(체결 {int(last.get('filled_qty', 0) or 0)}/{ordered_qty}주)")
)
return last

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()

View File

@@ -5,12 +5,20 @@ main.py의 lifespan에서 asyncio.create_task()로 시작되는
30초 주기 실행 흐름:
- 장외 시간: 30분 슬립 (CPU 낭비 방지)
- DAY_TRADE_ENABLED=False: 60초 슬립
- DAY_TRADE_ENABLED=False: **신규 진입만** 중단(ORB 수집·watchlist·진입 스캔 생략).
EOD·전일/고아·손절·시간손절 청산은 계속 수행한다(§8.1-11 청산 우선, 2026-10-09).
인메모리 보유가 없으면 전일·고아 점검은 DISABLED_ORPHAN_CHECK_INTERVAL_SEC 주기로 줄인다.
- 09:00~09:30: ORB 수집 (collect_opening_ranges)
- 09:30~14:30: 신호 스캔 + 진입 (scan_and_enter) + 청산 체크 (check_and_exit_positions)
- 14:30~15:20: 청산 체크만 (신규 진입 마감)
- 15:20~15:30: EOD 강제 청산 (force_close_all)
진입 스캔·ORB 수집 중 손절 감시 (2026-10-09):
- 진입 스캔(최대 150초)·ORB 수집/소급수집(최대 300초)은 순차 실행이라 그동안 보유
포지션의 손절 판정이 멈췄다. 이 단계들을 _run_with_stop_watch_impl 로 감싸
SCAN_STOP_WATCH_INTERVAL_SEC 마다 손절 선판정(_fast_stop_check_impl, 현재가 1회)을
병행한다. 스캔 중 청산으로 일일 손실한도·3연패 쿨다운에 걸리면 스캔을 중단한다.
MDD 규칙 (CLAUDE.md 5.3):
- MDD -10% 이하 시 신규 진입 차단
- 기존 포지션의 청산 체크는 MDD 무관하게 계속 수행 (포지션 보호)
@@ -38,8 +46,11 @@ from app.services.daytrading.entry_service import (
from app.services.daytrading.exit_service import (
check_and_exit_positions, force_close_all, check_stops_only_v2,
)
# close_stale_positions 는 호출 시점에 모듈 속성으로 참조한다(테스트 패치가 새지 않게).
from app.services.daytrading import exit_service as _dt_exit
from app.core.log_throttle import warn_throttled # noqa: E402
from app.services.day_trading_strategy import is_daily_loss_exceeded # noqa: E402
logger = logging.getLogger(__name__)
@@ -60,6 +71,201 @@ async def _guard(coro, step: str, timeout: int = STEP_TIMEOUT):
return None
# [2026-10-09] 진입 스캔·ORB 수집 중 손절 감시 주기(초). 설정에 같은 이름의 키가 있으면
# 그 값을 쓴다(현재 config.py 에는 없다 — 기본값 15초). 최소 5초.
SCAN_STOP_WATCH_INTERVAL_SEC: float = 15.0
_SCAN_STOP_WATCH_MIN_SEC: float = 5.0
# [2026-10-09] DAY_TRADE_ENABLED=False 이고 인메모리 보유가 0일 때 전일·고아 DT 잔고
# 점검(close_stale_positions: 잔고 조회 + SELECT) 주기. 보유가 있으면 매 사이클 판정한다.
DISABLED_ORPHAN_CHECK_INTERVAL_SEC: float = 300.0
_last_disabled_orphan_check: float = 0.0
# [하드닝 R2] DT 청산 단계 연속 실패(예외·_guard 타임아웃 None) 경보. 스윙의
# _record_exit_failure_v2 에 대응. 실패가 이어지면 손절·시간손절·트레일링이 멈춘 것이다.
DT_EXIT_FAILURE_ALERT_THRESHOLD: int = 3
_dt_exit_health: dict = {"consecutive_failures": 0, "last_error": None}
# [하드닝 R3] fire-and-forget 경보 태스크 강참조 보관(GC 방지). 이벤트 루프는 태스크를
# 약참조로만 들고 있어, 참조를 버리면 텔레그램 HTTP await 중 GC 로 경보가 사라질 수 있다.
_alert_tasks: set = set()
DT_ALERT_SEND_TIMEOUT_SEC: float = 10.0
async def _send_dt_warning_impl(message: str) -> None:
"""경보 1건 발송 — 타임아웃 상한 + 실패는 error 로그(삼키지 않고 남긴다)."""
try:
from app.services.telegram_notifier import telegram_notifier
await asyncio.wait_for(
telegram_notifier.notify_system_warning(category="DAYTRADING", message=message),
timeout=DT_ALERT_SEND_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.error(
f"[DayTrading] 경보 텔레그램 {DT_ALERT_SEND_TIMEOUT_SEC}초 초과 — 미발송: {message}"
)
except Exception as e:
logger.error(f"[DayTrading] 경보 텔레그램 실패: {type(e).__name__}: {e} — 미발송: {message}")
def _notify_dt_warning_bg_v2(message: str) -> None:
try:
_t = asyncio.get_running_loop().create_task(_send_dt_warning_impl(message))
_alert_tasks.add(_t)
_t.add_done_callback(_alert_tasks.discard)
except Exception as e:
logger.error(f"[DayTrading] 경보 태스크 생성 실패: {type(e).__name__}: {e} — 미발송: {message}")
def _record_dt_exit_result_v2(ok: bool, err: str = "") -> None:
"""청산 단계 결과 기록. 연속 실패가 임계값에 도달하면 경보(이후 임계값 배수마다 재경보)."""
if ok:
_dt_exit_health["consecutive_failures"] = 0
_dt_exit_health["last_error"] = None
return
_dt_exit_health["consecutive_failures"] += 1
_dt_exit_health["last_error"] = err
n = _dt_exit_health["consecutive_failures"]
logger.warning(f"[DayTrading] 청산 단계 실패 {n}회 연속: {err}")
if n % DT_EXIT_FAILURE_ALERT_THRESHOLD == 0:
logger.error(f"[DayTrading] 🚨 청산 단계 {n}회 연속 실패 — 손절·시간손절 멈춤 가능: {err}")
_notify_dt_warning_bg_v2(f"DT 청산 {n}회 연속 실패(손절 정지 가능): {err}")
def _beat_v2(summary: str) -> None:
try:
from app.core import heartbeat as _hb
_hb.beat("daytrading", summary)
except Exception as e:
logger.warning(f"[DayTrading] heartbeat 기록 실패: {type(e).__name__}: {e}")
async def _cycle_sleep(seconds: float) -> None:
"""루프 대기 — 테스트가 사이클 경계를 잡을 수 있게 한 곳으로 모은다."""
await asyncio.sleep(seconds)
def _stop_watch_interval() -> float:
raw = getattr(settings, "DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC", None)
try:
val = float(raw) if raw is not None else SCAN_STOP_WATCH_INTERVAL_SEC
except (TypeError, ValueError):
val = SCAN_STOP_WATCH_INTERVAL_SEC
if val <= 0:
val = SCAN_STOP_WATCH_INTERVAL_SEC
return max(_SCAN_STOP_WATCH_MIN_SEC, val)
async def _stop_watch_round_impl(lock_held: bool) -> list[dict]:
"""보유 DT 포지션 손절선 선판정 1회. 청산 결과 목록.
lock_held=True: 호출자(스케줄러)가 이미 dt_cycle_lock 을 쥔 단계(진입 스캔) 안에서 돈다.
lock_held=False: 락 밖 단계(ORB 수집·소급수집) — 라운드마다 락을 잡고, 다른 사이클
(/cycle 수동 실행)이 쥐고 있으면 그쪽이 청산 체크를 하므로 이번 라운드는 건너뛴다.
전일 포지션은 count_daily_pnl=False 경로(close_stale_positions)가 맡으므로 제외한다.
중복 매도는 _execute_exit_impl 의 exits_in_progress·포지션 동일성 검사가 막는다.
"""
codes = [
c for c, p in list(day_trading_state.positions.items())
if not _dt_exit._is_stale_position(p)
]
if not codes:
return []
timeout = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0
async def _run() -> list[dict]:
res = await _dt_exit._run_exit_checks_bounded_impl(
codes, _dt_exit._fast_stop_check_impl, timeout, "스캔 중 손절 감시",
)
return [r[1] for r in res.values() if r and r[1]]
if lock_held:
return await _run()
if dt_cycle_lock.locked():
return []
async with dt_cycle_lock:
return await _run()
def _entry_blocked_by_exit_results() -> str | None:
"""스캔 도중 청산으로 신규 진입 차단 조건이 새로 걸렸는지 — scan_and_enter 의 선행
게이트(일일 손실한도·3연패 쿨다운)와 같은 판정. 걸렸으면 사유 문자열."""
if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl):
return "일일 손실 한도 초과"
if day_trading_state.is_cooldown_active():
return "당일 연패 쿨다운"
return None
async def _run_with_stop_watch_impl(
coro, step: str, timeout: float, *, lock_held: bool, abort_on_entry_block: bool = False,
):
"""오래 걸리는 단계(coro)를 _guard 로 실행하면서, 그동안 주기적으로 손절 선판정을 병행한다.
abort_on_entry_block=True(진입 스캔)면 감시 청산 뒤 일일 손실한도·연패 쿨다운이 걸릴 때
단계를 취소한다(남은 종목 진입 차단). 매수 주문~상태 반영은 entry_service 가 shield 하므로
취소돼도 이미 나간 주문의 기록은 끝까지 진행된다(_guard 타임아웃 취소와 같은 성격).
"""
stop = asyncio.Event()
interval = _stop_watch_interval()
main_task = asyncio.ensure_future(_guard(coro, step, timeout))
aborted = {"reason": None}
async def _watch() -> None:
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), timeout=interval)
return
except asyncio.TimeoutError:
pass
if not day_trading_state.positions:
continue
try:
exits = await _stop_watch_round_impl(lock_held)
except Exception as e:
logger.error(f"[DayTrading] '{step}' 중 손절 감시 오류: {e}", exc_info=True)
continue
if not exits:
continue
logger.warning(f"[DayTrading] '{step}' 중 손절 감시 청산: {len(exits)}건")
if abort_on_entry_block and not main_task.done():
_why = _entry_blocked_by_exit_results()
if _why:
aborted["reason"] = _why
logger.warning(
f"[DayTrading] ⛔ '{step}' 중 청산으로 {_why} — 남은 진입 스캔 중단"
)
main_task.cancel()
watcher = asyncio.ensure_future(_watch())
try:
try:
return await main_task
except asyncio.CancelledError:
# 감시가 스스로 취소한 경우만 흡수한다. 바깥(스케줄러 종료) 취소는 전파.
_cur = asyncio.current_task()
_outer = bool(_cur and getattr(_cur, "cancelling", lambda: 0)())
if aborted["reason"] and main_task.cancelled() and not _outer:
return None
raise
finally:
stop.set()
_cap = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 10.0
try:
await asyncio.wait_for(watcher, timeout=_cap)
except asyncio.TimeoutError:
logger.warning(
f"[DayTrading] '{step}' 손절 감시 종료 {_cap:.0f}초 초과 — 취소 "
f"(전송된 매도는 shield 로 반영 계속)"
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"[DayTrading] '{step}' 손절 감시 종료 오류: {e}", exc_info=True)
# [M1 2026-09-28] 장중 기동 시 ORB 소급수집 지연 실행 플래그.
# 기동 시퀀스에서 바로 await 하면 첫 청산 체크가 수 분 밀린다 → 루프 안에서
# 청산 체크 뒤에 1회만 실행한다(§8.1-11 청산 우선).
@@ -83,8 +289,8 @@ async def daytrading_scheduler() -> None:
- 엔트리 윈도우: 진입 + 청산 동시 처리
- 기타: 보유 포지션 청산 체크만
"""
global _orb_backfill_pending
await asyncio.sleep(5)
global _orb_backfill_pending, _last_disabled_orphan_check
await _cycle_sleep(5)
logger.info("⚡ [DayTrading] Scheduler started (every 30s)")
# ── 재기동 복원 시퀀스 (순서가 안전에 직결) ────────────────────────────
@@ -104,10 +310,15 @@ async def daytrading_scheduler() -> None:
except Exception as e:
logger.warning(f"[DayTrading] 일일 안전장치 복원 실패: {e}")
if not daily_restored:
logger.warning(
"[DayTrading] ⚠️ 일일 안전장치 복원분 없음 — 당일 실현손익 0 / 연패 0 / "
"당일청산 0종목 으로 시작한다(일일 손실한도·연패 쿨다운·재진입 차단 초기화). "
"당일 첫 기동이면 정상이다."
# [R2 2026-10-09] Redis 단일 소스였다 — 복원 실패(Redis 재기동·volatile-lru 축출·저장 실패)
# 면 0 부터 매매를 재개했다(fail-open). 당일 DT 원장(TradeHistory, SELECT 전용)으로 재구성하고,
# 그것도 실패하면 position_manager 가 DT 신규 진입을 막고(is_cooldown_active) 텔레그램으로
# 알린 뒤 재시도한다. 이 메서드는 예외를 던지지 않는다.
daily_restored = await day_trading_state.rebuild_daily_state_after_redis_miss_v2()
if not daily_restored:
logger.error(
"[DayTrading] 🚨 일일 안전장치 복원 불가(Redis·TradeHistory) — DT 신규 진입 차단, "
f"청산은 계속 (사유: {day_trading_state.daily_state_unverified_reason})"
)
# 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다
@@ -128,18 +339,33 @@ async def daytrading_scheduler() -> None:
restored = await day_trading_state.restore_from_db(db) or 0
if restored:
logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨")
# 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치
# (전일 진입분은 복원된 채로 두어 close_stale_positions 가 잡는다 — §7)
try:
from app.services.daytrading.exit_service import close_stale_positions
stale = await close_stale_positions()
if stale:
logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산")
except Exception as e:
logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True)
except Exception as e:
logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True)
# [R3 2026-10-09] 당일 복원분 검증 — closed_today·KIS 실보유−타전략(§3.5) 기준.
# restore_from_db 는 이를 보지 않아 청산 큐 유실 뒤 재기동하면 이미 판 물량을 다시 팔았다.
# 불일치는 복원 보류 + 경보(장중 close_stale_positions 가 같은 규칙으로 재복원). 예외를 던지지 않는다.
if restored:
try:
_vr = await day_trading_state.verify_restored_today_positions_v2()
if _vr.get("held"):
restored = max(0, restored - len(_vr["held"]))
except Exception as e:
logger.error(f"[DayTrading] 기동 복원 검증 실패: {e}", exc_info=True)
# 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치
# (전일 진입분은 복원된 채로 두어 close_stale_positions 가 잡는다 — §7)
# [2026-10-08] 복원 0건이어도 호출한다 — active 행이 이미 closed 인 DT 고아 잔고
# (Portfolio 'daytrading' 만 남은 경우, 10/06 KB금융)는 복원 대상이 아니라서
# 예전에는 기동 시 점검 자체가 생략됐다. 장외면 함수가 즉시 [] 를 반환한다.
try:
async with dt_cycle_lock:
stale = await _guard(_dt_exit.close_stale_positions(), "기동 시 전일·고아 포지션 청산")
if stale:
logger.warning(f"[DayTrading] 기동 시 전일·고아 포지션 {len(stale)}건 청산")
except Exception as e:
logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True)
# [F5] 복원 성공 로그만으로는 "살아남았는지"를 알 수 없었다 → 확정 개수를 찍는다.
_alive_pos = day_trading_state.position_count()
_alive_orb = len(day_trading_state.orb_cache)
@@ -178,6 +404,7 @@ async def daytrading_scheduler() -> None:
try:
while True:
try:
_beat_v2("loop")
day_trading_state.reset_if_new_day()
if not is_market_open():
@@ -185,7 +412,8 @@ async def daytrading_scheduler() -> None:
# ORB 수집(09:00~09:30)을 통째로 놓칠 수 있다 → 개장까지 남은 시간만큼만 대기.
_until_open = seconds_until_market_open()
# 개장 3분 전이면 watchlist를 미리 채워 09:00부터 ORB 수집 가능
if _until_open is not None and _until_open <= 180 and not get_watchlist():
if (settings.DAY_TRADE_ENABLED and _until_open is not None
and _until_open <= 180 and not get_watchlist()):
try:
count = await _guard(populate_daytrading_watchlist(), "watchlist 구성", 120)
if count:
@@ -193,19 +421,39 @@ async def daytrading_scheduler() -> None:
except Exception as e:
logger.warning(f"[DayTrading] 개장 전 watchlist 구성 실패: {e}")
_sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))
await asyncio.sleep(_sleep)
await _cycle_sleep(_sleep)
continue
if not settings.DAY_TRADE_ENABLED:
await asyncio.sleep(60)
continue
# [2026-10-09] DAY_TRADE_ENABLED=False 는 **신규 진입만** 막는다.
# 예전에는 여기서 sleep(60)+continue 로 EOD·전일/고아·손절 청산까지 전부
# 건너뛰어, 장애 대응으로 끈 채 재기동하면 DT 보유분이 손절 없이 오버나이트로
# 넘어갔다(DT 잔고는 Portfolio 'daytrading' 태그라 스윙 청산에서도 빠진다).
dt_enabled = bool(settings.DAY_TRADE_ENABLED)
if not dt_enabled:
warn_throttled(
logger, "dt_sched:disabled",
"[DayTrading] DAY_TRADE_ENABLED=False — 신규 진입 중단, 보유·전일·고아 "
"포지션 청산 판정만 수행 (보유 %d개)",
day_trading_state.position_count(), interval=600.0,
)
if not day_trading_state.positions:
_now_mono = time.monotonic()
if (_last_disabled_orphan_check
and _now_mono - _last_disabled_orphan_check
< DISABLED_ORPHAN_CHECK_INTERVAL_SEC):
await _cycle_sleep(30)
continue
_last_disabled_orphan_check = _now_mono
# MDD 체크 (CLAUDE.md 5.3)
short_term_ok = await _guard(
mdd_manager.is_short_term_strategy_enabled(), "MDD 체크", 30
)
if short_term_ok is None:
short_term_ok = False # 판단 불가 → 보수적으로 신규 진입 차단
# MDD 체크 (CLAUDE.md 5.3) — 진입 판단 전용. 비활성이면 조회하지 않는다.
if dt_enabled:
short_term_ok = await _guard(
mdd_manager.is_short_term_strategy_enabled(), "MDD 체크", 30
)
if short_term_ok is None:
short_term_ok = False # 판단 불가 → 보수적으로 신규 진입 차단
else:
short_term_ok = False
# EOD 강제 청산 (15:20)
if is_eod_flat_time():
@@ -216,7 +464,15 @@ async def daytrading_scheduler() -> None:
)
if exits:
logger.info(f"[DayTrading] EOD 청산: {len(exits)}건")
await asyncio.sleep(30)
else:
# [2026-10-08] 인메모리가 비어도 전일·고아 DT 잔고는 남아 있을 수 있다
async with dt_cycle_lock:
orphan_exits = await _guard(
_dt_exit.close_stale_positions(), "EOD 전일·고아 포지션 청산",
)
if orphan_exits:
logger.warning(f"[DayTrading] EOD 전일·고아 청산: {len(orphan_exits)}건")
await _cycle_sleep(30)
continue
# 09:00~09:30 ORB 수집
@@ -229,17 +485,21 @@ async def daytrading_scheduler() -> None:
orb_exits = await _guard(check_stops_only_v2(), "ORB 구간 손절 체크")
if orb_exits:
logger.info(f"[DayTrading] ORB 구간 청산: {len(orb_exits)}건")
if not get_watchlist():
try:
count = await populate_daytrading_watchlist()
if count:
logger.info(f"[DayTrading] 자동 watchlist: {count}개")
except Exception as e:
logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}")
await _guard(collect_opening_ranges(), "ORB 수집", 300)
if dt_enabled:
if not get_watchlist():
try:
count = await populate_daytrading_watchlist()
if count:
logger.info(f"[DayTrading] 자동 watchlist: {count}개")
except Exception as e:
logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}")
# [2026-10-09] ORB 수집(최대 300초) 중에도 손절 감시 병행
await _run_with_stop_watch_impl(
collect_opening_ranges(), "ORB 수집", 300, lock_held=False,
)
# 정규 수집 구간이면 소급수집 예약은 불필요
_orb_backfill_pending = False
await asyncio.sleep(30)
await _cycle_sleep(30)
continue
# 09:30~15:00 신호 스캔 + 청산 체크
@@ -248,7 +508,9 @@ async def daytrading_scheduler() -> None:
wl = get_watchlist()
# 30분마다 갱신 (비어 있을 때만 → 항상): 09:00에 전일 분석으로 채운 목록을
# 당일 배치 완료 후 당일 분석 결과로 교체하기 위함 (ORB 캐시는 종목코드 기준 유지)
if not wl or (is_watchlist_auto() and (time.time() - _last_wl_sync) > 1800):
if dt_enabled and (
not wl or (is_watchlist_auto() and (time.time() - _last_wl_sync) > 1800)
):
_last_wl_sync = time.time()
try:
await populate_daytrading_watchlist()
@@ -265,45 +527,65 @@ async def daytrading_scheduler() -> None:
# [M5] 수동 /cycle API 와 공유하는 전역 락 — 같은 포지션 이중 매도 방지
async with dt_cycle_lock:
exits = await _guard(check_and_exit_positions(), "청산 체크")
_record_dt_exit_result_v2(exits is not None, "청산 체크 타임아웃")
if exits is not None:
_beat_v2("exit_ok")
if exits:
logger.info(f"[DayTrading] 청산: {len(exits)}건")
# [M1] 장중 기동 ORB 소급수집 — 청산 체크를 먼저 끝낸 뒤 1회만
if _orb_backfill_pending:
if _orb_backfill_pending and dt_enabled:
_orb_backfill_pending = False
try:
from app.services.daytrading.entry_service import (
backfill_opening_ranges,
)
await _guard(backfill_opening_ranges(), "ORB 소급수집", 300)
# [2026-10-09] 소급수집(최대 300초) 중에도 손절 감시 병행
await _run_with_stop_watch_impl(
backfill_opening_ranges(), "ORB 소급수집", 300, lock_held=False,
)
except Exception as e:
logger.error(
f"[DayTrading] 장중 기동 ORB 소급수집 실패: {e}", exc_info=True
)
# 진입 (MDD 허용 시만)
if short_term_ok:
# 진입 (DAY_TRADE_ENABLED + MDD 허용 시만)
if not dt_enabled:
pass # 진입 비활성 — 청산만(위에서 수행)
elif short_term_ok:
# [2026-10-09] 스캔(최대 150초, _guard 180초) 동안 손절 판정이 멈추던
# 문제 — 스캔과 병행해 손절 선판정을 주기 실행한다. 락은 이 단계가
# 이미 쥐고 있으므로 감시는 같은 락 아래에서 돈다(/cycle 과 배타 유지).
async with dt_cycle_lock:
entries = await _guard(scan_and_enter(), "진입 스캔")
entries = await _run_with_stop_watch_impl(
scan_and_enter(), "진입 스캔", STEP_TIMEOUT,
lock_held=True, abort_on_entry_block=True,
)
if entries:
logger.info(f"[DayTrading] 진입: {len(entries)}건")
else:
logger.info("[DayTrading] ⏸️ MDD -10% 이상 — 신규 진입 차단")
await asyncio.sleep(30)
await _cycle_sleep(30)
continue
# 그 외 시간 — 보유 포지션 청산 체크만
if day_trading_state.positions:
async with dt_cycle_lock:
exits = await _guard(check_and_exit_positions(), "장외 청산 체크")
if exits:
logger.info(f"[DayTrading] 장외 청산: {len(exits)}건")
# [2026-10-08] 인메모리가 비어 있어도 호출한다 — check_and_exit_positions 는
# 그 경우 close_stale_positions(전일·고아 DT 잔고 청산)만 수행한다(15:00 이후도
# 청산은 허용, §8.1-11).
async with dt_cycle_lock:
exits = await _guard(check_and_exit_positions(), "장외 청산 체크")
_record_dt_exit_result_v2(exits is not None, "장외 청산 체크 타임아웃")
if exits is not None:
_beat_v2("exit_ok")
if exits:
logger.info(f"[DayTrading] 장외 청산: {len(exits)}건")
except Exception as e:
logger.error(f"[DayTrading] 스케줄러 오류: {e}", exc_info=True)
_record_dt_exit_result_v2(False, f"{type(e).__name__}: {e}")
await asyncio.sleep(30)
await _cycle_sleep(30)
except asyncio.CancelledError:
logger.info("[DayTrading] Scheduler stopped")