- 스윙 체결가 확인 전 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
271 lines
16 KiB
TypeScript
271 lines
16 KiB
TypeScript
/**
|
||
* 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
|