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:
@@ -30,6 +30,20 @@ TTL_MINUTE_CHART = 60 # 분봉 캐시 1분 (실시간성 유지)
|
||||
TTL_MINUTE_FULLDAY = 60 # 당일 전체 분봉 캐시 1분
|
||||
TTL_OVERSEAS_CHART = 300 # 해외 일봉 캐시 5분
|
||||
|
||||
# ── 분봉 페이지네이션 상수 ────────────────────────────────────────────────────
|
||||
MINUTE_CHART_MAX_PAGES = 15 # 1회 30봉 × 15 = 450봉 (장 전체 390분 + 여유)
|
||||
# 전일 분봉 워밍업 **상한**(분). 종료 판정의 기준은 아래 `_WARMUP_BARS_5MIN`(봉 수)이고
|
||||
# 이 값은 "아무리 모아도 여기서 멈춘다"는 안전장치다 — 저유동성 종목은 거래 없는 분이
|
||||
# 응답에서 빠져 분 수로는 영원히 차지 않기 때문이다. 전략 임계값이 아니다.
|
||||
MINUTE_CHART_PREV_DAY_MINUTES = 150
|
||||
# 워밍업 목표는 **5분봉 개수**다: BB(20)=20봉, RSI(14)=15봉
|
||||
# (`day_trading_strategy._bollinger(candles_5min, 20)` / `_rsi(candles_5min, 14)`).
|
||||
# 당일 5분봉이 k개면 추가로 필요한 것은 (20-k)개이므로 장 중반 이후엔 0이 된다.
|
||||
MINUTE_CHART_WARMUP_BARS_5MIN = 20
|
||||
# 전일 구간 전용 페이지 상한 — 당일 수집과 별도로 더 낮게 잡아 레이트 리밋을 보호한다
|
||||
# (한 페이지 30봉이 5분봉 6~30개를 덮으므로 3페이지면 20봉 목표에 충분하다).
|
||||
MINUTE_CHART_PREV_DAY_MAX_PAGES = 3
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""KIS API Circuit Breaker — 연속 N회 실패 시 일정 시간 요청 차단.
|
||||
@@ -908,6 +922,14 @@ class KoreaInvestmentAPI:
|
||||
) -> list:
|
||||
"""당일 전체 분봉 데이터 수집 (Redis 캐시 적용, 여러 번 호출 집계)
|
||||
|
||||
**당일 봉만 반환한다** — 동작·시그니처 모두 기존과 같다(CLAUDE.md §4).
|
||||
ORB 레인지·평균분당거래량·VWAP·현재가처럼 **세션 앵커** 기준이 필요한
|
||||
소비지점(`daytrading/entry_service.collect_opening_ranges`,
|
||||
`daytrading/exit_service`, `api/endpoints/day_trading`)이 이 메서드를 쓴다.
|
||||
|
||||
전일 봉을 워밍업으로 함께 받아야 하는 경우(5분봉 RSI(14)/BB(20))는
|
||||
`get_minute_chart_full_day_v2(..., include_prev_day=True)` 를 쓴다.
|
||||
|
||||
캐시 키: chart:minute_fullday:{stock_code}:{YYYYMMDD_HHMM}
|
||||
- HHMM: 현재 시각의 분 단위 버킷 → 1분마다 갱신
|
||||
TTL: 1분
|
||||
@@ -918,8 +940,72 @@ class KoreaInvestmentAPI:
|
||||
Returns:
|
||||
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
|
||||
"""
|
||||
return await self._get_minute_chart_full_day_impl(
|
||||
stock_code, market_open, include_prev_day=False
|
||||
)
|
||||
|
||||
async def get_minute_chart_full_day_v2(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_open: str = "090000",
|
||||
include_prev_day: bool = False,
|
||||
prev_day_minutes: int = MINUTE_CHART_PREV_DAY_MINUTES,
|
||||
) -> list:
|
||||
"""분봉 수집 — 전일 봉 포함 여부를 **호출부가 선택**한다.
|
||||
|
||||
`include_prev_day=False`(기본)면 `get_minute_chart_full_day()` 와 완전히 같다.
|
||||
|
||||
`include_prev_day=True` 면 전일 종반 봉을 **부족한 5분봉 개수만큼**
|
||||
(`MINUTE_CHART_WARMUP_BARS_5MIN` 기준, `prev_day_minutes`·
|
||||
`MINUTE_CHART_PREV_DAY_MAX_PAGES` 가 상한) 더 수집해 앞쪽에 붙인다.
|
||||
용도는 **지표 워밍업 하나뿐**이다 — 5분봉 RSI(14)=15봉/BB(20)=20봉 요건 때문에
|
||||
당일 봉만으로는 VWAP 전략이 약 10:15, RSI_BB 가 약 10:40 이전에 발화하지 못한다
|
||||
(`indicators_core.rsi_wilder` 는 봉이 모자라면 `None` 을 반환하고 짧은
|
||||
기간으로 대체 계산하지 않는다).
|
||||
|
||||
⚠️ **미검증 전제**: 이 경로는 "장중에 `FID_INPUT_HOUR_1` 로 **미래 시각**
|
||||
(예: 전일 14:51)을 주면 KIS 가 전일 데이터를 돌려준다"에 의존한다. 요청 파라미터에
|
||||
날짜 필드가 없어(`get_minute_chart` 참조) 그렇게 밖에 거슬러 갈 수 없는데,
|
||||
**KIS 가 실제로 그렇게 응답한다는 보장은 코드·테스트에 없다.** 최신 봉을 그대로
|
||||
돌려주면 전부 dedupe 되어 `new_added==0` 으로 즉시 중단되고, 전일 봉은 경계
|
||||
페이지 1장(약 29봉)만 남는다 — 그때는 워밍업이 부분적으로만 채워져 발화 시각이
|
||||
조금 앞당겨지는 수준에 그친다. 확보량은 종료 시 로그로 남으므로 **운영 로그로만
|
||||
확정할 수 있다**. 따라서 "개장 직후부터 발화한다"고 단언하지 말 것.
|
||||
|
||||
⚠️ 반환 시계열에는 **날짜가 다른 봉이 섞여 있다.** 세션 앵커 지표
|
||||
(VWAP·ORB 레인지·평균분당거래량·현재가·당일 거래량·갭)에 그대로 넘기면
|
||||
CLAUDE.md §7 마지막 항목이 경고하는 오염이 발생한다. 호출부는
|
||||
`entry_service.filter_today_candles()` 로 당일 봉을 분리해 쓸 것.
|
||||
|
||||
Returns:
|
||||
List[dict] — output2 레코드 목록 (영업일자+시각 오름차순)
|
||||
"""
|
||||
return await self._get_minute_chart_full_day_impl(
|
||||
stock_code,
|
||||
market_open,
|
||||
include_prev_day=include_prev_day,
|
||||
prev_day_minutes=prev_day_minutes,
|
||||
)
|
||||
|
||||
async def _get_minute_chart_full_day_impl(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_open: str = "090000",
|
||||
include_prev_day: bool = False,
|
||||
prev_day_minutes: int = MINUTE_CHART_PREV_DAY_MINUTES,
|
||||
) -> list:
|
||||
"""분봉 수집 공통 구현 — 목킹은 공통 종단인 이 메서드가 아니라 공개
|
||||
메서드(`get_minute_chart_full_day`/`_v2`)에 걸어도 되게 위임 구조를 유지한다
|
||||
(CLAUDE.md §4.1).
|
||||
"""
|
||||
_d = self._quote_delegate()
|
||||
if _d is not None:
|
||||
# 위임 대상의 **공개 메서드**를 호출한다 — 기존 목킹 지점 보존
|
||||
if include_prev_day:
|
||||
return await _d.get_minute_chart_full_day_v2(
|
||||
stock_code, market_open,
|
||||
include_prev_day=True, prev_day_minutes=prev_day_minutes,
|
||||
)
|
||||
return await _d.get_minute_chart_full_day(stock_code, market_open)
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
@@ -928,7 +1014,9 @@ class KoreaInvestmentAPI:
|
||||
today_str = now_dt.strftime("%Y%m%d")
|
||||
now_bucket = now_dt.strftime("%H%M") # 1분 단위 버킷
|
||||
|
||||
cache_key = f"chart:minute_fullday:{stock_code}:{today_str}_{now_bucket}"
|
||||
# 전일 포함 응답은 내용이 다르므로 캐시 키를 분리한다 (섞이면 세션 앵커 오염)
|
||||
_variant = f":prev{prev_day_minutes}" if include_prev_day else ""
|
||||
cache_key = f"chart:minute_fullday:{stock_code}:{today_str}_{now_bucket}{_variant}"
|
||||
|
||||
# ── 1. 캐시 히트 확인 ───────────────────────────────────────────────
|
||||
cached = await _cache_get(cache_key)
|
||||
@@ -941,8 +1029,13 @@ class KoreaInvestmentAPI:
|
||||
all_candles: list = []
|
||||
ref = now_str
|
||||
seen_times: set = set()
|
||||
prev_day_count = 0 # include_prev_day 일 때 수집한 전일 1분봉 수(상한 판정용)
|
||||
today_count = 0 # 당일 1분봉 수
|
||||
today_buckets: set = set() # 당일 5분봉 버킷 (워밍업 충족 판정)
|
||||
prev_buckets: set = set() # 전일 5분봉 버킷
|
||||
prev_pages = 0 # 전일 구간을 파기 위해 쓴 페이지 수
|
||||
|
||||
for _ in range(15): # 최대 15회 (450분, 여유 있게)
|
||||
for _ in range(MINUTE_CHART_MAX_PAGES):
|
||||
try:
|
||||
result = await self.get_minute_chart(stock_code, ref_time=ref)
|
||||
except Exception as e:
|
||||
@@ -965,20 +1058,57 @@ class KoreaInvestmentAPI:
|
||||
d = (c.get("stck_bsop_date") or "").strip()
|
||||
if not t:
|
||||
continue
|
||||
# 영업일자가 있으면 당일이 아닌 캔들은 제외 (없으면 필터 불가 → 유지)
|
||||
if d and d != today_str:
|
||||
_is_prev = bool(d) and d != today_str
|
||||
# 영업일자가 있으면 당일이 아닌 캔들은 제외 (없으면 필터 불가 → 유지).
|
||||
# include_prev_day=True 는 워밍업 목적으로 이 제외를 끈다.
|
||||
if _is_prev and not include_prev_day:
|
||||
continue
|
||||
key = f"{d}_{t}"
|
||||
if key not in seen_times:
|
||||
seen_times.add(key)
|
||||
all_candles.append(c)
|
||||
new_added += 1
|
||||
if _is_prev:
|
||||
prev_day_count += 1
|
||||
else:
|
||||
today_count += 1
|
||||
# 5분봉 버킷 집계 — 워밍업 종료 판정의 기준(분 수가 아니라 봉 수).
|
||||
# 거래 없는 분이 응답에서 빠져도 봉 수는 정확히 센다.
|
||||
if len(t) >= 4 and t[:4].isdigit():
|
||||
_bkt = (int(t[:2]) * 60 + int(t[2:4])) // 5
|
||||
(prev_buckets if _is_prev else today_buckets).add((d, _bkt))
|
||||
|
||||
times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")]
|
||||
oldest = min(times) if times else "000000"
|
||||
|
||||
if oldest <= market_open or new_added == 0:
|
||||
break
|
||||
if include_prev_day:
|
||||
# 다음 기준 시각은 **영업일자+시각의 최솟값**으로 잡아야 한다.
|
||||
# 시각만 비교하면 전일 14:51 봉이 당일 09:00 보다 크게 평가돼
|
||||
# ref 가 갱신되지 않고(=같은 페이지 재요청) 한 페이지에서 멈춘다.
|
||||
_keyed = sorted(
|
||||
((c.get("stck_bsop_date") or today_str).strip(),
|
||||
c.get("stck_cntg_hour") or "999999")
|
||||
for c in candles if c.get("stck_cntg_hour")
|
||||
)
|
||||
oldest_date, oldest = _keyed[0] if _keyed else (today_str, "000000")
|
||||
_today_done = oldest_date < today_str or oldest <= market_open
|
||||
# 워밍업 목표 = **부족한 5분봉 개수**. 당일 5분봉이 이미 20개면 0이 되어
|
||||
# 전일 봉을 한 장도 더 받지 않는다(10:40 이후). 09:30 엔 6개뿐이라
|
||||
# 14개만 더 받으면 끝난다. 분 수 상한·전일 페이지 상한은 저유동성 종목에서
|
||||
# 무한 페이징을 막는 안전장치다.
|
||||
_needed_bars = max(0, MINUTE_CHART_WARMUP_BARS_5MIN - len(today_buckets))
|
||||
_warm_enough = (
|
||||
len(prev_buckets) >= _needed_bars
|
||||
or prev_day_count >= prev_day_minutes
|
||||
or prev_pages >= MINUTE_CHART_PREV_DAY_MAX_PAGES
|
||||
)
|
||||
if (_today_done and _warm_enough) or new_added == 0:
|
||||
break
|
||||
if _today_done:
|
||||
# 다음 요청은 전일 구간을 파러 가는 페이지다
|
||||
prev_pages += 1
|
||||
else:
|
||||
times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")]
|
||||
oldest = min(times) if times else "000000"
|
||||
if oldest <= market_open or new_added == 0:
|
||||
break
|
||||
|
||||
ref = oldest
|
||||
await asyncio.sleep(self.min_request_interval + 0.05)
|
||||
@@ -991,6 +1121,26 @@ class KoreaInvestmentAPI:
|
||||
)
|
||||
)
|
||||
|
||||
# 워밍업 미달은 조용히 지나가면 "왜 여전히 발화하지 않는가"를 추적할 수 없다
|
||||
# (지표가 None 이 되어 진입 판정이 그냥 사라진다).
|
||||
# ⚠️ **이 로그가 "KIS 가 미래 ref_time 에 전일 데이터를 주는가"를 확정하는 유일한
|
||||
# 근거다.** 전일 봉이 경계 페이지 1장(약 29봉)에서 멈추면 여기서 드러난다.
|
||||
if include_prev_day:
|
||||
_bars = len(today_buckets) + len(prev_buckets)
|
||||
if _bars < MINUTE_CHART_WARMUP_BARS_5MIN:
|
||||
logger.warning(
|
||||
f"[분봉] ⚠️ {stock_code} 워밍업 미달 — 5분봉 {_bars}/"
|
||||
f"{MINUTE_CHART_WARMUP_BARS_5MIN}봉 "
|
||||
f"(당일 {len(today_buckets)} + 전일 {len(prev_buckets)}, "
|
||||
f"전일 1분봉 {prev_day_count}봉/{prev_pages}페이지). "
|
||||
f"BB(20) 미충족 → RSI_BB 스킵, 15봉 미달이면 VWAP 도 스킵된다"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"[분봉] {stock_code} 워밍업 5분봉 {_bars}봉 "
|
||||
f"(당일 {len(today_buckets)} + 전일 {len(prev_buckets)})"
|
||||
)
|
||||
|
||||
# ── 3. 캐시 저장 ────────────────────────────────────────────────────
|
||||
if all_candles:
|
||||
await _cache_set(cache_key, TTL_MINUTE_FULLDAY, all_candles)
|
||||
|
||||
Reference in New Issue
Block a user