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:
@@ -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>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user