사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
180 lines
8.8 KiB
Python
180 lines
8.8 KiB
Python
"""portfolio_cache_bridge — 매매 엔진(데이트레이딩/스윙)에서 포트폴리오 화면 상태를 즉시 갱신.
|
|
|
|
역할:
|
|
- 매수 직후: Portfolio 행 upsert(db_worker 큐) + 인메모리 캐시에 즉시 추가 (strategy_type 태깅)
|
|
- 매도 직후: Portfolio 수량 차감/0 (db_worker 큐) + 캐시에서 즉시 제거/차감 + 최근매도 기록
|
|
(KIS 잔고 반영 지연으로 동기화가 되살리는 것 방지)
|
|
|
|
DB 쓰기는 전부 db_worker 큐 경유(CLAUDE.md §3.2). 캐시는 인메모리라 실시간 계층에서 호출 가능.
|
|
portfolio.py 는 지연 import (순환 import·기동 순서 회피).
|
|
"""
|
|
import logging
|
|
from decimal import Decimal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _cache():
|
|
from app.api.endpoints.portfolio import portfolio_cache
|
|
return portfolio_cache
|
|
|
|
|
|
async def _db_quantity(stock_code: str) -> int | None:
|
|
"""Portfolio 테이블의 현재 수량 (읽기 전용). 조회 실패 시 None."""
|
|
try:
|
|
from sqlalchemy import select
|
|
from app.core.database import AsyncSessionLocal
|
|
from app.models.models import Portfolio
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
row = (await session.execute(
|
|
select(Portfolio.quantity).where(Portfolio.stock_code == stock_code)
|
|
)).scalars().first()
|
|
return int(row) if row is not None else None
|
|
except Exception as e:
|
|
logger.debug(f"[cache_bridge] Portfolio 수량 조회 실패 ({stock_code}): {e}")
|
|
return None
|
|
|
|
|
|
async def reflect_buy(
|
|
stock_code: str,
|
|
stock_name: str,
|
|
quantity: int,
|
|
price: float,
|
|
strategy_type: str = "swing",
|
|
market: str = "KRX",
|
|
) -> None:
|
|
"""매수 체결 즉시 Portfolio(큐) + 캐시 반영."""
|
|
try:
|
|
from app.services.db_worker import enqueue_portfolio_upsert
|
|
from app.models.models import Portfolio
|
|
|
|
cache = _cache()
|
|
existing = next((h for h in cache.holdings if h.stock_code == stock_code), None)
|
|
if existing is not None:
|
|
old_qty = int(existing.quantity or 0)
|
|
old_avg = float(existing.average_price or price)
|
|
new_qty = old_qty + quantity
|
|
new_avg = (old_avg * old_qty + price * quantity) / new_qty if new_qty > 0 else price
|
|
existing.quantity = new_qty
|
|
existing.average_price = Decimal(str(round(new_avg, 2)))
|
|
existing.current_price = Decimal(str(price))
|
|
# [2026-09-23] 기존 행의 strategy_type 은 변경하지 않는다.
|
|
# 스윙 8주 보유 중 DT 4주를 사면 행(12주)이 daytrading 이 되어
|
|
# get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다.
|
|
# 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다.
|
|
#
|
|
# [2026-09-30] **부분 혼재 승격을 막는다.**
|
|
# 종전에는 유입 격리('external') 행에 스윙 매수가 들어오면 바로 swing 으로
|
|
# 승격했다. 그런데 Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로
|
|
# (CLAUDE.md §3.5) 승격하면 **유입 수량까지 스윙 자동 손절 대상이 된다.**
|
|
# 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주
|
|
# → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다.
|
|
#
|
|
# 따라서 승격은 **행 전체가 이번 매수로 설명될 때만** 허용한다
|
|
# (old_qty == 0 = 유입 잔여 수량이 없음). 수량이 남아 있는 external 행은
|
|
# 승격하지 않는다.
|
|
# 애초에 이 경로는 `trade_executor` 가 external 행 추가매수를 차단하므로
|
|
# 스윙 매수로는 도달하지 않는다 — 여기 조건은 2차 방어다.
|
|
# DT 매수는 여전히 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고,
|
|
# 'daytrading' 으로 바꾸면 격리 수량이 external 표시에서 사라진다.
|
|
if (
|
|
(existing.strategy_type or "swing") == "external"
|
|
and strategy_type == "swing"
|
|
and old_qty <= 0
|
|
):
|
|
existing.strategy_type = "swing"
|
|
_promote_to_swing = True
|
|
logger.warning(
|
|
f"[cache_bridge] ⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
|
|
f"external → swing (스윙 시스템 매수 {quantity}주, 유입 잔여 0주)"
|
|
)
|
|
else:
|
|
_promote_to_swing = False
|
|
if (existing.strategy_type or "swing") == "external" and strategy_type == "swing":
|
|
logger.warning(
|
|
f"[cache_bridge] 🧩 부분 혼재 승격 거부: {stock_name}({stock_code}) "
|
|
f"유입 {old_qty}주 + 스윙 매수 {quantity}주 → "
|
|
f"strategy_type='external' 유지 (승격하면 유입분이 스윙 자동 "
|
|
f"손절 대상이 된다). 이 매수 자체가 trade_executor 에서 차단돼야 한다"
|
|
)
|
|
avg_for_db, qty_for_db = new_avg, new_qty
|
|
else:
|
|
cache.holdings.append(Portfolio(
|
|
stock_code=stock_code, stock_name=stock_name, quantity=quantity,
|
|
average_price=Decimal(str(price)), current_price=Decimal(str(price)),
|
|
profit_loss=Decimal("0"), profit_loss_rate=Decimal("0"),
|
|
market=market, strategy_type=strategy_type,
|
|
currency_code="KRW" if market == "KRX" else "USD",
|
|
))
|
|
avg_for_db, qty_for_db = price, quantity
|
|
|
|
_upsert = {
|
|
"stock_code": stock_code,
|
|
"stock_name": stock_name,
|
|
"quantity": qty_for_db,
|
|
"average_price": round(avg_for_db, 2),
|
|
"current_price": price,
|
|
"market": market,
|
|
}
|
|
if existing is None:
|
|
# 신규 행일 때만 전략 태깅 (기존 행은 다른 전략 보유분이 섞여 있을 수 있음)
|
|
_upsert["strategy_type"] = strategy_type
|
|
elif _promote_to_swing:
|
|
# external → swing 승격만 DB 에도 반영 (강등·DT 전환은 하지 않는다)
|
|
_upsert["strategy_type"] = "swing"
|
|
await enqueue_portfolio_upsert(_upsert)
|
|
await cache.apply_buy(stock_code, stock_name, quantity, price)
|
|
except Exception as e:
|
|
logger.warning(f"[cache_bridge] reflect_buy 실패 ({stock_code}): {e}")
|
|
|
|
|
|
async def reflect_sell(
|
|
stock_code: str,
|
|
quantity: int,
|
|
price: float,
|
|
remaining_qty: int | None = None,
|
|
) -> None:
|
|
"""매도 체결 즉시 Portfolio(큐) 차감 + 캐시 제거/차감 + 최근매도 기록.
|
|
|
|
remaining_qty: 호출측(전략 엔진)이 아는 '그 전략 포지션'의 잔량. Portfolio 행은
|
|
같은 종목의 스윙+데이트레이딩 수량이 합쳐진 값이므로, 행 잔량은 항상
|
|
`행 수량 - 매도 수량`으로 계산한다. remaining_qty는 캐시에 행이 없을 때의 폴백으로만 쓴다.
|
|
(2026-09-23: DT 전량청산이 스윙 보유분까지 0으로 만들던 버그)
|
|
"""
|
|
try:
|
|
from app.services.db_worker import enqueue_portfolio_upsert
|
|
|
|
cache = _cache()
|
|
existing = next((h for h in cache.holdings if h.stock_code == stock_code), None)
|
|
avg = float(existing.average_price or price) if existing is not None else price
|
|
|
|
# 잔량 산출 기준은 DB(Portfolio) — 캐시는 sync 실패 시 최대 5분까지 낡을 수 있어
|
|
# 캐시 수량으로 절대값을 쓰면 실제와 다른 잔량이 기록된다(2026-09-23 감사 #14).
|
|
db_qty = await _db_quantity(stock_code)
|
|
if db_qty is not None:
|
|
remaining = max(0, db_qty - quantity)
|
|
elif existing is not None:
|
|
remaining = max(0, int(existing.quantity or 0) - quantity)
|
|
elif remaining_qty is not None:
|
|
remaining = max(0, int(remaining_qty))
|
|
else:
|
|
remaining = None # 알 수 없음 → DB 수량은 건드리지 않음
|
|
if existing is not None and remaining is not None:
|
|
if remaining <= 0:
|
|
cache.holdings = [h for h in cache.holdings if h.stock_code != stock_code]
|
|
else:
|
|
existing.quantity = remaining
|
|
existing.current_price = Decimal(str(price))
|
|
|
|
if remaining is not None:
|
|
await enqueue_portfolio_upsert({
|
|
"stock_code": stock_code,
|
|
"quantity": remaining, # 0 → 목록에서 제외, 다음 동기화에서 삭제
|
|
"current_price": price,
|
|
})
|
|
cache.note_sell(stock_code, quantity)
|
|
await cache.apply_sell(stock_code, quantity, price, avg)
|
|
except Exception as e:
|
|
logger.warning(f"[cache_bridge] reflect_sell 실패 ({stock_code}): {e}")
|