사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
130 lines
6.1 KiB
Python
130 lines
6.1 KiB
Python
"""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()
|