분석레포트 타임라인 표시

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
@@ -1,40 +1,101 @@
/** /**
* 분석레포트 — 중앙 패널 (종목·실행 분석 상세) * 분석레포트 — 중앙 패널 (분석요약 · 거래이력 탭)
*/ */
import React from 'react'; import React, { useCallback, useState } from 'react';
import { BacktestDashboard } from '../BacktestResultModal'; import { BacktestDashboard } from '../BacktestResultModal';
import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal';
import LiveTradeTimelinePanel from './LiveTradeTimelinePanel';
import type { LiveTimelineFilter } from '../../utils/liveTradeTimeline';
import type { Timeframe } from '../../types';
export type AnalysisCenterTab = 'summary' | 'history';
const CENTER_TAB_STORAGE_KEY = 'arp-center-tab';
function loadSavedCenterTab(): AnalysisCenterTab {
try {
const v = localStorage.getItem(CENTER_TAB_STORAGE_KEY);
if (v === 'summary' || v === 'history') return v;
} catch { /* ok */ }
return 'summary';
}
interface Props { interface Props {
model: BacktestAnalysisReportModel | null; model: BacktestAnalysisReportModel | null;
timelineFilter?: LiveTimelineFilter;
timelineFilterLabel?: string;
chartTimeframe?: Timeframe;
} }
export function AnalysisReportCenterPanel({ model }: Props) { export function AnalysisReportCenterPanel({
if (!model) { model,
return ( timelineFilter,
<div className="arp-center-shell arp-report-theme"> timelineFilterLabel,
<div className="arp-report-empty"> chartTimeframe = '3m',
<span className="arp-report-empty-icon" aria-hidden>📊</span> }: Props) {
<p> .</p> const [centerTab, setCenterTab] = useState<AnalysisCenterTab>(loadSavedCenterTab);
</div>
</div> const handleTabChange = useCallback((tab: AnalysisCenterTab) => {
); setCenterTab(tab);
} try { localStorage.setItem(CENTER_TAB_STORAGE_KEY, tab); } catch { /* ok */ }
}, []);
return ( return (
<div className="arp-center-shell arp-report-theme"> <div className="arp-center-shell arp-report-theme">
<div className="arp-center-inner"> <div className="arp-center-tabs" role="tablist" aria-label="분석 결과">
<BacktestDashboard <button
analysis={model.analysis} type="button"
signals={model.signals} role="tab"
strategyName={model.strategyName} aria-selected={centerTab === 'summary'}
symbol={model.symbol} className={`arp-center-tab${centerTab === 'summary' ? ' arp-center-tab--on' : ''}`}
timeframe={model.timeframe} onClick={() => handleTabChange('summary')}
barCount={model.barCount} >
createdAt={model.createdAt}
reportMode </button>
hideSignals <button
/> type="button"
role="tab"
aria-selected={centerTab === 'history'}
className={`arp-center-tab${centerTab === 'history' ? ' arp-center-tab--on' : ''}`}
onClick={() => handleTabChange('history')}
>
</button>
</div>
<div className="arp-center-tab-body">
{centerTab === 'summary' && (
model ? (
<div className="arp-center-inner">
<BacktestDashboard
analysis={model.analysis}
signals={model.signals}
strategyName={model.strategyName}
symbol={model.symbol}
timeframe={model.timeframe}
barCount={model.barCount}
createdAt={model.createdAt}
reportMode
hideSignals
/>
</div>
) : (
<div className="arp-report-empty">
<span className="arp-report-empty-icon" aria-hidden>📊</span>
<p> .</p>
</div>
)
)}
{centerTab === 'history' && (
<LiveTradeTimelinePanel
active
embedded
filter={timelineFilter}
filterLabel={timelineFilterLabel}
chartTimeframe={chartTimeframe}
/>
)}
</div> </div>
</div> </div>
); );
@@ -2,7 +2,7 @@
* 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널) * 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널)
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { Theme } from '../../types'; import type { Theme, Timeframe } from '../../types';
import { import {
loadBacktestResults, loadBacktestResults,
loadPaperTrades, loadPaperTrades,
@@ -28,6 +28,7 @@ import {
buildLiveReportModel, buildLiveReportModel,
} from '../../utils/backtestReportModel'; } from '../../utils/backtestReportModel';
import { enrichStrategyNameMap, strategyNamesFromList } from '../../utils/strategyNameResolver'; import { enrichStrategyNameMap, strategyNamesFromList } from '../../utils/strategyNameResolver';
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
import '../../styles/backtestDashboard.css'; import '../../styles/backtestDashboard.css';
import '../../styles/analysisReportPage.css'; import '../../styles/analysisReportPage.css';
@@ -142,6 +143,42 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
return null; return null;
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]); }, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
const timelineFilter = useMemo(() => {
if (sourceTab === 'live' && selectedLive) {
return {
symbol: selectedLive.symbol,
strategyId: selectedLive.strategyId,
};
}
if (sourceTab === 'backtest' && selectedBacktest) {
return {
symbol: selectedBacktest.symbol,
strategyId: selectedBacktest.strategyId ?? null,
};
}
return undefined;
}, [sourceTab, selectedLive, selectedBacktest]);
const timelineFilterLabel = useMemo(() => {
if (sourceTab === 'live' && selectedLive) {
return `${selectedLive.symbol} · ${selectedLive.strategyLabel}`;
}
if (sourceTab === 'backtest' && selectedBacktest) {
return `${selectedBacktest.symbol} · ${selectedBacktest.strategyName}`;
}
return undefined;
}, [sourceTab, selectedLive, selectedBacktest]);
const timelineChartTimeframe = useMemo((): Timeframe => {
if (sourceTab === 'live' && selectedLive?.timeframe) {
return candleTypeToTimeframe(selectedLive.timeframe);
}
if (sourceTab === 'backtest' && selectedBacktest?.timeframe) {
return candleTypeToTimeframe(selectedBacktest.timeframe);
}
return '3m';
}, [sourceTab, selectedLive, selectedBacktest]);
const headerActions = ( const headerActions = (
<> <>
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button> <button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button>
@@ -193,7 +230,14 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
}} }}
/> />
)} )}
center={<AnalysisReportCenterPanel model={reportModel} />} center={(
<AnalysisReportCenterPanel
model={reportModel}
timelineFilter={timelineFilter}
timelineFilterLabel={timelineFilterLabel}
chartTimeframe={timelineChartTimeframe}
/>
)}
right={<AnalysisReportSignalsPanel model={reportModel} />} right={<AnalysisReportSignalsPanel model={reportModel} />}
/> />
@@ -0,0 +1,184 @@
/**
* 분석레포트 — 실시간 매매 거래 이력 타임라인 (패널 / 팝업 공용)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
loadPaperPendingOrders,
loadPaperTrades,
loadTradeSignals,
} from '../../utils/backendApi';
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
import {
buildLiveTradeTimeline,
type LiveTimelineEvent,
type LiveTimelineFilter,
} from '../../utils/liveTradeTimeline';
import {
LIVE_TIMELINE_VIEW_OPTIONS,
TimelineListView,
TimelineSplitView,
type LiveTimelineViewMode,
} from './LiveTradeTimelineViews';
import TimelineAxisView from './TimelineAxisView';
import type { Timeframe } from '../../types';
export const ARP_TIMELINE_VIEW_STORAGE_KEY = 'arp-timeline-view-mode';
const REFRESH_MS = 4000;
export function loadSavedTimelineViewMode(): LiveTimelineViewMode {
try {
const v = localStorage.getItem(ARP_TIMELINE_VIEW_STORAGE_KEY);
if (v === 'chart') return 'axis';
if (v === 'list' || v === 'split' || v === 'axis') return v;
} catch { /* ok */ }
return 'axis';
}
interface Props {
active?: boolean;
filter?: LiveTimelineFilter;
filterLabel?: string;
chartTimeframe?: Timeframe;
embedded?: boolean;
}
const LiveTradeTimelinePanel: React.FC<Props> = ({
active = true,
filter,
filterLabel,
chartTimeframe = '3m',
embedded = false,
}) => {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<LiveTimelineEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<LiveTimelineViewMode>(loadSavedTimelineViewMode);
const fetchTimeline = useCallback(async () => {
if (!active) return;
setError(null);
try {
const [signals, orders, trades] = await Promise.all([
loadTradeSignals(filter?.symbol),
loadPaperPendingOrders(),
loadPaperTrades(),
]);
setEvents(buildLiveTradeTimeline(signals, orders, trades, filter));
} catch (e) {
setError(e instanceof Error ? e.message : '거래 이력을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [active, filter]);
useEffect(() => {
if (!active) return;
setLoading(true);
void fetchTimeline();
const onPaper = () => { void fetchTimeline(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
const timer = window.setInterval(() => { void fetchTimeline(); }, REFRESH_MS);
return () => {
window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
window.clearInterval(timer);
};
}, [active, fetchTimeline]);
const handleViewChange = useCallback((mode: LiveTimelineViewMode) => {
setViewMode(mode);
try { localStorage.setItem(ARP_TIMELINE_VIEW_STORAGE_KEY, mode); } catch { /* ok */ }
}, []);
const subtitle = useMemo(() => {
if (filterLabel) return filterLabel;
if (filter?.symbol) return filter.symbol;
return '전체 종목';
}, [filter, filterLabel]);
const viewHint = `${chartTimeframe} 봉 시간 기준 · 매수/매도 거래 이력`;
const shellClass = [
'arp-timeline-panel',
embedded ? 'arp-timeline-panel--embedded' : '',
viewMode === 'split' ? 'arp-timeline-panel--split' : '',
viewMode === 'axis' ? 'arp-timeline-panel--axis' : '',
].filter(Boolean).join(' ');
return (
<div className={shellClass}>
<div className="arp-timeline-panel-toolbar">
<div className="arp-timeline-head">
<span className="arp-timeline-scope">{subtitle}</span>
<span className="arp-timeline-hint">{viewHint}</span>
</div>
<div className="arp-timeline-header-tools">
<div className="arp-timeline-view-switch" role="tablist" aria-label="타임라인 유형">
{LIVE_TIMELINE_VIEW_OPTIONS.map(opt => (
<button
key={opt.id}
type="button"
role="tab"
aria-selected={viewMode === opt.id}
className={`arp-timeline-view-btn${viewMode === opt.id ? ' arp-timeline-view-btn--on' : ''}`}
onClick={() => handleViewChange(opt.id)}
>
{opt.label}
</button>
))}
</div>
<button
type="button"
className="arp-timeline-refresh"
title="새로고침"
aria-label="새로고침"
onClick={() => void fetchTimeline()}
>
</button>
{!embedded && events.length > 0 && (
<span className="arp-timeline-badge">{events.length}</span>
)}
</div>
</div>
{viewMode === 'split' && (
<div className="arp-timeline-split-legend" aria-hidden>
<span className="arp-timeline-split-legend-item arp-timeline-split-legend-item--buy"> </span>
<span className="arp-timeline-split-legend-item arp-timeline-split-legend-item--sell"> </span>
</div>
)}
{viewMode === 'axis' && (
<div className="arp-timeline-axis-legend" aria-hidden>
<span className="arp-timeline-axis-legend-item arp-timeline-axis-legend-item--sell"> </span>
<span className="arp-timeline-axis-legend-item arp-timeline-axis-legend-item--buy"> </span>
</div>
)}
<div className="arp-timeline-panel-body">
{loading && events.length === 0 && (
<p className="arp-timeline-empty"> </p>
)}
{error && (
<p className="arp-timeline-error" role="alert">{error}</p>
)}
{!loading && !error && events.length === 0 && (
<p className="arp-timeline-empty"> .</p>
)}
{events.length > 0 && viewMode === 'list' && (
<TimelineListView events={events} timeframe={chartTimeframe} />
)}
{events.length > 0 && viewMode === 'split' && (
<TimelineSplitView events={events} timeframe={chartTimeframe} />
)}
{events.length > 0 && viewMode === 'axis' && (
<TimelineAxisView events={events} timeframe={chartTimeframe} />
)}
</div>
</div>
);
};
export default LiveTradeTimelinePanel;
@@ -0,0 +1,48 @@
/**
* 분석레포트 — 실시간 매매 거래 이력 타임라인 팝업
*/
import React from 'react';
import { AppPopup } from '../AppPopup';
import LiveTradeTimelinePanel from './LiveTradeTimelinePanel';
import type { LiveTimelineFilter } from '../../utils/liveTradeTimeline';
import type { Timeframe } from '../../types';
interface Props {
open: boolean;
onClose: () => void;
filter?: LiveTimelineFilter;
filterLabel?: string;
chartTimeframe?: Timeframe;
}
const LiveTradeTimelinePopup: React.FC<Props> = ({
open,
onClose,
filter,
filterLabel,
chartTimeframe = '3m',
}) => {
if (!open) return null;
return (
<AppPopup
onClose={onClose}
title="Trade History"
titleKo="실시간 거래 이력"
accentColor="#c9a227"
width={1120}
maxWidth="96vw"
shellClassName="arp-timeline-popup"
bodyClassName="app-popup-body arp-timeline-popup__body"
>
<LiveTradeTimelinePanel
active={open}
filter={filter}
filterLabel={filterLabel}
chartTimeframe={chartTimeframe}
/>
</AppPopup>
);
};
export default LiveTradeTimelinePopup;
@@ -0,0 +1,175 @@
/**
* 분석레포트 — 거래 이력 타임라인 뷰 (목록 / 좌우 분할)
*/
import React from 'react';
import {
LIVE_TIMELINE_KIND_LABEL,
LIVE_TIMELINE_SIDE_LABEL,
type LiveTimelineEvent,
} from '../../utils/liveTradeTimeline';
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import TimelineBarTimeBox, { eventToBarTimeSec } from './TimelineBarTimeBox';
import type { Timeframe } from '../../types';
export type LiveTimelineViewMode = 'list' | 'split' | 'axis';
export const LIVE_TIMELINE_VIEW_OPTIONS: { id: LiveTimelineViewMode; label: string }[] = [
{ id: 'list', label: '세로 목록' },
{ id: 'split', label: '좌우 분할' },
{ id: 'axis', label: '봉 시간축' },
];
function kindClass(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return 'signal';
if (kind === 'ORDER_PENDING') return 'pending';
return 'fill';
}
function sideClass(side: LiveTimelineEvent['side']): 'buy' | 'sell' {
return side === 'BUY' ? 'buy' : 'sell';
}
interface EventBodyProps {
ev: LiveTimelineEvent;
compact?: boolean;
}
export const TimelineEventBody: React.FC<EventBodyProps> = ({ ev, compact = false }) => {
const { koreanName } = resolveVirtualTargetNames(ev.symbol);
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
return (
<>
<div className="arp-timeline-card-tags">
<span className={`arp-timeline-kind arp-timeline-kind--${kCls}`}>
{LIVE_TIMELINE_KIND_LABEL[ev.kind]}
</span>
<span className={`arp-timeline-side arp-timeline-side--${sCls}`}>
{LIVE_TIMELINE_SIDE_LABEL[ev.side]}
</span>
</div>
<div className={`arp-timeline-card-title${compact ? ' arp-timeline-card-title--compact' : ''}`}>
{koreanName || ev.symbol}
<span className="arp-timeline-symbol">{ev.symbol.replace(/^KRW-/, '')}</span>
</div>
<div className="arp-timeline-meta">
{ev.price > 0 && <span>{formatSignalPrice(ev.price)}</span>}
{ev.quantity != null && ev.quantity > 0 && (
<span>{ev.quantity.toLocaleString(undefined, { maximumFractionDigits: 8 })}</span>
)}
{ev.strategyName && <span>{ev.strategyName}</span>}
{ev.sourceLabel && <span>{ev.sourceLabel}</span>}
{ev.detail && <span className="arp-timeline-detail">{ev.detail}</span>}
</div>
</>
);
};
interface ListViewProps {
events: LiveTimelineEvent[];
timeframe: Timeframe;
}
export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe }) => (
<div className="btd-timeline arp-timeline-scroll arp-timeline-scroll--list" aria-label="거래 이력 타임라인">
<div className="btd-timeline-line" aria-hidden />
{events.map(ev => {
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
const barTimeSec = eventToBarTimeSec(ev.ts, timeframe);
return (
<article
key={ev.id}
className={[
'btd-timeline-item',
'arp-timeline-item',
'arp-timeline-item--bar-anchor',
`arp-timeline-item--${kCls}`,
`arp-timeline-item--${sCls}`,
].join(' ')}
>
<TimelineBarTimeBox
barTimeSec={barTimeSec}
timeframe={timeframe}
side={sCls}
variant="compact"
className="arp-timeline-bar-time--list-node"
/>
<div className="btd-timeline-card">
<TimelineEventBody ev={ev} />
</div>
</article>
);
})}
</div>
);
interface SplitViewProps {
events: LiveTimelineEvent[];
timeframe: Timeframe;
}
export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe }) => (
<div className="arp-timeline-split-scroll" aria-label="좌우 분할 거래 이력 타임라인">
<div className="arp-timeline-split-axis" aria-hidden />
{events.map(ev => {
const isBuy = ev.side === 'BUY';
const kCls = kindClass(ev.kind);
const sCls = sideClass(ev.side);
const barTimeSec = eventToBarTimeSec(ev.ts, timeframe);
const cardCls = [
'arp-timeline-split-card',
`arp-timeline-split-card--${sCls}`,
`arp-timeline-split-card--${kCls}`,
isBuy ? 'arp-timeline-split-card--left' : 'arp-timeline-split-card--right',
].join(' ');
const card = (
<article className={cardCls}>
<TimelineEventBody ev={ev} compact />
</article>
);
return (
<div
key={ev.id}
className={`arp-timeline-split-row arp-timeline-split-row--${sCls}`}
>
<div className="arp-timeline-split-col arp-timeline-split-col--left">
{isBuy ? (
<div className="arp-timeline-split-stack arp-timeline-split-stack--buy">
{card}
<div
className="arp-timeline-split-connector arp-timeline-split-connector--buy"
aria-hidden
/>
</div>
) : null}
</div>
<div className="arp-timeline-split-col arp-timeline-split-col--center">
<TimelineBarTimeBox
barTimeSec={barTimeSec}
timeframe={timeframe}
side={sCls}
variant="compact"
className="arp-timeline-bar-time--split-node"
/>
</div>
<div className="arp-timeline-split-col arp-timeline-split-col--right">
{!isBuy ? (
<div className="arp-timeline-split-stack arp-timeline-split-stack--sell">
<div
className="arp-timeline-split-connector arp-timeline-split-connector--sell"
aria-hidden
/>
{card}
</div>
) : null}
</div>
</div>
);
})}
</div>
);
@@ -0,0 +1,66 @@
/**
* 분석레포트 — 봉 시간축 / 캔들 연동 공통 거래 카드
*/
import React from 'react';
import {
LIVE_TIMELINE_KIND_LABEL,
LIVE_TIMELINE_SIDE_LABEL,
type LiveTimelineEvent,
} from '../../utils/liveTradeTimeline';
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
function kindClass(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return 'signal';
if (kind === 'ORDER_PENDING') return 'pending';
return 'fill';
}
function kindIcon(kind: LiveTimelineEvent['kind']): string {
if (kind === 'SIGNAL') return '⚡';
if (kind === 'ORDER_PENDING') return '◷';
return '✓';
}
interface Props {
ev: LiveTimelineEvent;
side: 'top' | 'bottom';
}
const TimelineAxisEventCard: React.FC<Props> = ({ ev, side }) => {
const { koreanName } = resolveVirtualTargetNames(ev.symbol);
const kCls = kindClass(ev.kind);
const isSell = ev.side === 'SELL';
return (
<article
className={[
'arp-timeline-axis-card',
`arp-timeline-axis-card--${side}`,
`arp-timeline-axis-card--${kCls}`,
isSell ? 'arp-timeline-axis-card--sell' : 'arp-timeline-axis-card--buy',
].join(' ')}
>
<div className="arp-timeline-axis-card-icon" aria-hidden>
{kindIcon(ev.kind)}
</div>
<div className="arp-timeline-axis-card-body">
<header className="arp-timeline-axis-card-head">
<strong>{LIVE_TIMELINE_KIND_LABEL[ev.kind]}</strong>
<span>{LIVE_TIMELINE_SIDE_LABEL[ev.side]}</span>
</header>
<p className="arp-timeline-axis-card-title">
{koreanName || ev.symbol.replace(/^KRW-/, '')}
</p>
<div className="arp-timeline-axis-card-meta">
{ev.price > 0 && <span>{formatSignalPrice(ev.price)}</span>}
{ev.quantity != null && ev.quantity > 0 && (
<span>{ev.quantity.toLocaleString(undefined, { maximumFractionDigits: 6 })}</span>
)}
</div>
</div>
</article>
);
};
export default TimelineAxisEventCard;
@@ -0,0 +1,114 @@
/**
* 분석레포트 — 가로 봉 시간축 타임라인 (중앙 축 · 상단 매도 · 하단 매수)
*/
import React, { useMemo, useRef } from 'react';
import {
groupEventsByBarTime,
type LiveTimelineEvent,
} from '../../utils/liveTradeTimeline';
import TimelineBarTimeBox from './TimelineBarTimeBox';
import TimelineAxisEventCard from './TimelineAxisEventCard';
import { useHorizontalWheelScroll } from '../../hooks/useHorizontalWheelScroll';
import { useCenterAxisTimelineScroll } from '../../hooks/useCenterAxisTimelineScroll';
import type { Timeframe } from '../../types';
const SEGMENT_COLORS = ['c0', 'c1', 'c2', 'c3', 'c4'] as const;
interface Props {
events: LiveTimelineEvent[];
timeframe: Timeframe;
}
const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
useHorizontalWheelScroll(scrollRef);
const slots = useMemo(
() => groupEventsByBarTime(events, timeframe),
[events, timeframe],
);
useCenterAxisTimelineScroll(scrollRef, trackRef, slots.length > 0, [slots.length, events.length]);
if (slots.length === 0) {
return <p className="arp-timeline-empty"> .</p>;
}
const trackMinWidth = Math.max(slots.length * 160, 320);
return (
<div
ref={scrollRef}
className="arp-timeline-axis-scroll arp-timeline-h-scroll"
aria-label="봉 시간축 거래 이력 타임라인"
>
<div className="arp-timeline-axis-scroll-inner">
<div
ref={trackRef}
className="arp-timeline-axis-track"
style={{ minWidth: trackMinWidth }}
>
<div className="arp-timeline-axis-zone arp-timeline-axis-zone--top">
<span className="arp-timeline-axis-zone-label"></span>
<div className="arp-timeline-axis-cols">
{slots.map(slot => (
<div key={`top-${slot.barTimeSec}`} className="arp-timeline-axis-col">
{slot.sells.map(ev => (
<div key={ev.id} className="arp-timeline-axis-col-stack">
<TimelineAxisEventCard ev={ev} side="top" />
<div
className="arp-timeline-axis-connector arp-timeline-axis-connector--down arp-timeline-axis-connector--sell"
aria-hidden
/>
</div>
))}
</div>
))}
</div>
</div>
<div className="arp-timeline-axis-rail" aria-hidden>
{slots.map((slot, i) => (
<div
key={slot.barTimeSec}
className={[
'arp-timeline-axis-segment',
`arp-timeline-axis-segment--${SEGMENT_COLORS[i % SEGMENT_COLORS.length]}`,
].join(' ')}
>
<TimelineBarTimeBox
barTimeSec={slot.barTimeSec}
timeframe={timeframe}
variant="segment"
showTimeframe={false}
/>
</div>
))}
</div>
<div className="arp-timeline-axis-zone arp-timeline-axis-zone--bottom">
<div className="arp-timeline-axis-cols">
{slots.map(slot => (
<div key={`bottom-${slot.barTimeSec}`} className="arp-timeline-axis-col">
{slot.buys.map(ev => (
<div key={ev.id} className="arp-timeline-axis-col-stack">
<div
className="arp-timeline-axis-connector arp-timeline-axis-connector--up arp-timeline-axis-connector--buy"
aria-hidden
/>
<TimelineAxisEventCard ev={ev} side="bottom" />
</div>
))}
</div>
))}
</div>
<span className="arp-timeline-axis-zone-label"></span>
</div>
</div>
</div>
</div>
);
};
export default TimelineAxisView;
@@ -0,0 +1,49 @@
/**
* 타임라인 공통 — 봉 시작 시각 카드
*/
import React from 'react';
import { formatBarAxisLabel, snapMsToBarTimeSec } from '../../utils/liveTradeTimeline';
import type { Timeframe } from '../../types';
export type TimelineBarTimeVariant = 'default' | 'compact' | 'segment';
export function eventToBarTimeSec(tsMs: number, timeframe: string): number {
return snapMsToBarTimeSec(tsMs, timeframe);
}
interface Props {
barTimeSec: number;
timeframe: Timeframe | string;
side?: 'buy' | 'sell';
variant?: TimelineBarTimeVariant;
className?: string;
showTimeframe?: boolean;
}
const TimelineBarTimeBox: React.FC<Props> = ({
barTimeSec,
timeframe,
side,
variant = 'default',
className = '',
showTimeframe = variant !== 'segment',
}) => (
<div
className={[
'arp-timeline-bar-time',
variant !== 'default' ? `arp-timeline-bar-time--${variant}` : '',
side ? `arp-timeline-bar-time--${side}` : '',
className,
].filter(Boolean).join(' ')}
title={`${timeframe}`}
>
<span className="arp-timeline-bar-time-label">
{formatBarAxisLabel(barTimeSec, timeframe)}
</span>
{showTimeframe && (
<span className="arp-timeline-bar-time-tf">{timeframe}</span>
)}
</div>
);
export default TimelineBarTimeBox;
@@ -0,0 +1,43 @@
import { useLayoutEffect, type RefObject } from 'react';
/** 봉 시간축 — 시간 레일이 스크롤 영역 중앙(세로·가로)에 오도록 조정 */
export function useCenterAxisTimelineScroll(
scrollRef: RefObject<HTMLElement | null>,
trackRef: RefObject<HTMLElement | null>,
enabled: boolean,
deps: unknown[] = [],
): void {
useLayoutEffect(() => {
if (!enabled) return;
const scroll = scrollRef.current;
const track = trackRef.current;
if (!scroll || !track) return;
const center = () => {
const rail = track.querySelector('.arp-timeline-axis-rail') as HTMLElement | null;
if (rail) {
const scrollRect = scroll.getBoundingClientRect();
const railRect = rail.getBoundingClientRect();
const railMidInContent =
scroll.scrollTop + (railRect.top - scrollRect.top) + railRect.height / 2;
const targetTop = railMidInContent - scroll.clientHeight / 2;
scroll.scrollTop = Math.max(0, Math.min(
targetTop,
scroll.scrollHeight - scroll.clientHeight,
));
}
const targetLeft = (scroll.scrollWidth - scroll.clientWidth) / 2;
scroll.scrollLeft = Math.max(0, targetLeft);
};
center();
requestAnimationFrame(center);
const ro = new ResizeObserver(() => center());
ro.observe(scroll);
ro.observe(track);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, scrollRef, trackRef, ...deps]);
}
@@ -0,0 +1,29 @@
import { useEffect, type RefObject } from 'react';
/** 가로 overflow 컨테이너 — 세로 휠을 좌우 스크롤로 변환 */
export function useHorizontalWheelScroll(
ref: RefObject<HTMLElement | null>,
enabled = true,
): void {
useEffect(() => {
if (!enabled) return;
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (el.scrollWidth <= el.clientWidth + 1) return;
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (delta === 0) return;
const prev = el.scrollLeft;
el.scrollLeft += delta;
if (el.scrollLeft !== prev) {
e.preventDefault();
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [enabled, ref]);
}
+960 -1
View File
@@ -191,8 +191,66 @@
background: var(--se-panel-card-bg); background: var(--se-panel-card-bg);
border: 1px solid var(--se-panel-card-border); border: 1px solid var(--se-panel-card-border);
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
overflow: hidden;
padding: 10px 12px 12px;
}
.bps-page--arp .arp-center-tabs {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-bottom: 10px;
padding: 3px;
border-radius: 10px;
background: color-mix(in srgb, var(--text) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
}
.bps-page--arp .arp-center-tab {
flex: 1;
border: none;
background: transparent;
color: var(--text3);
font-size: 12px;
font-weight: 700;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.bps-page--arp .arp-center-tab:hover {
color: var(--text2);
background: color-mix(in srgb, var(--text) 6%, transparent);
}
.bps-page--arp .arp-center-tab--on {
color: var(--text);
background: var(--bg3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.bps-page--arp .arp-center-tab-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.bps-page--arp .arp-center-tab-body .arp-center-inner {
flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
padding: 12px 14px 14px; }
.bps-page--arp .arp-center-tab-body .arp-report-empty {
flex: 1;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
} }
.bps-page--arp .arp-center-shell .arp-report-empty { .bps-page--arp .arp-center-shell .arp-report-empty {
@@ -1859,3 +1917,904 @@
.bps-page--arp .arp-report-print-footer--signals { .bps-page--arp .arp-report-print-footer--signals {
font-size: 0.72rem; font-size: 0.72rem;
} }
/* ── 툴바 거래 이력 아이콘 ── */
.bps-page--arp .arp-toolbar-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-width: 34px;
padding: 0;
}
.bps-page--arp .arp-toolbar-icon-btn svg {
opacity: 0.88;
}
/* ── 실시간 거래 이력 타임라인 (패널 / 팝업) ── */
.arp-timeline-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.arp-timeline-panel--embedded {
flex: 1;
height: 100%;
}
.arp-timeline-panel-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-shrink: 0;
flex-wrap: wrap;
}
.arp-timeline-panel-toolbar .arp-timeline-head {
flex: 1;
min-width: 180px;
}
.arp-timeline-panel-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.arp-timeline-badge {
font-size: 11px;
font-weight: 800;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, #c9a227 22%, transparent);
color: #c9a227;
}
.arp-timeline-popup .app-popup-body.arp-timeline-popup__body {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(72vh, 640px);
padding-top: 4px;
}
.arp-timeline-popup .arp-timeline-panel {
flex: 1;
min-height: 0;
}
.bps-page--arp .arp-timeline-panel--embedded .arp-timeline-scroll,
.bps-page--arp .arp-timeline-panel--embedded .arp-timeline-split-scroll,
.bps-page--arp .arp-timeline-panel--embedded .arp-timeline-axis-scroll {
flex: 1;
min-height: 200px;
max-height: none;
}
.bps-page--arp .arp-timeline-panel--embedded.arp-timeline-panel--split .arp-timeline-split-scroll,
.bps-page--arp .arp-timeline-panel--embedded.arp-timeline-panel--axis .arp-timeline-axis-scroll {
max-height: none;
}
.bps-page--arp .arp-timeline-panel--embedded.arp-timeline-panel--axis .arp-timeline-panel-body {
min-height: 480px;
}
.arp-timeline-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.arp-timeline-scope {
font-size: 12px;
font-weight: 700;
color: var(--text);
}
.arp-timeline-hint {
font-size: 10px;
color: var(--text3);
}
.arp-timeline-refresh {
border: none;
background: transparent;
color: var(--text2);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.arp-timeline-refresh:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.arp-timeline-empty,
.arp-timeline-error {
margin: 12px 0;
font-size: 12px;
text-align: center;
color: var(--text3);
}
.arp-timeline-error {
color: var(--se-sell, #ef4444);
}
.arp-timeline-scroll {
position: relative;
flex: 1;
min-height: 120px;
max-height: min(58vh, 520px);
overflow-y: auto;
padding-right: 4px;
}
.arp-timeline-item {
cursor: default;
}
/* ── 공통 봉 시간 카드 ── */
.arp-timeline-bar-time {
min-width: 96px;
padding: 7px 10px;
border-radius: 4px;
background: rgba(12, 12, 16, 0.96);
border: 2px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
text-align: center;
flex-shrink: 0;
box-sizing: border-box;
}
.arp-timeline-bar-time--buy {
border-color: rgba(34, 197, 94, 0.65);
background: linear-gradient(0deg, rgba(34, 197, 94, 0.16), rgba(12, 12, 16, 0.96));
}
.arp-timeline-bar-time--sell {
border-color: rgba(239, 68, 68, 0.65);
background: linear-gradient(180deg, rgba(239, 68, 68, 0.16), rgba(12, 12, 16, 0.96));
}
.arp-timeline-bar-time-label {
display: block;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.3;
color: rgba(255, 255, 255, 0.96);
white-space: nowrap;
}
.arp-timeline-bar-time-tf {
display: block;
margin-top: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.58;
}
.arp-timeline-bar-time--compact {
min-width: 88px;
padding: 6px 9px;
}
.arp-timeline-bar-time--compact .arp-timeline-bar-time-label {
font-size: 12px;
}
.arp-timeline-bar-time--segment {
min-width: 0;
padding: 0;
border: none;
background: transparent;
box-shadow: none;
}
.arp-timeline-bar-time--segment .arp-timeline-bar-time-label {
font-size: 14px;
font-weight: 800;
color: #fff;
letter-spacing: -0.01em;
}
/* 세로 목록 — 타임라인 지점 */
.arp-timeline-scroll--list {
padding-left: 4px;
}
.arp-timeline-scroll--list .btd-timeline-line {
display: none;
}
.arp-timeline-item--bar-anchor {
position: relative;
display: grid;
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
gap: 10px;
align-items: start;
padding-left: 0 !important;
margin-bottom: 12px;
}
.arp-timeline-item--bar-anchor .arp-timeline-bar-time--list-node {
position: relative;
z-index: 1;
margin-top: 4px;
}
.arp-timeline-item--bar-anchor::before {
content: '';
position: absolute;
left: 46px;
top: 0;
bottom: -12px;
width: 2px;
background: linear-gradient(180deg, rgba(201, 162, 39, 0.45), rgba(201, 162, 39, 0.08));
pointer-events: none;
}
.arp-timeline-item--bar-anchor:last-child::before {
bottom: 50%;
}
.arp-timeline-item--signal .btd-timeline-node {
border-color: #6366f1;
background: color-mix(in srgb, #6366f1 18%, var(--bg3));
}
.arp-timeline-item--pending .btd-timeline-node {
border-color: #f59e0b;
background: color-mix(in srgb, #f59e0b 18%, var(--bg3));
}
.arp-timeline-item--fill.arp-timeline-item--buy .btd-timeline-node {
border-color: #22c55e;
background: color-mix(in srgb, #22c55e 18%, var(--bg3));
}
.arp-timeline-item--fill.arp-timeline-item--sell .btd-timeline-node {
border-color: #ef4444;
background: color-mix(in srgb, #ef4444 18%, var(--bg3));
}
.arp-timeline-kind {
display: inline-block;
margin-right: 6px;
padding: 2px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
letter-spacing: -0.02em;
}
.arp-timeline-kind--signal {
background: rgba(99, 102, 241, 0.14);
color: #818cf8;
border: 1px solid rgba(99, 102, 241, 0.28);
}
.arp-timeline-kind--pending {
background: rgba(245, 158, 11, 0.14);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.28);
}
.arp-timeline-kind--fill {
background: rgba(34, 197, 94, 0.12);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.24);
}
.arp-timeline-item--sell .arp-timeline-kind--fill {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border-color: rgba(239, 68, 68, 0.24);
}
.arp-timeline-side {
display: inline-block;
font-size: 10px;
font-weight: 700;
color: var(--text3);
}
.arp-timeline-side--buy { color: #22c55e; }
.arp-timeline-side--sell { color: #ef4444; }
.arp-timeline-symbol {
margin-left: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
}
.arp-timeline-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 4px;
font-size: 11px;
color: var(--text2);
}
.arp-timeline-detail {
color: var(--text3);
font-size: 10px;
}
/* ── 타이틀바: 유형 선택 + 새로고침 ── */
.arp-timeline-header-tools {
display: flex;
align-items: center;
gap: 6px;
margin-right: 4px;
}
.arp-timeline-view-switch {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
}
.arp-timeline-view-btn {
border: none;
background: transparent;
color: var(--text3);
font-size: 10px;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.arp-timeline-view-btn:hover {
color: var(--text);
}
.arp-timeline-view-btn--on {
background: color-mix(in srgb, #c9a227 22%, var(--bg2));
color: var(--text);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.arp-timeline-card-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 6px;
margin-bottom: 4px;
}
.arp-timeline-card-date {
display: block;
font-size: 10px;
color: var(--text3);
margin-bottom: 4px;
}
.arp-timeline-card-title {
font-size: 12px;
font-weight: 700;
color: var(--text);
line-height: 1.35;
margin-bottom: 2px;
}
.arp-timeline-card-title--compact {
font-size: 11px;
}
/* ── 좌우 분할 타임라인 (React Timeline 스타일) ── */
.arp-timeline-popup--split .app-popup-body.arp-timeline-popup__body {
max-height: min(76vh, 680px);
}
.arp-timeline-split-legend {
display: flex;
justify-content: space-between;
padding: 0 8%;
font-size: 10px;
font-weight: 800;
flex-shrink: 0;
}
.arp-timeline-split-legend-item--buy { color: #22c55e; }
.arp-timeline-split-legend-item--sell { color: #ef4444; }
.arp-timeline-split-scroll {
position: relative;
flex: 1;
min-height: 160px;
max-height: min(62vh, 560px);
overflow-y: auto;
overflow-x: hidden;
padding: 8px 12px 16px;
}
.arp-timeline-split-axis {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 3px;
transform: translateX(-50%);
background: linear-gradient(
180deg,
rgba(201, 162, 39, 0.75),
rgba(201, 162, 39, 0.15)
);
border-radius: 2px;
pointer-events: none;
z-index: 0;
}
.arp-timeline-split-row {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 0 8px;
align-items: center;
margin-bottom: 28px;
min-height: 72px;
z-index: 1;
}
.arp-timeline-split-col--left {
display: flex;
justify-content: flex-end;
min-width: 0;
padding-right: 4px;
}
.arp-timeline-split-col--center {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
padding: 0 2px;
z-index: 2;
}
.arp-timeline-split-col--right {
display: flex;
justify-content: flex-start;
min-width: 0;
padding-left: 4px;
}
.arp-timeline-split-stack {
display: flex;
align-items: center;
width: 100%;
max-width: 340px;
gap: 0;
}
.arp-timeline-split-stack--buy {
flex-direction: row;
justify-content: flex-end;
margin-left: auto;
}
.arp-timeline-split-stack--sell {
flex-direction: row;
justify-content: flex-start;
margin-right: auto;
}
.arp-timeline-split-connector {
flex: 1 1 28px;
min-width: 28px;
max-width: 56px;
height: 0;
border-top: 2px dashed rgba(255, 255, 255, 0.34);
align-self: center;
margin: 0 6px;
}
.arp-timeline-split-connector--buy {
border-top-color: rgba(34, 197, 94, 0.45);
}
.arp-timeline-split-connector--sell {
border-top-color: rgba(239, 68, 68, 0.45);
}
.arp-timeline-bar-time--split-node {
width: auto;
max-width: 116px;
flex-shrink: 0;
}
.arp-timeline-split-node {
width: 14px;
height: 14px;
border-radius: 50%;
border: 3px solid rgba(201, 162, 39, 0.65);
background: var(--bg2);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bg1) 80%, transparent);
flex-shrink: 0;
}
.arp-timeline-split-node--signal {
border-color: #6366f1;
background: color-mix(in srgb, #6366f1 25%, var(--bg2));
}
.arp-timeline-split-node--pending {
border-color: #f59e0b;
background: color-mix(in srgb, #f59e0b 25%, var(--bg2));
}
.arp-timeline-split-node--fill.arp-timeline-split-node--buy {
border-color: #22c55e;
background: color-mix(in srgb, #22c55e 25%, var(--bg2));
}
.arp-timeline-split-node--fill.arp-timeline-split-node--sell {
border-color: #ef4444;
background: color-mix(in srgb, #ef4444 25%, var(--bg2));
}
.arp-timeline-split-card {
position: relative;
width: 100%;
max-width: 300px;
flex-shrink: 0;
padding: 10px 12px;
border-radius: 10px;
background: var(--bg3);
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
}
.arp-timeline-split-card--left {
border-color: color-mix(in srgb, #22c55e 35%, transparent);
}
.arp-timeline-split-card--right {
border-color: color-mix(in srgb, #ef4444 35%, transparent);
}
.arp-timeline-split-card--pending {
border-style: dashed;
}
.arp-timeline-split-card--signal.arp-timeline-split-card--left {
border-color: color-mix(in srgb, #6366f1 40%, transparent);
}
.arp-timeline-split-card--signal.arp-timeline-split-card--right {
border-color: color-mix(in srgb, #6366f1 40%, transparent);
}
@media (max-width: 720px) {
.arp-timeline-split-row {
grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1fr);
gap: 0 6px;
}
.arp-timeline-split-card {
max-width: none;
padding: 8px 10px;
}
.arp-timeline-view-btn {
padding: 4px 7px;
font-size: 9px;
}
}
/* ── 봉 시간축 타임라인 (가로 축 · 상단 매도 · 하단 매수) ── */
.arp-timeline-popup--axis .app-popup-body.arp-timeline-popup__body {
max-height: min(78vh, 720px);
padding-bottom: 12px;
}
.arp-timeline-axis-legend {
display: flex;
justify-content: space-between;
padding: 0 6%;
font-size: 10px;
font-weight: 800;
flex-shrink: 0;
margin-bottom: 4px;
}
.arp-timeline-axis-legend-item--sell { color: #ef4444; }
.arp-timeline-axis-legend-item--buy { color: #22c55e; }
.arp-timeline-axis-scroll {
flex: 1;
min-height: 280px;
max-height: min(64vh, 580px);
overflow-x: auto;
overflow-y: auto;
padding: 8px 4px 12px;
}
.arp-timeline-axis-scroll.arp-timeline-h-scroll {
overscroll-behavior-x: contain;
}
.arp-timeline-axis-scroll-inner {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100%;
box-sizing: border-box;
}
.arp-timeline-axis-track {
display: flex;
flex-direction: column;
gap: 0;
margin: 0 auto;
flex-shrink: 0;
}
.arp-timeline-axis-zone {
position: relative;
padding: 0 8px;
}
.arp-timeline-axis-zone--top {
padding-bottom: 12px;
min-height: 148px;
}
.arp-timeline-axis-zone--bottom {
padding-top: 12px;
min-height: 148px;
}
.arp-timeline-axis-zone-label {
position: absolute;
left: 0;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.45;
}
.arp-timeline-axis-zone--top .arp-timeline-axis-zone-label {
top: 0;
color: #ef4444;
}
.arp-timeline-axis-zone--bottom .arp-timeline-axis-zone-label {
bottom: 0;
color: #22c55e;
}
.arp-timeline-axis-cols {
display: flex;
align-items: stretch;
gap: 0;
}
.arp-timeline-axis-col {
flex: 1 0 148px;
min-width: 148px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 0 6px;
}
.arp-timeline-axis-zone--top .arp-timeline-axis-col {
justify-content: flex-end;
}
.arp-timeline-axis-zone--bottom .arp-timeline-axis-col {
justify-content: flex-start;
}
.arp-timeline-axis-col-stack {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 140px;
margin-bottom: 4px;
}
.arp-timeline-axis-zone--top .arp-timeline-axis-col-stack {
flex-direction: column-reverse;
}
.arp-timeline-axis-zone--bottom .arp-timeline-axis-col-stack {
flex-direction: column;
}
.arp-timeline-axis-connector {
width: 0;
border-left: 2px dashed rgba(255, 255, 255, 0.34);
min-height: 32px;
flex: 0 0 auto;
}
.arp-timeline-axis-connector--down {
margin-top: 6px;
}
.arp-timeline-axis-connector--up {
margin-bottom: 6px;
}
.arp-timeline-axis-connector--sell {
border-left-color: rgba(239, 68, 68, 0.4);
}
.arp-timeline-axis-connector--buy {
border-left-color: rgba(34, 197, 94, 0.4);
}
.arp-timeline-axis-rail {
display: flex;
align-items: stretch;
min-height: 58px;
margin: 0 4px;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.35));
}
.arp-timeline-axis-segment {
flex: 1 0 136px;
min-width: 136px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 18px 0 22px;
margin-left: -10px;
clip-path: polygon(
0 0,
calc(100% - 14px) 0,
100% 50%,
calc(100% - 14px) 100%,
0 100%,
14px 50%
);
color: #fff;
font-weight: 800;
text-align: center;
}
.arp-timeline-axis-segment:first-child {
margin-left: 0;
padding-left: 14px;
clip-path: polygon(
0 0,
calc(100% - 14px) 0,
100% 50%,
calc(100% - 14px) 100%,
0 100%
);
}
.arp-timeline-axis-segment--c0 { background: linear-gradient(135deg, #16a34a, #15803d); }
.arp-timeline-axis-segment--c1 { background: linear-gradient(135deg, #7c3aed, #6d28d9); }
.arp-timeline-axis-segment--c2 { background: linear-gradient(135deg, #ca8a04, #a16207); }
.arp-timeline-axis-segment--c3 { background: linear-gradient(135deg, #2563eb, #1d4ed8); }
.arp-timeline-axis-segment--c4 { background: linear-gradient(135deg, #ea580c, #c2410c); }
.arp-timeline-axis-card {
display: flex;
gap: 8px;
width: 100%;
padding: 8px 9px;
border-radius: 10px;
background: rgba(16, 16, 20, 0.94);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
}
.arp-timeline-axis-card-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border: 2px dashed rgba(255, 255, 255, 0.35);
background: rgba(0, 0, 0, 0.25);
}
.arp-timeline-axis-card-body {
min-width: 0;
flex: 1;
font-size: 10px;
line-height: 1.4;
color: rgba(255, 255, 255, 0.88);
}
.arp-timeline-axis-card-head {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.arp-timeline-axis-card-head strong {
font-size: 10px;
}
.arp-timeline-axis-card-head span {
font-size: 9px;
opacity: 0.7;
}
.arp-timeline-axis-card--sell .arp-timeline-axis-card-head strong { color: #ef4444; }
.arp-timeline-axis-card--buy .arp-timeline-axis-card-head strong { color: #22c55e; }
.arp-timeline-axis-card-title {
margin: 0 0 3px;
font-size: 10px;
font-weight: 700;
}
.arp-timeline-axis-card-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
font-size: 9px;
opacity: 0.82;
}
.arp-timeline-axis-card--signal {
border-color: rgba(99, 102, 241, 0.45);
}
.arp-timeline-axis-card--pending {
border-style: dashed;
opacity: 0.95;
}
.arp-timeline-axis-card--fill.arp-timeline-axis-card--sell {
border-color: rgba(239, 68, 68, 0.35);
}
.arp-timeline-axis-card--fill.arp-timeline-axis-card--buy {
border-color: rgba(34, 197, 94, 0.35);
}
@media (max-width: 720px) {
.arp-timeline-axis-segment .arp-timeline-bar-time--segment .arp-timeline-bar-time-label {
font-size: 12px;
}
.arp-timeline-axis-col {
flex-basis: 120px;
min-width: 120px;
}
.arp-timeline-axis-segment {
flex-basis: 120px;
min-width: 120px;
}
}
+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())}`;
}