분석레포트 타임라인 표시
This commit is contained in:
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user