매매 시그널 디테일 팝업 기능 적용
This commit is contained in:
@@ -30,6 +30,7 @@ export interface AppPopupProps {
|
||||
headerExtra?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
initialPosition?: { x: number; y: number };
|
||||
bodyRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const AppPopup: React.FC<AppPopupProps> = ({
|
||||
@@ -53,6 +54,7 @@ export const AppPopup: React.FC<AppPopupProps> = ({
|
||||
headerExtra,
|
||||
footer,
|
||||
initialPosition,
|
||||
bodyRef,
|
||||
}) => {
|
||||
const accentGlow = accentColor === APP_POPUP_ACCENT
|
||||
? APP_POPUP_ACCENT_GLOW
|
||||
@@ -130,7 +132,7 @@ export const AppPopup: React.FC<AppPopupProps> = ({
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
<div ref={bodyRef} className={bodyClassName}>{children}</div>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,19 +16,22 @@ interface Props {
|
||||
/** compact: 스택 팝업, full: 목록/모달 요약 */
|
||||
variant?: 'compact' | 'full';
|
||||
showHeadline?: boolean;
|
||||
/** list: 라벨·값 세로 목록, matrix: n×n 정보 카드 그리드 */
|
||||
layout?: 'list' | 'matrix';
|
||||
}
|
||||
|
||||
export const TradeSignalDetailBody: React.FC<Props> = ({
|
||||
item,
|
||||
variant = 'compact',
|
||||
showHeadline = true,
|
||||
layout = 'list',
|
||||
}) => {
|
||||
useTradeAlertTimeFormat();
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const rows = buildSignalDetailRows(item);
|
||||
|
||||
return (
|
||||
<div className={`tsd-body tsd-body--${variant}`}>
|
||||
<div className={`tsd-body tsd-body--${variant}${layout === 'matrix' ? ' tsd-body--matrix' : ''}`}>
|
||||
{showHeadline && (
|
||||
<div className="tsd-headline">
|
||||
<span className={`tsd-direction ${isBuy ? 'tsd-direction--buy' : 'tsd-direction--sell'}`}>
|
||||
@@ -37,7 +40,26 @@ export const TradeSignalDetailBody: React.FC<Props> = ({
|
||||
<span className="tsd-headline-price">{formatSignalPrice(item.price)}</span>
|
||||
</div>
|
||||
)}
|
||||
{layout !== 'matrix' && (
|
||||
<p className="tsd-summary">{getSignalHeadline(item)}</p>
|
||||
)}
|
||||
{layout === 'matrix' ? (
|
||||
<div className="tsd-detail-matrix" role="list">
|
||||
{rows.map(row => (
|
||||
<div key={row.label} className="tsd-detail-cell" role="listitem">
|
||||
<span className="tsd-detail-cell-label">{row.label}</span>
|
||||
<span
|
||||
className={[
|
||||
'tsd-detail-cell-value',
|
||||
row.highlight ? 'tsd-detail-cell-value--highlight' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="tsd-detail-grid">
|
||||
{rows.map(row => (
|
||||
<React.Fragment key={row.label}>
|
||||
@@ -46,6 +68,7 @@ export const TradeSignalDetailBody: React.FC<Props> = ({
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKe
|
||||
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
|
||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
|
||||
import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers';
|
||||
|
||||
interface TradingChartProps {
|
||||
bars: OHLCVBar[];
|
||||
@@ -182,6 +183,12 @@ interface TradingChartProps {
|
||||
resolveInitialDisplayCount?: (plotWidth: number) => number;
|
||||
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
|
||||
skipViewportRecovery?: boolean;
|
||||
/** true — 캔들 숨김, 보조지표 pane 선형 그래프만 표시 (시그널 상세 하단 차트 등) */
|
||||
auxIndicatorOnly?: boolean;
|
||||
/** 지정 시 초기 visible range 대신 시그널 봉 시간축 중앙 정렬 */
|
||||
centerOnSignalTime?: number;
|
||||
/** centerOnSignalTime 사용 시 화면에 보일 봉 수 (미지정 시 전체) */
|
||||
centerVisibleBarCount?: number;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -237,6 +244,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onOpenCustom1OverlaySettings,
|
||||
resolveInitialDisplayCount,
|
||||
skipViewportRecovery = false,
|
||||
auxIndicatorOnly = false,
|
||||
centerOnSignalTime,
|
||||
centerVisibleBarCount,
|
||||
}) => {
|
||||
const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
|
||||
const showOverlayControlsOnly = !!(
|
||||
@@ -324,6 +334,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const chartVisibleRef = useRef(chartVisible);
|
||||
const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
|
||||
const skipViewportRecoveryRef = useRef(skipViewportRecovery);
|
||||
const auxIndicatorOnlyRef = useRef(auxIndicatorOnly);
|
||||
const centerOnSignalTimeRef = useRef(centerOnSignalTime);
|
||||
const centerVisibleBarCountRef = useRef(centerVisibleBarCount);
|
||||
const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -334,6 +347,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
skipViewportRecoveryRef.current = skipViewportRecovery;
|
||||
}, [skipViewportRecovery]);
|
||||
|
||||
useEffect(() => {
|
||||
auxIndicatorOnlyRef.current = auxIndicatorOnly;
|
||||
}, [auxIndicatorOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
centerOnSignalTimeRef.current = centerOnSignalTime;
|
||||
}, [centerOnSignalTime]);
|
||||
|
||||
useEffect(() => {
|
||||
centerVisibleBarCountRef.current = centerVisibleBarCount;
|
||||
}, [centerVisibleBarCount]);
|
||||
|
||||
const applySnapshotViewport = useCallback((mgr: ChartManager) => {
|
||||
const t = centerOnSignalTimeRef.current;
|
||||
if (t != null && Number.isFinite(t)) {
|
||||
centerChartOnSignalBar(mgr, t, centerVisibleBarCountRef.current);
|
||||
} else {
|
||||
mgr.fitContent();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const chartTimeFormat = useChartTimeFormat();
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
|
||||
@@ -523,7 +557,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.applyCandleOnlyLayout(true, wrapperH);
|
||||
required = wrapperH;
|
||||
} else {
|
||||
if (mgr.isCandleOnlyLayout()) {
|
||||
if (auxIndicatorOnlyRef.current) {
|
||||
mgr.applyAuxIndicatorOnlyLayout(true);
|
||||
} else if (mgr.isCandleOnlyLayout()) {
|
||||
mgr.restoreFromCandleFullscreen(wrapperH);
|
||||
}
|
||||
// 사용자 드래그 비율 우선, 없으면 prop 기반 비율
|
||||
@@ -587,9 +623,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => {
|
||||
try { mgr.autoScale(); } catch { /* ok */ }
|
||||
if (centerOnSignalTimeRef.current != null && Number.isFinite(centerOnSignalTimeRef.current)) {
|
||||
applySnapshotViewport(mgr);
|
||||
} else {
|
||||
applyInitialVisibleRange(mgr);
|
||||
}
|
||||
});
|
||||
}, [applyPaneLayout, applyInitialVisibleRange]);
|
||||
}, [applyPaneLayout, applyInitialVisibleRange, applySnapshotViewport]);
|
||||
|
||||
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
||||
useEffect(() => {
|
||||
@@ -647,6 +687,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
|
||||
if (candleOnlyModeRef.current) {
|
||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||
} else if (auxIndicatorOnlyRef.current) {
|
||||
mgr.applyAuxIndicatorOnlyLayout(true);
|
||||
} else if (mgr.isCandleOnlyLayout()) {
|
||||
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
||||
}
|
||||
@@ -960,6 +1002,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
|
||||
mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
|
||||
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||
if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true);
|
||||
onManagerReady(mgr);
|
||||
|
||||
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
|
||||
import {
|
||||
buildLiveTradeTimeline,
|
||||
toTimelineSignalDetail,
|
||||
type LiveTimelineEvent,
|
||||
type LiveTimelineFilter,
|
||||
type TimelineSignalDetailSource,
|
||||
} from '../../utils/liveTradeTimeline';
|
||||
import {
|
||||
LIVE_TIMELINE_VIEW_OPTIONS,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
type LiveTimelineViewMode,
|
||||
} from './LiveTradeTimelineViews';
|
||||
import TimelineAxisView from './TimelineAxisView';
|
||||
import TimelineSignalDetailModal from './TimelineSignalDetailModal';
|
||||
import type { Timeframe } from '../../types';
|
||||
|
||||
export const ARP_TIMELINE_VIEW_STORAGE_KEY = 'arp-timeline-view-mode';
|
||||
@@ -54,6 +57,12 @@ const LiveTradeTimelinePanel: React.FC<Props> = ({
|
||||
const [events, setEvents] = useState<LiveTimelineEvent[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<LiveTimelineViewMode>(loadSavedTimelineViewMode);
|
||||
const [signalDetail, setSignalDetail] = useState<TimelineSignalDetailSource | null>(null);
|
||||
|
||||
const handleSignalDetail = useCallback((ev: LiveTimelineEvent) => {
|
||||
const detail = toTimelineSignalDetail(ev);
|
||||
if (detail) setSignalDetail(detail);
|
||||
}, []);
|
||||
|
||||
const fetchTimeline = useCallback(async () => {
|
||||
if (!active) return;
|
||||
@@ -168,15 +177,34 @@ const LiveTradeTimelinePanel: React.FC<Props> = ({
|
||||
)}
|
||||
|
||||
{events.length > 0 && viewMode === 'list' && (
|
||||
<TimelineListView events={events} timeframe={chartTimeframe} />
|
||||
<TimelineListView
|
||||
events={events}
|
||||
timeframe={chartTimeframe}
|
||||
onSignalDetail={handleSignalDetail}
|
||||
/>
|
||||
)}
|
||||
{events.length > 0 && viewMode === 'split' && (
|
||||
<TimelineSplitView events={events} timeframe={chartTimeframe} />
|
||||
<TimelineSplitView
|
||||
events={events}
|
||||
timeframe={chartTimeframe}
|
||||
onSignalDetail={handleSignalDetail}
|
||||
/>
|
||||
)}
|
||||
{events.length > 0 && viewMode === 'axis' && (
|
||||
<TimelineAxisView events={events} timeframe={chartTimeframe} />
|
||||
<TimelineAxisView
|
||||
events={events}
|
||||
timeframe={chartTimeframe}
|
||||
onSignalDetail={handleSignalDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signalDetail && (
|
||||
<TimelineSignalDetailModal
|
||||
source={signalDetail}
|
||||
onClose={() => setSignalDetail(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
|
||||
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||
import TimelineBarTimeBox, { eventToBarTimeSec } from './TimelineBarTimeBox';
|
||||
import TimelineSignalCardShell from './TimelineSignalCardShell';
|
||||
import type { Timeframe } from '../../types';
|
||||
|
||||
export type LiveTimelineViewMode = 'list' | 'split' | 'axis';
|
||||
@@ -70,9 +71,10 @@ export const TimelineEventBody: React.FC<EventBodyProps> = ({ ev, compact = fals
|
||||
interface ListViewProps {
|
||||
events: LiveTimelineEvent[];
|
||||
timeframe: Timeframe;
|
||||
onSignalDetail?: (ev: LiveTimelineEvent) => void;
|
||||
}
|
||||
|
||||
export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe }) => (
|
||||
export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe, onSignalDetail }) => (
|
||||
<div className="btd-timeline arp-timeline-scroll arp-timeline-scroll--list" aria-label="거래 이력 타임라인">
|
||||
<div className="btd-timeline-line" aria-hidden />
|
||||
{events.map(ev => {
|
||||
@@ -97,9 +99,13 @@ export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe })
|
||||
variant="compact"
|
||||
className="arp-timeline-bar-time--list-node"
|
||||
/>
|
||||
<div className="btd-timeline-card">
|
||||
<TimelineSignalCardShell
|
||||
ev={ev}
|
||||
className="btd-timeline-card"
|
||||
onSignalDetail={onSignalDetail}
|
||||
>
|
||||
<TimelineEventBody ev={ev} />
|
||||
</div>
|
||||
</TimelineSignalCardShell>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -109,10 +115,12 @@ export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe })
|
||||
interface SplitViewProps {
|
||||
events: LiveTimelineEvent[];
|
||||
timeframe: Timeframe;
|
||||
onSignalDetail?: (ev: LiveTimelineEvent) => void;
|
||||
}
|
||||
|
||||
export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe }) => (
|
||||
export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe, onSignalDetail }) => (
|
||||
<div className="arp-timeline-split-scroll" aria-label="좌우 분할 거래 이력 타임라인">
|
||||
<div className="arp-timeline-split-track">
|
||||
<div className="arp-timeline-split-axis" aria-hidden />
|
||||
{events.map(ev => {
|
||||
const isBuy = ev.side === 'BUY';
|
||||
@@ -127,9 +135,13 @@ export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe
|
||||
].join(' ');
|
||||
|
||||
const card = (
|
||||
<article className={cardCls}>
|
||||
<TimelineSignalCardShell
|
||||
ev={ev}
|
||||
className={cardCls}
|
||||
onSignalDetail={onSignalDetail}
|
||||
>
|
||||
<TimelineEventBody ev={ev} compact />
|
||||
</article>
|
||||
</TimelineSignalCardShell>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -172,4 +184,5 @@ export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../utils/liveTradeTimeline';
|
||||
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
|
||||
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||
import TimelineSignalDetailButton from './TimelineSignalDetailButton';
|
||||
|
||||
function kindClass(kind: LiveTimelineEvent['kind']): string {
|
||||
if (kind === 'SIGNAL') return 'signal';
|
||||
@@ -25,9 +26,10 @@ function kindIcon(kind: LiveTimelineEvent['kind']): string {
|
||||
interface Props {
|
||||
ev: LiveTimelineEvent;
|
||||
side: 'top' | 'bottom';
|
||||
onSignalDetail?: (ev: LiveTimelineEvent) => void;
|
||||
}
|
||||
|
||||
const TimelineAxisEventCard: React.FC<Props> = ({ ev, side }) => {
|
||||
const TimelineAxisEventCard: React.FC<Props> = ({ ev, side, onSignalDetail }) => {
|
||||
const { koreanName } = resolveVirtualTargetNames(ev.symbol);
|
||||
const kCls = kindClass(ev.kind);
|
||||
const isSell = ev.side === 'SELL';
|
||||
@@ -39,8 +41,17 @@ const TimelineAxisEventCard: React.FC<Props> = ({ ev, side }) => {
|
||||
`arp-timeline-axis-card--${side}`,
|
||||
`arp-timeline-axis-card--${kCls}`,
|
||||
isSell ? 'arp-timeline-axis-card--sell' : 'arp-timeline-axis-card--buy',
|
||||
].join(' ')}
|
||||
ev.kind === 'SIGNAL' && ev.signalMeta ? 'arp-timeline-signal-card-wrap' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{ev.kind === 'SIGNAL' && ev.signalMeta && onSignalDetail && (
|
||||
<TimelineSignalDetailButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onSignalDetail(ev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="arp-timeline-axis-card-icon" aria-hidden>
|
||||
{kindIcon(ev.kind)}
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,10 @@ const SEGMENT_COLORS = ['c0', 'c1', 'c2', 'c3', 'c4'] as const;
|
||||
interface Props {
|
||||
events: LiveTimelineEvent[];
|
||||
timeframe: Timeframe;
|
||||
onSignalDetail?: (ev: LiveTimelineEvent) => void;
|
||||
}
|
||||
|
||||
const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
|
||||
const TimelineAxisView: React.FC<Props> = ({ events, timeframe, onSignalDetail }) => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
useHorizontalWheelScroll(scrollRef);
|
||||
@@ -54,11 +55,18 @@ const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
|
||||
<div className="arp-timeline-axis-cols">
|
||||
{slots.map(slot => (
|
||||
<div key={`top-${slot.barTimeSec}`} className="arp-timeline-axis-col">
|
||||
{slot.sells.map(ev => (
|
||||
{slot.sells.map((ev, sellIdx) => (
|
||||
<div key={ev.id} className="arp-timeline-axis-col-stack">
|
||||
<TimelineAxisEventCard ev={ev} side="top" />
|
||||
<TimelineAxisEventCard ev={ev} side="top" onSignalDetail={onSignalDetail} />
|
||||
<div
|
||||
className="arp-timeline-axis-connector arp-timeline-axis-connector--down arp-timeline-axis-connector--sell"
|
||||
className={[
|
||||
'arp-timeline-axis-connector',
|
||||
'arp-timeline-axis-connector--down',
|
||||
'arp-timeline-axis-connector--sell',
|
||||
sellIdx === slot.sells.length - 1
|
||||
? 'arp-timeline-axis-connector--to-rail'
|
||||
: '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
@@ -91,13 +99,20 @@ const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
|
||||
<div className="arp-timeline-axis-cols">
|
||||
{slots.map(slot => (
|
||||
<div key={`bottom-${slot.barTimeSec}`} className="arp-timeline-axis-col">
|
||||
{slot.buys.map(ev => (
|
||||
{slot.buys.map((ev, buyIdx) => (
|
||||
<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"
|
||||
className={[
|
||||
'arp-timeline-axis-connector',
|
||||
'arp-timeline-axis-connector--up',
|
||||
'arp-timeline-axis-connector--buy',
|
||||
buyIdx === slot.buys.length - 1
|
||||
? 'arp-timeline-axis-connector--to-rail'
|
||||
: '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-hidden
|
||||
/>
|
||||
<TimelineAxisEventCard ev={ev} side="bottom" />
|
||||
<TimelineAxisEventCard ev={ev} side="bottom" onSignalDetail={onSignalDetail} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 타임라인 카드 — 매매 시그널일 때 상세 버튼 포함 래퍼
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { LiveTimelineEvent } from '../../utils/liveTradeTimeline';
|
||||
import TimelineSignalDetailButton from './TimelineSignalDetailButton';
|
||||
|
||||
interface Props {
|
||||
ev: LiveTimelineEvent;
|
||||
className: string;
|
||||
onSignalDetail?: (ev: LiveTimelineEvent) => void;
|
||||
children: React.ReactNode;
|
||||
tag?: 'article' | 'div';
|
||||
}
|
||||
|
||||
const TimelineSignalCardShell: React.FC<Props> = ({
|
||||
ev,
|
||||
className,
|
||||
onSignalDetail,
|
||||
children,
|
||||
tag = 'article',
|
||||
}) => {
|
||||
const Tag = tag;
|
||||
const showDetail = ev.kind === 'SIGNAL' && ev.signalMeta && onSignalDetail;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
className={[
|
||||
className,
|
||||
showDetail ? 'arp-timeline-signal-card-wrap' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{showDetail && (
|
||||
<TimelineSignalDetailButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onSignalDetail(ev);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineSignalCardShell;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 타임라인 매매 시그널 카드 — 우측 상단 상세 아이콘
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
const IcSignalDetail = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M3 3v18h18" />
|
||||
<rect x="7" y="10" width="3" height="7" rx="0.5" />
|
||||
<rect x="12" y="7" width="3" height="10" rx="0.5" />
|
||||
<rect x="17" y="12" width="3" height="5" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface Props {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const TimelineSignalDetailButton: React.FC<Props> = ({
|
||||
onClick,
|
||||
title = '시그널 상세 (차트 · 조건)',
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className="arp-timeline-signal-detail-btn"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IcSignalDetail />
|
||||
</button>
|
||||
);
|
||||
|
||||
export default TimelineSignalDetailButton;
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 타임라인 매매 시그널 상세 팝업 — 스냅샷 차트 + 조건 일치 설명
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AppPopup } from '../AppPopup';
|
||||
import TradeSignalDetailBody from '../TradeSignalDetailBody';
|
||||
import StrategyConditionView from '../tradeNotification/StrategyConditionView';
|
||||
import TimelineSignalSnapshotChart from './TimelineSignalSnapshotChart';
|
||||
import type { TimelineSignalDetailSource } from '../../utils/liveTradeTimeline';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import {
|
||||
formatCandleTypeKo,
|
||||
formatSignalTime,
|
||||
getExecutionLabel,
|
||||
getSignalTypeKo,
|
||||
} from '../../utils/tradeSignalDisplay';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../../utils/tradeSignalColors';
|
||||
import type { Theme } from '../../types';
|
||||
import { nodeToText } from '../../utils/strategyEditorShared';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { useCenterElementInScrollContainer } from '../../hooks/useCenterElementInScrollContainer';
|
||||
|
||||
interface Props {
|
||||
source: TimelineSignalDetailSource;
|
||||
onClose: () => void;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
function collectConditionTexts(root: LogicNode | null | undefined): string[] {
|
||||
if (!root) return [];
|
||||
const out: string[] = [];
|
||||
const walk = (node: LogicNode) => {
|
||||
if (node.type === 'CONDITION') {
|
||||
const text = nodeToText(node).trim();
|
||||
if (text) out.push(text);
|
||||
return;
|
||||
}
|
||||
for (const child of node.children ?? []) walk(child);
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
const TimelineSignalDetailModal: React.FC<Props> = ({
|
||||
source,
|
||||
onClose,
|
||||
theme = 'dark',
|
||||
}) => {
|
||||
const isBuy = source.side === 'BUY';
|
||||
const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR;
|
||||
const [strategy, setStrategy] = useState<StrategyDto | null>(null);
|
||||
const [stratLoading, setStratLoading] = useState(false);
|
||||
const [chartsReady, setChartsReady] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const chartSectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const handleChartsReady = useCallback(() => {
|
||||
setChartsReady(true);
|
||||
}, []);
|
||||
|
||||
useCenterElementInScrollContainer(
|
||||
bodyRef,
|
||||
chartSectionRef,
|
||||
true,
|
||||
[source, chartsReady],
|
||||
);
|
||||
|
||||
const displayItem = useMemo(() => ({
|
||||
market: source.symbol,
|
||||
signalType: source.side,
|
||||
price: source.price,
|
||||
candleTime: source.signalMeta.candleTime,
|
||||
strategyName: source.strategyName,
|
||||
strategyId: source.signalMeta.strategyId,
|
||||
executionType: source.signalMeta.executionType,
|
||||
candleType: source.signalMeta.candleType,
|
||||
receivedAt: source.ts,
|
||||
}), [source]);
|
||||
|
||||
useEffect(() => {
|
||||
setChartsReady(false);
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = source.signalMeta.strategyId;
|
||||
if (!sid) {
|
||||
setStrategy(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setStratLoading(true);
|
||||
void loadStrategy(sid)
|
||||
.then(s => { if (!cancelled) setStrategy(s); })
|
||||
.catch(() => { if (!cancelled) setStrategy(null); })
|
||||
.finally(() => { if (!cancelled) setStratLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [source.signalMeta.strategyId]);
|
||||
|
||||
const conditionTexts = useMemo(() => {
|
||||
if (!strategy) return [];
|
||||
const root = isBuy ? strategy.buyCondition : strategy.sellCondition;
|
||||
return collectConditionTexts(root as LogicNode | null | undefined);
|
||||
}, [strategy, isBuy]);
|
||||
|
||||
const execLabel = getExecutionLabel(
|
||||
source.signalMeta.executionType,
|
||||
source.signalMeta.candleType,
|
||||
);
|
||||
|
||||
return (
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title="Signal Detail"
|
||||
titleKo="매매 시그널 상세"
|
||||
accentColor={accentColor}
|
||||
width={920}
|
||||
maxWidth="96vw"
|
||||
shellClassName="arp-timeline-signal-detail-popup"
|
||||
bodyClassName="app-popup-body arp-timeline-signal-detail-popup__body"
|
||||
bodyRef={bodyRef}
|
||||
centered
|
||||
>
|
||||
<div className="arp-timeline-signal-detail">
|
||||
<section className="arp-timeline-signal-detail-summary">
|
||||
<TradeSignalDetailBody
|
||||
item={displayItem}
|
||||
variant="full"
|
||||
showHeadline={false}
|
||||
layout="matrix"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section ref={chartSectionRef} className="arp-timeline-signal-detail-chart">
|
||||
<TimelineSignalSnapshotChart
|
||||
source={source}
|
||||
theme={theme}
|
||||
onChartsReady={handleChartsReady}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="arp-timeline-signal-detail-reason">
|
||||
<header className="arp-timeline-signal-detail-reason-head">
|
||||
<h3>조건 일치 사유</h3>
|
||||
<p>
|
||||
{formatSignalTime(source.signalMeta.candleTime)} · {formatCandleTypeKo(source.signalMeta.candleType)}
|
||||
{' '}기준 {getSignalTypeKo(source.side)} 시그널이 발생했습니다.
|
||||
{' '}({execLabel})
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{stratLoading && (
|
||||
<p className="arp-timeline-signal-detail-muted">전략 조건 불러오는 중…</p>
|
||||
)}
|
||||
|
||||
{!stratLoading && strategy && (
|
||||
<>
|
||||
{conditionTexts.length > 0 && (
|
||||
<ul className="arp-timeline-signal-detail-checklist">
|
||||
{conditionTexts.map(text => (
|
||||
<li key={text}>
|
||||
<span className="arp-timeline-signal-detail-check" aria-hidden>✓</span>
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="arp-timeline-signal-detail-strategy">
|
||||
<StrategyConditionView strategy={strategy} signalType={source.side} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!stratLoading && !strategy && (
|
||||
<p className="arp-timeline-signal-detail-muted">
|
||||
연결된 전략 정보가 없어 조건 트리를 표시할 수 없습니다.
|
||||
{source.strategyName ? ` (전략: ${source.strategyName})` : ''}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineSignalDetailModal;
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* 타임라인 매매 시그널 상세 — 발생 시점 ±5봉 스냅샷 차트
|
||||
* (상단 캔들+오버레이 · 하단 전략 조건 보조지표 분리)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme } from '../../types';
|
||||
import { loadSignalSnapshotCandles, SIGNAL_SNAPSHOT_VISIBLE_BARS } from '../../utils/analysisChartData';
|
||||
import type { TimelineSignalDetailSource } from '../../utils/liveTradeTimeline';
|
||||
import { loadStrategyForNotification, type StrategyDto } from '../../utils/backendApi';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { getIndicatorDef, formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
|
||||
import { buildChartIndicatorConfig } from '../../utils/indicatorPaneMerge';
|
||||
import { mergeGlobalDefaultsOntoChartIndicators } from '../../utils/indicatorMainConfig';
|
||||
import { buildNotificationChartIndicators } from '../../utils/notificationChartIndicators';
|
||||
import {
|
||||
buildStrategyChartIndicators,
|
||||
candleTypeToTimeframe,
|
||||
ensurePaperChartOverlays,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import {
|
||||
chartPaneFlexRatio,
|
||||
countNonOverlayIndicatorPanes,
|
||||
} from '../../utils/strategyOscillatorSeries';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { ensureWorkspaceChartIndicators } from '../../utils/workspaceChartIndicatorsCache';
|
||||
import {
|
||||
applyNotificationSignalMarkers,
|
||||
type TradeSignalChartMarker,
|
||||
} from '../../utils/tradeSignalChartMarkers';
|
||||
import { useLinkedChartLogicalRange } from '../../hooks/useLinkedChartLogicalRange';
|
||||
|
||||
interface Props {
|
||||
source: TimelineSignalDetailSource;
|
||||
theme?: Theme;
|
||||
onChartsReady?: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
let _indSeq = 0;
|
||||
function newIndId() {
|
||||
_indSeq += 1;
|
||||
return `tsd_${_indSeq}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const ALL_TF_VISIBLE = {
|
||||
'1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true,
|
||||
'1h': true, '4h': true, '1D': true, '1W': true, '1M': true,
|
||||
};
|
||||
|
||||
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||
|
||||
function splitChartIndicators(indicators: IndicatorConfig[]) {
|
||||
const overlay: IndicatorConfig[] = [];
|
||||
const aux: IndicatorConfig[] = [];
|
||||
for (const ind of indicators) {
|
||||
if (isOverlayType(ind.type)) overlay.push(ind);
|
||||
else aux.push(ind);
|
||||
}
|
||||
return { overlay, aux };
|
||||
}
|
||||
|
||||
interface SnapshotPaneProps {
|
||||
bars: OHLCVBar[];
|
||||
market: string;
|
||||
chartTimeframe: ReturnType<typeof candleTypeToTimeframe>;
|
||||
theme: Theme;
|
||||
indicators: IndicatorConfig[];
|
||||
chartKey: string;
|
||||
showMarker?: boolean;
|
||||
signalMarker?: TradeSignalChartMarker;
|
||||
signalCandleTime?: number;
|
||||
/** true — 캔들 숨김, 보조지표 선형 pane 만 표시 */
|
||||
indicatorOnly?: boolean;
|
||||
onReady?: () => void;
|
||||
chartLinkIndex?: number;
|
||||
registerLinkedChartManager?: (index: number, mgr: ChartManager | null) => void;
|
||||
}
|
||||
|
||||
const SignalSnapshotChartPane: React.FC<SnapshotPaneProps> = ({
|
||||
bars,
|
||||
market,
|
||||
chartTimeframe,
|
||||
theme,
|
||||
indicators,
|
||||
chartKey,
|
||||
showMarker = false,
|
||||
signalMarker,
|
||||
signalCandleTime,
|
||||
indicatorOnly = false,
|
||||
onReady,
|
||||
chartLinkIndex = 0,
|
||||
registerLinkedChartManager,
|
||||
}) => {
|
||||
const { defaults: appDefaults } = useAppSettings();
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const signalMarkerRef = useRef(signalMarker);
|
||||
signalMarkerRef.current = signalMarker;
|
||||
|
||||
const auxPaneCount = useMemo(
|
||||
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
|
||||
[indicators],
|
||||
);
|
||||
|
||||
const paneAreaRatio = useMemo(() => {
|
||||
if (indicatorOnly) return null;
|
||||
if (auxPaneCount <= 0) return null;
|
||||
const flex = chartPaneFlexRatio(auxPaneCount);
|
||||
return { candle: flex.candle, aux: flex.aux };
|
||||
}, [auxPaneCount, indicatorOnly]);
|
||||
|
||||
const applyMarkers = useCallback((attempt = 0) => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr?.hasMainSeries()) {
|
||||
if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1));
|
||||
return;
|
||||
}
|
||||
if (showMarker && signalMarkerRef.current) {
|
||||
applyNotificationSignalMarkers(mgr, signalMarkerRef.current, true);
|
||||
} else {
|
||||
mgr.clearBacktestMarkers();
|
||||
}
|
||||
onReady?.();
|
||||
}, [showMarker, onReady]);
|
||||
|
||||
const onManagerReady = useCallback((mgr: ChartManager) => {
|
||||
managerRef.current = mgr;
|
||||
registerLinkedChartManager?.(chartLinkIndex, mgr);
|
||||
applyMarkers();
|
||||
}, [applyMarkers, chartLinkIndex, registerLinkedChartManager]);
|
||||
|
||||
const onCandlesReady = useCallback(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) registerLinkedChartManager?.(chartLinkIndex, mgr);
|
||||
applyMarkers();
|
||||
}, [applyMarkers, chartLinkIndex, registerLinkedChartManager]);
|
||||
|
||||
useEffect(() => () => {
|
||||
registerLinkedChartManager?.(chartLinkIndex, null);
|
||||
}, [chartLinkIndex, registerLinkedChartManager]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
}, [bars, indicators, applyMarkers]);
|
||||
|
||||
const resolveInitialDisplayCount = useCallback(
|
||||
() => SIGNAL_SNAPSHOT_VISIBLE_BARS,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<TradingChart
|
||||
key={chartKey}
|
||||
bars={bars}
|
||||
barsMarket={market}
|
||||
market={market}
|
||||
timeframe={chartTimeframe}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode="chart"
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[]}
|
||||
logScale={false}
|
||||
onCrosshair={noop}
|
||||
onManagerReady={onManagerReady}
|
||||
onCandlesReady={onCandlesReady}
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
volumeVisible={false}
|
||||
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
||||
paneLayoutClamp
|
||||
showPaneResizeHandle={!indicatorOnly && auxPaneCount > 0}
|
||||
paneAreaRatio={paneAreaRatio}
|
||||
showPaneLegend
|
||||
crosshairInfoVisible
|
||||
indicatorAreaPriceLabelsEnabled={appDefaults.chartSeriesPriceLabels !== false}
|
||||
candleAreaPriceLabelsEnabled={false}
|
||||
seriesPriceLabelsEnabled={appDefaults.chartSeriesPriceLabels !== false}
|
||||
showHoverToolbar={false}
|
||||
showChartRightToolbar={false}
|
||||
showCandlePaneControls={false}
|
||||
skipViewportRecovery
|
||||
auxIndicatorOnly={indicatorOnly}
|
||||
centerOnSignalTime={signalCandleTime}
|
||||
centerVisibleBarCount={SIGNAL_SNAPSHOT_VISIBLE_BARS}
|
||||
resolveInitialDisplayCount={resolveInitialDisplayCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TimelineSignalSnapshotChart: React.FC<Props> = ({
|
||||
source,
|
||||
theme = 'dark',
|
||||
onChartsReady,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [strategyLoading, setStrategyLoading] = useState(false);
|
||||
const [strategy, setStrategy] = useState<StrategyDto | null>(null);
|
||||
const [wsIndicators, setWsIndicators] = useState<IndicatorConfig[]>([]);
|
||||
const paneReadyRef = useRef({ candle: false, aux: false });
|
||||
|
||||
const notifyChartsReady = useCallback(() => {
|
||||
requestAnimationFrame(() => onChartsReady?.());
|
||||
}, [onChartsReady]);
|
||||
|
||||
const market = source.symbol.startsWith('KRW-') ? source.symbol : `KRW-${source.symbol}`;
|
||||
const chartTimeframe = candleTypeToTimeframe(source.signalMeta.candleType);
|
||||
const candleTimeSec = source.signalMeta.candleTime;
|
||||
|
||||
const signalMarker = useMemo((): TradeSignalChartMarker => ({
|
||||
candleTime: candleTimeSec,
|
||||
signalType: source.side,
|
||||
price: source.price,
|
||||
}), [candleTimeSec, source.side, source.price]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void ensureWorkspaceChartIndicators().then(inds => {
|
||||
if (!cancelled) setWsIndicators(inds);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [settingsRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
const strategyId = source.signalMeta.strategyId;
|
||||
if (!strategyId) {
|
||||
setStrategy(null);
|
||||
setStrategyLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setStrategyLoading(true);
|
||||
void loadStrategyForNotification(strategyId).then(s => {
|
||||
if (!cancelled) {
|
||||
setStrategy(s ?? null);
|
||||
setStrategyLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [source.signalMeta.strategyId]);
|
||||
|
||||
const { candleIndicators, auxIndicators } = useMemo(() => {
|
||||
if (source.signalMeta.strategyId && strategyLoading) {
|
||||
return { candleIndicators: [] as IndicatorConfig[], auxIndicators: [] as IndicatorConfig[] };
|
||||
}
|
||||
|
||||
let strategyBuilt: IndicatorConfig[] = [];
|
||||
if (strategy) {
|
||||
strategyBuilt = buildStrategyChartIndicators(
|
||||
strategy,
|
||||
getParams,
|
||||
getVisualConfig,
|
||||
wsIndicators,
|
||||
source.side,
|
||||
);
|
||||
strategyBuilt = strategyBuilt.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
|
||||
} else {
|
||||
const sma = buildChartIndicatorConfig('SMA', newIndId(), getParams, getVisualConfig, {
|
||||
timeframeVisibility: ALL_TF_VISIBLE,
|
||||
});
|
||||
if (sma) strategyBuilt = [sma];
|
||||
}
|
||||
|
||||
const merged = mergeGlobalDefaultsOntoChartIndicators(
|
||||
strategyBuilt,
|
||||
getParams,
|
||||
getVisualConfig,
|
||||
wsIndicators,
|
||||
);
|
||||
const withWorkspace = buildNotificationChartIndicators(merged, wsIndicators);
|
||||
const { overlay, aux } = splitChartIndicators(withWorkspace);
|
||||
return {
|
||||
candleIndicators: ensurePaperChartOverlays(overlay, getParams, getVisualConfig),
|
||||
auxIndicators: aux,
|
||||
};
|
||||
}, [
|
||||
strategy,
|
||||
source.signalMeta.strategyId,
|
||||
source.side,
|
||||
strategyLoading,
|
||||
getParams,
|
||||
getVisualConfig,
|
||||
chartTimeframe,
|
||||
wsIndicators,
|
||||
]);
|
||||
|
||||
const auxLabels = useMemo(
|
||||
() => auxIndicators.map(ind => formatIndicatorDisplayLabel(ind.type)),
|
||||
[auxIndicators],
|
||||
);
|
||||
|
||||
const hasAuxChart = auxIndicators.length > 0;
|
||||
const { registerManager: registerLinkedChartManager } = useLinkedChartLogicalRange(2);
|
||||
|
||||
const markPaneReady = useCallback((pane: 'candle' | 'aux') => {
|
||||
paneReadyRef.current[pane] = true;
|
||||
const needAux = auxIndicators.length > 0;
|
||||
if (paneReadyRef.current.candle && (!needAux || paneReadyRef.current.aux)) {
|
||||
notifyChartsReady();
|
||||
}
|
||||
}, [notifyChartsReady, auxIndicators.length]);
|
||||
|
||||
useEffect(() => {
|
||||
paneReadyRef.current = { candle: false, aux: false };
|
||||
}, [bars, candleIndicators, auxIndicators, candleTimeSec]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void loadSignalSnapshotCandles(market, chartTimeframe, candleTimeSec)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setBars(data);
|
||||
})
|
||||
.catch(e => {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : '차트 로드 실패');
|
||||
setBars([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, chartTimeframe, candleTimeSec]);
|
||||
|
||||
const chartReady = !loading && !error && bars.length > 0
|
||||
&& !(source.signalMeta.strategyId && strategyLoading);
|
||||
|
||||
return (
|
||||
<div className="arp-timeline-signal-chart">
|
||||
<div className="arp-timeline-signal-chart-head">
|
||||
<span>시그널 발생 봉 기준 ±5캔들</span>
|
||||
<span>{bars.length > 0 ? `±5 표시 · ${bars.length}봉` : ''}</span>
|
||||
</div>
|
||||
|
||||
<div className="arp-timeline-signal-chart-stack">
|
||||
{(loading || strategyLoading) && (
|
||||
<div className="arp-timeline-signal-chart-loading">차트 로딩…</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="arp-timeline-signal-chart-error">{error}</div>
|
||||
)}
|
||||
|
||||
{chartReady && (
|
||||
<>
|
||||
<section className="arp-timeline-signal-chart-pane arp-timeline-signal-chart-pane--candle">
|
||||
<header className="arp-timeline-signal-chart-pane-head">
|
||||
<span className="arp-timeline-signal-chart-pane-label">캔들</span>
|
||||
</header>
|
||||
<div className="arp-timeline-signal-chart-pane-body">
|
||||
<SignalSnapshotChartPane
|
||||
bars={bars}
|
||||
market={market}
|
||||
chartTimeframe={chartTimeframe}
|
||||
theme={theme}
|
||||
indicators={candleIndicators}
|
||||
chartKey={`candle-${market}-${chartTimeframe}-${candleTimeSec}-${candleIndicators.map(i => i.id).join(',')}`}
|
||||
showMarker
|
||||
signalMarker={signalMarker}
|
||||
signalCandleTime={candleTimeSec}
|
||||
chartLinkIndex={0}
|
||||
registerLinkedChartManager={hasAuxChart ? registerLinkedChartManager : undefined}
|
||||
onReady={() => markPaneReady('candle')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{auxIndicators.length > 0 && (
|
||||
<section className="arp-timeline-signal-chart-pane arp-timeline-signal-chart-pane--aux">
|
||||
<header className="arp-timeline-signal-chart-pane-head">
|
||||
<span className="arp-timeline-signal-chart-pane-label">전략 조건 보조지표</span>
|
||||
<span className="arp-timeline-signal-chart-pane-meta">
|
||||
{auxLabels.join(' · ')}
|
||||
</span>
|
||||
</header>
|
||||
<div className="arp-timeline-signal-chart-pane-body arp-timeline-signal-chart-pane-body--aux">
|
||||
<SignalSnapshotChartPane
|
||||
bars={bars}
|
||||
market={market}
|
||||
chartTimeframe={chartTimeframe}
|
||||
theme={theme}
|
||||
indicators={auxIndicators}
|
||||
chartKey={`aux-${market}-${chartTimeframe}-${candleTimeSec}-${auxIndicators.map(i => i.id).join(',')}`}
|
||||
signalCandleTime={candleTimeSec}
|
||||
indicatorOnly
|
||||
chartLinkIndex={1}
|
||||
registerLinkedChartManager={registerLinkedChartManager}
|
||||
onReady={() => markPaneReady('aux')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineSignalSnapshotChart;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, type RefObject } from 'react';
|
||||
|
||||
/** 스크롤 컨테이너 안에서 target 요소가 뷰포트 중앙(세로)에 오도록 scrollTop 조정 */
|
||||
export function useCenterElementInScrollContainer(
|
||||
scrollRef: RefObject<HTMLElement | null>,
|
||||
targetRef: RefObject<HTMLElement | null>,
|
||||
enabled: boolean,
|
||||
deps: unknown[] = [],
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const scroll = scrollRef.current;
|
||||
const target = targetRef.current;
|
||||
if (!scroll || !target) return;
|
||||
|
||||
const center = () => {
|
||||
const scrollRect = scroll.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const targetMidInContent =
|
||||
scroll.scrollTop + (targetRect.top - scrollRect.top) + targetRect.height / 2;
|
||||
const targetTop = targetMidInContent - scroll.clientHeight / 2;
|
||||
scroll.scrollTop = Math.max(0, Math.min(
|
||||
targetTop,
|
||||
scroll.scrollHeight - scroll.clientHeight,
|
||||
));
|
||||
};
|
||||
|
||||
center();
|
||||
requestAnimationFrame(center);
|
||||
|
||||
const ro = new ResizeObserver(() => center());
|
||||
ro.observe(scroll);
|
||||
ro.observe(target);
|
||||
return () => ro.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, scrollRef, targetRef, ...deps]);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
|
||||
type LogicalRange = { from: number; to: number };
|
||||
|
||||
/** 분리된 TradingChart 인스턴스 — 가로 시간축(visible logical range) 동기화 */
|
||||
export function useLinkedChartLogicalRange(chartCount = 2) {
|
||||
const managersRef = useRef<(ChartManager | null)[]>(Array(chartCount).fill(null));
|
||||
const unsubsRef = useRef<Array<Array<() => void>>>(
|
||||
Array.from({ length: chartCount }, () => []),
|
||||
);
|
||||
const isSyncingRef = useRef(false);
|
||||
|
||||
const clearUnsubs = useCallback((index: number) => {
|
||||
for (const u of unsubsRef.current[index] ?? []) {
|
||||
try { u(); } catch { /* ok */ }
|
||||
}
|
||||
unsubsRef.current[index] = [];
|
||||
}, []);
|
||||
|
||||
const syncPeers = useCallback((sourceIndex: number, range: LogicalRange) => {
|
||||
if (isSyncingRef.current) return;
|
||||
isSyncingRef.current = true;
|
||||
for (let i = 0; i < managersRef.current.length; i++) {
|
||||
if (i === sourceIndex) continue;
|
||||
const peer = managersRef.current[i];
|
||||
if (!peer?.hasMainSeries() || peer.getRawBarsLength() < 2) continue;
|
||||
peer.applyVisibleLogicalRange(range.from, range.to);
|
||||
}
|
||||
requestAnimationFrame(() => { isSyncingRef.current = false; });
|
||||
}, []);
|
||||
|
||||
const attachRangeSync = useCallback((index: number, mgr: ChartManager) => {
|
||||
clearUnsubs(index);
|
||||
|
||||
const onRange = (r: LogicalRange | null) => {
|
||||
if (!r || isSyncingRef.current) return;
|
||||
syncPeers(index, r);
|
||||
};
|
||||
|
||||
unsubsRef.current[index].push(mgr.subscribeVisibleLogicalRange(onRange));
|
||||
|
||||
// 커스텀 패닝 등 — LWC logical 이벤트가 누락될 수 있어 viewport 알림에도 연결
|
||||
unsubsRef.current[index].push(mgr.subscribeViewport(() => {
|
||||
if (isSyncingRef.current) return;
|
||||
const r = mgr.getVisibleLogicalRange();
|
||||
if (r) syncPeers(index, r);
|
||||
}));
|
||||
}, [clearUnsubs, syncPeers]);
|
||||
|
||||
const registerManager = useCallback((index: number, mgr: ChartManager | null) => {
|
||||
if (index < 0 || index >= managersRef.current.length) return;
|
||||
|
||||
clearUnsubs(index);
|
||||
managersRef.current[index] = mgr;
|
||||
if (!mgr) return;
|
||||
|
||||
attachRangeSync(index, mgr);
|
||||
|
||||
// 후행 차트 마운트 시 — 이미 준비된 peer 시간축에 맞춤
|
||||
for (let i = 0; i < managersRef.current.length; i++) {
|
||||
if (i === index) continue;
|
||||
const peer = managersRef.current[i];
|
||||
if (!peer?.hasMainSeries() || peer.getRawBarsLength() < 2) continue;
|
||||
const peerRange = peer.getVisibleLogicalRange();
|
||||
if (!peerRange) continue;
|
||||
isSyncingRef.current = true;
|
||||
mgr.applyVisibleLogicalRange(peerRange.from, peerRange.to);
|
||||
requestAnimationFrame(() => { isSyncingRef.current = false; });
|
||||
return;
|
||||
}
|
||||
}, [attachRangeSync, clearUnsubs]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (let i = 0; i < unsubsRef.current.length; i++) {
|
||||
clearUnsubs(i);
|
||||
}
|
||||
}, [clearUnsubs]);
|
||||
|
||||
return { registerManager };
|
||||
}
|
||||
@@ -2353,19 +2353,20 @@
|
||||
padding: 8px 12px 16px;
|
||||
}
|
||||
|
||||
.arp-timeline-split-track {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.arp-timeline-split-axis {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(201, 162, 39, 0.75),
|
||||
rgba(201, 162, 39, 0.15)
|
||||
);
|
||||
border-radius: 2px;
|
||||
background: rgba(201, 162, 39, 0.58);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -2389,6 +2390,7 @@
|
||||
}
|
||||
|
||||
.arp-timeline-split-col--center {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -2581,12 +2583,12 @@
|
||||
}
|
||||
|
||||
.arp-timeline-axis-zone--top {
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: 0;
|
||||
min-height: 148px;
|
||||
}
|
||||
|
||||
.arp-timeline-axis-zone--bottom {
|
||||
padding-top: 12px;
|
||||
padding-top: 0;
|
||||
min-height: 148px;
|
||||
}
|
||||
|
||||
@@ -2643,14 +2645,6 @@
|
||||
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);
|
||||
@@ -2666,6 +2660,10 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.arp-timeline-axis-connector--to-rail {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.arp-timeline-axis-connector--sell {
|
||||
border-left-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
@@ -2818,3 +2816,287 @@
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 매매 시그널 상세 (타임라인 카드 버튼 · 팝업) ── */
|
||||
.arp-timeline-signal-card-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
|
||||
background: color-mix(in srgb, var(--bg2) 88%, transparent);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-btn:hover {
|
||||
background: color-mix(in srgb, #c9a227 18%, var(--bg2));
|
||||
border-color: color-mix(in srgb, #c9a227 45%, transparent);
|
||||
color: #c9a227;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-card-wrap.btd-timeline-card,
|
||||
.arp-timeline-signal-card-wrap.arp-timeline-split-card,
|
||||
.arp-timeline-signal-card-wrap.arp-timeline-axis-card {
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-popup .app-popup-body.arp-timeline-signal-detail-popup__body {
|
||||
max-height: min(88vh, 860px);
|
||||
overflow-y: auto;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary {
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary .tsd-body {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-matrix {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 56px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--bg2) 72%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-cell-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, var(--text) 52%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-cell-value {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: color-mix(in srgb, var(--text) 90%, transparent);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-cell-value--highlight {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-matrix {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.arp-timeline-signal-detail-summary .tsd-detail-matrix {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-chart {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--bg1) 92%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: color-mix(in srgb, var(--text) 62%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-body {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
height: min(52vh, 420px);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-stack {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--bg1) 94%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane--candle .arp-timeline-signal-chart-pane-body {
|
||||
min-height: 240px;
|
||||
height: min(38vh, 300px);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane--aux .arp-timeline-signal-chart-pane-body {
|
||||
min-height: 180px;
|
||||
height: min(28vh, 220px);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--bg2) 55%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane-meta {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--text) 52%, transparent);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane-body {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane-body .tv-chart-wrap {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-pane-body--aux .tv-chart-wrap {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-loading,
|
||||
.arp-timeline-signal-chart-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--text) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--bg1) 72%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-chart-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-reason {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--bg2) 70%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-reason-head h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-reason-head p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: color-mix(in srgb, var(--text) 72%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-muted {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--text) 55%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-checklist {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-checklist li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: color-mix(in srgb, var(--text) 86%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-check {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #22c55e;
|
||||
background: color-mix(in srgb, #22c55e 16%, transparent);
|
||||
}
|
||||
|
||||
.arp-timeline-signal-detail-strategy .scv-root {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -344,6 +344,9 @@ export class ChartManager {
|
||||
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
|
||||
private _paneAreaRatio: { candle: number; aux: number } | null = null;
|
||||
|
||||
/** 보조지표 전용 패널 — 캔들 pane 최소화·선형 지표만 표시 (시그널 상세 등) */
|
||||
private _auxIndicatorOnlyLayout = false;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
theme: Theme,
|
||||
@@ -502,16 +505,24 @@ export class ChartManager {
|
||||
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
} else if (panesInit[0]) {
|
||||
} else if (panesInit[0] && !this._auxIndicatorOnlyLayout) {
|
||||
panesInit[0].setStretchFactor(1);
|
||||
}
|
||||
|
||||
this._reapplyAllPatternMarkers();
|
||||
|
||||
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
||||
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
|
||||
try { this.chart.timeScale().fitContent(); } catch { /* ok */ }
|
||||
} else {
|
||||
this._applyDefaultVisibleRange(200, 0);
|
||||
}
|
||||
|
||||
if (this._auxIndicatorOnlyLayout) {
|
||||
this.syncChartOverlayVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시용 visible range 설정.
|
||||
* bar < displayCount 이면 fitContent (음수 logical index 사용 금지).
|
||||
@@ -4692,6 +4703,24 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보조지표 전용 레이아웃 — 캔들·오버레이 숨김, 하단 pane 선형 지표만 표시.
|
||||
*/
|
||||
applyAuxIndicatorOnlyLayout(enabled: boolean): void {
|
||||
this._auxIndicatorOnlyLayout = enabled;
|
||||
if (enabled) {
|
||||
this.setChartOverlayVisibility({
|
||||
ma: false,
|
||||
bollinger: false,
|
||||
ichimoku: false,
|
||||
candle: false,
|
||||
});
|
||||
} else {
|
||||
this.setChartOverlayVisibility({ ...DEFAULT_CHART_OVERLAY_VISIBILITY });
|
||||
}
|
||||
this.resetPaneHeights();
|
||||
}
|
||||
|
||||
private _volumeFrac(H: number): number {
|
||||
if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0;
|
||||
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
||||
@@ -4808,6 +4837,51 @@ export class ChartManager {
|
||||
return H;
|
||||
}
|
||||
|
||||
/** 보조지표 전용 — pane 0(캔들) 최소화, 보조 pane 균등 확장 */
|
||||
private _resetPaneHeightsAuxIndicatorOnly(availableHeight?: number): number {
|
||||
const panes = this.chart.panes();
|
||||
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
||||
|
||||
const ORPHAN_STRETCH = 0.0001;
|
||||
const IND_EQUAL_WEIGHT = 1;
|
||||
const H = (availableHeight && availableHeight > 0)
|
||||
? availableHeight
|
||||
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||
? this._lastLayoutAvailableHeight
|
||||
: this.container.clientHeight);
|
||||
const activeIndPanes = this._activeIndicatorPaneIndices();
|
||||
const volumeShown = this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
|
||||
|
||||
try {
|
||||
this.volumeSeries?.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||
} catch { /* ok */ }
|
||||
|
||||
for (let i = 0; i < panes.length; i++) {
|
||||
const paneIndex = panes[i].paneIndex();
|
||||
if (paneIndex === 0) {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
} else if (paneIndex === 1) {
|
||||
if (volumeShown) {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
} else if (activeIndPanes.has(1)) {
|
||||
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
|
||||
} else {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
}
|
||||
} else if (activeIndPanes.has(paneIndex)) {
|
||||
panes[i].setStretchFactor(IND_EQUAL_WEIGHT);
|
||||
} else {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncChartOverlayVisibility();
|
||||
this._restoreSubPaneIndicatorVisibility();
|
||||
this._scheduleIndicatorLastUpdate();
|
||||
|
||||
return H;
|
||||
}
|
||||
|
||||
resetPaneHeights(availableHeight?: number): number {
|
||||
const panes = this.chart.panes();
|
||||
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
||||
@@ -4820,6 +4894,10 @@ export class ChartManager {
|
||||
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
||||
}
|
||||
|
||||
if (this._auxIndicatorOnlyLayout) {
|
||||
return this._resetPaneHeightsAuxIndicatorOnly(availableHeight);
|
||||
}
|
||||
|
||||
const H = (availableHeight && availableHeight > 0)
|
||||
? availableHeight
|
||||
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||
|
||||
@@ -51,3 +51,57 @@ export async function loadAnalysisCandles(
|
||||
|
||||
return data.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/** 시그널 발생 봉 기준 좌우 N개 캔들 (기본 ±5) */
|
||||
export const SIGNAL_SNAPSHOT_CONTEXT_BARS = 5;
|
||||
|
||||
/** 화면에 표시할 봉 수 (±context) */
|
||||
export const SIGNAL_SNAPSHOT_VISIBLE_BARS = SIGNAL_SNAPSHOT_CONTEXT_BARS * 2 + 1;
|
||||
|
||||
/** CCI·RSI 등 보조지표 계산용 선행 워밍업 봉 (표시 범위와 별도) */
|
||||
const SIGNAL_SNAPSHOT_WARMUP_BARS = 50;
|
||||
|
||||
function findNearestBarIndex(bars: OHLCVBar[], candleTimeSec: number): number {
|
||||
if (bars.length === 0) return -1;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Math.abs(bars[0].time - candleTimeSec);
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const dist = Math.abs(bars[i].time - candleTimeSec);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
export async function loadSignalSnapshotCandles(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
candleTimeSec: number,
|
||||
contextBars = SIGNAL_SNAPSHOT_CONTEXT_BARS,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const barSec = timeframeToBarSeconds(timeframe);
|
||||
const normalizedCandle = normalizeEpochSec(candleTimeSec);
|
||||
const wantVisible = contextBars * 2 + 1;
|
||||
const toTimeSec = normalizedCandle + barSec * (contextBars + 2);
|
||||
const fetchCount = Math.max(wantVisible + SIGNAL_SNAPSHOT_WARMUP_BARS + 10, 60);
|
||||
const raw = await loadAnalysisCandles(market, timeframe, toTimeSec, fetchCount);
|
||||
if (raw.length === 0) return raw;
|
||||
|
||||
let idx = raw.findIndex(b => b.time === normalizedCandle);
|
||||
if (idx < 0) idx = findNearestBarIndex(raw, normalizedCandle);
|
||||
if (idx < 0) return raw.slice(-wantVisible);
|
||||
|
||||
const start = Math.max(0, idx - contextBars - SIGNAL_SNAPSHOT_WARMUP_BARS);
|
||||
const end = Math.min(raw.length, idx + contextBars + 1);
|
||||
return raw.slice(start, end);
|
||||
}
|
||||
|
||||
function timeframeToBarSeconds(timeframe: string): number {
|
||||
const map: 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,
|
||||
};
|
||||
return map[timeframe] ?? 180;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ import type {
|
||||
|
||||
export type LiveTimelineEventKind = 'SIGNAL' | 'ORDER_PENDING' | 'FILL';
|
||||
|
||||
export interface LiveTimelineSignalMeta {
|
||||
signalId: number;
|
||||
candleTime: number;
|
||||
candleType: string;
|
||||
executionType: string;
|
||||
strategyId?: number | null;
|
||||
}
|
||||
|
||||
export interface LiveTimelineFilter {
|
||||
symbol?: string;
|
||||
strategyId?: number | null;
|
||||
@@ -25,6 +33,8 @@ export interface LiveTimelineEvent {
|
||||
strategyName?: string | null;
|
||||
sourceLabel?: string;
|
||||
detail?: string;
|
||||
/** kind === 'SIGNAL' 일 때만 채워짐 */
|
||||
signalMeta?: LiveTimelineSignalMeta;
|
||||
}
|
||||
|
||||
export const LIVE_TIMELINE_KIND_LABEL: Record<LiveTimelineEventKind, string> = {
|
||||
@@ -79,6 +89,13 @@ export function buildLiveTradeTimeline(
|
||||
price: s.price,
|
||||
strategyName: s.strategyName,
|
||||
detail: [s.candleType, s.executionType].filter(Boolean).join(' · ') || undefined,
|
||||
signalMeta: {
|
||||
signalId: s.id,
|
||||
candleTime: s.candleTime > 1e12 ? Math.floor(s.candleTime / 1000) : s.candleTime,
|
||||
candleType: s.candleType,
|
||||
executionType: s.executionType,
|
||||
strategyId: s.strategyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,3 +247,27 @@ export function formatTimelineTime(ts: number): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 타임라인 SIGNAL 이벤트 → 상세 팝업용 메타 */
|
||||
export interface TimelineSignalDetailSource {
|
||||
side: 'BUY' | 'SELL';
|
||||
price: number;
|
||||
symbol: string;
|
||||
strategyName?: string | null;
|
||||
ts: number;
|
||||
signalMeta: LiveTimelineSignalMeta;
|
||||
}
|
||||
|
||||
export function toTimelineSignalDetail(
|
||||
ev: LiveTimelineEvent,
|
||||
): TimelineSignalDetailSource | null {
|
||||
if (ev.kind !== 'SIGNAL' || !ev.signalMeta) return null;
|
||||
return {
|
||||
side: ev.side,
|
||||
price: ev.price,
|
||||
symbol: ev.symbol,
|
||||
strategyName: ev.strategyName,
|
||||
ts: ev.ts,
|
||||
signalMeta: ev.signalMeta,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,11 +145,15 @@ function walkIndicatorRefs(
|
||||
node.children?.forEach(ch => walkIndicatorRefs(ch, out, seen));
|
||||
}
|
||||
|
||||
function collectIndicatorRefs(strategy: StrategyDto): IndicatorRef[] {
|
||||
function collectIndicatorRefs(strategy: StrategyDto, side?: 'BUY' | 'SELL'): IndicatorRef[] {
|
||||
const out: IndicatorRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
if (!side || side === 'BUY') {
|
||||
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
|
||||
}
|
||||
if (!side || side === 'SELL') {
|
||||
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -240,10 +244,11 @@ export function buildStrategyChartIndicators(
|
||||
getParams: GetParams,
|
||||
getVisual: GetVisual,
|
||||
workspaceIndicators?: IndicatorConfig[],
|
||||
side?: 'BUY' | 'SELL',
|
||||
): IndicatorConfig[] {
|
||||
if (!strategy) return [];
|
||||
|
||||
const refs = collectIndicatorRefs(strategy);
|
||||
const refs = collectIndicatorRefs(strategy, side);
|
||||
const byType = new Map<string, IndicatorRef>();
|
||||
|
||||
for (const ref of refs) {
|
||||
|
||||
@@ -54,6 +54,67 @@ export function buildNotificationSignalMarkers(
|
||||
return [{ time, type: marker.signalType, price: marker.price }];
|
||||
}
|
||||
|
||||
/** 스냅샷 차트 — 시그널 발생 봉이 시간축 중앙에 오도록 visible logical range 설정 */
|
||||
export function centerChartOnSignalBar(
|
||||
mgr: ChartManager,
|
||||
candleTimeSec: number,
|
||||
visibleBars?: number,
|
||||
): void {
|
||||
const bars = mgr.getRawBars();
|
||||
if (bars.length < 2) {
|
||||
mgr.fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeEpochSec(candleTimeSec);
|
||||
const barTime = resolveSignalMarkerBarTime(mgr, normalized);
|
||||
if (barTime == null) {
|
||||
mgr.fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = bars.findIndex(b => b.time === barTime);
|
||||
if (idx < 0) {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Math.abs(bars[0].time - barTime);
|
||||
for (let i = 1; i < bars.length; i++) {
|
||||
const dist = Math.abs(bars[i].time - barTime);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
idx = bestIdx;
|
||||
}
|
||||
|
||||
const totalBars = bars.length;
|
||||
const span = visibleBars != null && visibleBars > 0
|
||||
? visibleBars
|
||||
: totalBars;
|
||||
|
||||
if (visibleBars == null && totalBars <= 21) {
|
||||
mgr.fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
const halfSpan = span / 2;
|
||||
let from = idx - halfSpan + 0.5;
|
||||
let to = idx + halfSpan - 0.5;
|
||||
|
||||
if (from < 0) {
|
||||
to -= from;
|
||||
from = 0;
|
||||
}
|
||||
if (to > totalBars - 1) {
|
||||
from -= to - (totalBars - 1);
|
||||
to = totalBars - 1;
|
||||
}
|
||||
from = Math.max(0, from);
|
||||
to = Math.min(totalBars - 1, to);
|
||||
|
||||
mgr.applyVisibleLogicalRange(from, to);
|
||||
}
|
||||
|
||||
export function applyNotificationSignalMarkers(
|
||||
mgr: ChartManager,
|
||||
marker: TradeSignalChartMarker | undefined,
|
||||
|
||||
Reference in New Issue
Block a user