분석레포트 타임라인 표시
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;
|
||||
@@ -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);
|
||||
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);
|
||||
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;
|
||||
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 {
|
||||
@@ -1859,3 +1917,904 @@
|
||||
.bps-page--arp .arp-report-print-footer--signals {
|
||||
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