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:
@@ -223,6 +223,27 @@ def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
|
||||
return result
|
||||
|
||||
|
||||
def _last_session_candles(candles: List[MinuteCandle]) -> List[MinuteCandle]:
|
||||
"""마지막 봉과 **같은 영업일자**의 봉만 남긴다 (세션 경계 차단, 순수 함수).
|
||||
|
||||
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있다
|
||||
(`korea_investment_api.get_minute_chart_full_day_v2(include_prev_day=True)`).
|
||||
RSI·BB 처럼 연속 시계열이 필요한 지표는 그대로 써도 되지만, **거래량 비교는
|
||||
같은 세션 안에서만 의미가 있다.** 예컨대 09:30 에 5분봉 `[-3]` 이 전일 마지막
|
||||
5분봉(15:15~15:19, 종가 동시호가 물량)이면 당일 개장봉과의 비교가 등가가 아니고,
|
||||
거래량 게이트가 개장 직후 자동 통과하거나 반대로 영구 탈락한다.
|
||||
|
||||
영업일자 필드가 비어 있는 응답(목/레거시)은 기존 동작을 유지하기 위해
|
||||
그대로 통과시킨다.
|
||||
"""
|
||||
if not candles:
|
||||
return []
|
||||
last_date = getattr(candles[-1], "date", "") or ""
|
||||
if not last_date:
|
||||
return list(candles)
|
||||
return [c for c in candles if (getattr(c, "date", "") or "") == last_date]
|
||||
|
||||
|
||||
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
|
||||
@@ -310,13 +331,22 @@ def check_orb_signal(
|
||||
"""ORB 상향 돌파 매수 신호
|
||||
|
||||
조건:
|
||||
1. 현재가 > ORB 고점 × 1.002 (0.2% 이상 돌파)
|
||||
2. 최근 1분봉 거래량 ≥ ORB 분당평균 × 1.5 (거래량 폭발)
|
||||
1. 현재가 > ORB 고점 × (1 + DAY_TRADE_ORB_BREAKOUT_MARGIN)
|
||||
2. **마지막 완성 1분봉** 거래량 ≥ ORB 분당평균 × DAY_TRADE_ORB_BREAKOUT_VOL_MULT
|
||||
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
|
||||
|
||||
[2026-09-29] 2번은 `candles_1min[-1]`(형성 중 봉)을 쓰고 있었다. 30초 주기 스캔의
|
||||
사이클 대부분에서 마지막 1분봉은 절반만 채워져 있어 **실효 요구가 임계값의 약 2배**가
|
||||
됐다(volume_surge_ratio 의 F7 과 같은 클래스의 버그). 비교 대상만 마지막 **완성봉**
|
||||
으로 고치고 임계값은 건드리지 않는다. `orb.avg_vol_per_min` 이 당일 기준이므로
|
||||
분자도 당일이어야 한다 — `_last_session_candles()` 로 세션을 고정한다.
|
||||
"""
|
||||
if not orb or current_price <= 0:
|
||||
return None
|
||||
|
||||
# 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다
|
||||
candles_1min = _last_session_candles(candles_1min)
|
||||
|
||||
# M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부
|
||||
# prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용
|
||||
# orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주
|
||||
@@ -336,10 +366,13 @@ def check_orb_signal(
|
||||
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
|
||||
return None
|
||||
|
||||
# 최근 1분봉 거래량 체크
|
||||
if not candles_1min:
|
||||
# 거래량 체크 — 마지막 **완성** 1분봉([-2]) 기준. 형성 중 봉([-1])은 부분 거래량이라
|
||||
# 임계값을 실질적으로 부풀린다. 완성봉이 없으면(당일 봉 2개 미만) 게이트 미통과.
|
||||
if len(candles_1min) < 2:
|
||||
return None
|
||||
recent_vol = candles_1min[-2].volume
|
||||
if orb.avg_vol_per_min <= 0:
|
||||
return None
|
||||
recent_vol = candles_1min[-1].volume
|
||||
if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT:
|
||||
return None
|
||||
|
||||
@@ -379,11 +412,44 @@ def check_orb_signal(
|
||||
reason=(
|
||||
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
|
||||
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
|
||||
f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)"
|
||||
),
|
||||
confidence=round(confidence, 2),
|
||||
)
|
||||
|
||||
|
||||
def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]:
|
||||
"""직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값.
|
||||
|
||||
[F7 2026-09-28] 기존 게이트는 ``candles_5min[-1].volume >= candles_5min[-2].volume
|
||||
* DAY_TRADE_VOLUME_MULTIPLIER`` 였다. 그런데 ``aggregate_to_5min`` 은 **형성 중인
|
||||
5분 버킷도 하나의 봉으로 만든다.** 즉 좌변은 "아직 채워지는 중인 봉의 부분
|
||||
거래량"이고 우변은 "완성된 봉의 전체 거래량"이라, 실제로 거래량이 1.3배로 늘어난
|
||||
구간에서도 **버킷의 마지막 1분 남짓에만** 조건이 성립했다. 30초 주기 스캔에서
|
||||
5분 버킷은 약 10사이클에 걸치므로 ~80% 사이클이 구조적으로 전멸했다.
|
||||
|
||||
비교 대상만 고친다(임계값 1.3 은 그대로): 마지막 **완성봉**([-2])을 그 직전
|
||||
완성봉([-3])과 비교한다. 두 항이 모두 전체 5분치라 비교가 등가이고, 벽시계에
|
||||
의존하지 않아 순수 함수로 남는다.
|
||||
|
||||
[2026-09-29] 진입 경로의 시계열에 전일 워밍업 봉이 포함되므로, 비교는
|
||||
`_last_session_candles()` 로 **마지막 봉과 같은 세션**에 한정한다. 세션을 넘겨
|
||||
비교하면 전일 종가 동시호가 물량과 당일 개장봉을 견주게 되어 게이트가 무의미해진다.
|
||||
|
||||
Returns:
|
||||
float | None: 배율. **당일** 완성봉이 2개 미만이거나 분모가 0이면 None
|
||||
(= 게이트 미통과. 데이터 부족은 통과 쪽으로 열지 않는다)
|
||||
"""
|
||||
session = _last_session_candles(candles_5min)
|
||||
if len(session) < 3:
|
||||
return None
|
||||
prev_completed = session[-3].volume
|
||||
last_completed = session[-2].volume
|
||||
if prev_completed <= 0:
|
||||
return None
|
||||
return last_completed / prev_completed
|
||||
|
||||
|
||||
def check_vwap_bounce_signal(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
@@ -396,7 +462,7 @@ def check_vwap_bounce_signal(
|
||||
조건:
|
||||
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
|
||||
2. 5분봉 RSI(14) ≤ threshold
|
||||
3. 최근 봉 거래량 ≥ 직전 봉 × vol_mult
|
||||
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
|
||||
4. 최근 5분봉 양봉 전환 (close > open)
|
||||
"""
|
||||
if not candles_5min or vwap <= 0 or current_price <= 0:
|
||||
@@ -409,9 +475,9 @@ def check_vwap_bounce_signal(
|
||||
if rsi is None or rsi > settings.DAY_TRADE_VWAP_RSI_THRESHOLD:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||||
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
|
||||
vol_ratio = volume_surge_ratio(candles_5min)
|
||||
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||||
return None
|
||||
|
||||
# 최근 봉 양봉 확인
|
||||
@@ -435,8 +501,9 @@ def check_vwap_bounce_signal(
|
||||
stop_loss=round(stop_loss, 0),
|
||||
target_price=round(target, 0),
|
||||
reason=(
|
||||
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}, "
|
||||
f"현재={current_price:.0f}"
|
||||
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}(≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}), "
|
||||
f"현재={current_price:.0f}, 거래량={vol_ratio:.2f}배"
|
||||
f"(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
|
||||
),
|
||||
confidence=0.65,
|
||||
)
|
||||
@@ -453,7 +520,7 @@ def check_rsi_bb_signal(
|
||||
조건:
|
||||
1. RSI(14) ≤ threshold (강한 과매도)
|
||||
2. 종가 ≤ BB 하단 + buffer
|
||||
3. 직전 봉 대비 거래량 ≥ vol_mult배
|
||||
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
|
||||
4. 최근 봉 양봉 (음봉이면 스킵)
|
||||
"""
|
||||
if not candles_5min or current_price <= 0:
|
||||
@@ -471,9 +538,9 @@ def check_rsi_bb_signal(
|
||||
if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||||
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
|
||||
vol_ratio = volume_surge_ratio(candles_5min)
|
||||
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
|
||||
return None
|
||||
|
||||
last = candles_5min[-1]
|
||||
@@ -500,8 +567,9 @@ def check_rsi_bb_signal(
|
||||
stop_loss=round(stop_loss, 0),
|
||||
target_price=round(target, 0),
|
||||
reason=(
|
||||
f"5분봉 과매도 반등: RSI={rsi:.1f}, "
|
||||
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}"
|
||||
f"5분봉 과매도 반등: RSI={rsi:.1f}(≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}), "
|
||||
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}, "
|
||||
f"거래량={vol_ratio:.2f}배(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
|
||||
),
|
||||
confidence=0.60,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(시초가 레인지) 데이터 저장.
|
||||
|
||||
|
||||
@@ -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(), "진입 스캔")
|
||||
|
||||
@@ -30,6 +30,7 @@ Supported task types:
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -116,6 +117,163 @@ def _apply_strategy_name_filter(query, data: dict, column):
|
||||
DLQ_DIR = Path.home() / ".cache" / "auto-stock" / "dlq"
|
||||
|
||||
|
||||
# 종료 drain 시간 예산(초). 초과분은 커밋을 포기하고 DLQ 로 보관한다.
|
||||
DRAIN_DEADLINE_SECONDS = 20
|
||||
# DLQ 재처리 완료 파일 이동 위치 — 재처리 무한루프 방지
|
||||
DLQ_PROCESSED_DIR = DLQ_DIR / "processed"
|
||||
# 한 번의 기동에서 재큐잉할 최대 DLQ 파일 수 (큐 폭주 방지)
|
||||
DLQ_REPLAY_MAX_FILES = 500
|
||||
|
||||
|
||||
# [M5 2026-09-28] DLQ 재처리 시 타입 복원.
|
||||
# _save_to_dlq 는 json.dumps(..., default=str) 를 쓰므로 Decimal → "1234.5",
|
||||
# datetime/date → ISO 문자열로 **열화**된다. 그 dict 를 그대로 큐에 되돌리면
|
||||
# `TradeHistory(**data)` 같은 ORM 대입에서 asyncpg 가 DateTime 컬럼의 str 을 거부해
|
||||
# 3회 재시도 후 **새 DLQ 파일로 다시 쌓인다**(기동마다 증식). 재큐잉 전에 모델 컬럼
|
||||
# 타입을 보고 되돌린다.
|
||||
_DLQ_TASK_MODELS: dict[str, str] = {
|
||||
"insert_trade_history": "TradeHistory",
|
||||
"update_trade_history": "TradeHistory",
|
||||
"update_trade_history_by_order_id": "TradeHistory",
|
||||
"upsert_portfolio": "Portfolio",
|
||||
"upsert_active_trade": "ActiveTrade",
|
||||
"close_active_trade": "ActiveTrade",
|
||||
"upsert_stock_analysis": "StockAnalysis",
|
||||
"bulk_upsert_stock_analysis": "StockAnalysis",
|
||||
"insert_trade_journal": "TradeJournal",
|
||||
"upsert_stock_ban": "StockBan",
|
||||
"upsert_stock_ban_v2": "StockBanV2",
|
||||
"insert_news": "News",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_value_for_column(value, col_type):
|
||||
"""컬럼 타입에 맞춰 문자열을 원래 타입으로 되돌린다. 실패 시 원본 그대로."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
try:
|
||||
from sqlalchemy import DateTime, Date, Numeric, Float, Integer, Boolean
|
||||
if isinstance(col_type, DateTime):
|
||||
return datetime.fromisoformat(value)
|
||||
if isinstance(col_type, Date):
|
||||
return date.fromisoformat(value)
|
||||
if isinstance(col_type, Numeric) and not isinstance(col_type, Float):
|
||||
from decimal import Decimal
|
||||
return Decimal(value)
|
||||
if isinstance(col_type, Float):
|
||||
return float(value)
|
||||
if isinstance(col_type, Integer):
|
||||
return int(value)
|
||||
if isinstance(col_type, Boolean):
|
||||
return value.lower() in ("true", "1", "yes")
|
||||
except (ValueError, TypeError, ArithmeticError):
|
||||
# 되돌리지 못하면 원본을 유지 — 핸들러가 판단하게 한다(조용히 버리지 않는다)
|
||||
logger.warning("[DLQ] 타입 복원 실패 — 원본 유지: %r (%s)", value, col_type)
|
||||
return value
|
||||
|
||||
|
||||
def _restore_dlq_task_types(task: dict) -> dict:
|
||||
"""[M5] DLQ 파일에서 읽은 task 의 payload 타입을 모델 스키마 기준으로 복원."""
|
||||
model_name = _DLQ_TASK_MODELS.get(task.get("type", ""))
|
||||
if not model_name:
|
||||
return task
|
||||
try:
|
||||
from app.models import models as _m
|
||||
model = getattr(_m, model_name, None)
|
||||
if model is None:
|
||||
return task
|
||||
cols = {c.key: c.type for c in model.__table__.columns}
|
||||
except Exception as e:
|
||||
logger.warning("[DLQ] 모델 스키마 조회 실패 (%s): %s", model_name, e)
|
||||
return task
|
||||
|
||||
def _fix(d):
|
||||
if not isinstance(d, dict):
|
||||
return d
|
||||
return {
|
||||
k: (_coerce_value_for_column(v, cols[k]) if k in cols else v)
|
||||
for k, v in d.items()
|
||||
}
|
||||
|
||||
data = task.get("data")
|
||||
if isinstance(data, list):
|
||||
task["data"] = [_fix(row) for row in data]
|
||||
elif isinstance(data, dict):
|
||||
task["data"] = _fix(data)
|
||||
# update 계열은 갱신 필드를 별도 키에 담는다
|
||||
if isinstance(task.get("updates"), dict):
|
||||
task["updates"] = _fix(task["updates"])
|
||||
return task
|
||||
|
||||
|
||||
async def replay_dlq() -> int:
|
||||
"""기동 시 DLQ 파일을 읽어 db_task_queue 에 재큐잉. 재큐잉한 건수 반환.
|
||||
|
||||
[F4 2026-09-28] DLQ 는 지금까지 **쓰기 전용**이었다(읽는 코드 0건). 3회 재시도
|
||||
실패분과 종료 drain 미커밋분이 파일로만 남아 유실이 영구화되고, 복구 수단은
|
||||
수동 psql 뿐이었다.
|
||||
|
||||
CLAUDE.md §3.2 에 따라 **직접 DB 쓰기를 하지 않고 반드시 큐를 경유**한다.
|
||||
파일은 재큐잉 **전에** ``processed/`` 로 옮긴다 — 옮긴 뒤 큐 투입이 실패해도
|
||||
파일은 보존되고(수동 복구 가능), 다음 기동에서 같은 태스크를 두 번 큐에 넣지
|
||||
않는다(insert_trade_history 는 멱등이 아니므로 중복이 더 위험하다).
|
||||
|
||||
손상된 JSON 은 건너뛰고 warning 을 남긴다(파일은 그대로 두어 사람이 본다).
|
||||
"""
|
||||
def _list_files() -> list:
|
||||
if not DLQ_DIR.exists():
|
||||
return []
|
||||
return sorted(f for f in DLQ_DIR.glob("*.json") if f.is_file())
|
||||
|
||||
files = await asyncio.to_thread(_list_files)
|
||||
if not files:
|
||||
return 0
|
||||
|
||||
if len(files) > DLQ_REPLAY_MAX_FILES:
|
||||
logger.error(
|
||||
"❌ [DLQ] 파일 %d건이 상한 %d건을 초과 — 앞의 %d건만 재처리하고 나머지는 남긴다",
|
||||
len(files), DLQ_REPLAY_MAX_FILES, DLQ_REPLAY_MAX_FILES,
|
||||
)
|
||||
files = files[:DLQ_REPLAY_MAX_FILES]
|
||||
|
||||
logger.warning("[DLQ] 재처리 시작: %d건", len(files))
|
||||
requeued = 0
|
||||
skipped = 0
|
||||
for f in files:
|
||||
try:
|
||||
raw = await asyncio.to_thread(f.read_text, encoding="utf-8")
|
||||
entry = json.loads(raw)
|
||||
task = entry.get("task") if isinstance(entry, dict) else None
|
||||
if not isinstance(task, dict) or not task.get("type"):
|
||||
logger.warning("[DLQ] 건너뜀(형식 불량): %s", f.name)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
def _move(path=f):
|
||||
DLQ_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.rename(DLQ_PROCESSED_DIR / path.name)
|
||||
|
||||
await asyncio.to_thread(_move)
|
||||
# 재시도 카운터를 초기화해 재큐잉분도 3회 재시도를 받게 한다
|
||||
task.pop("_retry_count", None)
|
||||
# [M5] default=str 로 열화된 Decimal/datetime/date 를 되돌린다
|
||||
task = _restore_dlq_task_types(task)
|
||||
await enqueue_db_write(task)
|
||||
requeued += 1
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("[DLQ] 건너뜀(JSON 손상): %s — %s", f.name, e)
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
logger.error("❌ [DLQ] 재처리 실패: %s — %s", f.name, e, exc_info=True)
|
||||
skipped += 1
|
||||
|
||||
logger.warning(
|
||||
"[DLQ] 재처리 완료: 재큐잉 %d건 / 건너뜀 %d건 (처리분은 %s 로 이동)",
|
||||
requeued, skipped, DLQ_PROCESSED_DIR,
|
||||
)
|
||||
return requeued
|
||||
|
||||
|
||||
async def _save_to_dlq(task: dict, error: str) -> None:
|
||||
"""3회 재시도 실패한 task를 DLQ 파일로 저장.
|
||||
|
||||
@@ -381,6 +539,167 @@ async def enqueue_trade_journal(trade, content: str) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 포지션 추적 계열 핸들러 (공통 종단)
|
||||
#
|
||||
# [F3 2026-09-28] 종료 drain 이 insert_trade_history 만 커밋하고
|
||||
# upsert_portfolio / upsert_active_trade / close_active_trade 는 DLQ 로 보냈다
|
||||
# → "체결 기록은 남는데 포지션 추적 행은 사라지는" 비대칭이 설계에 내장돼 있었다
|
||||
# (재기동 시 restore_from_db 가 복원할 행이 없어 포지션이 고아가 된다).
|
||||
# 정상 경로와 drain 이 **같은 구현**을 쓰도록 여기로 추출한다. 두 곳에 복제하면
|
||||
# 한쪽만 바뀌어 더 나쁜 불일치가 된다(CLAUDE.md §3.3 과 동일한 취지).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_upsert_portfolio(session, data: dict) -> None:
|
||||
"""Portfolio UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import Portfolio
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
user_id = data.get("user_id")
|
||||
query = select(Portfolio).where(Portfolio.stock_code == stock_code)
|
||||
if user_id is not None:
|
||||
query = query.where(Portfolio.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
record = Portfolio(**data)
|
||||
session.add(record)
|
||||
else:
|
||||
for key, val in data.items():
|
||||
if key != "stock_code" and val is not None:
|
||||
setattr(record, key, val)
|
||||
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] Portfolio upsert: %s (strategy=%s)",
|
||||
stock_code,
|
||||
data.get("strategy_type", "swing"),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_upsert_active_trade(session, data: dict) -> None:
|
||||
"""ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import ActiveTrade
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
strategy_name = data.get("strategy_name")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
query = select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
# [§8.5 항목6] strategy_name(동등) / strategy_names(IN) /
|
||||
# strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경.
|
||||
query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name)
|
||||
if user_id is not None:
|
||||
query = query.where(ActiveTrade.user_id == user_id)
|
||||
# [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선.
|
||||
# scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을
|
||||
# 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다.
|
||||
# (멀티테넌트 플래그 ON 전제조건)
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
else:
|
||||
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
|
||||
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
|
||||
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
|
||||
if record is None:
|
||||
# remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시
|
||||
create_data = {
|
||||
k: v for k, v in data.items()
|
||||
if k not in _ACTIVE_TRADE_META_KEYS
|
||||
}
|
||||
# 필수 컬럼(quantity/entry_price) 없이 신규 생성 요청이 오면
|
||||
# IntegrityError로 3회 재시도만 낭비 → 경고 후 스킵
|
||||
_missing = [k for k in ("quantity", "entry_price") if create_data.get(k) is None]
|
||||
if _missing:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] ActiveTrade upsert 스킵: %s active 행 없음 + 필수 키 누락 %s (keys=%s)",
|
||||
stock_code, _missing, sorted(create_data.keys()),
|
||||
)
|
||||
return
|
||||
record = ActiveTrade(**create_data)
|
||||
session.add(record)
|
||||
else:
|
||||
# remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함
|
||||
# (MDD 축소 시 기존 분할매도 이력 반영)
|
||||
# data.get() 사용 — pop()으로 task dict를 파괴적으로 변경하면
|
||||
# 재시도 시 이 키가 사라져 no-op으로 성공 처리되는 버그 방지
|
||||
multiply_ratio = data.get("remaining_pct_multiply")
|
||||
if multiply_ratio is not None:
|
||||
current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0
|
||||
record.remaining_pct = float(current_remaining) * float(multiply_ratio)
|
||||
for key, val in data.items():
|
||||
# [C1 2026-09-28] 반드시 continue — return 이면 'stock_code' 가 모든 payload 의
|
||||
# 첫 키라서 기존 active 행에 대한 모든 upsert 가 첫 이터레이션에서 빠져나가
|
||||
# commit() 에 도달하지 못한다(예외가 없어 성공 처리되고 DLQ 에도 안 남는다).
|
||||
# 파급: 분할매도 잔량 갱신·remaining_pct_multiply·highest_since_entry 트레일링
|
||||
# 갱신이 전부 무효 → 2026-09-23 '4주 보유 중 7주 매도' 재발 경로.
|
||||
if key == "stock_code" or key in _ACTIVE_TRADE_META_KEYS:
|
||||
continue
|
||||
if val is not None:
|
||||
setattr(record, key, val)
|
||||
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] ActiveTrade upsert: %s (strategy=%s)",
|
||||
stock_code,
|
||||
strategy_name,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_close_active_trade(session, data: dict) -> None:
|
||||
"""ActiveTrade close — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import ActiveTrade
|
||||
from datetime import datetime as _dt
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
strategy_name = data.get("strategy_name")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
query = select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
# [§8.5 항목6] strategy_name(동등) / strategy_names(IN) /
|
||||
# strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경.
|
||||
query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name)
|
||||
if user_id is not None:
|
||||
query = query.where(ActiveTrade.user_id == user_id)
|
||||
# [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선.
|
||||
# scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을
|
||||
# 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다.
|
||||
# (멀티테넌트 플래그 ON 전제조건)
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
else:
|
||||
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
|
||||
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
|
||||
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
|
||||
if record:
|
||||
record.status = "closed"
|
||||
record.updated_at = now_kst()
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] ActiveTrade closed: %s (strategy=%s)",
|
||||
stock_code,
|
||||
strategy_name,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 워커 본체
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -579,144 +898,15 @@ async def db_writer_worker() -> None:
|
||||
|
||||
# ── 6. Portfolio UPSERT ────────────────────────────────────────
|
||||
elif task_type == "upsert_portfolio":
|
||||
from app.models.models import Portfolio
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
user_id = data.get("user_id")
|
||||
query = select(Portfolio).where(Portfolio.stock_code == stock_code)
|
||||
if user_id is not None:
|
||||
query = query.where(Portfolio.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
record = Portfolio(**data)
|
||||
session.add(record)
|
||||
else:
|
||||
for key, val in data.items():
|
||||
if key != "stock_code" and val is not None:
|
||||
setattr(record, key, val)
|
||||
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] Portfolio upsert: %s (strategy=%s)",
|
||||
stock_code,
|
||||
data.get("strategy_type", "swing"),
|
||||
)
|
||||
await _handle_upsert_portfolio(session, data)
|
||||
|
||||
# ── 7. ActiveTrade UPSERT ──────────────────────────────────────
|
||||
elif task_type == "upsert_active_trade":
|
||||
from app.models.models import ActiveTrade
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
strategy_name = data.get("strategy_name")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
query = select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
# [§8.5 항목6] strategy_name(동등) / strategy_names(IN) /
|
||||
# strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경.
|
||||
query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name)
|
||||
if user_id is not None:
|
||||
query = query.where(ActiveTrade.user_id == user_id)
|
||||
# [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선.
|
||||
# scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을
|
||||
# 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다.
|
||||
# (멀티테넌트 플래그 ON 전제조건)
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
else:
|
||||
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
|
||||
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
|
||||
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
|
||||
if record is None:
|
||||
# remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시
|
||||
create_data = {
|
||||
k: v for k, v in data.items()
|
||||
if k not in _ACTIVE_TRADE_META_KEYS
|
||||
}
|
||||
# 필수 컬럼(quantity/entry_price) 없이 신규 생성 요청이 오면
|
||||
# IntegrityError로 3회 재시도만 낭비 → 경고 후 스킵
|
||||
_missing = [k for k in ("quantity", "entry_price") if create_data.get(k) is None]
|
||||
if _missing:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] ActiveTrade upsert 스킵: %s active 행 없음 + 필수 키 누락 %s (keys=%s)",
|
||||
stock_code, _missing, sorted(create_data.keys()),
|
||||
)
|
||||
continue
|
||||
record = ActiveTrade(**create_data)
|
||||
session.add(record)
|
||||
else:
|
||||
# remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함
|
||||
# (MDD 축소 시 기존 분할매도 이력 반영)
|
||||
# data.get() 사용 — pop()으로 task dict를 파괴적으로 변경하면
|
||||
# 재시도 시 이 키가 사라져 no-op으로 성공 처리되는 버그 방지
|
||||
multiply_ratio = data.get("remaining_pct_multiply")
|
||||
if multiply_ratio is not None:
|
||||
current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0
|
||||
record.remaining_pct = float(current_remaining) * float(multiply_ratio)
|
||||
for key, val in data.items():
|
||||
if key == "stock_code" or key in _ACTIVE_TRADE_META_KEYS:
|
||||
continue
|
||||
if val is not None:
|
||||
setattr(record, key, val)
|
||||
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] ActiveTrade upsert: %s (strategy=%s)",
|
||||
stock_code,
|
||||
strategy_name,
|
||||
)
|
||||
await _handle_upsert_active_trade(session, data)
|
||||
|
||||
# ── 8. ActiveTrade close ───────────────────────────────────────
|
||||
elif task_type == "close_active_trade":
|
||||
from app.models.models import ActiveTrade
|
||||
from datetime import datetime as _dt
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
strategy_name = data.get("strategy_name")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
query = select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
# [§8.5 항목6] strategy_name(동등) / strategy_names(IN) /
|
||||
# strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경.
|
||||
query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name)
|
||||
if user_id is not None:
|
||||
query = query.where(ActiveTrade.user_id == user_id)
|
||||
# [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선.
|
||||
# scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을
|
||||
# 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다.
|
||||
# (멀티테넌트 플래그 ON 전제조건)
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
else:
|
||||
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
|
||||
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
|
||||
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
|
||||
if record:
|
||||
record.status = "closed"
|
||||
record.updated_at = now_kst()
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] ActiveTrade closed: %s (strategy=%s)",
|
||||
stock_code,
|
||||
strategy_name,
|
||||
)
|
||||
await _handle_close_active_trade(session, data)
|
||||
|
||||
# ── 9. TradeJournal INSERT ────────────────────────────────────
|
||||
elif task_type == "insert_trade_journal":
|
||||
@@ -735,6 +925,7 @@ async def db_writer_worker() -> None:
|
||||
except asyncio.CancelledError:
|
||||
# 큐 drain: 종료 전 남은 태스크 처리하여 데이터 손실 방지
|
||||
remaining = 0
|
||||
_drain_started = time.monotonic()
|
||||
while not queue.empty():
|
||||
try:
|
||||
drain_task = queue.get_nowait()
|
||||
@@ -744,10 +935,35 @@ async def db_writer_worker() -> None:
|
||||
try:
|
||||
drain_type = drain_task.get("type", "unknown")
|
||||
drain_data = drain_task.get("data")
|
||||
# [F3 2026-09-28] 시간 예산 초과분은 커밋을 시도하지 않고 DLQ 로 보낸다.
|
||||
# 종료가 무한정 늘어지면 컨테이너가 SIGKILL 을 받아 전량 유실된다.
|
||||
if (time.monotonic() - _drain_started) > DRAIN_DEADLINE_SECONDS:
|
||||
logger.error(
|
||||
"❌ [DBWorker] Drain 시간 예산 %.0f초 초과 — DLQ 로 보관 (type=%s)",
|
||||
DRAIN_DEADLINE_SECONDS, drain_type,
|
||||
)
|
||||
await _save_to_dlq(drain_task, "shutdown_drain_deadline_exceeded")
|
||||
remaining += 1
|
||||
continue
|
||||
async with AsyncSessionLocal() as drain_session:
|
||||
if drain_type == "insert_trade_history":
|
||||
drain_session.add(TradeHistory(**drain_data))
|
||||
await drain_session.commit()
|
||||
# [F3 2026-09-28] 포지션 추적 계열도 커밋한다.
|
||||
# 이전에는 insert_trade_history 만 커밋하고 아래 3종을 DLQ 로
|
||||
# 보내서 "체결 기록은 남는데 포지션 추적 행은 사라지는" 비대칭이
|
||||
# 있었다 → 재기동 시 restore_from_db 가 복원할 행이 없어
|
||||
# 포지션이 고아가 되고 손절·EOD 청산이 적용되지 않았다.
|
||||
elif drain_type == "upsert_portfolio":
|
||||
await _handle_upsert_portfolio(drain_session, drain_data)
|
||||
elif drain_type == "upsert_active_trade":
|
||||
await _handle_upsert_active_trade(drain_session, drain_data)
|
||||
elif drain_type == "close_active_trade":
|
||||
await _handle_close_active_trade(drain_session, drain_data)
|
||||
elif drain_type == "insert_trade_journal":
|
||||
from app.models.models import TradeJournal
|
||||
drain_session.add(TradeJournal(**drain_data))
|
||||
await drain_session.commit()
|
||||
elif drain_type in (
|
||||
"update_trade_history",
|
||||
"update_trade_history_by_order_id",
|
||||
@@ -755,15 +971,18 @@ async def db_writer_worker() -> None:
|
||||
"bulk_upsert_stock_analysis",
|
||||
"upsert_stock_ban",
|
||||
"upsert_stock_ban_v2",
|
||||
"upsert_portfolio",
|
||||
"upsert_active_trade",
|
||||
"close_active_trade",
|
||||
"insert_trade_journal",
|
||||
):
|
||||
# 단순 재큐잉 대신, 중요 태스크만 커밋 시도
|
||||
# insert_trade_history 외에는 복잡한 로직이므로 DLQ 저장
|
||||
# 분석·밴 계열은 다음 배치/기동 시 재생성되므로 DLQ 보관.
|
||||
# F4 의 replay_dlq() 가 기동 시 재큐잉한다.
|
||||
logger.error(
|
||||
"❌ [DBWorker] Drain 미커밋 → DLQ (type=%s) — 기동 시 replay_dlq() 로 재처리",
|
||||
drain_type,
|
||||
)
|
||||
await _save_to_dlq(drain_task, "shutdown_drain")
|
||||
else:
|
||||
logger.error(
|
||||
"❌ [DBWorker] Drain 미지원 type → DLQ (type=%s)", drain_type,
|
||||
)
|
||||
await _save_to_dlq(drain_task, "shutdown_drain_unknown_type")
|
||||
remaining += 1
|
||||
except Exception as drain_err:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -859,6 +859,7 @@ async def _reflect_reduction(
|
||||
# 예) 잔고 quantity=10, 인메모리 dt_qty=15, 매도 3주 → 잔고 7주인데 인메모리 12주
|
||||
# → 다음 DT 청산이 12주 매도를 시도해 과다매도/주문거부.
|
||||
# 따라서 (1) DT 우선 차감, (2) 실제 잔고 잔량으로 상한 클램프, (3) 음수 금지.
|
||||
_dt_daily_state_dirty = False
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
async with day_trading_state.trade_lock:
|
||||
@@ -884,6 +885,8 @@ async def _reflect_reduction(
|
||||
_left = min(_left, remaining_qty)
|
||||
if _left <= 0:
|
||||
day_trading_state.remove_position(stock_code)
|
||||
# remove_position() 이 closed_today 에 추가한다 → 영속화 필요
|
||||
_dt_daily_state_dirty = True
|
||||
logger.warning(
|
||||
f"[MDD] DT 포지션 전량 축소: {stock_code} "
|
||||
f"(인메모리 {_dt_now}주 → 0, 매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)"
|
||||
@@ -896,3 +899,18 @@ async def _reflect_reduction(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}")
|
||||
|
||||
# [F9 2026-09-28] MDD 축소가 DT 포지션을 닫으면 closed_today 가 바뀌므로 영속화한다.
|
||||
# 이 경로에 저장이 없으면 축소 직후 재기동 시 당일 재진입 차단이 풀려, MDD -10%/-20%
|
||||
# 구간에서 방금 강제 축소한 종목을 그날 다시 매수할 수 있다.
|
||||
# - trade_lock **해제 후** 호출 — Redis I/O 로 락을 잡지 않는다(§3.1 실시간 계층 보호)
|
||||
# - 저장 실패가 축소 자체를 실패시키지 않도록 예외를 삼키되 warning 으로 남긴다
|
||||
if _dt_daily_state_dirty:
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts_save
|
||||
await _dts_save.save_daily_state()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[MDD] DT 일일 안전장치 저장 실패 ({stock_code}): {e} — 재기동 시 "
|
||||
f"당일 재진입 차단이 풀릴 수 있다"
|
||||
)
|
||||
|
||||
@@ -63,6 +63,21 @@ async def reflect_buy(
|
||||
# 스윙 8주 보유 중 DT 4주를 사면 행(12주)이 daytrading 이 되어
|
||||
# get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다.
|
||||
# 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다.
|
||||
#
|
||||
# [2026-09-28] 예외 하나: 유입 격리('external') 행에 **스윙** 시스템 매수가
|
||||
# 들어오면 swing 으로 승격한다. 승격하지 않으면 새로 산 스윙 수량이
|
||||
# get_swing_holdings 에서 빠져 다음 sync 까지 손절·익절이 붙지 않는다.
|
||||
# DT 매수는 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고,
|
||||
# 'daytrading' 으로 바꾸면 격리 수량이 external 표시에서 사라진다.
|
||||
if (existing.strategy_type or "swing") == "external" and strategy_type == "swing":
|
||||
existing.strategy_type = "swing"
|
||||
_promote_to_swing = True
|
||||
logger.warning(
|
||||
f"[cache_bridge] ⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
|
||||
f"external → swing (스윙 시스템 매수 {quantity}주)"
|
||||
)
|
||||
else:
|
||||
_promote_to_swing = False
|
||||
avg_for_db, qty_for_db = new_avg, new_qty
|
||||
else:
|
||||
cache.holdings.append(Portfolio(
|
||||
@@ -85,6 +100,9 @@ async def reflect_buy(
|
||||
if existing is None:
|
||||
# 신규 행일 때만 전략 태깅 (기존 행은 다른 전략 보유분이 섞여 있을 수 있음)
|
||||
_upsert["strategy_type"] = strategy_type
|
||||
elif _promote_to_swing:
|
||||
# external → swing 승격만 DB 에도 반영 (강등·DT 전환은 하지 않는다)
|
||||
_upsert["strategy_type"] = "swing"
|
||||
await enqueue_portfolio_upsert(_upsert)
|
||||
await cache.apply_buy(stock_code, stock_name, quantity, price)
|
||||
except Exception as e:
|
||||
|
||||
@@ -44,6 +44,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드
|
||||
_recent_exits: dict[str, float] = {}
|
||||
# [F5 2026-09-28] "스윙 보유 종목 없음" 연속 횟수 — 조용한 무동작을 드러내기 위한 카운터.
|
||||
# _EMPTY_HOLDINGS_WARN_AFTER 회 이상 연속되면 info → warning 으로 승격한다.
|
||||
_empty_holdings_streak: int = 0
|
||||
_EMPTY_HOLDINGS_WARN_AFTER: int = 10
|
||||
EXIT_COOLDOWN_SEC = 180
|
||||
|
||||
|
||||
@@ -161,6 +165,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
Returns:
|
||||
list[dict]: 청산 실행 결과 목록
|
||||
"""
|
||||
global _empty_holdings_streak
|
||||
executed_exits = []
|
||||
|
||||
try:
|
||||
@@ -168,9 +173,24 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
_now_ts = time.time()
|
||||
holdings = await get_swing_holdings(db)
|
||||
if not holdings:
|
||||
logger.debug("[SwingExit] 스윙 보유 종목 없음")
|
||||
# [F5 2026-09-28] 이 로그가 debug 였기 때문에 2026-05-15~09-21 4개월간
|
||||
# 청산 판정이 사실상 멈춰 있던 것이 운영 로그에서 드러나지 않았다.
|
||||
# info 로 올리고, 연속으로 계속되면 warning 으로 승격해 눈에 띄게 한다.
|
||||
_empty_holdings_streak += 1
|
||||
if _empty_holdings_streak >= _EMPTY_HOLDINGS_WARN_AFTER:
|
||||
logger.warning(
|
||||
f"[SwingExit] 스윙 보유 종목 없음 — {_empty_holdings_streak}회 연속 "
|
||||
f"(청산 판정이 계속 no-op 이다: 실제 무보유인지, "
|
||||
f"Portfolio.strategy_type 불일치인지 확인 필요)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"[SwingExit] 스윙 보유 종목 없음 ({_empty_holdings_streak}회 연속)"
|
||||
)
|
||||
return executed_exits
|
||||
|
||||
_empty_holdings_streak = 0
|
||||
|
||||
# 시장 국면 (동적 계산 — 기존 코드의 하드코딩 BULL 개선)
|
||||
try:
|
||||
regime_dict = await check_market_regime()
|
||||
@@ -486,6 +506,27 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
)
|
||||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||||
|
||||
# [2026-09-28] 전략 간 당일 쿨다운 — 스윙이 손절한 종목을 같은 날
|
||||
# 데이트레이딩이 다시 사는 것을 막는다(09-04 009420 -14% 손절 직후
|
||||
# DT 워치리스트 잔류 확인). 조회는 daytrading/entry_service 의
|
||||
# is_closed_today() 경로를 그대로 쓴다.
|
||||
try:
|
||||
from app.services.daytrading.position_manager import (
|
||||
day_trading_state as _dts_cd,
|
||||
)
|
||||
# closed_today 변경은 trade_lock 보유가 규약(position_manager 참조)
|
||||
async with _dts_cd.trade_lock:
|
||||
_dts_cd.closed_today.add(stock_code)
|
||||
# [M4 2026-09-28 / F9] 영속화 — 이 경로만 저장이 빠져 있어
|
||||
# 재기동하면 "스윙이 손절한 종목을 같은 날 DT 가 재매수"가
|
||||
# 되살아났다(전략 간 당일 쿨다운이 무효화). 락 해제 후 호출한다
|
||||
# (Redis I/O 로 trade_lock 을 잡지 않는다).
|
||||
await _dts_cd.save_daily_state()
|
||||
except Exception as _cd_err:
|
||||
logger.warning(
|
||||
f"[SwingExit] DT 당일 재진입 차단 등록 실패 ({stock_code}): {_cd_err}"
|
||||
)
|
||||
|
||||
executed_exits.append({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
|
||||
Reference in New Issue
Block a user