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

@@ -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