fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류

## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:49:25 +09:00
parent 3fdc19c749
commit 7abbb3411c
27 changed files with 4164 additions and 227 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
from sqlalchemy import select, func, or_
from typing import List, Optional
from decimal import Decimal
from pydantic import BaseModel, Field
@@ -36,6 +36,75 @@ logger = logging.getLogger(__name__)
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
# 유입(외부) 잔고 격리 태그 — 시스템이 매수한 적 없는 보유분.
# 스윙 자동 청산(get_swing_holdings: strategy_type=='swing')에서 자동 제외된다.
EXTERNAL_STRATEGY_TYPE = "external"
# sync 가 자동 시딩한 ActiveTrade 전략명 — '시스템 매수 근거'로 인정하지 않는다.
_SEEDED_AT_STRATEGY_NAMES = ("synced",)
def _external_stop_breaches(holdings: list) -> list:
"""유입 격리분 중 기본 손절선(-STOP_LOSS_PERCENT)을 이탈한 종목 목록 (순수 함수).
격리분은 **집행하지 않는다** — 이 함수는 "조용한 실패"를 막기 위한 가시화 전용이다.
ATR 손절(진입가 − 2.5×ATR)은 분봉·일봉이 필요해 여기서 계산하지 않는다.
따라서 이 판정은 기본 SL 기준이며 실제 전략 손절선보다 느슨할 수 있다.
Returns:
list[dict]: stock_code / stock_name / profit_loss_rate / breach_pct(초과 이탈폭)
"""
from app.core.config import settings as _cfg
_sl = -abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
out = []
for h in holdings:
if (getattr(h, "strategy_type", None) or "swing") != EXTERNAL_STRATEGY_TYPE:
continue
if not h.quantity or h.quantity <= 0:
continue
_avg = float(h.average_price or 0)
_cur = float(h.current_price or 0)
if _avg <= 0 or _cur <= 0:
continue
_rate = (_cur / _avg - 1) * 100
if _rate <= _sl:
out.append({
"stock_code": h.stock_code,
"stock_name": h.stock_name or h.stock_code,
"profit_loss_rate": round(_rate, 2),
"breach_pct": round(_rate - _sl, 2), # 손절선 대비 추가 이탈폭(음수)
"stop_loss_pct": _sl,
})
return out
def _classify_holding_strategy_type(
stock_code: str,
quantity: int,
dt_qty: dict,
system_codes: set,
) -> str:
"""KIS 잔고 1행의 `Portfolio.strategy_type` 판정 (순수 함수).
Args:
stock_code: 종목코드
quantity: KIS 잔고 수량
dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty}
system_codes: 시스템 매수 근거가 있는 종목 집합
(TradeHistory BUY ∪ 시딩 아닌 ActiveTrade ∪ DT 인메모리)
Returns:
"daytrading" | "swing" | "external"
- 전량이 DT 보유분이면 daytrading (부분 혼재면 스윙 청산 대상 유지)
- 시스템 매수 근거가 없으면 external (유입 잔고 → 자동 청산 제외)
"""
_dt_held = int(dt_qty.get(stock_code, 0) or 0)
if _dt_held > 0 and quantity <= _dt_held:
return "daytrading"
if stock_code in system_codes:
return "swing"
return EXTERNAL_STRATEGY_TYPE
# Request/Response Models
class SellRequest(BaseModel):
stock_code: str
@@ -95,7 +164,21 @@ def _calculate_portfolio_values(holdings: list) -> dict:
total_investment = domestic_investment
total_profit_loss = domestic_profit_loss
# 유입 격리(strategy_type='external') 보유분 — 자동 청산 대상에서 빠진 잔고.
# 총자산·평가액에서 빼지 않는다(계좌 전체 리스크는 이 잔고를 포함해야 한다).
_external = [
h for h in holdings
if (getattr(h, "strategy_type", None) or "swing") == "external"
]
external_count = len(_external)
# 국내 종목은 KRW, 해외 종목은 USD 원본 통화 — 환산은 하지 않는다(표시 전용).
external_value = sum(float(h.current_price or 0) * h.quantity for h in _external)
return {
"external_count": external_count,
"external_value": external_value,
# 손절선 이탈 격리분 — 집행은 하지 않되 프론트·로그에서 반드시 보이게 한다
"external_stop_breaches": _external_stop_breaches(_external),
"total_stocks": total_stocks,
"domestic_value": domestic_value,
"overseas_value_usd": overseas_value_usd,
@@ -420,6 +503,11 @@ class PortfolioCache:
"bfdy_total_assets": bfdy_total_assets,
"today_profit_loss": today_profit_loss,
"today_profit_loss_rate": today_profit_loss_rate,
# 유입 격리(external) 노출 — 자동 청산 대상에서 빠진 보유분을 프론트가
# 구분할 수 있게 한다(리스크 지표 total_assets/total_value 에는 포함).
"external_count": portfolio_values["external_count"],
"external_value": portfolio_values["external_value"],
"external_stop_breaches": portfolio_values["external_stop_breaches"],
# 해외 관련 추가 필드
"overseas_value_usd": overseas_value_usd,
"overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw,
@@ -614,6 +702,43 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if ghost_candidates:
logger.info(f"🚫 Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}")
# ── 유입(external) 잔고 격리용 '시스템 매수 근거' 수집 ──────────────
# [2026-09-28] 모의계좌 교체 등으로 계좌에 편입된 "시스템이 매수하지 않은"
# 잔고를 strategy_type='external' 로 태깅해 스윙 자동 청산 대상에서 뺀다.
# 태깅이 없으면 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()
)
_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()
)
_system_codes = _system_bought | {c for c in _at_rows_prov.scalars().all() if c}
_system_codes |= _dt_codes # 당일 DT 인메모리 포지션도 시스템 매수분
def _classify_strategy_type(_code: str, _qty: int) -> str:
"""Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임)."""
return _classify_holding_strategy_type(_code, _qty, _dt_qty, _system_codes)
# 기존 레코드 로드 (삭제 대신 업데이트)
result = await db.execute(select(Portfolio))
existing_all = {p.stock_code: p for p in result.scalars().all()}
@@ -689,6 +814,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
)
processed_codes.add(stock_code)
_classified = _classify_strategy_type(stock_code, quantity)
if stock_code in existing_all:
h = existing_all[stock_code]
h.stock_name = stock_name
@@ -701,8 +827,25 @@ 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) 강등은
# 하지 않는다: 체결 이력이 유실된 정상 포지션이 손절·익절 관리를
# 통째로 잃는 쪽이 더 위험하다.
h.strategy_type = "swing"
logger.warning(
f"⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
f"external → swing (시스템 매수 근거 확인)"
)
new_holdings.append(h)
else:
if _classified == EXTERNAL_STRATEGY_TYPE:
logger.warning(
f"🚧 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 "
f"평단={average_price} 평가액={float(current_price) * quantity:,.0f}원 "
f"— 시스템 매수 이력 없음 → strategy_type='external' "
f"(자동 손절·익절 대상 제외, 수동 판단 필요)"
)
new_holding = Portfolio(
stock_code=stock_code,
stock_name=stock_name,
@@ -712,11 +855,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
market="KRX",
strategy_type=(
"daytrading"
if quantity <= _dt_qty.get(stock_code, 0)
else "swing"
),
strategy_type=_classified,
)
db.add(new_holding)
new_holdings.append(new_holding)
@@ -771,6 +910,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
)
processed_codes.add(stock_code)
_classified = _classify_strategy_type(stock_code, quantity)
if stock_code in existing_all:
h = existing_all[stock_code]
h.stock_name = stock_name
@@ -784,8 +924,19 @@ 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":
# 승격만 허용 (국내 경로와 동일 규칙)
h.strategy_type = "swing"
logger.warning(
f"⬆️ [해외] 유입 격리 해제(승격): {stock_name}({stock_code}) external → swing"
)
new_holdings.append(h)
else:
if _classified == EXTERNAL_STRATEGY_TYPE:
logger.warning(
f"🚧 [해외] 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 "
f"평단={average_price} — 시스템 매수 이력 없음 → strategy_type='external'"
)
new_holding = Portfolio(
stock_code=stock_code,
stock_name=stock_name,
@@ -795,11 +946,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
market=exchange_code,
strategy_type=(
"daytrading"
if quantity <= _dt_qty.get(stock_code, 0)
else "swing"
),
strategy_type=_classified,
)
db.add(new_holding)
new_holdings.append(new_holding)
@@ -869,6 +1016,13 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
continue
if (_h.strategy_type or "swing") == "daytrading":
continue
# [2026-09-28] 유입 격리분(external)은 ActiveTrade 를 만들지 않는다.
# ① 사용자 결정: 유입 잔고는 자동 손절·익절 대상에서 제외한다.
# ② 시딩하면 그 행이 다음 sync 의 '시스템 매수 근거'로 오인될 위험이
# 있어(strategy_name 이 'synced' 가 아닌 값으로 굳는 경로) 격리가
# 스스로 풀릴 수 있다.
if (_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
continue
_avg = float(_h.average_price or 0)
if _avg <= 0:
continue
@@ -926,6 +1080,34 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
except Exception as _seed_err:
logger.warning(f"ActiveTrade 시딩 큐잉 실패(무시): {_seed_err}")
# 격리된 유입 잔고는 "조용히 방치"되면 안 된다 — 매 동기화마다 한 줄로 노출.
# (자동 손절·익절이 걸리지 않으므로 운영자의 수동 판단이 유일한 관리 수단)
_external_rows = [
(h.stock_name or h.stock_code, h.stock_code, int(h.quantity),
float(h.current_price or 0) * int(h.quantity))
for h in new_holdings
if h.quantity > 0 and (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
]
if _external_rows:
logger.warning(
"🚧 유입 격리 보유 %d종목 (자동 청산 제외): %s",
len(_external_rows),
", ".join(
f"{_n}({_c}) {_q}주/{_v:,.0f}" for _n, _c, _q, _v in _external_rows
),
)
# 손절선 이탈은 절대 침묵시키지 않는다 — 이번 사건의 본질이 '조용한 실패'였다.
# 집행은 하지 않으며 처분 판단은 운영자 수동 청산(portfolio_v2)에 맡긴다.
for _br in _external_stop_breaches(
[h for h in new_holdings if h.quantity > 0]
):
logger.warning(
"🔻 유입 격리분 손절선 이탈(자동 청산 안 함): %s(%s) "
"손익률 %.2f%% / 기준 %.1f%% / 초과 이탈 %.2f%%p — 수동 판단 필요",
_br["stock_name"], _br["stock_code"], _br["profit_loss_rate"],
_br["stop_loss_pct"], _br["breach_pct"],
)
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
portfolio_cache.last_synced = datetime.now()
@@ -1151,6 +1333,12 @@ async def check_and_execute_exit_trades(db: AsyncSession):
for holding in holdings:
try:
# [2026-09-28] 유입 격리분은 자동 청산 대상이 아니다.
# 이 함수는 deprecated 이고 현재 스케줄러에 배선돼 있지 않지만
# (auto_trade_scheduler 는 create_task 되지 않는다), 되살아날 때
# strategy_type 필터가 없는 전수 조회라 격리가 통째로 뚫린다.
if (holding.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
continue
stock_code = holding.stock_code
stock_name = holding.stock_name
quantity = holding.quantity
@@ -1553,6 +1741,7 @@ async def sell_stock(
stock_name = holding.stock_name
original_holding_qty = holding.quantity
original_avg_price = float(holding.average_price or 0)
original_strategy_type = holding.strategy_type or "swing"
# 매도 주문 실행
# [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로
@@ -1633,6 +1822,21 @@ async def sell_stock(
except Exception as _at_err:
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}")
# LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지).
# [2026-09-28] 수동 청산이 빠져 있어 실질 3연패 종목이 2로 집계됐다
# (카카오뱅크: 9-22 수동청산 1건 누락 → CONSECUTIVE_LOSS_BAN 미발동).
# 조건·순서는 swing/exit_service.py 와 동일하게 record → save_to_db.
# 유입 격리분(external)은 전략 성과가 아니므로 연패에 넣지 않는다.
if quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE:
try:
await loss_tracker.record_trade_result_safe(
stock_code=stock_code,
is_loss=profit_loss < 0,
)
await loss_tracker.save_to_db(stock_code=stock_code)
except Exception as _lt_err:
logger.warning(f"[SELL] LossTracker 기록 실패 {stock_code}: {_lt_err}")
# 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다.
# (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도)
try: