Files
auto-stock/backend/app/services/system_buy_registry.py
shuim 9de0715165 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>
2026-09-30 16:50:54 +09:00

130 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()