분석레포트 타임라인 표시
This commit is contained in:
@@ -1,40 +1,101 @@
|
||||
/**
|
||||
* 분석레포트 — 중앙 패널 (종목·실행 분석 상세)
|
||||
* 분석레포트 — 중앙 패널 (분석요약 · 거래이력 탭)
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { BacktestDashboard } from '../BacktestResultModal';
|
||||
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 {
|
||||
model: BacktestAnalysisReportModel | null;
|
||||
timelineFilter?: LiveTimelineFilter;
|
||||
timelineFilterLabel?: string;
|
||||
chartTimeframe?: Timeframe;
|
||||
}
|
||||
|
||||
export function AnalysisReportCenterPanel({ model }: Props) {
|
||||
if (!model) {
|
||||
return (
|
||||
<div className="arp-center-shell arp-report-theme">
|
||||
<div className="arp-report-empty">
|
||||
<span className="arp-report-empty-icon" aria-hidden>📊</span>
|
||||
<p>좌측 목록에서 백테스팅 또는 실시간 매매 실행을 선택하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function AnalysisReportCenterPanel({
|
||||
model,
|
||||
timelineFilter,
|
||||
timelineFilterLabel,
|
||||
chartTimeframe = '3m',
|
||||
}: Props) {
|
||||
const [centerTab, setCenterTab] = useState<AnalysisCenterTab>(loadSavedCenterTab);
|
||||
|
||||
const handleTabChange = useCallback((tab: AnalysisCenterTab) => {
|
||||
setCenterTab(tab);
|
||||
try { localStorage.setItem(CENTER_TAB_STORAGE_KEY, tab); } catch { /* ok */ }
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="arp-center-shell arp-report-theme">
|
||||
<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 className="arp-center-tabs" role="tablist" aria-label="분석 결과">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={centerTab === 'summary'}
|
||||
className={`arp-center-tab${centerTab === 'summary' ? ' arp-center-tab--on' : ''}`}
|
||||
onClick={() => handleTabChange('summary')}
|
||||
>
|
||||
분석요약
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { Theme, Timeframe } from '../../types';
|
||||
import {
|
||||
loadBacktestResults,
|
||||
loadPaperTrades,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
buildLiveReportModel,
|
||||
} from '../../utils/backtestReportModel';
|
||||
import { enrichStrategyNameMap, strategyNamesFromList } from '../../utils/strategyNameResolver';
|
||||
import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import '../../styles/backtestDashboard.css';
|
||||
import '../../styles/analysisReportPage.css';
|
||||
|
||||
@@ -142,6 +143,42 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
|
||||
return null;
|
||||
}, [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 = (
|
||||
<>
|
||||
<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} />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user