274 lines
7.8 KiB
TypeScript
274 lines
7.8 KiB
TypeScript
/**
|
|
* 실시간 매매 거래 이력 — 시그널·미체결·체결 통합 타임라인
|
|
*/
|
|
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<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,
|
|
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<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())}`;
|
|
}
|
|
|
|
/** 타임라인 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,
|
|
};
|
|
}
|