fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -13,9 +13,20 @@
* - journals: react-query 'journals' 쿼리 결과
* - tradeHistory: react-query 'trade-history' 쿼리 결과
*/
import React, { useState } from 'react'
import React, { useMemo, useState } from 'react'
import { BookOpen, ArrowUpDown } from 'lucide-react'
import { Journal, TradeHistory } from '../types/trading'
import { parseKst } from '../utils/formatUtils'
const kstDateKey = (d: Date | null): string =>
d ? d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' }) : ''
const kstDateLabel = (d: Date | null, todayKey: string): string => {
if (!d) return '날짜 미상'
const key = kstDateKey(d)
const md = d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short', timeZone: 'Asia/Seoul' })
return key === todayKey ? `${md} (오늘)` : md
}
/** JournalPanel Props 정의 */
interface JournalPanelProps {
@@ -38,6 +49,25 @@ interface JournalPanelProps {
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => {
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
// 최신순 정렬 (created_at/executed_at 기준, KST 보정). 시각 파싱 불가 건은 id 로 보조 정렬
const sortedTrades = useMemo(() => {
const todayKey = kstDateKey(new Date())
return (tradeHistory ?? [])
.map((t: TradeHistory) => {
const dt = parseKst(t.created_at || t.executed_at || '')
return { t, dt, key: kstDateKey(dt) }
})
.sort((a, b) => {
const d = (b.dt?.getTime() ?? 0) - (a.dt?.getTime() ?? 0)
return d !== 0 ? d : Number(b.t.id || 0) - Number(a.t.id || 0)
})
.map((x, i, arr) => ({
...x,
// 날짜가 바뀌는 첫 행에 구분선 라벨
header: i === 0 || arr[i - 1].key !== x.key ? kstDateLabel(x.dt, todayKey) : null,
}))
}, [tradeHistory])
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
{/* Header with tabs */}
@@ -83,8 +113,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<div className="overflow-y-auto custom-scrollbar flex-1 p-2 space-y-1">
{tab === 'trades' ? (
/* ── 매매내역 탭 ── */
tradeHistory && tradeHistory.length > 0 ? (
tradeHistory.map((trade: TradeHistory, idx: number) => {
sortedTrades.length > 0 ? (
sortedTrades.map(({ t: trade, dt: createdAt, header }, idx: number) => {
// 거래 유형 판별 (매수/매도)
const isBuy = trade.order_type === 'BUY'
const isSell = trade.order_type === 'SELL'
@@ -98,17 +128,25 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED'
const isFailed = st === 'FAILED'
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정
const rawDt = trade.created_at || trade.executed_at || ''
const createdAt = new Date(rawDt.includes('+') || rawDt.includes('Z') ? rawDt : rawDt + '+09:00')
const timeStr = createdAt.toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, timeZone: 'Asia/Seoul',
})
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정 (parseKst)
const timeStr = createdAt
? createdAt.toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, timeZone: 'Asia/Seoul',
})
: '--:--:--'
const dateShort = createdAt
? createdAt.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
: ''
return (
<React.Fragment key={trade.id || idx}>
{header && (
<div className="sticky top-0 z-10 px-1 py-0.5 text-[9px] font-mono text-[#5b9aff] bg-[#0c1522] border-b border-[#152035]">
{header}
</div>
)}
<div
key={trade.id || idx}
className={`px-2.5 py-2 rounded-sm border transition-colors ${
isFailed
? 'bg-[#1a0808] border-red-900/20'
@@ -129,13 +167,23 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<span className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#7f1d1d] text-[#fca5a5]">실패</span>
)}
</div>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{timeStr}</span>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateShort} {timeStr}</span>
</div>
<div className="flex justify-between items-center mt-1">
<div className="text-[10px] font-mono text-[#64748b]">
{/* 시장가 주문은 order_price가 null → 체결가(executed_price) 우선 표시 */}
{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price || trade.order_price || 0)).toLocaleString()}원
{/* 체결가(executed_price) 우선. null 이면 아직 체결 확인 전 — 주문가는 회색으로 구분 */}
{Number(trade.executed_price) > 0 ? (
<>{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price)).toLocaleString()}원</>
) : (
<>
{Number(trade.order_quantity || trade.executed_quantity || 0)}주
{Number(trade.order_price) > 0 && (
<span className="text-[#3a4a5c]" title="체결 전 주문가"> × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원</span>
)}
{!isFailed && <span className="ml-1 text-[9px] text-[#eab308]/80">체결 확인 중</span>}
</>
)}
</div>
{isSell && statusOk && pnl !== 0 && (
<div className={`text-[10px] font-mono font-semibold ${isProfit ? 'text-[#ef4444]' : isLoss ? 'text-[#3b82f6]' : 'text-[#64748b]'}`}>
@@ -152,6 +200,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.exit_reason}</div>
)}
</div>
</React.Fragment>
)
})
) : (