fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류

## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:49:25 +09:00
parent 3fdc19c749
commit 7abbb3411c
27 changed files with 4164 additions and 227 deletions

View File

@@ -32,6 +32,7 @@ day_trading_strategy.py의 신호 함수를 재활용하고
"""
import asyncio
import logging
import time
from datetime import datetime, time as dtime
import pytz
@@ -68,6 +69,42 @@ _watchlist: dict[str, str] = {}
# False = API로 수동 설정된 목록 (자동 갱신으로 덮어쓰지 않음)
_watchlist_auto: bool = False
# ── [F6 2026-09-28] 스캔 처리량 ───────────────────────────────────────────────
# 증상: 감시종목 20개인데 매 사이클 앞쪽 5~6종목만 검사되고 뒤쪽은 영구히 기회를
# 못 얻었다. 스케줄러의 _guard(scan_and_enter(), "진입 스캔") 가 180초에서 코루틴을
# 취소하는데, 종목별 분봉 조회가 순차라 20종목이 예산에 들어오지 못했다.
#
# 대책 3종:
# 1) 분봉 조회를 제한 동시성으로 **선조회**(읽기 전용)해 지배적 지연을 제거한다.
# 주문은 그대로 순차 실행 → MAX_POSITIONS·예산 클램프 순서가 바뀌지 않는다.
# 2) 순차 실행 구간에 시간 예산을 둬 **잘린 종목 수를 warning 으로 남긴다**
# (기존에는 _guard 가 조용히 취소했다).
# 3) 라운드로빈 시작 오프셋 — 그래도 잘리면 다음 사이클은 다른 종목부터 본다.
#
# 동시성은 보수적으로 잡는다. KIS 호출은 korea_investment_api 내부에서
# semaphore(API_MAX_CONCURRENT_REQUESTS=8) + _throttle_lock(min_request_interval)
# 으로 이미 직렬화·쓰로틀되므로 여기서 병렬도를 올려도 레이트리밋을 넘지 않는다.
_SCAN_PREFETCH_CONCURRENCY: int = 4
# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다
_SCAN_PREFETCH_TIMEOUT: float = 20.0
# 순차 진입 구간 시간 예산(초). 스케줄러 _guard(STEP_TIMEOUT=180) 보다 작게 잡아
# 취소가 아니라 '남은 종목 수 로그'로 끝나게 한다.
_SCAN_TIME_BUDGET: float = 150.0
# 선조회 분봉 캐시 {code: (저장시각, candles)} — 한 사이클 안에서만 유효
_candle_cache: dict[str, tuple[float, list]] = {}
# [F11 2026-09-28] 수명은 '시간'이 아니라 '한 사이클'이다 — scan_and_enter() 가 진입
# 시점에 캐시를 비운다. TTL 만 180초로 두면 30초 주기 스케줄러에서 같은 스냅샷이 5개
# 사이클을 덮어, 세 신호 함수가 (candles, orb, price) 의 순수 함수인 탓에 t=0 에
# 신호가 없으면 t=150 까지 무조건 no-signal 이 되고 진입가·손절·수량이 최대 3분 묵는다.
# [M3 2026-09-28] TTL 은 **한 사이클 상한(_SCAN_TIME_BUDGET)보다 커야** 한다.
# 이전 값 25초는 선조회 자체보다 짧았다: 모든 KIS 요청이 싱글톤 _throttle_lock 안에서
# min_request_interval(0.15초) 만큼 sleep 하므로 실효 처리율은 ≈6.7 req/s 로 전역
# 고정이고(semaphore(8) 은 상한만 제공), 20종목 × 종목당 다회 호출이면 선조회가 수십
# 초를 쓴다 → 앞쪽 항목이 순차 루프 시작 전에 만료돼 재조회됐다(종목당 호출 2배).
_CANDLE_CACHE_TTL: float = 180.0
# 라운드로빈 시작 오프셋
_scan_offset: int = 0
def _clamp_day_trade_budget(
budget: float,
@@ -195,6 +232,29 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
return 0
stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows]
# [F8 2026-09-28] ORB 를 이미 수집한 종목은 교체에서 살린다.
# set_watchlist() 는 목록을 **통째로 재대입**하고, 스케줄러의 30분 갱신은
# _last_wl_sync=0.0 초기값 때문에 09:30 첫 사이클에서 즉시 발동한다.
# 09:00~09:29 에 ORB 를 수집한 종목이 여기서 빠지면(당일 배치가 다른 종목을
# 상위로 올리면 실제로 빠진다) 스캔 루프가 _watchlist 만 순회하므로
# 그 ORB 는 버려지고 당일 ORB 진입이 0건이 된다.
_kept = [
{"code": c, "name": _watchlist.get(c, c)}
for c in _watchlist
if day_trading_state.has_orb(c)
]
if _kept:
_kept_codes = {x["code"] for x in _kept}
merged = _kept + [x for x in stocks if x["code"] not in _kept_codes]
dropped = len(merged) - max_stocks
merged = merged[:max_stocks]
logger.info(
f"[DayEntry] watchlist 갱신 시 ORB 보유 {len(_kept)}종목 유지"
+ (f" (신규 후보 {dropped}종목은 상한 {max_stocks}개로 탈락)" if dropped > 0 else "")
)
stocks = merged
set_watchlist(stocks)
global _watchlist_auto
_watchlist_auto = True
@@ -202,13 +262,141 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
return len(stocks)
async def _fetch_candles_impl(stock_code: str) -> list:
"""분봉 조회 + 파싱. 실패 시 빈 리스트.
⚠️ **전일 봉이 섞인 시계열을 반환한다** — 진입 판정 경로 전용이다.
`get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **지표
워밍업 하나뿐**이다: 5분봉 RSI(14)=15봉·BB(20)=20봉 요건 때문에 당일 봉만으로는
VWAP 이 약 10:15, RSI_BB 가 약 10:40 이전에 구조적으로 발화하지 못했다
(`indicators_core.rsi_wilder` 는 봉 부족 시 `None`). 원장 실측으로 필터
도입(1b9257c, 2026-09-28) 이전에는 09:30·10:02 진입이 실재했음이 확인됐다.
⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.**
조회 계층이 전일로 거슬러 가려면 미래 `ref_time` 에 KIS 가 전일 데이터를 준다는
전제가 필요한데 그 전제는 확인되지 않았다(`get_minute_chart_full_day_v2` docstring).
전일 봉이 경계 페이지 1장만 들어오면 발화 시각이 소폭 앞당겨지는 데 그친다.
실제 확보량은 조회 계층의 `[분봉] ... 워밍업 5분봉 N봉` 로그로 확인할 것.
ORB 수집(`collect_opening_ranges`)·DT 청산(`daytrading/exit_service`)·레거시
엔드포인트는 **기존 메서드(`get_minute_chart_full_day`)를 그대로 쓴다** — 당일 봉만
받는다.
⚠️ 호출부(`_try_enter`)는 용도를 반드시 분리해야 한다:
- 워밍업 허용(전일 포함): `aggregate_to_5min(candles)` → RSI/BB
- 당일 봉만: `calculate_vwap`, `check_orb_signal`, 현재가, 갭·당일 거래량
→ `filter_today_candles()` 로 분리 (CLAUDE.md §7 마지막 항목)
세션 경계 비교는 순수 계산 모듈 쪽에서 막는다 —
`day_trading_strategy.volume_surge_ratio()` 와 `check_orb_signal()` 의 거래량
게이트는 **마지막 봉과 같은 영업일자의 봉끼리만** 비교한다(`_last_session_candles`).
"""
raw = await korea_investment_api.get_minute_chart_full_day_v2(
stock_code, include_prev_day=True
)
return parse_minute_candles(raw)
async def _prefetch_minute_charts(codes: list[str]) -> int:
"""[F6] 후보 종목의 분봉을 제한 동시성으로 선조회해 캐시에 담는다.
읽기 전용이며 주문·상태 변경을 하지 않는다. 실패·타임아웃 종목은 캐시에
담기지 않고, 뒤이은 순차 스캔이 개별적으로 재시도한다(기존 동작과 동일).
Returns:
int: 캐시에 담긴 종목 수
"""
if not codes:
return 0
sem = asyncio.Semaphore(_SCAN_PREFETCH_CONCURRENCY)
now = time.monotonic()
async def _one(code: str) -> None:
cached = _candle_cache.get(code)
if cached and (now - cached[0]) < _CANDLE_CACHE_TTL:
return
async with sem:
try:
candles = await asyncio.wait_for(
_fetch_candles_impl(code), timeout=_SCAN_PREFETCH_TIMEOUT
)
if candles:
_candle_cache[code] = (time.monotonic(), candles)
except asyncio.TimeoutError:
logger.warning(
f"[DayEntry] 분봉 선조회 {_SCAN_PREFETCH_TIMEOUT:.0f}초 초과: {code} — 이번 사이클 스킵"
)
except Exception as e:
logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}")
await asyncio.gather(*(_one(c) for c in codes), return_exceptions=True)
# 오래된 항목 정리 (감시목록 교체로 무한 증식 방지)
stale = [c for c, (ts, _) in _candle_cache.items() if (time.monotonic() - ts) > _CANDLE_CACHE_TTL * 2]
for c in stale:
_candle_cache.pop(c, None)
return sum(1 for c in codes if c in _candle_cache)
async def _get_candles_cached(stock_code: str) -> list:
"""[F6] 선조회 캐시를 우선 사용하고, 없으면 그 자리에서 조회한다.
``_try_enter()`` 의 시그니처를 바꾸지 않기 위해 인자 전달 대신 모듈 캐시를 쓴다
(CLAUDE.md §4: 기존 시그니처 변경 금지 / §4.1: 목킹 지점이 빗나가지 않게 유지).
"""
cached = _candle_cache.get(stock_code)
if cached and (time.monotonic() - cached[0]) < _CANDLE_CACHE_TTL:
return cached[1]
candles = await _fetch_candles_impl(stock_code)
if candles:
_candle_cache[stock_code] = (time.monotonic(), candles)
return candles
def filter_today_candles(candles: list) -> list:
"""당일(KST) 캔들만 남긴다.
[CLAUDE.md §7 마지막 항목] ``get_minute_chart_full_day`` 는
``FID_PW_DATA_INCU_YN="Y"`` 로 과거 데이터를 포함해 요청하므로 **전일 캔들이
섞인다.** ``calculate_opening_range`` 는 시각(090000~092959)만 보고 날짜를
보지 않기 때문에, 필터 없이 넘기면 전일 09:00~09:29 봉이 ORB 고저·거래량에
합산되어 레인지가 오염된다(레인지 과대 → 돌파 미발생, 평균분당거래량 과대 →
거래량 게이트 통과 불가).
[M2 2026-09-28] "날짜 필드가 없다"와 "날짜가 다 있는데 전부 당일이 아니다"를
**구분한다**. 전자는 목/레거시 응답 방어를 위해 원본을 통과시키지만, 후자는 §7 이
경고하는 오염 그 자체이므로 ``[]`` 를 반환하고 ``warning`` 을 남긴다. 통과시키면
전일 ORB 고저·평균분당거래량이 당일 ORB 로 Redis(TTL 24시간)에 저장돼 하루 남는다.
(`market_schedule` 에 휴장일 판정이 없어 휴장일에 당일 봉이 0건인 경로가 실재한다.)
"""
today_str = datetime.now(_KST).strftime("%Y%m%d")
todays = [c for c in candles if getattr(c, "date", "") == today_str]
if todays:
return todays
if not candles:
return candles
dated = [c for c in candles if getattr(c, "date", "")]
if len(dated) == len(candles):
# 전 캔들에 날짜가 있는데 당일이 0건 → 전일 데이터만 돌아온 것(오염)
_dates = sorted({getattr(c, "date", "") for c in candles})
logger.warning(
f"[DayEntry] ⚠️ 당일({today_str}) 분봉 0건 — 응답 날짜 {_dates[:3]} "
f"(총 {len(candles)}봉). 전일 캔들로 ORB 를 계산하면 고저·거래량이 오염되므로 "
f"폐기한다(휴장일 또는 분봉 조회 이상)."
)
return []
# 날짜 필드가 비어 있는 응답(목/레거시) — 기존 동작 유지
return candles
async def collect_opening_ranges() -> None:
"""09:00~09:30 ORB(Opening Range Breakout) 데이터 수집.
감시 종목의 시초가 레인지(고가/저가)를 수집하여
ORB 돌파 전략의 기준값으로 사용.
호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출
호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출.
장중 재기동으로 09:30 을 지나 기동한 경우는 backfill_opening_ranges() 가
같은 로직을 소급 호출한다(당일 분봉 전체 조회이므로 시각 제약 없음).
KIS API 호출 간격: 0.3초 (레이트 리밋 준수)
"""
day_trading_state.reset_if_new_day()
@@ -224,7 +412,7 @@ async def collect_opening_ranges() -> None:
try:
# 당일 분봉 전체 조회 → 시초가 레인지 계산
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = parse_minute_candles(raw)
candles = filter_today_candles(parse_minute_candles(raw))
if candles:
orb = calculate_opening_range(candles)
if orb:
@@ -239,6 +427,55 @@ async def collect_opening_ranges() -> None:
await day_trading_state.save_orb_cache()
async def backfill_opening_ranges() -> int:
"""장중 재기동 시 당일 ORB 를 소급 수집. 수집된 종목 수 반환.
[2026-09-28 재기동 내구성 수정]
``collect_opening_ranges()`` 의 유일한 호출부가 스케줄러의
``is_opening_range_period()``(09:00~09:29) 분기였다. 09:30 이후에 기동하면
재수집 경로가 없어 **당일 ORB 전략이 영구 0건**이 됐다(Redis 캐시가 살아
있으면 복원되지만, 캐시가 없거나 09:30 전에 한 번도 수집되지 않았으면 불가).
``get_minute_chart_full_day`` 는 당일 전체 분봉을 주므로 09:30 이후에도
09:00~09:29 레인지를 정확히 소급 계산할 수 있다. 전일 캔들 혼입은
``filter_today_candles()`` 가 차단한다.
진입 게이트는 건드리지 않는다 — 수집만 수행한다.
Returns:
int: 소급 수집 후 orb_cache 에 존재하는 종목 수
"""
if not _watchlist:
try:
count = await populate_daytrading_watchlist()
if count:
logger.info(f"[DayEntry] ORB 소급수집용 watchlist 구성: {count}개")
except Exception as e:
logger.warning(f"[DayEntry] ORB 소급수집용 watchlist 구성 실패: {e}")
if not _watchlist:
logger.warning(
"[DayEntry] ⚠️ 장중 기동 ORB 소급수집 불가 — watchlist 가 비어 있다 "
"(당일 stock_analysis 부재 가능). 당일 ORB 전략 0건이 된다."
)
return len(day_trading_state.orb_cache)
before = len(day_trading_state.orb_cache)
await collect_opening_ranges()
after = len(day_trading_state.orb_cache)
if after:
logger.info(
f"[DayEntry] 장중 기동 ORB 소급수집: {before}개 → {after}개 "
f"(watchlist {len(_watchlist)}개)"
)
else:
logger.warning(
f"[DayEntry] ⚠️ 장중 기동 ORB 소급수집 0건 (watchlist {len(_watchlist)}개) — "
"당일 ORB 전략 불가. 분봉 조회 실패 또는 당일 09:00~09:29 봉 부족."
)
return after
async def scan_and_enter() -> list[dict]:
"""감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행.
@@ -378,6 +615,10 @@ async def scan_and_enter() -> list[dict]:
skipped_held = 0
skipped_closed = 0
no_signal = 0
truncated = 0
# [F6] 후보 선별 → 라운드로빈 회전 → 분봉 선조회 → 순차 진입
candidates: list[tuple[str, str]] = []
for stock_code, stock_name in list(_watchlist.items()):
# 이미 보유 중이거나 오늘 이미 청산한 종목 제외
if stock_code in day_trading_state.positions:
@@ -386,8 +627,38 @@ async def scan_and_enter() -> list[dict]:
if day_trading_state.is_closed_today(stock_code):
skipped_closed += 1
continue
candidates.append((stock_code, stock_name))
# 라운드로빈: 시간 예산에 걸려 잘려도 다음 사이클은 다른 종목부터 본다
global _scan_offset
if candidates:
_scan_offset %= len(candidates)
candidates = candidates[_scan_offset:] + candidates[:_scan_offset]
# [F11] 사이클 경계 — 이전 사이클 스냅샷을 버려 매 사이클 새 데이터로 재평가한다.
# 재조회 비용은 get_minute_chart_full_day 의 Redis 1분 버킷 캐시가 대부분 흡수한다.
_candle_cache.clear()
# 선조회(읽기 전용, 제한 동시성) — 순차 조회로 예산을 다 쓰던 지배 원인 제거
_scan_started = time.monotonic()
prefetched = await _prefetch_minute_charts([c for c, _ in candidates])
_prefetch_sec = time.monotonic() - _scan_started
for idx, (stock_code, stock_name) in enumerate(candidates):
if day_trading_state.position_count() >= MAX_POSITIONS:
break
_elapsed = time.monotonic() - _scan_started
if _elapsed > _SCAN_TIME_BUDGET:
# 기존에는 스케줄러 _guard(180초)가 코루틴을 조용히 취소해 '뒤쪽 종목이
# 통째로 검사되지 않는다'는 사실이 로그에 전혀 남지 않았다.
truncated = len(candidates) - idx
logger.warning(
f"[DayEntry] ⏱️ 스캔 시간 예산 {_SCAN_TIME_BUDGET:.0f}초 초과 "
f"({_elapsed:.0f}초 경과) — 남은 {truncated}종목 미스캔. "
f"다음 사이클은 오프셋 {(_scan_offset + idx) % max(1, len(candidates))} 부터 시작"
)
_scan_offset = (_scan_offset + idx) % len(candidates)
break
scanned += 1
try:
@@ -402,10 +673,16 @@ async def scan_and_enter() -> list[dict]:
no_signal += 1
except Exception as e:
logger.error(f"[DayEntry] 진입 시도 오류 ({stock_code}): {e}", exc_info=True)
else:
# 전 종목을 소화했으면 오프셋을 리셋해 다음 사이클도 앞에서부터 본다
_scan_offset = 0
logger.info(
f"[DayEntry] 스캔 완료: {scanned}종목 스캔, {len(entries)}진입, "
f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료"
f"[DayEntry] 스캔 완료: 후보 {len(candidates)}종목 중 {scanned}종목 스캔"
f"{f' (미스캔 {truncated})' if truncated else ''}, {len(entries)}진입, "
f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료 "
f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, "
f"총 {time.monotonic() - _scan_started:.1f}초)"
)
return entries
@@ -449,26 +726,42 @@ async def _try_enter(
logger.debug(f"[DayEntry] VI 쿨다운 — {stock_code} 진입 스킵")
return None
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
candles = parse_minute_candles(raw)
# [F6] 선조회 캐시 사용 (miss 면 이 자리에서 조회).
# candles 에는 **전일 워밍업 봉이 섞여 있다**(_fetch_candles_impl docstring).
candles = await _get_candles_cached(stock_code)
if not candles:
return None
# 세션 앵커 지표(VWAP)·당일 거래량·갭·현재가는 '당일 봉'이라는 보장이 필요하다.
candles_today = filter_today_candles(candles)
if not candles_today:
# 당일 봉이 0건 = 휴장일·조회 이상. 현재가·VWAP·거래량 기준을 만들 수 없다.
# (filter_today_candles 가 날짜 필드가 채워진 경우 warning 을 남긴다)
return None
# 5분봉 집계 — 버킷 키에 날짜가 포함돼 서로 다른 날의 1분봉이 한 봉으로
# 합쳐지지 않는다. 전일 봉이 앞쪽에 붙어 RSI(14)/BB(20) 워밍업을 **부분적으로든
# 전부든** 채운다 → 채워진 만큼만 판정이 가능해진다(확보량은 조회 계층 로그 참조.
# 개장 직후 발화를 단언할 수 없다 — _fetch_candles_impl docstring 의 미검증 전제).
# 세션 경계 비교(거래량 게이트)는 day_trading_strategy 쪽에서 막는다.
candles_5m = aggregate_to_5min(candles)
if len(candles_5m) < 5:
return None
vwap = calculate_vwap(candles)
# VWAP: 당일 누적 (세션 앵커)
vwap = calculate_vwap(candles_today)
orb = day_trading_state.get_orb(stock_code)
current_price = float(candles_5m[-1].close) if candles_5m else 0.0
# 현재가: 당일 마지막 1분봉 종가 (형성 중 5분봉 종가보다 최신)
current_price = float(candles_today[-1].close)
if current_price <= 0:
return None
# 신호 체크 (ORB → VWAP → RSI+BB 순)
signal = None
if orb:
signal = check_orb_signal(stock_code, stock_name, candles, orb, current_price)
# ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다
signal = check_orb_signal(stock_code, stock_name, candles_today, orb, current_price)
# [P1] 점심시간(11:30~13:00)에는 VWAP/RSI_BB 진입 제한 — 유동성 저하
if not is_lunch_break:
if not signal and vwap:

View File

@@ -471,6 +471,11 @@ async def _finalize_exit_impl(
f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)"
)
# [F9 2026-09-28] 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화.
# 값이 바뀐 직후에 저장한다 — 재기동해도 일일 -3% 한도·3연패 쿨다운·당일
# 재진입 차단이 풀리지 않게 한다(락 밖에서 호출: Redis I/O 로 락을 잡지 않는다).
await day_trading_state.save_daily_state()
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
if is_full_exit:
is_loss = profit_loss < 0
@@ -612,6 +617,8 @@ async def close_stale_positions() -> list[dict]:
# 매수하면 전일 포지션이 되살아난다.
async with day_trading_state.trade_lock:
day_trading_state.closed_today.add(stock_code)
# [F9] closed_today 변경분 영속화 — 재기동 후에도 당일 재진입을 막는다
await day_trading_state.save_daily_state()
# [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다.
result = await _execute_exit_stale(

View File

@@ -18,7 +18,9 @@ CLAUDE.md 3.1: 실시간 레이어에서 DB I/O 절대 금지.
주의사항:
- [동시성] positions, daily_pnl, closed_today 수정 시 반드시 trade_lock 획득
- [영속성] 앱 재시작 시 인메모리 상태가 유실됨 (DB 복원 메커니즘 없음)
- [영속성] 앱 재시작 시 restore_from_db()/load_orb_cache() 로 복원한다.
복원 직전에 반드시 begin_restore() 를 호출할 것 — 호출하지 않으면
첫 reset_if_new_day() 가 복원분을 전부 지운다(2026-09-28 수정).
- [단일 프로세스] 다중 워커 환경에서는 상태 불일치 발생 가능
"""
import asyncio
@@ -114,6 +116,29 @@ class DayTradingState:
return True
return False
def begin_restore(self) -> bool:
"""기동 시 복원(load_orb_cache/restore_from_db) **직전에 1회** 호출.
[2026-09-28 재기동 내구성 수정]
새 프로세스는 ``last_reset_date is None`` 이고 복원 경로 3개
(load_orb_cache / restore_from_db / close_stale_positions) 중 어느 것도
이 필드를 설정하지 않았다. 그래서 스케줄러 첫 루프의
``reset_if_new_day()`` 가 무조건 참이 되어 **방금 복원한
positions/orb_cache 를 전부 지웠다**(복원 성공 로그는 그대로 출력되므로
조용히 실패). 손절·트레일링·시간손절·15:20 EOD 청산이 인메모리
positions 만 순회하므로 재기동한 당일의 포지션이 고아가 됐다.
복원 **전에** 날짜 경계를 확정하면 두 불변식을 동시에 만족한다:
1) 같은 날 첫 루프의 reset 은 no-op → 복원분 생존
2) 자정을 넘기면 ``last_reset_date != today`` → 정상 초기화되어
전일 closed_today / daily_pnl / loss_streak_today 가 새지 않는다
(§7 일일 -3% 한도·3연패 쿨다운·재진입 차단 보호)
Returns:
bool: 날짜 경계 확정 과정에서 초기화가 수행되었으면 True
"""
return self.reset_if_new_day()
def add_position(self, stock_code: str, data: dict) -> None:
"""포지션 추가.
@@ -282,6 +307,100 @@ class DayTradingState:
logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}")
return 0
async def save_daily_state(self) -> bool:
"""[F9 2026-09-28] 일일 안전장치 3종을 Redis 에 저장 (당일 키, 자정까지 TTL).
``daily_pnl`` / ``loss_streak_today`` / ``closed_today`` 는 어디에도
영속화되지 않아 **장중 재기동 시 전부 0/빈 집합으로 돌아갔다.** 이것은 단순
상태 유실이 아니라 **안전장치가 풀리는 방향**이다:
- ``daily_pnl=0`` → §7 일일 -3% 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)가
리셋되어, 이미 한도를 맞고 중단됐어야 할 날에 매매가 재개된다
- ``loss_streak_today=0`` → 당일 3연패 쿨다운이 풀린다
- ``closed_today=∅`` → 손절로 나온 종목을 같은 날 다시 매수한다
저장 포맷에 ``date`` 를 실어 ``load_daily_state()`` 가 **다른 날짜면 버린다**
(전일 값이 다음 날로 새면 F1 불변식 ②를 정면으로 깨뜨린다).
ORB 캐시(save_orb_cache)와 동일한 Redis 경로를 그대로 확장한 것이다.
"""
try:
from app.core.redis import setex_json
today = _today_kst()
payload = {
"date": today.isoformat(),
"daily_pnl": float(self.daily_pnl),
"loss_streak_today": int(self.loss_streak_today),
"closed_today": sorted(self.closed_today),
# [C2 2026-09-28] 손실률 분모. 이 값이 0 이면
# is_daily_loss_exceeded() 가 total_assets<=0 에서 **무조건 False** 라
# 재기동 직후 첫 scan_and_enter() 가 일일 -3% 한도를 통과해버린다
# (scan_and_enter 는 잔고 조회 전에 이 필드를 읽는다).
"last_total_assets": float(self.last_total_assets),
}
ok = await setex_json(f"daytrading:daily:{today}", 86400, payload)
if not ok:
logger.warning(
"[DayTrading] ⚠️ 일일 안전장치 저장 실패(Redis) — 재기동 시 "
"일일 손실한도·연패 쿨다운·재진입 차단이 초기화된다"
)
return ok
except Exception as e:
logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 저장 실패: {e}")
return False
async def load_daily_state(self) -> bool:
"""[F9] Redis 에서 당일 안전장치 3종을 복원. 복원했으면 True.
**날짜 게이트**: 저장된 ``date`` 가 오늘이 아니면 복원하지 않고 버린다.
키 자체에도 날짜가 들어 있지만(자정 TTL), 페이로드 날짜를 한 번 더 확인해
키 충돌·시계 오차·수동 주입으로 전일 값이 새는 경로를 이중으로 막는다.
복원값은 현재 인메모리 값과 **병합하지 않고 더 보수적인 쪽을 취한다**
(손익은 합산이 아니라 저장값 채택, closed_today 는 합집합, 연패는 최대값)
— 재기동 직후 인메모리는 비어 있는 것이 정상이고, 만약 비어 있지 않다면
안전장치를 약화시키지 않는 방향을 골라야 한다.
"""
try:
from app.core.redis import get_json
today = _today_kst()
data = await get_json(f"daytrading:daily:{today}")
if not data:
return False
saved_date = str(data.get("date") or "")
if saved_date != today.isoformat():
logger.warning(
f"[DayTrading] 일일 안전장치 복원 건너뜀: 저장 날짜 {saved_date!r} "
f"≠ 오늘 {today.isoformat()} — 전일 값 누수 방지(폐기)"
)
return False
_pnl = float(data.get("daily_pnl", 0) or 0)
_streak = int(data.get("loss_streak_today", 0) or 0)
_closed = {str(c) for c in (data.get("closed_today") or []) if c}
_assets = float(data.get("last_total_assets", 0) or 0)
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
# [C2] 분모는 **작은 쪽이 보수적**이다: pnl<0 일 때 (pnl/assets) 가 더 음수가
# 되어 -3% 한도가 더 빨리 걸린다. 단 0/음수는 게이트를 통째로 무력화하므로
# 후보에서 제외하고, 양수가 하나도 없으면 현재값(0.0)을 그대로 둔다.
_cands = [v for v in (self.last_total_assets, _assets) if v > 0]
if _cands:
self.last_total_assets = min(_cands)
logger.info(
f"[DayTrading] 일일 안전장치 복원: 실현손익={self.daily_pnl:+,.0f}원, "
f"연패={self.loss_streak_today}, 당일청산={len(self.closed_today)}종목, "
f"총자산기준={self.last_total_assets:,.0f}원"
)
return True
except Exception as e:
logger.warning(
f"[DayTrading] ⚠️ 일일 안전장치 복원 실패: {e} — 일일 손실한도·연패 "
f"쿨다운·재진입 차단이 초기화된 상태로 시작한다"
)
return False
def set_orb(self, stock_code: str, orb: Any) -> None:
"""ORB(시초가 레인지) 데이터 저장.

View File

@@ -56,6 +56,11 @@ async def _guard(coro, step: str, timeout: int = STEP_TIMEOUT):
return None
# [M1 2026-09-28] 장중 기동 시 ORB 소급수집 지연 실행 플래그.
# 기동 시퀀스에서 바로 await 하면 첫 청산 체크가 수 분 밀린다 → 루프 안에서
# 청산 체크 뒤에 1회만 실행한다(§8.1-11 청산 우선).
_orb_backfill_pending: bool = False
# watchlist 자동 갱신 타임스탬프 (epoch seconds)
# 30분(1800초)마다 stock_analysis에서 재조회
_last_wl_sync: float = 0.0
@@ -74,26 +79,53 @@ async def daytrading_scheduler() -> None:
- 엔트리 윈도우: 진입 + 청산 동시 처리
- 기타: 보유 포지션 청산 체크만
"""
global _orb_backfill_pending
await asyncio.sleep(5)
logger.info("⚡ [DayTrading] Scheduler started (every 30s)")
# 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다
# ── 재기동 복원 시퀀스 (순서가 안전에 직결) ────────────────────────────
# [F1 2026-09-28] begin_restore() 를 **복원보다 먼저** 호출한다.
# 새 프로세스는 last_reset_date is None 이므로, 먼저 날짜 경계를 확정하지 않으면
# 첫 루프의 reset_if_new_day() 가 방금 복원한 positions/orb_cache 를 전부 지운다
# (복원 성공 로그는 그대로 찍혀 조용히 실패 → 손절·트레일링·시간손절·15:20 EOD
# 청산이 전부 미적용, ORB 당일 영구 0건). 자정 경과 시 초기화는 그대로 동작한다.
day_trading_state.begin_restore()
# [F9 2026-09-28] 일일 안전장치(일일 -3% 한도·3연패 쿨다운·당일 재진입 차단) 복원.
# begin_restore() 로 날짜 경계를 확정한 **뒤**에 복원해야 한다(순서가 뒤바뀌면
# 복원값이 곧바로 초기화된다). 저장 페이로드의 날짜가 오늘이 아니면 버려진다.
daily_restored = False
try:
await day_trading_state.load_orb_cache()
daily_restored = await day_trading_state.load_daily_state()
except Exception as e:
logger.warning(f"[DayTrading] 일일 안전장치 복원 실패: {e}")
if not daily_restored:
logger.warning(
"[DayTrading] ⚠️ 일일 안전장치 복원분 없음 — 당일 실현손익 0 / 연패 0 / "
"당일청산 0종목 으로 시작한다(일일 손실한도·연패 쿨다운·재진입 차단 초기화). "
"당일 첫 기동이면 정상이다."
)
# 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다
orb_restored = 0
try:
orb_restored = await day_trading_state.load_orb_cache() or 0
except Exception as e:
logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}")
# 앱 재시작 시 DB에서 당일 데이트레이딩 포지션 복원 (CRITICAL 버그 수정)
restored = 0
try:
# [M9 / CLAUDE.md §3.1 예외 — 기동 시 1회, 읽기 전용 복원]
# restore_from_db()는 SELECT 만 수행하며 commit() 이 없다. 쓰기는 db_worker 경유.
# 세션 수명을 복원 쿼리로 한정하고, 주문이 발생하는 stale 청산은 블록 밖에서 수행한다.
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
restored = await day_trading_state.restore_from_db(db)
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()
@@ -104,6 +136,41 @@ async def daytrading_scheduler() -> None:
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)
logger.info(
f"[DayTrading] 복원 확정: 포지션 {restored}개 → {_alive_pos}개 / "
f"ORB {orb_restored}개 → {_alive_orb}개 / 일일안전장치="
f"{'복원' if daily_restored else '없음'}"
f"(손익={day_trading_state.daily_pnl:+,.0f}, 연패={day_trading_state.loss_streak_today}, "
f"청산={len(day_trading_state.closed_today)}종목) "
f"(reset_date={day_trading_state.last_reset_date})"
)
if (restored and not _alive_pos) or (orb_restored and not _alive_orb):
logger.error(
"[DayTrading] 🚨 복원분이 유실됐다 — 날짜 경계 확정(begin_restore) 실패 의심. "
"손절·EOD 청산이 인메모리 positions 만 순회하므로 포지션이 고아가 된다."
)
# [F2] 09:30 이후 장중 기동이면 ORB 수집 호출부가 없어 당일 ORB 가 영구 0건이었다.
# [M1 2026-09-28] 단 여기서 await 하지 않는다. 소급수집은 최대 300초를 점유하고
# 그 앞의 restore_from_db + close_stale_positions 까지 더하면 첫
# check_and_exit_positions() 까지 수 분이 걸린다 — 손절·트레일링·시간손절이 전부
# 그 루프 안에 있어 §8.1-11 "청산은 항상 매수보다 우선"을 위반한다.
# → 플래그만 세우고, 루프의 **첫 청산 체크 뒤**에 1회 실행한다.
if settings.DAY_TRADE_ENABLED and is_market_open() and not is_opening_range_period():
if not day_trading_state.orb_cache:
_orb_backfill_pending = True
logger.info(
"[DayTrading] 장중 기동 — ORB 소급수집을 첫 청산 체크 뒤로 지연 예약 "
"(청산 우선)"
)
else:
logger.info(
f"[DayTrading] 장중 기동 — ORB 캐시 {_alive_orb}개 보유, 소급수집 생략"
)
try:
while True:
try:
@@ -157,6 +224,8 @@ async def daytrading_scheduler() -> None:
except Exception as e:
logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}")
await _guard(collect_opening_ranges(), "ORB 수집", 300)
# 정규 수집 구간이면 소급수집 예약은 불필요
_orb_backfill_pending = False
await asyncio.sleep(30)
continue
@@ -184,6 +253,19 @@ async def daytrading_scheduler() -> None:
if exits:
logger.info(f"[DayTrading] 청산: {len(exits)}건")
# [M1] 장중 기동 ORB 소급수집 — 청산 체크를 먼저 끝낸 뒤 1회만
if _orb_backfill_pending:
_orb_backfill_pending = False
try:
from app.services.daytrading.entry_service import (
backfill_opening_ranges,
)
await _guard(backfill_opening_ranges(), "ORB 소급수집", 300)
except Exception as e:
logger.error(
f"[DayTrading] 장중 기동 ORB 소급수집 실패: {e}", exc_info=True
)
# 진입 (MDD 허용 시만)
if short_term_ok:
entries = await _guard(scan_and_enter(), "진입 스캔")