/** * 실시간 매매 거래 이력 — 시그널·미체결·체결 통합 타임라인 */ import type { PaperOrderDto, PaperTradeDto, TradeSignalDto, } from './backendApi'; export type LiveTimelineEventKind = 'SIGNAL' | 'ORDER_PENDING' | 'FILL'; export interface LiveTimelineSignalMeta { signalId: number; candleTime: number; candleType: string; executionType: string; strategyId?: number | null; } export interface LiveTimelineFilter { symbol?: string; strategyId?: number | null; } export interface LiveTimelineEvent { id: string; kind: LiveTimelineEventKind; side: 'BUY' | 'SELL'; ts: number; symbol: string; price: number; quantity?: number; strategyName?: string | null; sourceLabel?: string; detail?: string; /** kind === 'SIGNAL' 일 때만 채워짐 */ signalMeta?: LiveTimelineSignalMeta; } export const LIVE_TIMELINE_KIND_LABEL: Record = { SIGNAL: '매매 시그널', ORDER_PENDING: '미체결 등록', FILL: '체결', }; export const LIVE_TIMELINE_SIDE_LABEL = { BUY: '매수', SELL: '매도', } as const; function parseIsoMs(iso: string | null | undefined): number { if (!iso) return 0; const t = new Date(iso).getTime(); return Number.isFinite(t) ? t : 0; } function matchesFilter( symbol: string, strategyId: number | null | undefined, filter?: LiveTimelineFilter, ): boolean { if (!filter) return true; if (filter.symbol && symbol !== filter.symbol) return false; if (filter.strategyId != null && strategyId !== filter.strategyId) return false; return true; } /** 시그널·미체결 주문·체결 이력을 시간 역순 타임라인으로 병합 */ export function buildLiveTradeTimeline( signals: TradeSignalDto[], orders: PaperOrderDto[], trades: PaperTradeDto[], filter?: LiveTimelineFilter, ): LiveTimelineEvent[] { const events: LiveTimelineEvent[] = []; for (const s of signals) { if (!matchesFilter(s.market, s.strategyId, filter)) continue; const ts = s.createdAt ? parseIsoMs(s.createdAt) : (s.candleTime > 1e12 ? s.candleTime : s.candleTime * 1000); if (ts <= 0) continue; events.push({ id: `sig-${s.id}`, kind: 'SIGNAL', side: s.signalType, ts, symbol: s.market, price: s.price, strategyName: s.strategyName, detail: [s.candleType, s.executionType].filter(Boolean).join(' · ') || undefined, signalMeta: { signalId: s.id, candleTime: s.candleTime > 1e12 ? Math.floor(s.candleTime / 1000) : s.candleTime, candleType: s.candleType, executionType: s.executionType, strategyId: s.strategyId, }, }); } for (const o of orders) { if (!matchesFilter(o.symbol, o.strategyId, filter)) continue; const ts = parseIsoMs(o.createdAt); if (ts <= 0) continue; const orderTypeLabel = (o.orderType ?? o.orderKind ?? '').toUpperCase().includes('MARKET') ? '시장가' : '지정가'; events.push({ id: `ord-${o.id}`, kind: 'ORDER_PENDING', side: o.side, ts, symbol: o.symbol, price: o.limitPrice ?? 0, quantity: o.quantity, sourceLabel: o.source === 'STRATEGY' ? '자동' : '수동', detail: `${orderTypeLabel}${o.filledQuantity > 0 ? ` · ${o.filledQuantity}/${o.quantity}` : ''}`, }); } for (const t of trades) { if (!matchesFilter(t.symbol, t.strategyId, filter)) continue; const ts = parseIsoMs(t.createdAt); if (ts <= 0) continue; const pnl = t.realizedPnlDelta; const pnlHint = pnl != null && Number.isFinite(pnl) && pnl !== 0 ? ` · 손익 ${pnl >= 0 ? '+' : ''}${Math.round(pnl).toLocaleString()}` : ''; events.push({ id: `fill-${t.id}`, kind: 'FILL', side: t.side, ts, symbol: t.symbol, price: t.price, quantity: t.quantity, strategyName: t.strategyName, sourceLabel: t.source === 'STRATEGY' ? '자동' : '수동', detail: `${t.orderKind || '체결'}${pnlHint}`, }); } return events.sort((a, b) => b.ts - a.ts); } /** 이벤트 시각(ms) → 차트 bar time(초) — 최근접 봉 스냅 */ export function resolveEventBarTime( eventTsMs: number, bars: { time: number }[], ): number | null { if (!bars.length || !Number.isFinite(eventTsMs)) return null; const targetSec = Math.floor(eventTsMs / 1000); let bestTime = bars[0].time; let bestDist = Math.abs(bars[0].time - targetSec); for (let i = 1; i < bars.length; i++) { const dist = Math.abs(bars[i].time - targetSec); if (dist < bestDist) { bestDist = dist; bestTime = bars[i].time; } } const interval = bars.length >= 2 ? Math.abs(bars[bars.length - 1].time - bars[bars.length - 2].time) || 60 : 60; const maxSnap = Math.max(86400, interval * 3); return bestDist <= maxSnap ? bestTime : null; } const TIMEFRAME_BAR_SECONDS: Record = { '1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800, '1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000, }; /** 시간봉 길이(초) */ export function timeframeToBarSeconds(timeframe: string): number { return TIMEFRAME_BAR_SECONDS[timeframe] ?? 180; } /** 이벤트 시각(ms) → 해당 봉 시작 시각(초) */ export function snapMsToBarTimeSec(tsMs: number, timeframe: string): number { const barSec = timeframeToBarSeconds(timeframe); const sec = Math.floor(tsMs / 1000); return Math.floor(sec / barSec) * barSec; } export interface BarTimelineSlot { barTimeSec: number; sells: LiveTimelineEvent[]; buys: LiveTimelineEvent[]; } /** 거래 이력을 시간봉 시작 시각 기준 슬롯으로 그룹 (시간순) */ export function groupEventsByBarTime( events: LiveTimelineEvent[], timeframe: string, ): BarTimelineSlot[] { const map = new Map(); for (const ev of events) { if (ev.ts <= 0) continue; const bar = snapMsToBarTimeSec(ev.ts, timeframe); let bucket = map.get(bar); if (!bucket) { bucket = { sells: [], buys: [] }; map.set(bar, bucket); } if (ev.side === 'SELL') bucket.sells.push(ev); else bucket.buys.push(ev); } return [...map.entries()] .sort((a, b) => a[0] - b[0]) .map(([barTimeSec, { sells, buys }]) => ({ barTimeSec, sells: sells.sort((a, b) => b.ts - a.ts), buys: buys.sort((a, b) => b.ts - a.ts), })); } /** 가로 축 타임라인 봉 라벨 */ export function formatBarAxisLabel(barTimeSec: number, timeframe: string): string { const d = new Date(barTimeSec * 1000); if (Number.isNaN(d.getTime())) return '—'; const pad = (n: number) => String(n).padStart(2, '0'); const isDaily = timeframe === '1D' || timeframe === '1W' || timeframe === '1M'; if (isDaily) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } export function formatTimelineTime(ts: number): string { if (!ts) return '—'; const d = new Date(ts); if (Number.isNaN(d.getTime())) return '—'; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } /** 타임라인 SIGNAL 이벤트 → 상세 팝업용 메타 */ export interface TimelineSignalDetailSource { side: 'BUY' | 'SELL'; price: number; symbol: string; strategyName?: string | null; ts: number; signalMeta: LiveTimelineSignalMeta; } export function toTimelineSignalDetail( ev: LiveTimelineEvent, ): TimelineSignalDetailSource | null { if (ev.kind !== 'SIGNAL' || !ev.signalMeta) return null; return { side: ev.side, price: ev.price, symbol: ev.symbol, strategyName: ev.strategyName, ts: ev.ts, signalMeta: ev.signalMeta, }; }