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

@@ -85,7 +85,10 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup
const total_profit_loss_rate = n(summary.total_profit_loss_rate); // 수익률
const today_profit_loss = n(summary.today_profit_loss); // 금일 손익
const today_profit_loss_rate = n(summary.today_profit_loss_rate); // 금일 수익률
const cumulative_realized_pl = n(summary.cumulative_realized_pl); // 누적 실현손익
// 누적 실현손익 — 아직 조회되지 않은 상태(undefined/null)와 실제 0원을 구분한다.
// (2026-09-28: legacy 모드는 별도 쿼리로 병합되는 필드라 초기 렌더 시 undefined일 수 있음)
const hasCumulativeRealizedPl = summary.cumulative_realized_pl !== undefined && summary.cumulative_realized_pl !== null;
const cumulative_realized_pl = n(summary.cumulative_realized_pl);
// 당일 매매 레이블 (오늘 거래 있으면 '오늘', 없으면 '최근')
const todayLabel = todayTradeStats?.isToday !== false ? '오늘' : '최근';
@@ -169,9 +172,15 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup
<BarChart3 size={11} className="text-[#475569]" />
<span className="text-[10px] font-medium text-[#475569] tracking-wide">누적 실현손익</span>
</div>
<div className={`text-lg font-mono font-bold tracking-tight ${color(cumulative_realized_pl)}`}>
{fmtSign(cumulative_realized_pl)}<span className="text-xs ml-0.5">원</span>
</div>
{hasCumulativeRealizedPl ? (
<div className={`text-lg font-mono font-bold tracking-tight ${color(cumulative_realized_pl)}`}>
{fmtSign(cumulative_realized_pl)}<span className="text-xs ml-0.5">원</span>
</div>
) : (
<div className="text-lg font-mono font-bold tracking-tight text-[#334155]">
조회중…
</div>
)}
<div className="text-[10px] font-mono text-[#475569] mt-0.5">매도 체결 기준</div>
</div>
</div>

View File

@@ -103,10 +103,37 @@ export const usePortfolioData = () => {
refetchOnWindowFocus: true, // 탭 전환 시 즉시 갱신
})
/**
* 누적 실현손익 보강 조회 (2026-09-28 추가)
*
* 백엔드 `/portfolio/combined`(legacy)는 응답에 `cumulative_realized_pl`을
* 절대 포함하지 않는다(모든 분기가 `_build_combined_resp()`를 거치는데 그 함수가
* 이 필드를 채우지 않음 — backend/app/api/endpoints/portfolio.py 확인).
* 반면 `/portfolio/summary`는 TradeHistory SELL 체결 합계로 이 필드를 채운다.
* 백엔드를 건드리지 않고 프론트에서만 고치기 위해 이 값만 별도로 가볍게 조회해 병합한다.
* (v2 모드는 `/portfolio/v2/summary`에 이 필드 자체가 없어 대상에서 제외 — 현재 MT 플래그 OFF)
*/
const { data: cumulativeRealizedPl } = useQuery({
queryKey: ['portfolio-cumulative-realized-pl', mode],
queryFn: async (): Promise<number | undefined> => {
const res = await api.get('/portfolio/summary')
const v = (res.data as { cumulative_realized_pl?: number })?.cumulative_realized_pl
return typeof v === 'number' ? v : undefined
},
enabled: mode === 'legacy',
staleTime: 30000,
refetchInterval: 30000,
refetchOnWindowFocus: true,
})
/** 보유 종목 배열 */
const portfolio = portfolioCombined?.holdings
/** 포트폴리오 요약 데이터 */
const portfolioSummary = portfolioCombined?.summary ?? undefined
/** 포트폴리오 요약 데이터 — legacy 모드는 누적 실현손익을 별도 조회로 병합 */
const portfolioSummary = portfolioCombined?.summary
? mode === 'legacy'
? { ...portfolioCombined.summary, cumulative_realized_pl: cumulativeRealizedPl }
: portfolioCombined.summary
: undefined
const accountSetupRequired = portfolioCombined?.accountSetupRequired ?? false
const accountSetupMessage = portfolioCombined?.accountSetupMessage

View File

@@ -77,6 +77,21 @@ const checkMarketOpen = () => {
/** 마켓 필터 타입 (전체/국내/미국) */
type MarketFilter = 'ALL' | 'KR' | 'US'
/**
* liveSummary 보정 신뢰 임계값 (2026-09-28)
*
* `/portfolio/combined` 폴링 주기는 30초(usePortfolioData.ts staleTime/refetchInterval),
* livePrices는 WebSocket tick마다 1초 단위로 flush된다(useMarketData.ts). 즉 보정항은
* "백엔드 스냅샷과 최대 30초 사이의 실시간 가격 변동분"만 반영해야 정상이다.
* 국내 개별종목의 30초 단위 변동은 일중 변동성 기준으로도 통상 1% 미만이고,
* 포트폴리오 전체(다종목 분산) 단위로는 이보다 더 작다 — 3%는 정상적인 실시간 변동
* 범위를 넉넉히 덮으면서도, 2026-09-28 사고에서 실제 관측된 오차(9,937,380원 −
* 8,709,324원 = 1,228,056원, 총자산 대비 12.36%)는 확실히 걸러낸다.
* (당시 "한미사이언스 10주 매도가 원인"이라는 가설을 세웠으나 뒷받침 근거가 없어 철회 —
* 정확한 유발 메커니즘은 미규명. 아래 F12 설명 참조.)
*/
const LIVE_CORRECTION_MAX_RATIO = 0.03
/**
* Dashboard — 메인 대시보드 컴포넌트
*
@@ -488,10 +503,38 @@ const Dashboard = () => {
if (!portfolioSummary || !portfolio) return portfolioSummary
// live 현재가 기반 주식 평가금액
// 가격 결손 종목(WS tick도 없고 DB current_price도 0/NaN)을 별도 수집 —
// 이런 종목이 있으면 liveValue 자체가 축소 계산되어 있어 보정에 쓸 수 없다.
//
// M7 (2026-09-29): backendStockValue(stock_value_from_api = KIS 국내계좌
// scts_evlu_amt)는 국내 원화 전용이다(backend/app/api/endpoints/portfolio.py:274
// — output2, 국내 잔고 API에서만 추출). liveValue는 해외 종목의 USD 단가를
// 환산 없이 그대로 더하므로(기존 동작, 이번 수정 대상 아님) 해외 보유가 있으면
// 통화·범위가 다른 값끼리 비교하게 되어 correctionRatio가 항상 커진다.
// 백엔드 summary에는 domestic_value 필드가 없어(실측: /portfolio/summary 응답에
// 없음 — _calculate_portfolio_values가 계산은 하지만 summary_data에 담지 않음)
// 새 필드 없이, 프론트에서 국내 보유만 골라 liveDomesticValue로 비교한다
// (backendStockValue와 동일 범위·통화를 맞추는 최소 수정 — 해외 USD 환산 로직
// 자체는 건드리지 않음).
const missingPriceCodes: string[] = []
const domesticMissingPriceCodes: string[] = []
let liveDomesticValue = 0
let overseasHoldingsCount = 0
const liveValue = portfolio.reduce((sum, s) => {
const price =
livePrices[s.stock_code] ?? Number(s.current_price || 0)
return sum + price * Number(s.quantity || 0)
const isDomestic = !isUsStock(s.stock_code)
const rawPrice = livePrices[s.stock_code] ?? Number(s.current_price || 0)
const price = Number.isFinite(rawPrice) ? rawPrice : 0
const qty = Number(s.quantity || 0)
if (!(price > 0)) {
missingPriceCodes.push(s.stock_code)
if (isDomestic) domesticMissingPriceCodes.push(s.stock_code)
}
if (isDomestic) {
liveDomesticValue += price * qty
} else {
overseasHoldingsCount += 1
}
return sum + price * qty
}, 0)
// 총 매입금액 (평균단가 × 수량) — average_price null 시 백엔드 summary 값 사용
@@ -512,21 +555,85 @@ const Dashboard = () => {
// 총 자산 = KIS tot_evlu_amt 기준으로 실시간 주식평가 변동분 보정
// stock_value_from_api(scts_evlu_amt)가 0이면 예수금+실시간주식평가로 단순 계산
const backendStockValue = portfolioSummary.stock_value_from_api || 0
const liveTotal =
backendStockValue > 0
? (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue)
const backendTotalAssets = Number(portfolioSummary.total_assets || 0)
// 보정 신뢰도 가드 (2026-09-28 추가, 09-28 2차 보강, 09-29 F12 주석 정정)
//
// 2026-09-28 사고(총자산 12.36% 과소표시)의 정확한 유발 메커니즘은 규명되지 않았다.
// "portfolioSummary와 portfolio가 서로 다른 시점에 어긋난다"는 초기 가설은 legacy
// 경로에서는 성립하지 않는다 — 감사 결과(F12): 이 경로에서 `portfolio`(holdings)와
// `portfolioSummary`는 `/portfolio/combined` 단일 응답의 두 필드이고
// (usePortfolioData.ts의 react-query 쿼리 하나가 원자적으로 반환, invalidatePortfolio()도
// 같은 쿼리키 하나만 무효화), 백엔드도 `summary.total_stocks`를
// `len([h for h in self.holdings if h.quantity > 0])`로 **같은 holdings 리스트에서**
// 계산한다(portfolio.py `_build_combined_resp`/`cache_summary`). 즉 legacy 경로에서
// 두 소스가 "다른 시점"일 수는 없다.
// 남은 실제 불일치 벡터는 세 가지뿐이다:
// (a) livePrices(WebSocket tick, 초 단위) vs 백엔드 스냅샷(30초 폴링) 사이의
// 진짜 시차 — 유일하게 확실한 "다른 시점" 소스
// (b) DB `current_price`가 0/NULL인 행이 있으면 liveValue가 축소 계산됨
// (유력 가설이나 미확증 — 아래 ②의 결손 가드가 대응)
// (c) 해외 종목 USD 미환산(M7) — 현재 US_TRADING_ENABLED=False라 해당 없음
// 이 가드는 원인을 특정해 막는 것이 아니라, 위 벡터로 인한 증상(허위 손익 표시)을
// 억제하는 안전망이다.
//
// ① summary.total_stocks 와 실제 보유 종목 수가 다르거나
// ② 보유 종목 중 실시간 가격이 결손(WS tick 없음 + DB current_price도 0/NaN)인 종목이
// 하나라도 있거나 — 종목 수는 같아도 liveValue 자체가 축소 계산돼 있어 위험하다
// ③ 보정폭이 총자산의 LIVE_CORRECTION_MAX_RATIO(3%, 근거는 상수 선언부 주석)를 넘으면
// — 두 소스가 어긋난 것으로 보고 보정을 스킵, 백엔드 원본값(total_assets/today_profit_loss)을
// 그대로 신뢰한다. 불변식: 보정을 적용하지 않을 때 백엔드 값보다 더 틀린 값을 만들지 않는다.
//
// F14 (2026-09-29): ①(holdingsCountMatches)은 위 근거대로 legacy 경로에서는
// summary.total_stocks 와 portfolio.length 가 같은 리스트에서 나오므로 구조적으로
// 항상 true다 — 즉 legacy 에서는 죽은 가드다. 제거하지 않는 이유는 v2/멀티테넌트
// 경로나 백엔드 구현이 바뀌면 의미가 생길 수 있고 무해하기 때문. 실효 가드는
// ②(가격 결손)와 ③(보정폭) 두 개뿐이라고 보고 다른 안전망으로 착각하지 말 것.
const summaryStockCount = Number(portfolioSummary.total_stocks)
const holdingsCountMatches = Number.isFinite(summaryStockCount)
? portfolio.length === summaryStockCount
: true
// M7: 비교는 국내분(liveDomesticValue)끼리만 — backendStockValue가 국내 전용이므로
const hasMissingPrice = domesticMissingPriceCodes.length > 0
const correctionDelta = backendStockValue > 0 ? liveDomesticValue - backendStockValue : 0
const correctionRatio =
backendTotalAssets > 0 ? Math.abs(correctionDelta) / backendTotalAssets : 1
const canTrustLiveCorrection =
backendStockValue > 0 &&
holdingsCountMatches &&
!hasMissingPrice &&
correctionRatio <= LIVE_CORRECTION_MAX_RATIO
// 가드가 발동해 보정을 스킵한 경우 원인 추적용 로그 (화면에는 노출하지 않음)
// overseasHoldingsCount > 0 이면 통화 혼재(M7) 가능성을 의심할 것 — 현재
// US_TRADING_ENABLED=False 라 해외 보유가 없어야 정상이므로 0이 아니면 이상 신호
if (backendStockValue > 0 && !canTrustLiveCorrection) {
console.warn('[liveSummary] 보정 스킵 — 백엔드 원본값 사용', {
expectedStockCount: summaryStockCount,
actualStockCount: portfolio.length,
overseasHoldingsCount,
correctionRatio,
missingPriceCodes,
domesticMissingPriceCodes,
})
}
const liveTotal = canTrustLiveCorrection
? backendTotalAssets + correctionDelta
: backendStockValue > 0
? backendTotalAssets // 신뢰 불가 → 백엔드 원본 총자산 그대로 사용
: Number(portfolioSummary.available_cash || 0) + liveValue
// 금일 손익도 live 주식 변동분 반영
// 금일 손익도 live 주식 변동분 반영 — 단, 보정을 신뢰할 수 없으면 백엔드 원본값 사용
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
const liveTodayPL =
bfdyTotal > 0
canTrustLiveCorrection && bfdyTotal > 0
? liveTotal - bfdyTotal
: portfolioSummary.today_profit_loss || 0
: Number(portfolioSummary.today_profit_loss || 0)
const liveTodayPLRate =
bfdyTotal > 0
canTrustLiveCorrection && bfdyTotal > 0
? (liveTodayPL / bfdyTotal) * 100
: portfolioSummary.today_profit_loss_rate || 0
: Number(portfolioSummary.today_profit_loss_rate || 0)
return {
...portfolioSummary,