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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user