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

@@ -9,7 +9,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy import select, func, or_, case
from typing import List, Optional
from decimal import Decimal
from pydantic import BaseModel, Field
@@ -41,6 +41,146 @@ KST = pytz.timezone('Asia/Seoul')
EXTERNAL_STRATEGY_TYPE = "external"
# sync 가 자동 시딩한 ActiveTrade 전략명 — '시스템 매수 근거'로 인정하지 않는다.
_SEEDED_AT_STRATEGY_NAMES = ("synced",)
# sync 시딩 ActiveTrade 의 entry_reason 접두사.
# [2026-09-30] strategy_name='synced' 규약은 2026-09-29(7abbb34) 에 도입됐고, 그
# 이전(6207d13)에는 `strategy_name = Portfolio.strategy_type` 즉 제네릭 **'swing'**
# 으로 시딩했다. 그 legacy 행은 strategy_name 만 보면 시딩인지 알 수 없어 '시스템
# 매수 근거'로 오인되고, 유입 잔고 격리가 스스로 풀린다(강등 불가). entry_reason
# 은 두 시기 모두 동일 문구라 시딩 판별의 유일한 불변 표식이다.
_SEEDED_AT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 동기화 시 자동 등록"
# ── '시스템 매수 근거' 로 인정하지 않는 BUY status (거부목록) ────────────────
# [M1 2026-09-30] 이 판정은 **허용목록이면 안 된다.**
# 허용목록(`status.in_([...])`)은 값이 하나라도 빠지면 근거가 있는데도 못 찾아
# 정상 보유를 external 로 강등시킨다 — 손절·익절이 조용히 사라진다.
# 반대로 거부목록은 미지의 status 를 자동으로 "근거 있음"으로 처리하므로
# 불변식 "정상 포지션은 절대 강등되지 않는다" 를 **미래의 새 status 값에 대해서도**
# 지킨다. 유입 잔고 격리가 늦어지는 쪽은 허용 가능하고, 오강등은 허용 불가다.
#
# [실측 2026-09-30 11:39 KST] 운영 원장 전수(GET /api/v1/trades?days=800&limit=5000
# → 352건, 2026-04-02~09-30)의 status 분포는 **{'COMPLETED': 352}** — 단 하나다.
# 즉 이 수정은 **현재 영향이 없는 순수 방어**이며, 추측이 아니라 실측이다.
# 그리고 코드베이스에서
# 'FILLED'/'EXECUTED' 를 **쓰는 곳은 없다**(읽는 쪽 5개 파일의 방어적 레거시 값).
# 실제 writer 가 내는 값은 COMPLETED / PARTIAL / PENDING / FAILED 네 개이고,
# 이전 허용목록 `["EXECUTED","COMPLETED","PARTIAL"]` 의 진짜 구멍은 'FILLED' 가
# 아니라 **'PENDING'** 이었다: `trade_executor._execute_buy` 는 KIS rt_cd=="1"
# (주문접수·체결 미확인)에 PENDING 을 기록하고 `_verify_and_sync_execution` 이
# 나중에 COMPLETED/PARTIAL 로 갱신한다. 체결 확인이 실패하면 주식은 보유 중인데
# 원장은 영구 PENDING 으로 남아 강등 대상이 된다.
#
# 'FAILED'(주문 거부)만 제외한다 — 주식이 생기지 않으므로 Portfolio 행 자체가
# 없고, 근거로 인정하면 '매수 실패' 만으로 유입 잔고가 격리를 면한다.
#
# ⚠️ 다른 5개 파일(`trade_analyzer`·`schedulers`·`journals`·`risk_management`)의
# 허용목록과 **통합하지 않았다.** 그쪽은 손익·성과 집계라 "확정 체결"만 세야 하고
# (PENDING 을 넣으면 실현손익이 오염된다) 여기는 "매수 시도 흔적이 있는가"로
# 의미가 다르다. 같은 상수로 묶으면 한쪽을 고칠 때 반대쪽이 조용히 망가진다.
_NON_FILL_BUY_STATUSES = ("FAILED",)
# ── 유입 잔고 강등(swing → external) 확정 규칙 ──────────────────────────────
# [2026-09-30] 사용자 결정: "스윙으로 편입하는 건 없어야 해" → 승격뿐 아니라 강등도
# 허용한다. 단 **정상 포지션은 절대 강등되지 않는다**가 불변식이다.
#
# 레이스: TradeHistory·ActiveTrade 는 둘 다 db_worker 큐를 거쳐 비동기 INSERT 되고
# (§3.2), 매수 직후 `trade_executor._verify_and_sync_execution()` 이
# `_delayed_sync_portfolio(0)` 로 즉시 sync 를 돌린다. 그 창에서 강등하면 방금 산
# 포지션이 손절·익절·트레일링을 통째로 잃는다.
#
# 3중 방어:
# 1) `system_buy_registry` — 주문 성공 시점의 **즉시 인메모리 근거**(주 방어).
# DB 반영 전이라도 근거가 보인다.
# 2) 연속 관측 + 최소 경과시간 — 프로세스 재기동으로 1)이 비었을 때의 2차 방어.
# 같은 종목이 서로 다른 sync 에서 _DEMOTE_CONFIRM_SYNCS 회 이상 근거 없이
# 관측되고, 첫 관측으로부터 _DEMOTE_MIN_AGE_SEC 이상 지나야 강등한다.
# (매수 직후 sync 가 여러 번 연달아 도는 경우를 횟수만으로는 못 막는다 →
# 벽시계 하한을 함께 둔다.)
# 3) db_worker 큐가 비어 있지 않으면 그 사이클은 강등하지 않는다 — 재시도
# 백오프(1→2→4초)로 근거 INSERT 가 밀려 있을 수 있다.
# 반대 방향(유입 잔고 격리가 최대 수 분 늦어짐)은 허용 가능하다.
_DEMOTE_CONFIRM_SYNCS = 2
_DEMOTE_MIN_AGE_SEC = 120.0
# {stock_code: [첫 미관측 monotonic 시각, 미관측 관측 횟수]}
_demote_miss_state: dict = {}
def _demote_state_reset(stock_code: str = None) -> None:
"""강등 미관측 카운터 초기화 (테스트·근거 확인 시)."""
if stock_code is None:
_demote_miss_state.clear()
else:
_demote_miss_state.pop(stock_code, None)
def _db_queue_busy() -> bool:
"""db_worker 큐에 미처리 태스크가 남아 있는가 (강등 보류 판단용).
조회 실패는 **보류(True)** 로 해석한다 — 불확실할 때 강등하지 않는 쪽이 안전하다.
"""
try:
from app.services.db_worker import get_db_task_queue
return get_db_task_queue().qsize() > 0
except Exception as e:
logger.warning(f"db_worker 큐 상태 조회 실패 → 강등 보류: {e}")
return True
def _should_demote_to_external(
stock_code: str,
current_type: str,
classified: str,
queue_busy: bool,
now: float = None,
) -> bool:
"""기존 Portfolio 행을 swing → external 로 강등할지 판정 (부수효과=미관측 카운터).
Args:
stock_code: 종목코드
current_type: 현재 `Portfolio.strategy_type`
classified: `_classify_holding_strategy_type()` 결과
queue_busy: db_worker 큐에 미처리 태스크가 있는가
now: monotonic 시각 (테스트 주입용)
Returns:
bool: True 면 강등. 판정 근거는 위 _DEMOTE_* 주석 참조.
"""
import time as _time
_now = _time.monotonic() if now is None else float(now)
# 근거가 보이면 즉시 카운터 소멸 — 이후 어떤 사이클도 누적을 물려받지 않는다.
if classified != EXTERNAL_STRATEGY_TYPE:
_demote_state_reset(stock_code)
return False
# 강등 대상은 제네릭 'swing' 뿐이다. 'manual'·'us_swing'·'daytrading' 은
# 애초에 get_swing_holdings(strategy_type=='swing') 에 안 잡혀 스윙 자동 청산
# 대상이 아니고, 각 엔진이 strategy_name 으로 자기 행을 매칭한다.
if (current_type or "swing") != "swing":
return False
# 1차 방어: 주문 성공 직후의 즉시 인메모리 근거
try:
from app.services.system_buy_registry import has_recent_system_buy
if has_recent_system_buy(stock_code):
_demote_state_reset(stock_code)
return False
except Exception as e:
logger.warning(f"system_buy_registry 조회 실패 → 강등 보류 ({stock_code}): {e}")
return False
# 2차 방어: 연속 관측 + 최소 경과시간
st = _demote_miss_state.get(stock_code)
if st is None:
_demote_miss_state[stock_code] = [_now, 1]
return False
st[1] += 1
first_ts, count = st[0], st[1]
# 3차 방어: db_worker 큐가 밀려 있으면 이번 사이클은 보류 (카운터는 유지)
if queue_busy:
return False
if count < _DEMOTE_CONFIRM_SYNCS or (_now - first_ts) < _DEMOTE_MIN_AGE_SEC:
return False
_demote_state_reset(stock_code)
return True
def _external_stop_breaches(holdings: list) -> list:
@@ -105,6 +245,472 @@ def _classify_holding_strategy_type(
return "swing"
return EXTERNAL_STRATEGY_TYPE
def _classify_holding_strategy_type_v2(
stock_code: str,
quantity: int,
dt_qty: dict,
system_codes: set,
system_qty: dict,
) -> str:
"""`_classify_holding_strategy_type` + **수량 인식** 판정 (순수 함수).
## 왜 v2 가 필요한가 — 부분 혼재가 격리를 세탁한다 (2026-09-30 운영 실측)
`Portfolio.stock_code` 는 UNIQUE 라 같은 종목의 유입분·스윙·DT 수량이 **한 행에
합산**된다(CLAUDE.md §3.5). v1 은 "DT 전량이면 daytrading, 부분 혼재면 스윙 청산
대상 유지" 였기 때문에 유입 잔고에 시스템 매수가 1주라도 섞이면 **전체가 swing
으로 승격**됐다:
11:35 105560 KB금융 external 3주 ← 유입 잔고, 올바르게 격리됨
11:45 105560 BUY 3주 (DT VWAP 반등) ← DT 가 3주 매수
12:42 105560 KB금융 swing 6주 ← external → swing 세탁
유입 3주가 스윙 자동 손절 대상이 됐다. 사용자 결정("스윙으로 편입하는 건 없어야
해")의 정면 위반이고, 000100 과 같은 고아 청산으로 이어지는 경로다.
## 추가 분기 (v1 대비 단 하나)
시스템 근거 수량이 **정확히 DT 보유분과 같고** 행 수량보다 작으면 → `external`.
즉 "행에 섞인 시스템 몫이 전부 DT" 인 경우다. 이때 external 유지는 **관리 공백을
만들지 않는다** — DT 엔진은 `day_trading_state.positions`(인메모리)로 자기 수량을
관리하고 `Portfolio.strategy_type` 을 보지 않으며, 15:20 EOD 에 전량 청산한다.
청산되면 원장 net(BUY−SELL)이 0 으로 돌아와 순수 유입분만 남는다 → 태그가
`swing` 으로 굳는 문제가 원천적으로 사라진다.
## 왜 `==` 인가 (`<=` 가 아니라)
`sys_qty <= dt_held` 로 하면 원장이 스윙 몫을 **과소 집계**했을 때(체결 이력 유실)
스윙 보유분이 있는 행까지 external 이 되어 손절 관리를 잃는다. `==` 는 과소 집계
시 분기가 **발동하지 않아** swing 으로 남는다 = 안전한 방향으로 실패한다.
호출부의 `system_qty` 산출도 같은 이유로 **과대 집계 쪽으로 편향**시킨다
(BUY 는 PENDING 의 order_quantity 까지 세고, SELL 은 확정 체결만 차감).
## 스윙이 섞인 혼재(`dt_held == 0`)는 `swing` 을 유지한다
그쪽을 external 로 돌리면 **시스템이 산 수량이 손절·익절 밖에 남는다**
(CLAUDE.md §8.1-11 위반). 대신 ① `trade_executor` 가 external 행에 대한 스윙
추가매수를 차단해 이 혼재가 **발생하지 않게** 하고 ② 기존 행은 호출부가
`⚠️ 유입분 혼재` warning 으로 노출한다. 근본 해결(스윙 청산 시 유입 수량 차감)은
`swing/exit_service` 변경이 필요해 이 작업 범위 밖이다.
Args:
stock_code: 종목코드
quantity: KIS 잔고 수량 (= Portfolio 행 수량)
dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty}
system_codes: 시스템 매수 근거가 있는 종목 집합 (v1 과 동일)
system_qty: 시스템 매수 근거 **수량** {code: qty} — `_compose_system_quantities`
Returns:
"daytrading" | "swing" | "external"
## 한계 — 불변식이 보장되지 않는 유일한 경로 (M-3, 2026-09-30)
"정상 포지션은 절대 강등되지 않는다" 는 `system_qty > 0` 에 의존하고, 그 값은
**네 소스의 하한 합성**이다(원장 net / 비DT active ActiveTrade 잔량 / DT 인메모리 /
system_buy_registry). 따라서 **네 개가 동시에 0 이 되어야** 정상 포지션이 external
이 된다. 구체적으로 다음이 **전부** 성립할 때다:
① 원장 net ≤ 0 — 같은 매도가 중복 INSERT 되어 수량이 두 번 차감됨
(`db_worker.insert_trade_history` 는 멱등이 아니고 DLQ 가 기록 계열을
24시간 내 재큐잉한다). **`order_id` 단위 중복 제거로 막았으나**
`order_id` 가 NULL 인 행은 중복 판정이 불가능해 남는다.
② 비DT active ActiveTrade 가 없음 — 정합 루프가 `_actual == 0` 으로 closed
시킨 뒤 재생성되지 않은 경우.
[2026-09-30] `db_worker` 의 `close_active_trade` 가 상태 계열에서 빠지고
**충돌 판정만** 받게 바뀌어(나이 상한 7일) 유령 active 행이 줄었다 —
"닫아라" 지시가 시간이 지나도 거짓이 되지 않으므로 유실이 줄고, 동시에
유령 행이 유입 잔고를 "시스템 보유" 로 오인시키는 경로도 약해졌다.
즉 이 하한의 **정확도가 양방향으로 올라갔다**.
③ DT 인메모리에도 없음 (스윙 포지션이므로 정상)
④ registry TTL(15분) 경과
①~④ 동시 성립은 확률이 낮지만 **불가능하지는 않다.** 발생하면 그 포지션은 자동
손절·익절에서 빠지고 `/portfolio/summary` 의 `external_stop_breaches` 와
`🚧 유입 잔고 격리` warning 으로 노출된다(조용히 사라지지는 않는다).
근본 해결은 `insert_trade_history` 의 멱등화(order_id UNIQUE)이며 별도 작업이다.
"""
_base = _classify_holding_strategy_type(stock_code, quantity, dt_qty, system_codes)
if _base != "swing":
return _base
_dt_held = int(dt_qty.get(stock_code, 0) or 0)
_sys = int(system_qty.get(stock_code, 0) or 0)
if _sys <= 0 < int(quantity):
# 근거가 '있었지만' 수량이 남아 있지 않다 → 남은 수량은 유입분이다.
# [2026-09-30] 이 분기가 없으면 DT 가 EOD 청산한 뒤에도 과거 BUY 기록 때문에
# `system_codes` 멤버십이 영구히 유지돼 순수 유입분이 swing 으로 되돌아간다
# (105560 사건의 후속 경로 — 팀리드 요구사항 2).
# 과소 집계 위험은 호출부가 막는다: 원장 net 외에 **비시딩 ActiveTrade 잔량**,
# **DT 인메모리 수량**, **system_buy_registry 주문 수량**(원장 INSERT 전)을
# 모두 하한으로 합성하므로, 살아 있는 시스템 포지션은 _sys > 0 이 보장된다.
return EXTERNAL_STRATEGY_TYPE
if 0 < _sys < int(quantity) and _sys == _dt_held:
# 혼재 + 시스템 몫이 전부 DT → 격리 유지 (DT 는 인메모리로 자기 수량 관리)
return EXTERNAL_STRATEGY_TYPE
return "swing"
def _system_buy_codes_stmt():
"""시스템 매수 근거가 있는 종목코드 statement (수량 무관) — `(stock_code,)`.
status 는 **거부목록**으로 판정한다(`_NON_FILL_BUY_STATUSES`) — 허용목록은 값이
하나 빠지는 순간 정상 보유를 강등시킨다. 기간 제한은 두지 않는다: ghost 필터처럼
당일만 보면 자정을 넘긴 유입 잔고가 다시 정상 보유로 재편입된다.
"""
return (
select(TradeHistory.stock_code)
.where(
TradeHistory.order_type == "BUY",
or_(
TradeHistory.status.is_(None),
TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES),
),
)
.distinct()
)
def _evidence_active_trade_codes_stmt():
"""'시스템 매수 근거' 로 인정하는 ActiveTrade 종목코드 statement — `(stock_code,)`.
ActiveTrade 행도 근거로 인정한다(체결 이력 유실 대비). 단 sync 가 유입 포지션에
대해 자동 시딩한 행은 근거가 아니다 — 근거로 쓰면 external → swing 자동 승격이라는
자기순환이 생긴다. 시딩 판별은 **두 시기 모두** 걸러야 한다:
· `strategy_name == 'synced'` (2026-09-29 `7abbb34` 이후 규약)
· `entry_reason LIKE '외부 유입 포지션 — 동기화 시 자동 등록%'`
(그 이전 `6207d13` 는 `strategy_name = Portfolio.strategy_type` 즉 제네릭
'swing' 으로 시딩해 이름만으로는 구별이 불가능하다)
entry_reason 필터는 **양방향**으로 필요하다: 빼면 유입 잔고가 자기 시딩 행을
근거로 삼아 ① 강등이 영구 불가해지고 ② 이미 격리된 행이 오승격된다.
"""
return (
select(ActiveTrade.stock_code)
.where(
or_(
ActiveTrade.strategy_name.is_(None),
ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES),
),
or_(
ActiveTrade.entry_reason.is_(None),
ActiveTrade.entry_reason.notlike(
f"{_SEEDED_AT_ENTRY_REASON_PREFIX}%"
),
),
)
.distinct()
)
def _system_buy_qty_stmt():
"""원장 BUY 수량 합계 statement — `(stock_code, buy_qty)`.
**과대 집계 편향**: `executed_quantity` 가 0/NULL 인 PENDING 행은 `order_quantity`
로 센다 — 주문접수 후 체결 확인이 실패하면 주식은 보유 중인데 executed 가 0 으로
남는다. 수량이 크게 나오면 "시스템 보유분" 으로 판정돼 관리를 유지하므로 오판의
방향이 안전하다(CLAUDE.md §8.1-11).
BUY 는 `order_id` 중복 제거를 **하지 않는다** — 중복이 있으면 수량이 커지고, 그
방향은 안전하다. (SELL 은 반대이므로 제거한다: `_system_sell_qty_stmt`)
"""
_q = func.coalesce(
func.nullif(TradeHistory.executed_quantity, 0),
TradeHistory.order_quantity,
0,
)
return (
select(TradeHistory.stock_code, func.sum(_q))
.where(
TradeHistory.order_type == "BUY",
or_(
TradeHistory.status.is_(None),
TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES),
),
)
.group_by(TradeHistory.stock_code)
)
def _system_sell_qty_stmt():
"""원장 SELL 수량 statement — `(stock_code, dedupe_key, qty)`. 합산은 호출부에서.
## SELL 은 **양방향 모두 위험**하다 — 정확해야 한다 (2026-09-30 정정)
이전 판 docstring 은 "SELL 과대 집계는 위험, 과소는 안전" 이라고 썼는데 **틀렸다.**
강등만 있던 시점의 논증이었고, 같은 작업에서 **승격 경로가 함께 살아났기 때문에**
양쪽이 다 위험하다:
· **과대 집계**(중복 차감) → `_sys` 축소 → 정상 포지션이 유입분으로 보여 **강등**
(손절·익절 상실)
· **과소 집계**(중복 접힘) → `_sys` 확대 → 유입 행이 `_sys >= quantity` 로 보여
**승격**(유입분이 스윙 자동 손절 대상) + 근거가 계속 보여 강등 불가(고착)
## 중복 제거 키
`db_worker.insert_trade_history` 는 멱등이 아니고 DLQ 가 기록 계열 태스크를
재큐잉하므로 같은 매도가 두 행으로 들어올 수 있다. 같은 KIS 주문은 `order_id` 가
같으므로 그 단위로 1건만 센다.
⚠️ **`order_id` 는 NULL 이 아니라 빈 문자열 `''` 로 저장된다.**
`trade_helpers.build_kis_order_id()` 가 미발견 시 `""` 를 반환하고
(`swing/exit_service.py` 의 `.get("ODNO", "")` 도 동일), 그 행은
`status="COMPLETED"` + `executed_quantity` 가 채워져 FAILED 필터에 걸리지 않는다.
따라서 `coalesce(order_id, ...)` 만으로는 `''` 를 통과시켜 **서로 다른 매도들이 한
그룹으로 접히고 `max()` 만 남는다**(분할매도 2회로 재현). `nullif(trim(...), '')`
로 빈 문자열·공백 문자열을 NULL 로 환산한 뒤 `id` 폴백을 쓴다.
`trim` 을 키에 쓰므로 패딩만 다른 같은 주문번호도 같은 키가 된다.
`order_id` 가 비어 있는 행은 중복 판정이 불가능해 `id` 로 개별 계상된다 = 중복
차감 위험이 남는 유일한 경로다(`_classify_holding_strategy_type_v2` docstring 의
'한계' 항에 명시).
확정 체결(`executed_quantity`)만 차감한다 — 미체결 매도를 차감하면 보유분을
과소 집계한다.
"""
_key = func.coalesce(
func.nullif(func.trim(TradeHistory.order_id), ""),
func.concat("id:", TradeHistory.id),
)
return (
select(
TradeHistory.stock_code,
_key,
func.max(func.coalesce(TradeHistory.executed_quantity, 0)),
)
.where(
TradeHistory.order_type == "SELL",
or_(
TradeHistory.status.is_(None),
TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES),
),
)
.group_by(TradeHistory.stock_code, _key)
)
def _active_trade_qty_stmt():
"""비시딩·**비DT** active ActiveTrade 잔량 statement — `(stock_code, qty)`.
## 왜 DT 행을 제외하는가 (C-1, 2026-09-30)
`sync_portfolio` 의 ActiveTrade 정합 루프는 "같은 종목 active 행이 1개면 그 행이
전량을 대표한다"고 보고 **Portfolio 수량으로 교정**한다. 유입 3주 + DT 3주 = 6주
행에서 active 행은 DT 1개뿐이므로, DT 행 수량이 **3 → 6 으로 부풀려진다.**
그 값을 근거 수량으로 쓰면 다음 sync 에서 `_sys = 6 = quantity` 가 되어
**유입분이 swing 으로 승격되고, 승격 후에는 근거가 계속 보여 강등이 영구 불가**
(고착)가 된다. 즉 DT ActiveTrade 잔량은 구조적으로 'DT 소유 수량' 의 근거가 될
수 없다.
DT 의 정본 수량은 `day_trading_state.positions`(인메모리)이고 호출부가 그것을
별도 하한으로 합성한다. 여기서는 DT 행을 뺀다.
DT 판별은 `db_worker.not_dt_strategy_clause()` **단일 정본**을 쓴다
(CLAUDE.md §3.3 — 한 곳만 다르게 고치면 더 나쁜 불일치가 된다).
`status == "active"` 한정이고 집계는 `max` 다(`sum` 아님): 2026-09-23 에
active_trades 유령 행 129개가 실재했으므로(§8.4.1) 중복 행이 수량을 부풀리면
유입분이 시스템 보유로 오인된다.
"""
from app.services.db_worker import not_dt_strategy_clause
return (
select(ActiveTrade.stock_code, func.max(ActiveTrade.quantity))
.where(
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
or_(
ActiveTrade.strategy_name.is_(None),
ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES),
),
or_(
ActiveTrade.entry_reason.is_(None),
ActiveTrade.entry_reason.notlike(
f"{_SEEDED_AT_ENTRY_REASON_PREFIX}%"
),
),
)
.group_by(ActiveTrade.stock_code)
)
def _compose_system_quantities(
buy_rows,
sell_rows,
at_rows,
dt_qty: dict,
registry_qty: dict,
) -> dict:
"""시스템 매수 근거 **수량** {code: qty} 합성 (순수 함수).
원장 net(BUY − SELL)을 기본값으로 하고, 세 소스를 **하한(max)** 으로 덮는다.
하한이 여러 개인 이유는 어느 하나가 무너져도 살아 있는 시스템 포지션의 수량이
0 으로 보이지 않게 하기 위함이다(= 정상 포지션이 유입분으로 오인되지 않는다).
Args:
buy_rows: `_system_buy_qty_stmt()` 결과 `[(code, qty), ...]`
sell_rows: `_system_sell_qty_stmt()` 결과 `[(code, key, qty), ...]` — 키 단위
행이므로 여기서 종목별로 합산한다(order_id 중복은 이미 제거됨)
at_rows: `_active_trade_qty_stmt()` 결과 `[(code, qty), ...]`
dt_qty: DT 인메모리 보유 수량
registry_qty: `system_buy_registry.snapshot_quantities()` (원장 INSERT 전 창)
Returns:
dict[str, int]
"""
out: dict = {}
for _c, _q in buy_rows:
if _c:
out[_c] = out.get(_c, 0) + int(_q or 0)
# (code, dedupe_key) 단위로 1건만 센다. SQL 이 이미 GROUP BY 로 접었지만 여기서도
# 접어야 순수 함수만으로 중복 제거를 검증할 수 있다(그리고 두 겹이 손해가 없다).
_sold_by_key: dict = {}
for _row in sell_rows:
_c, _q = _row[0], _row[-1]
if not _c:
continue
_k = _row[1] if len(_row) >= 3 else None
# SQL 과 같은 규칙: 빈 문자열·공백 키는 '중복 판정 불가' 이므로 접지 않는다.
# (order_id 는 NULL 이 아니라 '' 로 저장된다 — _system_sell_qty_stmt docstring)
if _k is None or not str(_k).strip():
_sold_by_key[(_c, f"__row{len(_sold_by_key)}__")] = int(_q or 0)
continue
_k = str(_k).strip()
_sold_by_key[(_c, _k)] = max(_sold_by_key.get((_c, _k), 0), int(_q or 0))
_sold: dict = {}
for (_c, _k), _q in _sold_by_key.items():
_sold[_c] = _sold.get(_c, 0) + _q
for _c, _q in _sold.items():
out[_c] = out.get(_c, 0) - _q
out = {_c: max(0, _q) for _c, _q in out.items()}
for _src in (at_rows, (dt_qty or {}).items(), (registry_qty or {}).items()):
for _c, _q in _src:
if _c:
out[_c] = max(out.get(_c, 0), int(_q or 0))
return out
def _should_correct_active_trade_quantity(
portfolio_strategy_type: str,
active_row_count: int,
actual_quantity: int,
system_quantity: int = None,
) -> bool:
"""ActiveTrade 잔량을 Portfolio 수량으로 교정해도 되는가 (순수 함수).
교정의 전제는 **"active 행 1개 = 그 행이 Portfolio 수량 전량을 대표한다"** 다.
`strategy_type == 'external'` 행에서는 그 전제가 거짓이다 — 행 수량에 시스템이
소유하지 않은 유입분이 섞여 있다. 그런 행에서 교정하면
· 유입 3주 + DT 3주 = 6주 → DT ActiveTrade 가 3 → 6 으로 부풀려지고
· 그 값이 다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격되며
(승격 후에는 근거가 계속 보여 강등이 영구 불가 = 고착)
· DT 청산 시 잔량 6 으로 과다매도·주문거부 위험(CLAUDE.md §3.5)
**유령 행 정리(`actual_quantity == 0` → closed)는 이 함수와 무관하게 계속 수행해야
한다** — 그쪽은 "보유가 없다"는 사실만 쓰고 수량 귀속을 전제하지 않는다.
호출부가 `actual_quantity == 0` 분기를 먼저 처리하므로 여기서는 방어적으로만 본다.
Args:
portfolio_strategy_type: `Portfolio.strategy_type`
active_row_count: 그 종목의 status='active' ActiveTrade 행 수
actual_quantity: 실제 보유 수량(= Portfolio 수량)
system_quantity: 시스템 매수 근거 수량. **durable 값**(레지스트리 제외)을 넘긴다
— 휘발성 근거로 ActiveTrade 수량을 영구 변경하면 안 된다.
`None`(미제공)은 "전제 검증 불가" 로 해석해 **교정하지 않는다**.
Returns:
bool: True 면 교정 가능.
"""
if int(active_row_count) != 1:
return False # 스윙+DT 혼재 등 → 자동 교정하지 않음 (기존 규칙)
if int(actual_quantity) <= 0:
return False # 정리(closed) 대상 — 수량 교정 경로가 아니다
if (portfolio_strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
return False # 유입분이 섞여 있어 수량을 어느 전략에도 귀속시킬 수 없다
# [M-3 2026-09-30] **전제를 직접 검증한다.** 태그만 보는 것으로는 부족했다 —
# 유입 3 + 스윙 3 = 6 행은 `_classify_holding_strategy_type_v2` 가 `swing` 을
# 유지하므로(혼재 분기는 `_sys == _dt_held` 일 때만 발동) external 예외를
# 빠져나가 교정이 통과했고, 스윙 ActiveTrade 가 3 → 6 으로 부풀어
# ① 다음 스윙 청산이 **유입 3주까지 매도**(§3.5 과다매도·주문거부, §8.1-11 위반)
# ② 부풀린 6 이 `_active_trade_qty_stmt` 하한으로 돌아와 `_sys = 6 = quantity`
# → 승격 후 근거가 계속 보여 **영구 고착**
# 이 되었다. 근거 수량이 행 수량을 덮을 때만 "그 행이 전량을 대표한다"가 참이다.
if system_quantity is None:
return False # 근거 수량 미제공 → 전제를 검증할 수 없다 → 교정하지 않는다
if int(system_quantity) < int(actual_quantity):
return False # 설명되지 않는 수량(= 유입 추정분)이 남아 있다
return True
def _reconcile_active_trade_quantities(
active_rows_by_code: dict,
held_qty: dict,
strategy_by_code: dict,
system_qty: dict = None,
) -> list:
"""active ActiveTrade 행을 실제 보유 수량에 맞춘다 (ORM 객체를 **제자리 수정**).
2026-09-23 버그픽스의 본체다 — 분할매도·수동매도 누락으로 DB 잔량이 실제와
달라지면 다음 청산이 과다매도가 된다. `sync_portfolio` 에서 분리한 이유는 소스
문자열이 아니라 **동작으로 검증**할 수 있게 하기 위함이다(M-5).
두 가지를 한다:
1. **유령 행 정리** — 보유 수량이 0 이면 `status='closed'`. 태그와 무관하게
**항상** 수행한다(수량 귀속을 전제하지 않는 판정이므로).
2. **잔량 교정** — `_should_correct_active_trade_quantity()` 가 허용할 때만
`row.quantity = 실제 보유량`.
Args:
active_rows_by_code: {stock_code: [status=='active' ActiveTrade 행, ...]}
held_qty: {stock_code: Portfolio 수량} (수량 > 0 인 행만)
strategy_by_code: {stock_code: Portfolio.strategy_type}
system_qty: {stock_code: 근거 수량} — **durable 값**(레지스트리 제외).
미제공이면 어떤 행도 수량 교정하지 않는다(전제 검증 불가 → 보류).
Returns:
list[dict]: 수행한 조치 `{"action": "closed"|"corrected"|"skipped_external",
"stock_code", "from", "to"}` — 호출부가 로그로 남긴다(§4: debug 금지).
"""
actions: list = []
for _code, _rows in (active_rows_by_code or {}).items():
_actual = int(held_qty.get(_code, 0) or 0)
_tag = strategy_by_code.get(_code, "swing")
if len(_rows) == 1 and _actual == 0:
_rows[0].status = "closed"
actions.append({"action": "closed", "stock_code": _code,
"from": int(_rows[0].quantity or 0), "to": 0})
continue
_sys = None if system_qty is None else int(system_qty.get(_code, 0) or 0)
if not _should_correct_active_trade_quantity(_tag, len(_rows), _actual, _sys):
if len(_rows) == 1 and int(_rows[0].quantity or 0) != _actual:
# 태그가 external 이든 혼재 swing 이든 "설명되지 않는 수량이 있어
# 교정을 보류했다" 는 사실은 같다 — 조용히 넘기지 않는다(§4).
actions.append({
"action": "skipped_unattributable",
"stock_code": _code,
"from": int(_rows[0].quantity or 0),
"to": _actual,
"strategy_type": _tag,
"system_qty": _sys,
})
continue
_row = _rows[0]
if int(_row.quantity or 0) != _actual:
actions.append({"action": "corrected", "stock_code": _code,
"from": int(_row.quantity or 0), "to": _actual})
_row.quantity = _actual
return actions
def _mixed_inflow_surplus(stock_code: str, quantity: int, system_qty: dict) -> int:
"""행 수량 중 시스템 근거로 설명되지 않는 수량(= 유입 추정분). 순수 함수.
가시화(warning) 전용이다 — 집행 판단에 쓰지 않는다. 원장 과대 집계 편향 때문에
실제 유입분보다 **작게** 나올 수 있다(0 이면 "혼재 아님"이 아니라 "확증 불가").
"""
return max(0, int(quantity) - int(system_qty.get(stock_code, 0) or 0))
# Request/Response Models
class SellRequest(BaseModel):
stock_code: str
@@ -708,36 +1314,96 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 태깅이 없으면 sync 가 전부 'swing' 으로 편입하고 get_swing_holdings()가
# 곧바로 손절 판정을 내려 유입 잔고를 전략 손절로 처분했다
# (2026-04~09 고아 청산 9건 / -153,661원).
# 근거는 "기간 무제한" 이어야 한다 — ghost 필터처럼 당일만 보면 자정을
# 넘긴 유입 잔고가 다시 정상 보유로 재편입된다.
_buy_hist_rows = await db.execute(
select(TradeHistory.stock_code)
.where(
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(["EXECUTED", "COMPLETED", "PARTIAL"]),
)
.distinct()
)
# 근거 조회 규칙(status 거부목록 / 시딩 ActiveTrade 제외 2중 필터)은
# `_system_buy_codes_stmt` · `_evidence_active_trade_codes_stmt` docstring 참조.
_buy_hist_rows = await db.execute(_system_buy_codes_stmt())
_system_bought = {c for c in _buy_hist_rows.scalars().all() if c}
# ActiveTrade 행도 근거로 인정한다(체결 이력 유실 대비). 단 sync 가 유입
# 포지션에 대해 자동 시딩한 행(strategy_name='synced')은 근거가 아니다 —
# 근거로 쓰면 external → swing 자동 승격이라는 자기순환이 생긴다.
_at_rows_prov = await db.execute(
select(ActiveTrade.stock_code)
.where(
or_(
ActiveTrade.strategy_name.is_(None),
ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES),
)
)
.distinct()
_at_rows_prov = await db.execute(_evidence_active_trade_codes_stmt())
# ── 근거 집합 두 벌: durable(레지스트리 제외) / with-registry ──────
# [M-2 2026-09-30] `system_buy_registry` 는 **강등을 막는 근거**로 설계됐다.
# 그런데 같은 집합을 승격 판정에도 쓰면 안전 방향이 뒤집힌다:
# · 강등 방어 → "근거가 있다고 보는 것"이 안전 (보수적 = 관리 유지)
# · 승격 판정 → "근거가 없다고 보는 것"이 안전 (보수적 = 격리 유지)
# 실제 사고 경로: 유입 3주 + DT 3주 행에서 DT 가 15:20 EOD 청산하면 원장
# net·비DT active·DT 인메모리가 모두 0 이 되는데 레지스트리만 TTL 내라
# `_sys=3=quantity` → **승격**되고 `strategy_type='swing'` 이 DB 에 굳는다.
# 장이 닫혀 다음 사이클이 오지 않으면 그대로 밤을 넘기고 **다음 날 개장 시
# 유입분이 자동 손절 대상**이 된다(105560 과 같은 결말).
# → 승격은 durable 근거만 본다. 레지스트리는 신규 행 태깅과 강등 방어에만.
_system_codes_durable = (
_system_bought | {c for c in _at_rows_prov.scalars().all() if c} | _dt_codes
)
_system_codes = _system_bought | {c for c in _at_rows_prov.scalars().all() if c}
_system_codes |= _dt_codes # 당일 DT 인메모리 포지션도 시스템 매수분
try:
from app.services.system_buy_registry import snapshot_codes as _sbr_codes
_registry_codes = _sbr_codes()
except Exception as _sbr_err:
logger.warning(f"system_buy_registry 스냅샷 실패: {_sbr_err}")
_registry_codes = set()
_system_codes = _system_codes_durable | _registry_codes
# ── 시스템 매수 근거 **수량** (부분 혼재 판정용) ───────────────────
# [2026-09-30] 근거의 '유무'만으로는 부분 혼재를 못 잡는다 — 유입 3주에
# DT 3주가 섞이자 6주 전체가 swing 으로 세탁된 사건(105560 KB금융).
# 편향·중복제거·DT 제외의 근거는 각 statement 빌더 docstring 참조.
_sys_buy = (await db.execute(_system_buy_qty_stmt())).all()
_sys_sell = (await db.execute(_system_sell_qty_stmt())).all()
_sys_at = (await db.execute(_active_trade_qty_stmt())).all()
try:
from app.services.system_buy_registry import (
snapshot_quantities as _sbr_qty,
)
_sbr_q = _sbr_qty()
except Exception as _sbq_err:
logger.warning(f"system_buy_registry 수량 스냅샷 실패: {_sbq_err}")
_sbr_q = {}
# durable = 레지스트리 제외 (승격·ActiveTrade 잔량 교정 판정용)
_system_qty_durable = _compose_system_quantities(
_sys_buy, _sys_sell, _sys_at, _dt_qty, {}
)
_system_qty = _compose_system_quantities(
_sys_buy, _sys_sell, _sys_at, _dt_qty, _sbr_q
)
# db_worker 큐 적체 여부 — 강등 보류 판단(사이클당 1회 조회).
_queue_busy = _db_queue_busy()
def _classify_durable(_code: str, _qty: int) -> str:
"""레지스트리를 **제외한** 근거만으로 판정 — 승격 판정 전용 (M-2).
레지스트리는 "방금 주문했다" 는 휘발성 사실이라 `strategy_type` 을
DB 에 영구 기록하는 승격의 근거로 쓸 수 없다.
"""
return _classify_holding_strategy_type_v2(
_code, _qty, _dt_qty, _system_codes_durable, _system_qty_durable
)
def _classify_strategy_type(_code: str, _qty: int) -> str:
"""Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임)."""
return _classify_holding_strategy_type(_code, _qty, _dt_qty, _system_codes)
"""Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임).
[2026-09-30] v2 로 승격 — 부분 혼재(유입분 + DT)를 수량으로 구분한다.
"""
_t = _classify_holding_strategy_type_v2(
_code, _qty, _dt_qty, _system_codes, _system_qty
)
# 혼재를 조용히 넘기지 않는다 (CLAUDE.md §4: logger.debug 금지)
_surplus = _mixed_inflow_surplus(_code, _qty, _system_qty)
if _surplus > 0:
if _t == EXTERNAL_STRATEGY_TYPE and _code in _system_codes:
logger.warning(
f"🧩 부분 혼재 — 격리 유지: {_code} 행 {_qty}주 중 "
f"시스템 근거 {_system_qty.get(_code, 0)}주(전량 DT) / "
f"유입 추정 {_surplus}주 → strategy_type='external' "
f"(DT 는 인메모리로 자기 수량을 관리하므로 관리 공백 없음)"
)
elif _t == "swing":
logger.warning(
f"🧩 부분 혼재 — 스윙 유지: {_code} 행 {_qty}주 중 "
f"시스템 근거 {_system_qty.get(_code, 0)}주 / "
f"유입 추정 {_surplus}주 → strategy_type='swing' 유지. "
f"시스템 매수분의 손절·익절을 지키기 위한 선택이며, "
f"유입 추정분이 스윙 자동 청산에 함께 노출된다 — 수동 확인 필요"
)
return _t
# 기존 레코드 로드 (삭제 대신 업데이트)
result = await db.execute(select(Portfolio))
@@ -827,16 +1493,35 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지)
if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0):
h.strategy_type = "daytrading"
elif (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE and _classified == "swing":
# 승격만 허용 — 시스템 매수가 실제로 발생한 뒤에만 운용 대상으로.
# 반대 방향(운용 중인 swing/daytrading/manual → external) 강등은
# 하지 않는다: 체결 이력이 유실된 정상 포지션이 손절·익절 관리를
# 통째로 잃는 쪽이 더 위험하다.
elif (
(h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
and _classify_durable(stock_code, quantity) == "swing"
):
# 승격 — 시스템 매수가 실제로 발생한 뒤에만 운용 대상으로.
# [M-2] durable 근거만 본다(레지스트리 제외) — 휘발성 근거로
# DB 에 영구 기록하면 EOD 이후 격리가 밤새 풀린다.
h.strategy_type = "swing"
logger.warning(
f"⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
f"external → swing (시스템 매수 근거 확인)"
)
elif _should_demote_to_external(
stock_code, h.strategy_type or "swing", _classified, _queue_busy
):
# [2026-09-30] 강등 — 사용자 결정 "스윙으로 편입하는 건 없어야 해".
# 레이스 방어 3중( system_buy_registry / 연속관측+경과시간 /
# db_worker 큐 )을 모두 통과한 경우만 여기 도달한다.
h.strategy_type = EXTERNAL_STRATEGY_TYPE
logger.warning(
f"⬇️ 유입 잔고 격리(기존 강등): {stock_name}({stock_code}) "
f"{quantity}주 평단={average_price} "
f"평가액={float(current_price) * quantity:,.0f}원 "
f"swing → external — 시스템 매수 근거 없음"
f"(TradeHistory BUY·비시딩 ActiveTrade·DT 인메모리 모두 부재, "
f"{_DEMOTE_CONFIRM_SYNCS}회 연속 확인 / "
f"{_DEMOTE_MIN_AGE_SEC:.0f}초 경과) "
f"→ 자동 손절·익절 대상 제외, 수동 판단 필요"
)
new_holdings.append(h)
else:
if _classified == EXTERNAL_STRATEGY_TYPE:
@@ -924,12 +1609,29 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 전량이 DT 포지션일 때만 태깅한다(부분 혼재 시 스윙 청산 대상 유지).
if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0):
h.strategy_type = "daytrading"
elif (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE and _classified == "swing":
# 승격만 허용 (국내 경로와 동일 규칙)
elif (
(h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
and _classify_durable(stock_code, quantity) == "swing"
):
# 승격 (국내 경로와 동일 규칙 — durable 근거만, M-2)
h.strategy_type = "swing"
logger.warning(
f"⬆️ [해외] 유입 격리 해제(승격): {stock_name}({stock_code}) external → swing"
)
elif _should_demote_to_external(
stock_code, h.strategy_type or "swing", _classified, _queue_busy
):
# 강등 (국내 경로와 동일 규칙 — 레이스 방어 3중 통과 시만)
h.strategy_type = EXTERNAL_STRATEGY_TYPE
logger.warning(
f"⬇️ [해외] 유입 잔고 격리(기존 강등): {stock_name}({stock_code}) "
f"{quantity}주 평단={average_price} "
f"평가액={float(current_price) * quantity:,.2f} "
f"swing → external — 시스템 매수 근거 없음 "
f"({_DEMOTE_CONFIRM_SYNCS}회 연속 확인 / "
f"{_DEMOTE_MIN_AGE_SEC:.0f}초 경과) "
f"→ 자동 손절·익절 대상 제외, 수동 판단 필요"
)
new_holdings.append(h)
else:
if _classified == EXTERNAL_STRATEGY_TYPE:
@@ -981,19 +1683,33 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
_by_code: dict[str, list] = {}
for _row in _at_rows:
_by_code.setdefault(_row.stock_code, []).append(_row)
for _code, _rows in _by_code.items():
if len(_rows) != 1:
continue # 스윙+DT 혼재 등 → 자동 교정하지 않음
_row = _rows[0]
_actual = _held.get(_code, 0)
if _actual == 0:
_row.status = "closed"
logger.info(f"🧹 ActiveTrade 정리: {_code} 보유 없음 → closed")
elif int(_row.quantity or 0) != _actual:
# 정합 규칙은 `_reconcile_active_trade_quantities()` 가 정본이다
# (유령 행 정리 + 잔량 교정, external 행은 교정 제외 — C-1).
_strategy_by_code = {
h.stock_code: (h.strategy_type or "swing") for h in new_holdings
}
for _act in _reconcile_active_trade_quantities(
_by_code, _held, _strategy_by_code, _system_qty_durable
):
if _act["action"] == "closed":
logger.info(
f"🧹 ActiveTrade 잔량 교정: {_code} {_row.quantity} → {_actual}"
f"🧹 ActiveTrade 정리: {_act['stock_code']} 보유 없음 → closed"
)
elif _act["action"] == "corrected":
logger.info(
f"🧹 ActiveTrade 잔량 교정: {_act['stock_code']} "
f"{_act['from']} → {_act['to']}"
)
else: # skipped_unattributable
logger.warning(
f"🧩 ActiveTrade 잔량 교정 생략: {_act['stock_code']} "
f"{_act['from']} → {_act['to']} "
f"(strategy_type='{_act['strategy_type']}', "
f"근거수량={_act['system_qty']} < 보유={_act['to']}) — "
f"설명되지 않는 수량(유입 추정분)이 있어 교정하면 다음 청산이 "
f"유입분까지 매도하고(과다매도) 부풀린 값이 근거로 되돌아와 "
f"격리가 영구 고착된다"
)
_row.quantity = _actual
# 외부 유입 포지션(다른 인스턴스/증권사 앱 매수 등)은 ActiveTrade 행이 없어
# 손절·분할익절·트레일링 관리를 전혀 받지 못한다(2026-09-22 카카오뱅크 -13%).
@@ -1061,6 +1777,11 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
except Exception as _rec_err:
logger.warning(f"ActiveTrade 정합 실패(무시): {_rec_err}")
# 계좌에서 사라진 종목의 강등 미관측 카운터 정리 — 재편입 시 옛 누적을
# 물려받아 첫 관측에 곧바로 강등되는 것을 막는다.
for _gone in [c for c in _demote_miss_state if c not in processed_codes]:
_demote_state_reset(_gone)
await db.commit()
# [§8.5 항목4③] 외부 유입 포지션 ActiveTrade 시딩 — commit 성공 후 큐잉.