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

@@ -168,14 +168,132 @@ def test_manual_sell_closes_active_trade():
assert '"quantity": original_holding_qty - quantity' in src
class _StubActiveTrade:
"""ActiveTrade ORM 대역 — 정합 함수가 실제로 수정하는 필드만 갖는다."""
def __init__(self, stock_code, quantity, strategy_name="MEAN_REVERSION"):
self.stock_code = stock_code
self.quantity = quantity
self.strategy_name = strategy_name
self.status = "active"
def test_sync_reconciles_active_trade_quantity():
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다."""
import inspect
"""동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다.
[2026-09-30 M-5] 종전에는 `inspect.getsource()` + 문자열 매칭이라 정합 루프를
리팩터링하면 빗나갔다(실제로 빗나갔다). 이제 **불일치 상태를 주입하고 정합
함수를 실행해 교정 결과를 확인**한다 — 프로덕션 함수를 그대로 호출한다.
"""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
# 스윙 단일 행이 3주로 기록돼 있는데 실제 보유는 5주 → 5주로 교정돼야 한다
row = _StubActiveTrade("000270", 3)
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5, "잔량 교정이 동작해야 한다 (2026-09-23 픽스의 본래 대상)"
assert row.status == "active"
assert acts == [{"action": "corrected", "stock_code": "000270", "from": 3, "to": 5}]
# 일치하면 아무 것도 하지 않는다
row2 = _StubActiveTrade("000270", 5)
assert _reconcile_active_trade_quantities(
{"000270": [row2]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
) == []
assert row2.quantity == 5
def test_sync_closes_active_trade_without_holding():
"""보유가 없으면 active 행을 closed 로 정리한다 (유령 행 누적 방지)."""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
for tag in ("swing", "daytrading", "manual", "external"):
row = _StubActiveTrade("000270", 3)
acts = _reconcile_active_trade_quantities(
{"000270": [row]}, {}, {"000270": tag}, {"000270": 3}
)
assert row.status == "closed", f"{tag}: 유령 행 정리는 태그와 무관하게 수행"
assert row.quantity == 3, "정리 경로에서 수량은 건드리지 않는다"
assert acts[0]["action"] == "closed"
def test_sync_skips_reconcile_for_multiple_active_rows():
"""스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함 (2026-09-23 원래 규칙)."""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
rows = [_StubActiveTrade("000270", 3), _StubActiveTrade("000270", 2, "day_trade")]
assert _reconcile_active_trade_quantities(
{"000270": rows}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
) == []
assert [r.quantity for r in rows] == [3, 2]
def test_reconcile_skips_external_rows_but_keeps_swing_behaviour():
"""[C-1] external 행만 교정에서 빠진다 — 스윙 교정은 그대로 살아 있다.
external 행 수량에는 시스템이 소유하지 않은 유입분이 섞여 있어, 교정하면 그 값이
다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격된다(고착).
"""
from app.api.endpoints.portfolio import _reconcile_active_trade_quantities
ext = _StubActiveTrade("105560", 3, "day_trade")
acts = _reconcile_active_trade_quantities(
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
)
assert ext.quantity == 3, "external 행은 교정하지 않는다"
assert acts[0]["action"] == "skipped_unattributable"
assert acts[0]["from"] == 3 and acts[0]["to"] == 6
# [M-3] 태그가 swing 인 혼재 행도 교정하지 않는다 — 여기가 실제 주문 사고 경로다
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
acts = _reconcile_active_trade_quantities(
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert mixed.quantity == 3, "유입분이 섞인 swing 행도 부풀리지 않는다"
assert acts[0]["action"] == "skipped_unattributable"
def test_c1_guard_does_not_weaken_the_20260923_intent():
"""의도 보존 검증 — C-1 가드를 **C-1 이전 규칙으로 되돌려도** 위 의도는 통과한다.
즉 이 테스트 묶음이 깨진다면 그것은 2026-09-23 픽스가 깨진 것이고, C-1 가드
때문이 아니다. 반대로 C-1 회귀(2회 sync 유지)는 가드가 없으면 실패한다
(`tests/test_external_demotion_race.py::test_c1_would_have_regressed_...`).
"""
from unittest.mock import patch
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sync_portfolio)
assert "ActiveTrade 잔량 교정" in src
assert "len(_rows) != 1" in src, "스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함"
assert "_row.status = \"closed\"" in src, "보유 없음 → closed 처리 누락"
def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None):
# C-1/M-3 이전 규칙: 단일 행 + 보유 있음이면 태그·근거수량 무관 교정
return int(row_count) == 1 and int(actual) > 0
with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule):
row = _StubActiveTrade("000270", 3)
pf._reconcile_active_trade_quantities(
{"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5}
)
assert row.quantity == 5, "스윙 잔량 교정은 C-1/M-3 가드와 무관하게 성립한다"
ghost = _StubActiveTrade("000270", 3)
pf._reconcile_active_trade_quantities(
{"000270": [ghost]}, {}, {"000270": "swing"}, {"000270": 3}
)
assert ghost.status == "closed"
# 그리고 이 규칙에서는 유입분이 섞인 행이 부풀려진다 = C-1/M-3 의 인과
ext = _StubActiveTrade("105560", 3, "day_trade")
pf._reconcile_active_trade_quantities(
{"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3}
)
assert ext.quantity == 6, "가드가 없으면 C-1 이 재발한다"
mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION")
pf._reconcile_active_trade_quantities(
{"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3}
)
assert mixed.quantity == 6, "가드가 없으면 M-3 이 재발한다(유입분 과다매도)"
def test_active_trade_patch_supports_quantity():