분석레포트 타임라인 표시

This commit is contained in:
Macbook
2026-06-10 16:53:03 +09:00
parent 55b1c75226
commit bf5554d375
12 changed files with 2032 additions and 28 deletions
+232
View File
@@ -0,0 +1,232 @@
/**
* 실시간 매매 거래 이력 — 시그널·미체결·체결 통합 타임라인
*/
import type {
PaperOrderDto,
PaperTradeDto,
TradeSignalDto,
} from './backendApi';
export type LiveTimelineEventKind = 'SIGNAL' | 'ORDER_PENDING' | 'FILL';
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;
}
export const LIVE_TIMELINE_KIND_LABEL: Record<LiveTimelineEventKind, string> = {
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,
});
}
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<string, number> = {
'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<number, { sells: LiveTimelineEvent[]; buys: LiveTimelineEvent[] }>();
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())}`;
}