fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전

사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
  자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
  system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
  ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
    _delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
  _sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
  == 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
  승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
  직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
  PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
  (build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
  SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다

## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
  (120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
  타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가

## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
  오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
  클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
  → 진입 ATR == 청산 트레일링 ATR 보장
  ※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
  R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일

## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
  충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
  격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다

## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
  + 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)

테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:50:54 +09:00
parent b5e0d6a9bb
commit 9de0715165
19 changed files with 4519 additions and 126 deletions

View File

@@ -310,17 +310,91 @@ def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임.
알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder ATR 아님.
⚠️ **세션 경계를 먼저 끊는다** (`_last_session_candles`) — 2026-09-30 수정.
진입 판정 경로의 시계열에는 지표 워밍업용 **전일 봉**이 섞여 있다
(`get_minute_chart_full_day_v2(include_prev_day=True)`). TR 은
`max(H-L, |H-C_prev|, |L-C_prev|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**이고,
`atr_sma` 가 마지막 `period` 개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기
전(대략 10:10 이전)에는 갭 TR + 전일 오후 TR 이 윈도에 남는다. 그 구간이 바로
DT 진입이 가장 많이 나는 구간이다.
영향은 임계값이 아니라 **산출값**이었다:
- `check_vwap_bounce_signal` / `check_rsi_bb_signal` 의
`target = 현재가 + DAY_TRADE_TARGET_MULTIPLIER × ATR` 은 **클램프가 없어**
부푼 ATR 이 1.5R 목표를 그대로 멀리 밀었다.
- `raw_stop = 현재가 − ATR_MULT × ATR` 은 보통 최소손절폭(-2%)이 이기지만
고변동 종목에서는 -2% 를 뚫고 손절이 깊어졌다.
- 청산 경로(`daytrading/exit_service`)는 당일 전용 분봉을 쓰므로 트레일링 ATR 은
깨끗했다 → **진입은 갭 ATR, 청산은 당일 ATR** 로 기준이 엇갈렸다.
여기서 한 번 끊으면 네 호출부(ORB 손절·VWAP·RSI_BB·트레일링) 전부가 같은 기준을
쓴다. 당일 봉이 `period+1` 개 미만이면 `None` 이고, 호출부는 기존 고정 폴백
(`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 등)을 쓴다 — 워밍업 도입 이전과 같은 동작이다.
**임계값은 하나도 바뀌지 않는다.** RSI(14)/BB(20) 는 연속 시계열이 필요한 판정
게이트이므로 워밍업을 그대로 받는다(`_rsi`/`_bollinger` 는 손대지 않았다).
"""
if len(candles) < period + 1:
session = _last_session_candles(candles)
if len(session) < period + 1:
return None
highs = [c.high for c in candles]
lows = [c.low for c in candles]
closes = [c.close for c in candles]
highs = [c.high for c in session]
lows = [c.low for c in session]
closes = [c.close for c in session]
return calculate_atr(highs, lows, closes, period)
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
def _overnight_gap_pct(candles: List[MinuteCandle]) -> Optional[float]:
"""오버나이트 갭률(%) = (당일 첫 봉 시가 − 전일 마지막 봉 종가) / 전일 종가 × 100.
**진단용 순수 함수다 — 어떤 게이트도 이 값을 쓰지 않는다.**
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있으므로
(`get_minute_chart_full_day_v2(include_prev_day=True)`) 벽시계나 별도 조회 없이
데이터만으로 계산된다. 전일 봉이 없거나 날짜 필드가 비면 `None`.
용도(2026-09-30): 장중 5분봉 RSI 는 워밍업 시계열을 쓰므로 **오버나이트 갭이
종가 차분 한 개로 RSI 에 들어간다**(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3).
이는 차트 플랫폼 표준 동작이고 평균회귀가 과매도를 사는 의도와 방향이 맞아
**그대로 유지하기로 결정**됐으나, 의도한 설계가 아니었으므로 관찰 대상이다.
진입 `reason` 에 이 값을 남겨 두면 나중에 원장(`trade_history.entry_reason`)만으로
"갭 하락 종목에 쏠렸는가"를 집계할 수 있다.
"""
if not candles:
return None
today = _last_session_candles(candles)
if not today:
return None
today_date = getattr(today[0], "date", "") or ""
if not today_date or len(today) == len(candles):
return None # 전일 봉이 없다 / 날짜 필드 없음
prev = [c for c in candles if (getattr(c, "date", "") or "") != today_date]
if not prev:
return None
prev_close = prev[-1].close
first_open = today[0].open
if prev_close <= 0 or first_open <= 0:
return None
return (first_open - prev_close) / prev_close * 100.0
def _diag_suffix(candles: List[MinuteCandle], atr: Optional[float]) -> str:
"""진입 `reason` 뒤에 붙는 **진단 정보** 문자열 (게이트 아님).
- `갭=` : `_overnight_gap_pct` (RSI 갭 섭취 관찰용, 없으면 생략)
- `ATR=` : 손절·목표 산출에 실제로 쓰인 ATR. `None` 이면 폴백 손절이 쓰였음을
명시한다 — 09:30~10:10 구간은 당일 5분봉 15개 미만이라 ATR 이 없고
`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 폴백이 적용된다(CLAUDE.md §7 예외).
값만 넣는다. 임계값을 문자열에 하드코딩하지 않는다(§4).
"""
parts = ["ATR=없음→폴백손절" if atr is None else f"ATR={atr:.0f}"]
gap = _overnight_gap_pct(candles)
if gap is not None:
parts.append(f"갭={gap:+.2f}%")
return " [진단 " + ", ".join(parts) + "]"
def check_orb_signal(
stock_code: str,
stock_name: str,
@@ -413,11 +487,41 @@ def check_orb_signal(
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)"
# ORB 경로의 시계열은 당일 전용이라 오버나이트 갭을 계산할 수 없다
# (_diag_suffix 가 갭을 생략한다). ATR 만 남는다.
+ _diag_suffix(candles_1min, atr_orb)
),
confidence=round(confidence, 2),
)
def _last_completed_candle(candles_5min: List[MinuteCandle]) -> Optional[MinuteCandle]:
"""**당일** 마지막 완성 5분봉. 없으면 None (순수 함수).
[2026-09-30] `aggregate_to_5min` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.**
따라서 `candles_5min[-1]` 로 양봉/음봉을 판정하면 30초 주기 스캔에서 같은 버킷을
약 10회 보며 **같은 시장 상태에 사이클마다 다른 답**이 나온다(버킷 초반에 음봉이면
차단, 후반에 양봉이 되면 통과). 재현성이 없는 게이트다.
F7(2026-09-29)이 거래량 게이트를 완성봉으로 고칠 때 **양봉 판정은 빠졌다.**
`volume_surge_ratio()` 가 비교하는 봉과 **같은 봉**(`session[-2]`)을 돌려주므로
"거래량이 튄 그 봉이 양봉인가"라는 원래 의도가 성립한다 —
`check_orb_signal` 은 이미 `candles_5m[-2]` 로 그렇게 하고 있었다(`:407`).
세션 경계도 함께 끊는다(`_last_session_candles`): 진입 경로의 시계열에는 지표
워밍업용 전일 봉이 섞여 있어(`include_prev_day=True`) 개장 직후에는 마지막 완성봉이
**전일 종가 동시호가 봉**이 된다. 그걸로 양봉 판정을 하면 게이트가 전일 결과로
열린다(C1 과 같은 함정).
완성봉이 없으면(당일 봉 2개 미만) `None` — 호출부는 **미통과**로 닫는다.
데이터 부족을 통과 쪽으로 열지 않는다.
"""
session = _last_session_candles(candles_5min)
if len(session) < 2:
return None
return session[-2]
def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]:
"""직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값.
@@ -463,7 +567,7 @@ def check_vwap_bounce_signal(
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
2. 5분봉 RSI(14) ≤ threshold
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. 최근 5분봉 양봉 전환 (close > open)
4. **그 완성봉이 양봉** (close > open) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
"""
if not candles_5min or vwap <= 0 or current_price <= 0:
return None
@@ -480,9 +584,11 @@ def check_vwap_bounce_signal(
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 최근 봉 양봉 확인
last = candles_5min[-1]
if last.close <= last.open:
# 양봉 확인 — **당일 마지막 완성봉**(거래량 게이트가 본 그 봉). 형성 중 버킷을
# 보면 사이클마다 답이 바뀐다(_last_completed_candle docstring). 임계값·조건 정의는
# 그대로이고 '어느 봉을 보는가'만 고친다.
last = _last_completed_candle(candles_5min)
if last is None or last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
@@ -491,7 +597,21 @@ def check_vwap_bounce_signal(
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
stop_loss = min(raw_stop, min_stop)
stop_loss = max(stop_loss, 1.0) # 최소 1원 보장
target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
# [M-1 2026-09-30] **1.5R 하한 보장** — RSI_BB 경로와 같은 방식(`max(기존목표, 진입가
# + risk × TARGET_MULTIPLIER)`)으로 맞춘다. 새 임계값은 없다: 두 값 중 큰 쪽을 고를 뿐이다.
#
# 고치지 않으면 `target_price` 가 위험보다 작아진다 — 이 값은
# `daytrading/exit_service` 를 통해 **1.5R 분할매도 트리거로 실제 쓰인다**(§7).
# · ATR 폴백 구간(09:30~약 10:10, 당일 5분봉 15개 미만): 손절 -3.5%(폴백) vs
# 목표 `vwap×1.03`(≈+3%) → **위험 > 보상**
# · ATR 이 있는 구간도 마찬가지다: `risk = max(1.5×ATR, 2%)` 인데(최소 손절폭 보장의
# `min()` 때문에 손절이 -2% 쪽으로 느슨해진다) 목표는 `1.5×ATR` 이라
# **1.5×ATR < 2% 인 저변동 종목에서는 R:R 이 0.2~0.5** 로 떨어진다.
# 실측: 진입 10,120 / ATR 29 → 손절 9,918(-2.0%) / 목표 10,164(+0.43%) = **0.21R**.
# §7 은 "1.5R 도달 시 50% 분할"을 규약으로 못박고 있으므로 이는 규약 복귀다.
_target_base = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
risk = current_price - stop_loss
target = max(_target_base, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER)
return DayTradeSignal(
stock_code=stock_code,
@@ -504,6 +624,7 @@ def check_vwap_bounce_signal(
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}배, 완성봉 기준)"
+ _diag_suffix(candles_5min, atr)
),
confidence=0.65,
)
@@ -521,7 +642,7 @@ def check_rsi_bb_signal(
1. RSI(14) ≤ threshold (강한 과매도)
2. 종가 ≤ BB 하단 + buffer
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. 최근 봉 양봉 (음봉이면 스킵)
4. **그 완성봉이 양봉** (음봉이면 스킵) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
"""
if not candles_5min or current_price <= 0:
return None
@@ -543,8 +664,9 @@ def check_rsi_bb_signal(
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
last = candles_5min[-1]
if last.close <= last.open:
# 양봉 확인 — VWAP 경로와 동일하게 당일 마지막 완성봉 기준
last = _last_completed_candle(candles_5min)
if last is None or last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
@@ -570,6 +692,7 @@ def check_rsi_bb_signal(
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}배, 완성봉 기준)"
+ _diag_suffix(candles_5min, atr)
),
confidence=0.60,
)

View File

@@ -45,6 +45,9 @@ from app.services.day_trading_strategy import (
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
calculate_day_trade_position_size, is_daily_loss_exceeded,
MAX_POSITIONS,
# 진단 전용(읽기 전용 순수 함수). 게이트에 쓰이지 않는다 —
# `_log_rsi_gap_diagnostics()` 가 탈락 후보의 분포를 남기는 데만 쓴다.
_rsi as _rsi5, _overnight_gap_pct,
)
from app.services.daytrading.position_manager import day_trading_state
from app.services.db_worker import enqueue_trade_history, enqueue_active_trade_upsert
@@ -84,9 +87,31 @@ _watchlist_auto: bool = False
# 동시성은 보수적으로 잡는다. 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
# [2026-09-30] 4 → 8. 실제 병목은 전역 쓰로틀이 아니라 **이 동시성**이었다.
# 종목당 분봉 페이지 1장의 체인 비용은 약 3.2초(운영 실측 요청 3.0초 + in-loop
# sleep 0.2초)이므로 동시성 4 에서는 4/3.35 ≈ 1.2 req/s 밖에 나오지 않는다 —
# 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL = 6.67 req/s)의 5분의 1 이다.
# 8 로 올려도 8/3.35 ≈ 2.4 req/s 로 쓰로틀 상한에 닿지 않고, 오후(종목당 10~12장)
# 구간의 선조회 시간이 절반으로 줄어 스캔 포화가 풀린다.
# ⚠️ 상한은 8 이다: korea_investment_api 의 `semaphore(API_MAX_CONCURRENT_REQUESTS=8)`
# 와 같은 값이라 이 이상 올려도 실효가 없고, 세마포어를 완전히 점유해 다른 경로
# (스윙 청산·주문)의 대기만 늘린다. 세마포어는 FIFO 이므로 대기는 요청 1건
# (약 3초) 으로 유계다.
_SCAN_PREFETCH_CONCURRENCY: int = 8
# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다.
# [2026-09-30] 20 → 45. 종목당 페이지 수는 시간대에 비례해 늘어난다
# (당일 1분봉 ÷ 30봉): 09:30 4장 → 12:00 6장 → 14:00 10장 → 15:00 12장.
# 12장 × 3.2초 ≈ 38초이므로 20초에서는 **12:00 이후 전 종목이 타임아웃**했고,
# 그러면 순차 루프가 종목별 재조회를 떠안아(타임아웃 없음·직렬) 오히려 느려졌다.
# VWAP 이 세션 앵커 누적이라 페이지 수를 줄이는 방식은 지표 정의를 바꾸게 되므로
# 택하지 않았다. 대신 아래 **단계 전체 데드라인**으로 예산을 보호한다.
_SCAN_PREFETCH_TIMEOUT: float = 45.0
# 선조회 **단계 전체** 데드라인(초). per-stock 타임아웃만 올리면 최악의 경우
# ceil(N/동시성) 웨이브 × 45초가 스케줄러 _guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가
# 조용히 취소된다(진입 0건 + 로그 없음). 단계 자체에 상한을 둬서 **순차 진입 구간이
# 항상 실행되게** 한다. 이미 캐시에 담긴 종목은 취소돼도 남는다(_one 이 완료 즉시 저장).
# _SCAN_TIME_BUDGET(150초)에서 순차 구간·주문 몫 30초를 남긴 값이다.
_SCAN_PREFETCH_TOTAL_BUDGET: float = 120.0
# 순차 진입 구간 시간 예산(초). 스케줄러 _guard(STEP_TIMEOUT=180) 보다 작게 잡아
# 취소가 아니라 '남은 종목 수 로그'로 끝나게 한다.
_SCAN_TIME_BUDGET: float = 150.0
@@ -106,6 +131,79 @@ _CANDLE_CACHE_TTL: float = 180.0
_scan_offset: int = 0
# ── RSI·갭 진단 로깅 (2026-09-30) ─────────────────────────────────────────────
# 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가
# 차분 한 개로 RSI 에 들어간다(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). 사용자
# 결정은 **유지 + 관찰**인데, 관찰 수단이 없었다: `trade_history.entry_reason` 의
# `RSI=` 는 **게이트를 통과한 진입에만** 존재해 분포가 임계값 바로 아래에 고인다
# (실측 56건 29.3~44.9, 임계 45 절단). 기저 분포를 보려면 **탈락 후보**의 값이 필요하다.
#
# 볼륨 억제: 종목마다 한 줄이면 30종목 × 30초 주기 × 5.5시간 ≈ 2만 줄/일이고,
# `docker logs` 가 이미 수 분씩 걸린다. 그래서 **사이클당 1줄**로 집계하고, 다시
# N 사이클마다 1회만 남긴다(market_analyzer._ML_SKIP_LOG_EVERY 와 같은 패턴).
# 10 사이클 = 약 5분 → 하루 약 60줄.
_RSI_DIAG_LOG_EVERY: int = 10
_rsi_diag_cycles: int = 0
# 한 줄에 싣는 최저 RSI 종목 수 (전량 싣으면 줄이 길어져 그렙이 어려워진다)
_RSI_DIAG_TOP_N: int = 8
def _log_rsi_gap_diagnostics(candidates: list[tuple[str, str]]) -> None:
"""후보 전체의 (RSI, 갭) 분포를 **한 줄로** 남긴다 — 읽기 전용 진단.
진입 판정에 개입하지 않는다. 이미 선조회된 `_candle_cache` 만 읽으므로
**추가 KIS 호출·DB I/O 가 없다**(§3.1). 통과 여부와 무관하게 전 후보의 값을
남기므로 `entry_reason` 이 가진 **절단 편향이 없는 기저 분포**가 쌓인다.
로깅 위치를 전략 모듈이 아니라 **호출부**로 잡은 이유:
- `day_trading_strategy` 의 신호 함수는 `(candles, vwap, price)` 의 순수 함수다.
거기서 남기면 종목마다 한 줄이 되어 볼륨을 억제할 방법이 없고, 억제 카운터를
두면 모듈에 가변 상태가 생겨 순수성·테스트 순서 독립성이 깨진다(§4.1).
- 호출부는 사이클 경계를 알기 때문에 집계가 자연스럽다.
- 필요한 값(RSI·갭)은 `_rsi5`/`_overnight_gap_pct` 로 **같은 입력에서 재계산**된다.
둘 다 순수 함수이므로 신호 함수가 본 값과 동일하다(5분봉 45개 내외 연산).
"""
global _rsi_diag_cycles
_rsi_diag_cycles += 1
if _rsi_diag_cycles != 1 and _rsi_diag_cycles % _RSI_DIAG_LOG_EVERY != 0:
return
rows: list[tuple[float, float | None, str]] = []
for code, _ in candidates:
cached = _candle_cache.get(code)
if not cached:
continue
c5 = aggregate_to_5min(cached[1])
rsi = _rsi5(c5, 14)
if rsi is None:
continue
rows.append((rsi, _overnight_gap_pct(c5), code))
if not rows:
logger.info(
f"[DayEntry] RSI 진단: 후보 {len(candidates)}종목 중 RSI 산출 0건 "
f"(5분봉 부족 — 워밍업 미달)"
)
return
rows.sort()
_rsis = [r[0] for r in rows]
_gaps = [r[1] for r in rows if r[1] is not None]
_vwap_th = settings.DAY_TRADE_VWAP_RSI_THRESHOLD
_bb_th = settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD
_pass_vwap = sum(1 for v in _rsis if v <= _vwap_th)
_pass_bb = sum(1 for v in _rsis if v <= _bb_th)
_gap_down = sum(1 for g in _gaps if g <= -1.0)
_top = ", ".join(
f"{c}:{r:.1f}" + (f"/{g:+.1f}%" if g is not None else "")
for r, g, c in rows[:_RSI_DIAG_TOP_N]
)
logger.info(
f"[DayEntry] RSI 진단(사이클 {_rsi_diag_cycles}): n={len(rows)} "
f"평균={sum(_rsis) / len(_rsis):.1f} 최저={_rsis[0]:.1f} 최고={_rsis[-1]:.1f} | "
f"RSI≤{_vwap_th:.0f} {_pass_vwap}종목, RSI≤{_bb_th:.0f} {_pass_bb}종목 | "
f"갭 산출 {len(_gaps)}종목, 그중 갭≤-1% {_gap_down}종목 | "
f"최저 RSI(종목:RSI/갭): {_top}"
)
def _clamp_day_trade_budget(
budget: float,
total_assets: float,
@@ -159,7 +257,7 @@ def get_watchlist() -> dict[str, str]:
return dict(_watchlist)
async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20) -> int:
async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | None = None) -> int:
"""stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정.
배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을
@@ -173,7 +271,8 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
Args:
min_score: 최소 점수 기준 (기본 1)
max_stocks: 최대 감시 종목 수 (기본 20)
max_stocks: 최대 감시 종목 수. ``None`` 이면
``settings.DAY_TRADE_WATCHLIST_SIZE`` (하드코딩 상한 없음).
Returns:
int: 설정된 감시 종목 수
@@ -181,11 +280,20 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
Note:
이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당.
populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신.
[2026-09-30] 상한이 20 으로 하드코딩돼 있어 ORB 후보 풀을 넓힐 방법이 없었다.
``settings.DAY_TRADE_WATCHLIST_SIZE`` 로 이관했다 — 상한 근거·경합 계산은
config.py 의 해당 키 주석 참조. 기존 시그니처는 보존한다(§4): 인자를 명시한
호출부는 그대로 동작하고, 인자를 생략한 호출부만 settings 를 따른다.
"""
from app.core.database import AsyncSessionLocal
from app.models.models import StockAnalysis
from sqlalchemy import select, func
# [§4] 한도 결정은 try 밖에서 — 예외로 조용히 우회되면 상한이 사라진다.
if max_stocks is None:
max_stocks = settings.DAY_TRADE_WATCHLIST_SIZE
today = datetime.now(_KST).date()
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
# 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다.
@@ -266,12 +374,19 @@ 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`). 원장 실측으로 필터
`get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **RSI(14)/
BB(20) 판정 게이트의 워밍업**이다: 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 진입이 실재했음이 확인됐다.
⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30
정정). 같은 시계열이 `_atr` 로도 흘러가 **당일 첫 봉의 TR = 오버나이트 갭**을
윈도에 넣어 진입 손절·목표가를 부풀렸고, 청산 트레일링(당일 전용 분봉)과 기준이
엇갈렸다. 지금은 `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()`
로 세션을 끊는다 — 진입/청산 ATR 이 같은 기준이다. **봉 간 차분을 쓰는 지표를
새로 추가할 때 이 시계열을 그대로 먹이지 말 것.**
⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.**
조회 계층이 전일로 거슬러 가려면 미래 `ref_time` 에 KIS 가 전일 데이터를 준다는
전제가 필요한데 그 전제는 확인되지 않았다(`get_minute_chart_full_day_v2` docstring).
@@ -329,7 +444,17 @@ async def _prefetch_minute_charts(codes: list[str]) -> int:
except Exception as e:
logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}")
await asyncio.gather(*(_one(c) for c in codes), return_exceptions=True)
# 단계 전체 데드라인 — 초과분은 포기하고 순차 진입 구간으로 넘어간다(§8.1-11:
# 청산·진입 판정이 선조회 때문에 통째로 취소되는 것이 최악이다).
_gather = asyncio.gather(*(_one(c) for c in codes), return_exceptions=True)
try:
await asyncio.wait_for(_gather, timeout=_SCAN_PREFETCH_TOTAL_BUDGET)
except asyncio.TimeoutError:
logger.warning(
f"[DayEntry] ⏱️ 분봉 선조회 단계 예산 {_SCAN_PREFETCH_TOTAL_BUDGET:.0f}초 초과 "
f"— {sum(1 for c in codes if c in _candle_cache)}/{len(codes)}종목만 선조회됨. "
f"나머지는 순차 루프가 개별 조회한다(시간 예산에 걸리면 다음 사이클로 미룸)"
)
# 오래된 항목 정리 (감시목록 교체로 무한 증식 방지)
stale = [c for c, (ts, _) in _candle_cache.items() if (time.monotonic() - ts) > _CANDLE_CACHE_TTL * 2]
for c in stale:
@@ -417,7 +542,25 @@ async def collect_opening_ranges() -> None:
orb = calculate_opening_range(candles)
if orb:
day_trading_state.set_orb(stock_code, orb)
logger.debug(f"[DayEntry] ORB 수집: {stock_code} {orb}")
# [2026-09-30] debug → info. **레인지가 몇 개 봉으로 확정됐는지**가
# 로그에 남아야 한다: `calculate_opening_range` 는 봉 5개면 값을
# 확정하고 이 루프는 `has_orb()` 로 단락하므로, 실제로는 09:00~09:29
# 전체가 아니라 **첫 성공 시점까지의 레인지**로 하루가 고정될 수 있다
# (의도된 창은 09:00~09:29 — `is_day_trade_entry_window()` 가 09:30
# 부터라 조기 확정에 실익이 없다). 봉 수는 저장 필드에서 정확히
# 복원된다: total_volume / avg_vol_per_min = 봉 수.
_bars = (
round(orb.total_volume / orb.avg_vol_per_min)
if orb.avg_vol_per_min > 0 else 0
)
_width_pct = (
(orb.high - orb.low) / orb.low * 100 if orb.low > 0 else 0.0
)
logger.info(
f"[DayEntry] ORB 수집: {stock_code} {_bars}봉 "
f"고={orb.high:,.0f} 저={orb.low:,.0f} 폭={_width_pct:.2f}% "
f"분당평균거래량={orb.avg_vol_per_min:,.0f}"
)
# KIS API 레이트 리밋 방지 (초당 약 3회)
await asyncio.sleep(0.3)
except Exception as e:
@@ -589,7 +732,12 @@ async def scan_and_enter() -> list[dict]:
available_cash = _buyable
day_trading_state.last_total_assets = total_assets
except Exception as e:
logger.debug(f"[DayEntry] 잔고 조회 실패: {e}")
# [§4 2026-09-30] debug → warning. 잔고 조회가 실패하면 available_cash=0 이
# 되어 **모든 진입이 조용히 차단**된다(사이징이 0 → quantity 0). 원인 추적이
# 불가능한 무증상 정지의 전형이라 반드시 노출한다.
logger.warning(
f"[DayEntry] ⚠️ 잔고 조회 실패 — 가용현금 0 으로 진행(신규 진입 사실상 차단): {e}"
)
total_assets = day_trading_state.last_total_assets
available_cash = 0.0
@@ -684,6 +832,15 @@ async def scan_and_enter() -> list[dict]:
f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, "
f"총 {time.monotonic() - _scan_started:.1f}초)"
)
# RSI·갭 기저 분포 진단 — 읽기 전용, 사이클당 1줄(N 사이클마다). 진단 실패가
# 스캔 결과를 잃게 만들면 안 되므로 여기서만 흡수하고 warning 으로 드러낸다
# (§4: logger.debug 금지). 리스크 가드가 아니므로 try 안에 두어도 무해하다.
try:
_log_rsi_gap_diagnostics(candidates)
except Exception as e:
logger.warning(f"[DayEntry] RSI 진단 로깅 실패(진단 전용, 매매 영향 없음): {e}")
return entries

View File

@@ -121,6 +121,55 @@ DLQ_DIR = Path.home() / ".cache" / "auto-stock" / "dlq"
DRAIN_DEADLINE_SECONDS = 20
# DLQ 재처리 완료 파일 이동 위치 — 재처리 무한루프 방지
DLQ_PROCESSED_DIR = DLQ_DIR / "processed"
# [C2 2026-09-30] 나이·충돌로 재큐잉하지 않은 파일 격리 위치 (증거 보존 — 삭제하지 않는다)
DLQ_EXPIRED_DIR = DLQ_DIR / "expired"
# [C2] 나이 제한 — replay_dlq() 가 failed_at 을 읽지 않아 **재기동마다 오래된 상태를
# 되살렸다.** 예: 9/25 drain 으로 빠진 upsert_active_trade 가 그 사이 전량 청산돼
# status='closed' 가 된 뒤 9/30 기동에 재큐잉되면, 핸들러가 active 행을 못 찾고
# 필수 키가 다 있으니 **새 active 행을 INSERT** 한다 → §8.4.1 의 "9/23 유령 행 129개"와
# 같은 실패 모드가 자동화된다. upsert_portfolio 는 이미 매도된 종목의 Portfolio 행을
# 재생성해 스윙 청산이 없는 수량을 팔려 한다(주문거부·과다매도).
#
# 타입별 차등: 위험의 성질이 다르기 때문이다.
# - **상태 덮어쓰기**(active_trade/portfolio): payload 가 '그때의 현재 상태'다. 시간이
# 지나면 진실이 아니라 거짓이 되므로 짧게 잡는다. DT 포지션은 당일 청산(15:20 EOD)이
# 원칙이라 두 시간이면 이미 다른 국면이다.
# - **기록 추가**(trade_history/journal/analysis/ban/news): 늦게 들어가도 '없던 기록이
# 생기는' 것이라 상태를 거짓으로 만들지 않는다. 다만 무한정 허용하면 과거 시각 행이
# 집계(get_uncovered_trades(days=7)·누적 실현손익)를 오염시키므로 24시간으로 끊는다.
# 상태 덮어쓰기 계열 — payload 가 '그때의 현재 상태'라 오래되면 거짓이 된다.
# [M-4 2026-09-30] close_active_trade 를 **여기서 뺐다.** 성격이 다르다: "닫아라"는
# 지시는 시간이 지나도 거짓이 되지 않는다(이미 닫혀 있으면 no-op, 아직 열려 있으면
# 닫는 것이 맞다). 오히려 격리하는 쪽이 **반대 방향의 유령 행**을 만든다 — 야간
# 재기동으로 close 가 격리되면 이미 청산된 포지션의 ActiveTrade 가 active 로 영구
# 잔존하고(§8.4.1 '유령 행 129개'와 같은 형태), 그 잔량이 다른 안전장치(유입 잔고
# 격리의 '시스템 보유' 하한)로 흘러든다. portfolio.py 의 정합 루프도 active 행이
# 2개 이상이면 continue 하므로 자동 복구되지 않는다.
_DLQ_STATE_TASK_TYPES: tuple[str, ...] = (
"upsert_active_trade",
"upsert_portfolio",
)
# 현재 상태와의 충돌을 확인할 타입 (나이 분류와 별개 — close 는 충돌 확인만 받는다)
_DLQ_CONFLICT_CHECK_TYPES: tuple[str, ...] = (
"upsert_active_trade",
"upsert_portfolio",
"close_active_trade",
)
# 상태 덮어쓰기 계열: 같은 날(로컬 날짜) + 2시간 이내만 재큐잉
DLQ_REPLAY_MAX_AGE_STATE_SECONDS: float = 2 * 3600
# 기록 추가 계열: 24시간 이내만 재큐잉
DLQ_REPLAY_MAX_AGE_RECORD_SECONDS: float = 24 * 3600
# [M-4] close_active_trade: 대폭 완화(7일). 무제한으로 두지 않는 이유는 충돌 판정이
# 불가한 상황(DB 미가동 → M-2 단락)에서 '새 포지션을 닫을' 위험이 남기 때문이다.
# 충돌 판정이 불가하면 아래에서 기록 계열 상한(24시간)으로 되돌린다.
DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS: float = 7 * 24 * 3600
# [M-2 2026-09-30] 충돌 판정 단락 플래그. replay_dlq() 는 lifespan 에서 await 되므로
# 기동을 막는다. DB 가 아직 안 뜬 상태에서 파일마다 접속을 재시도하면 기동 지연이
# (파일 수 × 접속 타임아웃)까지 늘어난다 → 첫 실패 후에는 충돌 판정을 건너뛰고
# **나이 제한만** 적용한다(나이 제한은 단락 후에도 계속 적용된다).
_dlq_conflict_check_available: bool = True
# 한 번의 기동에서 재큐잉할 최대 DLQ 파일 수 (큐 폭주 방지)
DLQ_REPLAY_MAX_FILES = 500
@@ -206,6 +255,154 @@ def _restore_dlq_task_types(task: dict) -> dict:
return task
def _dlq_max_age_seconds(task_type: str) -> float:
"""타입별 재큐잉 허용 나이(초).
close_active_trade 는 충돌 판정이 **가능할 때만** 7일까지 허용한다. 판정이 단락된
상태(DB 미가동)에서는 '새 포지션을 닫을' 위험이 남으므로 기록 계열 상한으로 내린다.
"""
if task_type in _DLQ_STATE_TASK_TYPES:
return DLQ_REPLAY_MAX_AGE_STATE_SECONDS
if task_type == "close_active_trade":
if _dlq_conflict_check_available:
return DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS
return DLQ_REPLAY_MAX_AGE_RECORD_SECONDS
return DLQ_REPLAY_MAX_AGE_RECORD_SECONDS
def _dlq_age_verdict(entry: dict, task_type: str) -> tuple[bool, str]:
"""[C2] failed_at 기준 나이 판정. (재큐잉 허용?, 사유) 반환.
``failed_at`` 은 ``_save_to_dlq`` 가 ``datetime.now().isoformat()`` (naive 로컬)로
쓴다. 같은 시계로 비교해야 하므로 여기서도 ``datetime.now()`` 를 쓴다
(``now_kst()`` 와 섞으면 컨테이너 TZ 에 따라 9시간 오차가 난다).
나이를 알 수 없으면: 상태 덮어쓰기 계열은 **격리**(보수적), 기록 계열은 허용.
"""
raw = entry.get("failed_at")
failed_at = None
if isinstance(raw, str) and raw:
try:
failed_at = datetime.fromisoformat(raw)
except ValueError:
failed_at = None
if failed_at is None:
if task_type in _DLQ_STATE_TASK_TYPES:
return False, f"failed_at 없음/파싱불가({raw!r}) — 상태 덮어쓰기 계열은 보수적 격리"
# close_active_trade 는 나이를 몰라도 허용한다(닫으라는 지시는 늦어도 유효).
# 새 포지션을 닫는 위험은 충돌 판정(created_at > failed_at)이 막는다.
return True, "failed_at 없음 — 기록/close 계열이라 허용"
if failed_at.tzinfo is not None:
failed_at = failed_at.replace(tzinfo=None)
now = datetime.now()
age = (now - failed_at).total_seconds()
limit = _dlq_max_age_seconds(task_type)
if age > limit:
return False, (
f"나이 {age / 3600:.1f}시간 > 상한 {limit / 3600:.1f}시간 "
f"(failed_at={failed_at.isoformat()})"
)
if task_type in _DLQ_STATE_TASK_TYPES and failed_at.date() != now.date():
# 2시간 이내라도 날짜가 다르면 다른 세션이다(자정 전후 기동)
return False, (
f"상태 덮어쓰기 계열인데 날짜가 다름 "
f"(failed_at={failed_at.date()}, today={now.date()})"
)
return True, f"나이 {age / 60:.0f}분"
async def _dlq_state_conflict(task: dict, entry: dict) -> tuple[bool, str]:
"""[C2] 상태 덮어쓰기 계열이 **현재 상태와 충돌**하는지 읽기 전용으로 확인.
나이 제한을 통과해도(≤2시간) 그 사이 청산·재진입이 있었으면 유령 행을 만든다.
여기서는 SELECT 만 수행하고 ``commit()`` 을 호출하지 않는다 — 기동 시 1회,
실시간 계층이 아니다(CLAUDE.md §3.2 허용예외③ 와 동일한 성격). **쓰기는 여전히
큐 경유**다.
판정 실패(DB 미가동 등)는 '충돌 아님'으로 돌려 나이 제한만 적용한다 — 여기서
예외로 죽으면 기동이 막힌다.
Returns:
tuple[bool, str]: (충돌인가?, 사유)
"""
global _dlq_conflict_check_available
task_type = task.get("type", "")
data = task.get("data") or {}
stock_code = data.get("stock_code") if isinstance(data, dict) else None
if not stock_code or task_type not in _DLQ_CONFLICT_CHECK_TYPES:
return False, ""
# [M-2] 이미 판정 불가로 확인됐으면 더 시도하지 않는다(기동 지연 유계화).
# 로그도 남기지 않는다 — 첫 실패에서 한 번만 남겼다.
if not _dlq_conflict_check_available:
return False, ""
try:
from app.models.models import ActiveTrade, Portfolio
async with AsyncSessionLocal() as session:
active = (
await session.execute(
select(ActiveTrade)
.where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
)
.order_by(ActiveTrade.id.desc())
.limit(1)
)
).scalars().first()
pf = (
await session.execute(
select(Portfolio).where(Portfolio.stock_code == stock_code).limit(1)
)
).scalars().first()
pf_qty = int(getattr(pf, "quantity", 0) or 0) if pf is not None else 0
if task_type == "upsert_active_trade":
# active 행도 없고 보유 수량도 0 → 재큐잉하면 유령 active 행이 생긴다
if active is None and pf_qty <= 0:
return True, (
f"{stock_code}: active 행 없음 + 보유수량 0 → 유령 ActiveTrade 생성 위험"
)
elif task_type == "upsert_portfolio":
# Portfolio 행이 없으면 이미 정리된 종목이다. sync_portfolio() 가 KIS
# 잔고에서 매 주기 재생성하므로 스킵해도 실보유는 복구된다.
if pf is None:
return True, f"{stock_code}: Portfolio 행 없음 → 매도된 종목 재생성 위험"
elif task_type == "close_active_trade":
# failed_at 이후에 생긴 active 행이면 '새 포지션'이다 — 그걸 닫으면 안 된다
raw = entry.get("failed_at")
try:
_fa = datetime.fromisoformat(raw) if isinstance(raw, str) else None
except ValueError:
_fa = None
if _fa is not None and _fa.tzinfo is not None:
_fa = _fa.replace(tzinfo=None)
_created = getattr(active, "created_at", None) if active is not None else None
if _created is not None and _fa is not None:
if _created.tzinfo is not None:
_created = _created.replace(tzinfo=None)
if _created > _fa:
return True, (
f"{stock_code}: 현재 active 행이 failed_at 이후 생성"
f"(created_at={_created.isoformat()}) → 새 포지션을 닫을 위험"
)
except Exception as e:
# [M-2] 첫 실패에서 단락한다 — 이후 파일은 접속을 재시도하지 않는다.
_dlq_conflict_check_available = False
logger.warning(
"[DLQ] 상태 충돌 확인 실패 (%s/%s): %s — 이후 파일은 충돌 판정을 건너뛰고 "
"나이 제한만 적용한다(기동 지연 방지). close_active_trade 허용 나이도 "
"%.0f시간으로 내려간다.",
task_type, stock_code, e, DLQ_REPLAY_MAX_AGE_RECORD_SECONDS / 3600,
)
return False, ""
return False, ""
async def replay_dlq() -> int:
"""기동 시 DLQ 파일을 읽어 db_task_queue 에 재큐잉. 재큐잉한 건수 반환.
@@ -219,6 +416,11 @@ async def replay_dlq() -> int:
않는다(insert_trade_history 는 멱등이 아니므로 중복이 더 위험하다).
손상된 JSON 은 건너뛰고 warning 을 남긴다(파일은 그대로 두어 사람이 본다).
[C2 2026-09-30] **나이 제한 + 상태 충돌 확인**을 거친다. 초기 판은 failed_at 을
읽지 않아 재기동마다 오래된 상태를 되살렸다(유령 ActiveTrade/Portfolio 생성).
재큐잉하지 않은 파일은 삭제하지 않고 ``expired/`` 로 격리하고 사유를 warning 으로
남긴다. 정책 근거는 위 상수 주석 참조.
"""
def _list_files() -> list:
if not DLQ_DIR.exists():
@@ -237,8 +439,12 @@ async def replay_dlq() -> int:
files = files[:DLQ_REPLAY_MAX_FILES]
logger.warning("[DLQ] 재처리 시작: %d건", len(files))
# [M-2] 이번 기동의 충돌 판정 가용성 초기화 (이전 호출의 단락을 물려받지 않는다)
global _dlq_conflict_check_available
_dlq_conflict_check_available = True
requeued = 0
skipped = 0
expired = 0
for f in files:
try:
raw = await asyncio.to_thread(f.read_text, encoding="utf-8")
@@ -249,16 +455,46 @@ async def replay_dlq() -> int:
skipped += 1
continue
def _move(path=f):
DLQ_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
path.rename(DLQ_PROCESSED_DIR / path.name)
task_type = task.get("type", "unknown")
await asyncio.to_thread(_move)
def _move_to(dest, path=f):
dest.mkdir(parents=True, exist_ok=True)
path.rename(dest / path.name)
# [C2] ① 상태 충돌 확인 (읽기 전용) — 유령 행 생성 차단.
# 나이 판정보다 **먼저** 한다: close_active_trade 의 허용 나이가 '충돌 판정
# 가능 여부'에 달려 있어(M-4), 판정을 먼저 해야 첫 파일이 blind 로 통과하는
# 창이 생기지 않는다. DB 장애 시에는 여기서 한 번만 시도하고 단락된다(M-2).
_conflict, _creason = await _dlq_state_conflict(task, entry)
if _conflict:
await asyncio.to_thread(_move_to, DLQ_EXPIRED_DIR)
logger.warning(
"[DLQ] 격리(현재 상태와 충돌): %s | type=%s | failed_at=%s | 사유=%s",
f.name, task_type, entry.get("failed_at"), _creason,
)
expired += 1
continue
# [C2] ② 나이 제한 — 오래된 상태 재생이 유실보다 위험하다
_ok, _why = _dlq_age_verdict(entry, task_type)
if not _ok:
await asyncio.to_thread(_move_to, DLQ_EXPIRED_DIR)
logger.warning(
"[DLQ] 격리(재큐잉 안 함): %s | type=%s | failed_at=%s | 사유=%s",
f.name, task_type, entry.get("failed_at"), _why,
)
expired += 1
continue
await asyncio.to_thread(_move_to, DLQ_PROCESSED_DIR)
# 재시도 카운터를 초기화해 재큐잉분도 3회 재시도를 받게 한다
task.pop("_retry_count", None)
# [M5] default=str 로 열화된 Decimal/datetime/date 를 되돌린다
task = _restore_dlq_task_types(task)
await enqueue_db_write(task)
logger.info(
"[DLQ] 재큐잉: %s | type=%s | %s", f.name, task_type, _why,
)
requeued += 1
except json.JSONDecodeError as e:
logger.warning("[DLQ] 건너뜀(JSON 손상): %s — %s", f.name, e)
@@ -268,8 +504,9 @@ async def replay_dlq() -> int:
skipped += 1
logger.warning(
"[DLQ] 재처리 완료: 재큐잉 %d건 / 건너뜀 %d건 (처리분은 %s 로 이동)",
requeued, skipped, DLQ_PROCESSED_DIR,
"[DLQ] 재처리 완료: 재큐잉 %d건 / 격리 %d건 / 건너뜀 %d건 "
"(처리분→%s, 격리분→%s)",
requeued, expired, skipped, DLQ_PROCESSED_DIR, DLQ_EXPIRED_DIR,
)
return requeued

View File

@@ -958,10 +958,16 @@ class KoreaInvestmentAPI:
`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` 을 반환하고 짧은
기간으로 대체 계산하지 않는다).
용도는 **RSI(14)/BB(20) 판정 게이트의 워밍업**이다 — 5분봉 RSI(14)=15봉/
BB(20)=20봉 요건 때문에 당일 봉만으로는 VWAP 전략이 약 10:15, RSI_BB 가 약
10:40 이전에 발화하지 못한다 (`indicators_core.rsi_wilder` 는 봉이 모자라면
`None` 을 반환하고 짧은 기간으로 대체 계산하지 않는다).
⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30
정정). 이 시계열을 그대로 ATR 에 넘기면 **당일 첫 봉의 TR 이 오버나이트 갭**이
되어 손절·목표가가 부풀었다(`day_trading_strategy._atr` docstring 참조).
지금은 `_atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊어 막는다 —
**TR·ATR 처럼 봉 간 차분을 쓰는 지표에 이 시계열을 새로 먹이지 말 것.**
⚠️ **미검증 전제**: 이 경로는 "장중에 `FID_INPUT_HOUR_1` 로 **미래 시각**
(예: 전일 14:51)을 주면 KIS 가 전일 데이터를 돌려준다"에 의존한다. 요청 파라미터에

View File

@@ -64,20 +64,40 @@ async def reflect_buy(
# get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다.
# 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다.
#
# [2026-09-28] 예외 하나: 유입 격리('external') 행에 **스윙** 시스템 매수가
# 들어오면 swing 으로 승격한다. 승격하지 않으면 새로 산 스윙 수량이
# get_swing_holdings 에서 빠져 다음 sync 까지 손절·익절이 붙지 않는다.
# DT 매수는 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고,
# [2026-09-30] **부분 혼재 승격을 막는다.**
# 종전에는 유입 격리('external') 행에 스윙 매수가 들어오면 바로 swing 으로
# 승격했다. 그런데 Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로
# (CLAUDE.md §3.5) 승격하면 **유입 수량까지 스윙 자동 손절 대상이 된다.**
# 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
# → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다.
#
# 따라서 승격은 **행 전체가 이번 매수로 설명될 때만** 허용한다
# (old_qty == 0 = 유입 잔여 수량이 없음). 수량이 남아 있는 external 행은
# 승격하지 않는다.
# 애초에 이 경로는 `trade_executor` 가 external 행 추가매수를 차단하므로
# 스윙 매수로는 도달하지 않는다 — 여기 조건은 2차 방어다.
# DT 매수는 여전히 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고,
# 'daytrading' 으로 바꾸면 격리 수량이 external 표시에서 사라진다.
if (existing.strategy_type or "swing") == "external" and strategy_type == "swing":
if (
(existing.strategy_type or "swing") == "external"
and strategy_type == "swing"
and old_qty <= 0
):
existing.strategy_type = "swing"
_promote_to_swing = True
logger.warning(
f"[cache_bridge] ⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
f"external → swing (스윙 시스템 매수 {quantity}주)"
f"external → swing (스윙 시스템 매수 {quantity}주, 유입 잔여 0주)"
)
else:
_promote_to_swing = False
if (existing.strategy_type or "swing") == "external" and strategy_type == "swing":
logger.warning(
f"[cache_bridge] 🧩 부분 혼재 승격 거부: {stock_name}({stock_code}) "
f"유입 {old_qty}주 + 스윙 매수 {quantity}주 → "
f"strategy_type='external' 유지 (승격하면 유입분이 스윙 자동 "
f"손절 대상이 된다). 이 매수 자체가 trade_executor 에서 차단돼야 한다"
)
avg_for_db, qty_for_db = new_avg, new_qty
else:
cache.holdings.append(Portfolio(

View File

@@ -0,0 +1,129 @@
"""system_buy_registry — "시스템이 방금 이 종목을 매수했다"는 **즉시(인메모리) 근거**.
## 왜 필요한가 (유입 잔고 강등 레이스)
`portfolio.sync_portfolio()` 는 KIS 잔고 1행의 `strategy_type` 을 판정할 때
"시스템 매수 근거"를 **DB**(`TradeHistory` BUY ∪ 시딩 아닌 `ActiveTrade`)에서 찾는다.
그런데 두 테이블은 **둘 다 db_worker 큐를 거쳐 비동기로 INSERT** 된다(CLAUDE.md §3.2).
게다가 매수 직후 `trade_executor._verify_and_sync_execution()` 이
`_delayed_sync_portfolio(0)` 로 **즉시** sync 를 돌린다.
매수 체결 → Portfolio 행(swing) → [db_worker INSERT 전] → sync 실행
→ DB 근거 없음 → external 강등 → 손절·익절·트레일링이 전부 안 걸림
즉 **방금 산 정상 포지션이 리스크 관리 밖으로 떨어진다.** 이 모듈은 그 창을 막는다 —
주문 성공 시점에 호출부가 `note_system_buy()` 로 종목을 등록하면, DB 반영 전이라도
sync 가 `snapshot_codes()` 로 근거를 본다.
## 불변식
**정상 포지션은 강등되지 않는다.** 이 모듈은 근거를 *추가*만 하며 제거하지 않는다.
따라서 오탐(잘못된 강등)은 못 만들고, 최악의 경우 유입 잔고 격리가 TTL 만큼
늦어질 뿐이다(격리 지연은 허용 가능, 오강등은 허용 불가).
## ⚠️ 용도 제한 — 승격 근거로 쓰지 말 것 (M-2, 2026-09-30)
이 레지스트리가 쓰여도 되는 곳은 **두 군데뿐**이다:
① `portfolio._should_demote_to_external()` 의 1차 방어 (강등을 **막는** 근거)
② 신규 Portfolio 행의 초기 `strategy_type` 태깅 (원장 INSERT 전이라 필요)
**기존 `external` 행의 승격 판정에는 쓸 수 없다.** 두 용도의 안전 방향이 반대다 —
강등 방어는 "근거가 있다고 보는 것"이 안전하고, 승격은 "없다고 보는 것"이 안전하다.
실제 사고 경로: 유입 3주 + DT 3주 행에서 DT 가 15:20 EOD 전량 청산하면 원장 net·
비DT active·DT 인메모리가 모두 0 이 되는데 **이 레지스트리만 TTL 내라** `_sys=3=
행수량` 이 되어 승격되고 `strategy_type='swing'` 이 **DB 에 영구 기록**된다. 장이
닫혀 다음 sync 가 오지 않으면 그대로 밤을 넘기고 다음 날 개장 시 유입분이 자동 손절
대상이 된다. 그래서 `sync_portfolio` 는 근거를 `_system_*_durable`(레지스트리 제외)과
`_system_*`(포함) 두 벌로 만들고 **승격은 durable 만** 본다.
`note_system_buy()` 는 **매도를 반영하지 않는다** — 그래서 이 모듈의 수량은 "방금
주문한 양"이지 "현재 보유량"이 아니다. 위 용도 제한을 지키면 그 차이가 무해하다.
## 주의
- 프로세스 재기동으로 비워진다. 그 경우는 이미 DB 에 근거가 들어가 있고,
`portfolio.py` 쪽 "연속 확인 + 최소 경과시간" 게이트가 2차 방어로 남는다.
- 실시간 계층에서 호출되므로 **DB I/O 없음**(CLAUDE.md §3.1).
- TTL 은 db_worker 최대 지연(지수 백오프 1+2+4초 ×3회 + DLQ 재시도)을 넉넉히 덮는 값.
"""
import logging
import time
logger = logging.getLogger(__name__)
# 등록 유효 시간(초). db_worker 큐 지연·재시도(최대 수십 초)를 넉넉히 덮는다.
# 길게 잡아도 부작용은 "유입 잔고 격리가 최대 이 시간만큼 늦어짐" 뿐이다.
SYSTEM_BUY_TTL_SEC = 900
# {stock_code: [monotonic 등록 시각, 누적 주문 수량]}
_recent_buys: dict = {}
def note_system_buy(stock_code: str, quantity: int = 0) -> None:
"""시스템 매수 주문이 나갔음을 즉시 기록 (동기 함수 — 어디서나 호출 가능).
Args:
stock_code: 종목코드
quantity: 주문 수량. 부분 혼재 판정(`_classify_holding_strategy_type_v2`)이
**수량**을 쓰므로 함께 남긴다. 0 이면 "수량 미상" 이고, 그 경우 호출부는
근거 수량을 원장에서만 얻는다. TTL 안에서 여러 번 호출되면 **합산**한다
(분할 주문·추가매수).
실패해도 매매를 막아서는 안 되므로 절대 예외를 올리지 않는다.
"""
if not stock_code:
return
try:
_c = str(stock_code)
_q = max(0, int(quantity or 0))
st = _recent_buys.get(_c)
if st is None or time.monotonic() - st[0] > SYSTEM_BUY_TTL_SEC:
_recent_buys[_c] = [time.monotonic(), _q]
else:
st[0] = time.monotonic()
st[1] += _q
except Exception as e: # pragma: no cover - 방어적
logger.warning(f"[system_buy_registry] 등록 실패 ({stock_code}): {e}")
def snapshot_quantities() -> dict:
"""TTL 내 시스템 매수 **수량** {code: qty} (만료분 정리 포함).
`sync_portfolio` 가 `_system_qty` 의 하한으로 합성한다 — 원장 INSERT 전에도
"이 행의 N주는 시스템 몫" 이라는 사실이 보이게 해, 부분 혼재 판정이 방금 산
수량을 유입분으로 오인하지 않게 한다.
"""
now = time.monotonic()
for c in [c for c, st in _recent_buys.items() if now - st[0] > SYSTEM_BUY_TTL_SEC]:
_recent_buys.pop(c, None)
return {c: int(st[1]) for c, st in _recent_buys.items()}
def has_recent_system_buy(stock_code: str) -> bool:
"""해당 종목에 TTL 내 시스템 매수 기록이 있는가."""
if not stock_code:
return False
st = _recent_buys.get(str(stock_code))
if st is None:
return False
if time.monotonic() - st[0] > SYSTEM_BUY_TTL_SEC:
_recent_buys.pop(str(stock_code), None)
return False
return True
def snapshot_codes() -> set:
"""TTL 내 시스템 매수 종목 집합 (만료분 정리 포함).
`portfolio._classify_holding_strategy_type(system_codes=...)` 에 합집합으로 넣는다.
"""
now = time.monotonic()
expired = [c for c, st in _recent_buys.items() if now - st[0] > SYSTEM_BUY_TTL_SEC]
for c in expired:
_recent_buys.pop(c, None)
return set(_recent_buys)
def clear() -> None:
"""테스트용 초기화."""
_recent_buys.clear()

View File

@@ -1113,6 +1113,17 @@ async def _execute_buy(
is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인)
status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED")
# [2026-09-30] 유입 잔고 강등 레이스 방어 — '시스템 매수' 를 **DB 반영 전에**
# 인메모리로 즉시 등록한다. 아래 enqueue 들은 db_worker 큐를 거쳐 비동기로
# INSERT 되는데, `_verify_and_sync_execution()` 이 곧바로 `sync_portfolio()`
# 를 돌리므로 그 사이에 sync 가 "근거 없음 → external 강등" 판정을 내리면
# 방금 산 포지션의 손절·익절이 통째로 멈춘다.
# 주문 거부(FAILED)는 주식이 생기지 않으므로 등록하지 않는다 — 등록하면
# 기존 유입 잔고가 '매수 실패' 만으로 격리를 면한다.
if success or is_pending:
from app.services.system_buy_registry import note_system_buy
note_system_buy(stock_code, quantity)
# TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수)
kis_order_id = build_kis_order_id(order_result)
from app.services.db_worker import enqueue_trade_history
@@ -1912,6 +1923,28 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
if _r is not None:
return _r
# ── 유입 격리분 추가매수 차단 ────────────────────────────────────────
# [2026-09-30] 유입 잔고(strategy_type='external') 행에 스윙 매수를 얹으면
# Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로(CLAUDE.md §3.5) 행이
# swing 으로 승격되고 **유입분까지 스윙 자동 손절 대상이 된다.**
# 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
# → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 의 정면 위반이다.
#
# 여기서 막으면 그 혼재가 **발생하지 않는다** — 유입분 격리를 지키면서
# 시스템 매수분이 관리 밖에 남는 트레이드오프도 함께 사라진다(애초에 안 산다).
# `_guard` 를 쓰므로 **청산 판정은 그대로 통과**한다(§8.1-11).
# DT 경로(daytrading/entry_service)는 이 함수를 타지 않고, DT 는 인메모리로
# 자기 수량을 관리하므로 차단 대상이 아니다.
if holding is not None and (holding.strategy_type or "swing") == "external":
logger.warning(
f"🚧 유입 격리분 추가매수 차단: {stock_name}({stock_code}) "
f"보유 {holding.quantity}주 strategy_type='external' — "
f"매수하면 행 전체가 swing 으로 승격돼 유입분이 자동 손절 대상이 된다"
)
_r = _guard("external_inflow_holding")
if _r is not None:
return _r
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
_adaptive_max_holdings = settings.MAX_HOLDINGS_COUNT

View File

@@ -565,6 +565,13 @@ async def us_trade_cycle() -> dict:
ticker, rt_cd, order_id,
)
# [2026-09-30] 유입 잔고 강등 레이스 방어 — TradeHistory 는 db_worker
# 큐를 거쳐 비동기 INSERT 되므로, 그 전에 sync 가 돌면 근거가 없어
# 보인다. 주문 성공 시점에 인메모리 근거를 즉시 남긴다.
if rt_cd in ("0", "1"):
from app.services.system_buy_registry import note_system_buy
note_system_buy(ticker, quantity)
# 7-4. DB 기록 (db_worker 큐 사용)
await enqueue_trade_history({
"stock_code": ticker,