Files
auto-stock/frontend/src/components/JournalPanel.tsx
shuim a264561576 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
2026-10-10 12:17:49 +09:00

271 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* JournalPanel.tsx — 매매내역 및 매매일지 탭 패널 컴포넌트
*
* 역할:
* - 매매내역 탭: 최근 거래 이력 표시 (매수/매도, 손익, 체결 시간, 사유)
* - 매매일지 탭: AI 자동 생성 매매일지 표시 (기분, 태그, 내용)
* - 미체결 주문 정리 버튼 제공
*
* 최적화:
* - React.memo로 불필요한 리렌더 방지
*
* 데이터 흐름:
* - journals: react-query 'journals' 쿼리 결과
* - tradeHistory: react-query 'trade-history' 쿼리 결과
*/
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 {
/** 매매일지 배열 (장 마감 후 자동 생성) */
journals: Journal[] | undefined
/** 매매 내역 배열 (최근 거래 이력) */
tradeHistory?: TradeHistory[] | undefined
/** 미체결 주문 정리 콜백 */
onResetPending: () => void
/** true면 미체결 정리 버튼 비활성화 (예: 사용자 모드 확정 전) */
resetDisabled?: boolean
}
/**
* JournalPanel — 매매내역/매매일지 탭 전환 패널
*
* 상태(State):
* - tab: 현재 활성 탭 ('trades' 또는 'journal')
*/
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 */}
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-0.5">
<button
onClick={() => setTab('trades')}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'trades'
? 'text-[#3b82f6] bg-[#3b82f6]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
}`}
>
<ArrowUpDown size={10} />
매매내역
{tradeHistory && tradeHistory.length > 0 && (
<span className="ml-1 text-[8px] font-mono bg-[#1e293b] px-1 rounded">{tradeHistory.length}</span>
)}
</button>
<button
onClick={() => setTab('journal')}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'journal'
? 'text-[#22c55e] bg-[#22c55e]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
}`}
>
<BookOpen size={10} />
매매일지
</button>
</div>
<button
onClick={onResetPending}
disabled={resetDisabled}
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
{/* Content */}
<div className="overflow-y-auto custom-scrollbar flex-1 p-2 space-y-1">
{tab === 'trades' ? (
/* ── 매매내역 탭 ── */
sortedTrades.length > 0 ? (
sortedTrades.map(({ t: trade, dt: createdAt, header }, idx: number) => {
// 거래 유형 판별 (매수/매도)
const isBuy = trade.order_type === 'BUY'
const isSell = trade.order_type === 'SELL'
// 손익 금액 및 수익률 (순이익 우선, 없으면 손익 사용)
const pnl = Number(trade.net_profit_loss || trade.profit_loss || 0)
const pnlRate = Number(trade.profit_loss_rate || 0)
const isProfit = pnl > 0
const isLoss = pnl < 0
// 체결 상태 판별 (COMPLETED, EXECUTED, FILLED → 정상 체결)
const st = String(trade.status || '')
const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED'
const isFailed = st === 'FAILED'
// 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
className={`px-2.5 py-2 rounded-sm border transition-colors ${
isFailed
? 'bg-[#1a0808] border-red-900/20'
: 'bg-[#091018] border-[#152035] hover:border-[#1e3050]'
}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
isBuy ? 'bg-[#ef4444]/15 text-[#ef4444]' : 'bg-[#3b82f6]/15 text-[#3b82f6]'
}`}>
{isBuy ? '매수' : '매도'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
{trade.stock_name || trade.stock_code}
</span>
{isFailed && (
<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">{dateShort} {timeStr}</span>
</div>
<div className="flex justify-between items-center mt-1">
<div className="text-[10px] font-mono text-[#64748b]">
{/* 체결가(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]'}`}>
{pnl > 0 ? '+' : ''}{Math.round(pnl).toLocaleString()}원
<span className="ml-1 text-[9px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
</div>
)}
</div>
{trade.entry_reason && isBuy && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.entry_reason}</div>
)}
{trade.exit_reason && isSell && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.exit_reason}</div>
)}
</div>
</React.Fragment>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
<ArrowUpDown size={20} className="text-[#1e293b]" />
<span>매매내역 없음</span>
</div>
)
) : (
/* ── 매매일지 탭 ── */
journals && journals.length > 0 ? (
journals.map((journal: Journal) => {
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정
// (매매내역 경로 :102-103과 동일 규약 — 오프셋이 이미 있으면 그대로 둠)
const rawJournalDt = journal.journal_date || journal.created_at || ''
const journalDate = new Date(
rawJournalDt.includes('+') || rawJournalDt.includes('Z')
? rawJournalDt
: rawJournalDt + '+09:00'
)
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
return (
<div key={journal.id} className="p-2.5 bg-[#091018] border border-[#152035] rounded-sm hover:border-[#1e3050] transition-colors">
<div className="flex justify-between items-start mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm flex-shrink-0">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
{journal.stock_name || journal.stock_code}
</span>
</div>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateStr} {timeStr}</span>
</div>
<div className="text-[10px] text-[#4a6580] leading-relaxed line-clamp-2 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#152035] text-[#3a5570]">#{tag}</span>
))}
</div>
)}
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
<BookOpen size={20} className="text-[#1e293b]" />
<span>매매일지 없음</span>
<span className="text-[9px] text-[#2a3d55]">장 마감 후 자동 생성됩니다</span>
</div>
)
)}
</div>
</div>
)
})
JournalPanel.displayName = 'JournalPanel'
export default JournalPanel