매매 시그널 디테일 팝업 기능 적용
This commit is contained in:
@@ -30,6 +30,7 @@ export interface AppPopupProps {
|
|||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
initialPosition?: { x: number; y: number };
|
initialPosition?: { x: number; y: number };
|
||||||
|
bodyRef?: React.Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppPopup: React.FC<AppPopupProps> = ({
|
export const AppPopup: React.FC<AppPopupProps> = ({
|
||||||
@@ -53,6 +54,7 @@ export const AppPopup: React.FC<AppPopupProps> = ({
|
|||||||
headerExtra,
|
headerExtra,
|
||||||
footer,
|
footer,
|
||||||
initialPosition,
|
initialPosition,
|
||||||
|
bodyRef,
|
||||||
}) => {
|
}) => {
|
||||||
const accentGlow = accentColor === APP_POPUP_ACCENT
|
const accentGlow = accentColor === APP_POPUP_ACCENT
|
||||||
? APP_POPUP_ACCENT_GLOW
|
? APP_POPUP_ACCENT_GLOW
|
||||||
@@ -130,7 +132,7 @@ export const AppPopup: React.FC<AppPopupProps> = ({
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={bodyClassName}>{children}</div>
|
<div ref={bodyRef} className={bodyClassName}>{children}</div>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,19 +16,22 @@ interface Props {
|
|||||||
/** compact: 스택 팝업, full: 목록/모달 요약 */
|
/** compact: 스택 팝업, full: 목록/모달 요약 */
|
||||||
variant?: 'compact' | 'full';
|
variant?: 'compact' | 'full';
|
||||||
showHeadline?: boolean;
|
showHeadline?: boolean;
|
||||||
|
/** list: 라벨·값 세로 목록, matrix: n×n 정보 카드 그리드 */
|
||||||
|
layout?: 'list' | 'matrix';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TradeSignalDetailBody: React.FC<Props> = ({
|
export const TradeSignalDetailBody: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
variant = 'compact',
|
variant = 'compact',
|
||||||
showHeadline = true,
|
showHeadline = true,
|
||||||
|
layout = 'list',
|
||||||
}) => {
|
}) => {
|
||||||
useTradeAlertTimeFormat();
|
useTradeAlertTimeFormat();
|
||||||
const isBuy = item.signalType === 'BUY';
|
const isBuy = item.signalType === 'BUY';
|
||||||
const rows = buildSignalDetailRows(item);
|
const rows = buildSignalDetailRows(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`tsd-body tsd-body--${variant}`}>
|
<div className={`tsd-body tsd-body--${variant}${layout === 'matrix' ? ' tsd-body--matrix' : ''}`}>
|
||||||
{showHeadline && (
|
{showHeadline && (
|
||||||
<div className="tsd-headline">
|
<div className="tsd-headline">
|
||||||
<span className={`tsd-direction ${isBuy ? 'tsd-direction--buy' : 'tsd-direction--sell'}`}>
|
<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>
|
<span className="tsd-headline-price">{formatSignalPrice(item.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{layout !== 'matrix' && (
|
||||||
<p className="tsd-summary">{getSignalHeadline(item)}</p>
|
<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">
|
<dl className="tsd-detail-grid">
|
||||||
{rows.map(row => (
|
{rows.map(row => (
|
||||||
<React.Fragment key={row.label}>
|
<React.Fragment key={row.label}>
|
||||||
@@ -46,6 +68,7 @@ export const TradeSignalDetailBody: React.FC<Props> = ({
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKe
|
|||||||
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
|
import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge';
|
||||||
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
|
||||||
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
|
import PaneSeparatorOverlay from './PaneSeparatorOverlay';
|
||||||
|
import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers';
|
||||||
|
|
||||||
interface TradingChartProps {
|
interface TradingChartProps {
|
||||||
bars: OHLCVBar[];
|
bars: OHLCVBar[];
|
||||||
@@ -182,6 +183,12 @@ interface TradingChartProps {
|
|||||||
resolveInitialDisplayCount?: (plotWidth: number) => number;
|
resolveInitialDisplayCount?: (plotWidth: number) => number;
|
||||||
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
|
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
|
||||||
skipViewportRecovery?: boolean;
|
skipViewportRecovery?: boolean;
|
||||||
|
/** true — 캔들 숨김, 보조지표 pane 선형 그래프만 표시 (시그널 상세 하단 차트 등) */
|
||||||
|
auxIndicatorOnly?: boolean;
|
||||||
|
/** 지정 시 초기 visible range 대신 시그널 봉 시간축 중앙 정렬 */
|
||||||
|
centerOnSignalTime?: number;
|
||||||
|
/** centerOnSignalTime 사용 시 화면에 보일 봉 수 (미지정 시 전체) */
|
||||||
|
centerVisibleBarCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TradingChart: React.FC<TradingChartProps> = ({
|
const TradingChart: React.FC<TradingChartProps> = ({
|
||||||
@@ -237,6 +244,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
onOpenCustom1OverlaySettings,
|
onOpenCustom1OverlaySettings,
|
||||||
resolveInitialDisplayCount,
|
resolveInitialDisplayCount,
|
||||||
skipViewportRecovery = false,
|
skipViewportRecovery = false,
|
||||||
|
auxIndicatorOnly = false,
|
||||||
|
centerOnSignalTime,
|
||||||
|
centerVisibleBarCount,
|
||||||
}) => {
|
}) => {
|
||||||
const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
|
const effectiveShowChartRightToolbar = showChartRightToolbar ?? showPaneLegend;
|
||||||
const showOverlayControlsOnly = !!(
|
const showOverlayControlsOnly = !!(
|
||||||
@@ -324,6 +334,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const chartVisibleRef = useRef(chartVisible);
|
const chartVisibleRef = useRef(chartVisible);
|
||||||
const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
|
const resolveInitialDisplayCountRef = useRef(resolveInitialDisplayCount);
|
||||||
const skipViewportRecoveryRef = useRef(skipViewportRecovery);
|
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);
|
const lastViewportFitRef = useRef<{ count: number; width: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -334,6 +347,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
skipViewportRecoveryRef.current = skipViewportRecovery;
|
skipViewportRecoveryRef.current = skipViewportRecovery;
|
||||||
}, [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 chartTimeFormat = useChartTimeFormat();
|
||||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||||
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
|
/** 초기 reloadAll 완료 전 — pane 구분선·부분 레이아웃 노출 방지 */
|
||||||
@@ -523,7 +557,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
mgr.applyCandleOnlyLayout(true, wrapperH);
|
mgr.applyCandleOnlyLayout(true, wrapperH);
|
||||||
required = wrapperH;
|
required = wrapperH;
|
||||||
} else {
|
} else {
|
||||||
if (mgr.isCandleOnlyLayout()) {
|
if (auxIndicatorOnlyRef.current) {
|
||||||
|
mgr.applyAuxIndicatorOnlyLayout(true);
|
||||||
|
} else if (mgr.isCandleOnlyLayout()) {
|
||||||
mgr.restoreFromCandleFullscreen(wrapperH);
|
mgr.restoreFromCandleFullscreen(wrapperH);
|
||||||
}
|
}
|
||||||
// 사용자 드래그 비율 우선, 없으면 prop 기반 비율
|
// 사용자 드래그 비율 우선, 없으면 prop 기반 비율
|
||||||
@@ -587,9 +623,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
applyPaneLayout(mgr);
|
applyPaneLayout(mgr);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { mgr.autoScale(); } catch { /* ok */ }
|
try { mgr.autoScale(); } catch { /* ok */ }
|
||||||
|
if (centerOnSignalTimeRef.current != null && Number.isFinite(centerOnSignalTimeRef.current)) {
|
||||||
|
applySnapshotViewport(mgr);
|
||||||
|
} else {
|
||||||
applyInitialVisibleRange(mgr);
|
applyInitialVisibleRange(mgr);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [applyPaneLayout, applyInitialVisibleRange]);
|
}, [applyPaneLayout, applyInitialVisibleRange, applySnapshotViewport]);
|
||||||
|
|
||||||
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -647,6 +687,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
|
const afterIndicatorPaneMutation = useCallback((mgr: ChartManager) => {
|
||||||
if (candleOnlyModeRef.current) {
|
if (candleOnlyModeRef.current) {
|
||||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||||
|
} else if (auxIndicatorOnlyRef.current) {
|
||||||
|
mgr.applyAuxIndicatorOnlyLayout(true);
|
||||||
} else if (mgr.isCandleOnlyLayout()) {
|
} else if (mgr.isCandleOnlyLayout()) {
|
||||||
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
||||||
}
|
}
|
||||||
@@ -960,6 +1002,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
|
mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
|
||||||
mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
|
mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
|
||||||
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
|
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
|
||||||
|
if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true);
|
||||||
onManagerReady(mgr);
|
onManagerReady(mgr);
|
||||||
|
|
||||||
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
|
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
|
||||||
import {
|
import {
|
||||||
buildLiveTradeTimeline,
|
buildLiveTradeTimeline,
|
||||||
|
toTimelineSignalDetail,
|
||||||
type LiveTimelineEvent,
|
type LiveTimelineEvent,
|
||||||
type LiveTimelineFilter,
|
type LiveTimelineFilter,
|
||||||
|
type TimelineSignalDetailSource,
|
||||||
} from '../../utils/liveTradeTimeline';
|
} from '../../utils/liveTradeTimeline';
|
||||||
import {
|
import {
|
||||||
LIVE_TIMELINE_VIEW_OPTIONS,
|
LIVE_TIMELINE_VIEW_OPTIONS,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
type LiveTimelineViewMode,
|
type LiveTimelineViewMode,
|
||||||
} from './LiveTradeTimelineViews';
|
} from './LiveTradeTimelineViews';
|
||||||
import TimelineAxisView from './TimelineAxisView';
|
import TimelineAxisView from './TimelineAxisView';
|
||||||
|
import TimelineSignalDetailModal from './TimelineSignalDetailModal';
|
||||||
import type { Timeframe } from '../../types';
|
import type { Timeframe } from '../../types';
|
||||||
|
|
||||||
export const ARP_TIMELINE_VIEW_STORAGE_KEY = 'arp-timeline-view-mode';
|
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 [events, setEvents] = useState<LiveTimelineEvent[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<LiveTimelineViewMode>(loadSavedTimelineViewMode);
|
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 () => {
|
const fetchTimeline = useCallback(async () => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -168,15 +177,34 @@ const LiveTradeTimelinePanel: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{events.length > 0 && viewMode === 'list' && (
|
{events.length > 0 && viewMode === 'list' && (
|
||||||
<TimelineListView events={events} timeframe={chartTimeframe} />
|
<TimelineListView
|
||||||
|
events={events}
|
||||||
|
timeframe={chartTimeframe}
|
||||||
|
onSignalDetail={handleSignalDetail}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{events.length > 0 && viewMode === 'split' && (
|
{events.length > 0 && viewMode === 'split' && (
|
||||||
<TimelineSplitView events={events} timeframe={chartTimeframe} />
|
<TimelineSplitView
|
||||||
|
events={events}
|
||||||
|
timeframe={chartTimeframe}
|
||||||
|
onSignalDetail={handleSignalDetail}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{events.length > 0 && viewMode === 'axis' && (
|
{events.length > 0 && viewMode === 'axis' && (
|
||||||
<TimelineAxisView events={events} timeframe={chartTimeframe} />
|
<TimelineAxisView
|
||||||
|
events={events}
|
||||||
|
timeframe={chartTimeframe}
|
||||||
|
onSignalDetail={handleSignalDetail}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{signalDetail && (
|
||||||
|
<TimelineSignalDetailModal
|
||||||
|
source={signalDetail}
|
||||||
|
onClose={() => setSignalDetail(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
|
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
|
||||||
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||||
import TimelineBarTimeBox, { eventToBarTimeSec } from './TimelineBarTimeBox';
|
import TimelineBarTimeBox, { eventToBarTimeSec } from './TimelineBarTimeBox';
|
||||||
|
import TimelineSignalCardShell from './TimelineSignalCardShell';
|
||||||
import type { Timeframe } from '../../types';
|
import type { Timeframe } from '../../types';
|
||||||
|
|
||||||
export type LiveTimelineViewMode = 'list' | 'split' | 'axis';
|
export type LiveTimelineViewMode = 'list' | 'split' | 'axis';
|
||||||
@@ -70,9 +71,10 @@ export const TimelineEventBody: React.FC<EventBodyProps> = ({ ev, compact = fals
|
|||||||
interface ListViewProps {
|
interface ListViewProps {
|
||||||
events: LiveTimelineEvent[];
|
events: LiveTimelineEvent[];
|
||||||
timeframe: Timeframe;
|
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 arp-timeline-scroll arp-timeline-scroll--list" aria-label="거래 이력 타임라인">
|
||||||
<div className="btd-timeline-line" aria-hidden />
|
<div className="btd-timeline-line" aria-hidden />
|
||||||
{events.map(ev => {
|
{events.map(ev => {
|
||||||
@@ -97,9 +99,13 @@ export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe })
|
|||||||
variant="compact"
|
variant="compact"
|
||||||
className="arp-timeline-bar-time--list-node"
|
className="arp-timeline-bar-time--list-node"
|
||||||
/>
|
/>
|
||||||
<div className="btd-timeline-card">
|
<TimelineSignalCardShell
|
||||||
|
ev={ev}
|
||||||
|
className="btd-timeline-card"
|
||||||
|
onSignalDetail={onSignalDetail}
|
||||||
|
>
|
||||||
<TimelineEventBody ev={ev} />
|
<TimelineEventBody ev={ev} />
|
||||||
</div>
|
</TimelineSignalCardShell>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -109,10 +115,12 @@ export const TimelineListView: React.FC<ListViewProps> = ({ events, timeframe })
|
|||||||
interface SplitViewProps {
|
interface SplitViewProps {
|
||||||
events: LiveTimelineEvent[];
|
events: LiveTimelineEvent[];
|
||||||
timeframe: Timeframe;
|
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-scroll" aria-label="좌우 분할 거래 이력 타임라인">
|
||||||
|
<div className="arp-timeline-split-track">
|
||||||
<div className="arp-timeline-split-axis" aria-hidden />
|
<div className="arp-timeline-split-axis" aria-hidden />
|
||||||
{events.map(ev => {
|
{events.map(ev => {
|
||||||
const isBuy = ev.side === 'BUY';
|
const isBuy = ev.side === 'BUY';
|
||||||
@@ -127,9 +135,13 @@ export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe
|
|||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
const card = (
|
const card = (
|
||||||
<article className={cardCls}>
|
<TimelineSignalCardShell
|
||||||
|
ev={ev}
|
||||||
|
className={cardCls}
|
||||||
|
onSignalDetail={onSignalDetail}
|
||||||
|
>
|
||||||
<TimelineEventBody ev={ev} compact />
|
<TimelineEventBody ev={ev} compact />
|
||||||
</article>
|
</TimelineSignalCardShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,4 +184,5 @@ export const TimelineSplitView: React.FC<SplitViewProps> = ({ events, timeframe
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../utils/liveTradeTimeline';
|
} from '../../utils/liveTradeTimeline';
|
||||||
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
|
import { formatSignalPrice } from '../../utils/tradeSignalDisplay';
|
||||||
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||||
|
import TimelineSignalDetailButton from './TimelineSignalDetailButton';
|
||||||
|
|
||||||
function kindClass(kind: LiveTimelineEvent['kind']): string {
|
function kindClass(kind: LiveTimelineEvent['kind']): string {
|
||||||
if (kind === 'SIGNAL') return 'signal';
|
if (kind === 'SIGNAL') return 'signal';
|
||||||
@@ -25,9 +26,10 @@ function kindIcon(kind: LiveTimelineEvent['kind']): string {
|
|||||||
interface Props {
|
interface Props {
|
||||||
ev: LiveTimelineEvent;
|
ev: LiveTimelineEvent;
|
||||||
side: 'top' | 'bottom';
|
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 { koreanName } = resolveVirtualTargetNames(ev.symbol);
|
||||||
const kCls = kindClass(ev.kind);
|
const kCls = kindClass(ev.kind);
|
||||||
const isSell = ev.side === 'SELL';
|
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--${side}`,
|
||||||
`arp-timeline-axis-card--${kCls}`,
|
`arp-timeline-axis-card--${kCls}`,
|
||||||
isSell ? 'arp-timeline-axis-card--sell' : 'arp-timeline-axis-card--buy',
|
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>
|
<div className="arp-timeline-axis-card-icon" aria-hidden>
|
||||||
{kindIcon(ev.kind)}
|
{kindIcon(ev.kind)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ const SEGMENT_COLORS = ['c0', 'c1', 'c2', 'c3', 'c4'] as const;
|
|||||||
interface Props {
|
interface Props {
|
||||||
events: LiveTimelineEvent[];
|
events: LiveTimelineEvent[];
|
||||||
timeframe: Timeframe;
|
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 scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
useHorizontalWheelScroll(scrollRef);
|
useHorizontalWheelScroll(scrollRef);
|
||||||
@@ -54,11 +55,18 @@ const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
|
|||||||
<div className="arp-timeline-axis-cols">
|
<div className="arp-timeline-axis-cols">
|
||||||
{slots.map(slot => (
|
{slots.map(slot => (
|
||||||
<div key={`top-${slot.barTimeSec}`} className="arp-timeline-axis-col">
|
<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">
|
<div key={ev.id} className="arp-timeline-axis-col-stack">
|
||||||
<TimelineAxisEventCard ev={ev} side="top" />
|
<TimelineAxisEventCard ev={ev} side="top" onSignalDetail={onSignalDetail} />
|
||||||
<div
|
<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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,13 +99,20 @@ const TimelineAxisView: React.FC<Props> = ({ events, timeframe }) => {
|
|||||||
<div className="arp-timeline-axis-cols">
|
<div className="arp-timeline-axis-cols">
|
||||||
{slots.map(slot => (
|
{slots.map(slot => (
|
||||||
<div key={`bottom-${slot.barTimeSec}`} className="arp-timeline-axis-col">
|
<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 key={ev.id} className="arp-timeline-axis-col-stack">
|
||||||
<div
|
<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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<TimelineAxisEventCard ev={ev} side="bottom" />
|
<TimelineAxisEventCard ev={ev} side="bottom" onSignalDetail={onSignalDetail} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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;
|
padding: 8px 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arp-timeline-split-track {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.arp-timeline-split-axis {
|
.arp-timeline-split-axis {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 3px;
|
width: 2px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: linear-gradient(
|
background: rgba(201, 162, 39, 0.58);
|
||||||
180deg,
|
border-radius: 1px;
|
||||||
rgba(201, 162, 39, 0.75),
|
|
||||||
rgba(201, 162, 39, 0.15)
|
|
||||||
);
|
|
||||||
border-radius: 2px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -2389,6 +2390,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.arp-timeline-split-col--center {
|
.arp-timeline-split-col--center {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2581,12 +2583,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.arp-timeline-axis-zone--top {
|
.arp-timeline-axis-zone--top {
|
||||||
padding-bottom: 12px;
|
padding-bottom: 0;
|
||||||
min-height: 148px;
|
min-height: 148px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arp-timeline-axis-zone--bottom {
|
.arp-timeline-axis-zone--bottom {
|
||||||
padding-top: 12px;
|
padding-top: 0;
|
||||||
min-height: 148px;
|
min-height: 148px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2643,14 +2645,6 @@
|
|||||||
margin-bottom: 4px;
|
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 {
|
.arp-timeline-axis-connector {
|
||||||
width: 0;
|
width: 0;
|
||||||
border-left: 2px dashed rgba(255, 255, 255, 0.34);
|
border-left: 2px dashed rgba(255, 255, 255, 0.34);
|
||||||
@@ -2666,6 +2660,10 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arp-timeline-axis-connector--to-rail {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.arp-timeline-axis-connector--sell {
|
.arp-timeline-axis-connector--sell {
|
||||||
border-left-color: rgba(239, 68, 68, 0.4);
|
border-left-color: rgba(239, 68, 68, 0.4);
|
||||||
}
|
}
|
||||||
@@ -2818,3 +2816,287 @@
|
|||||||
min-width: 120px;
|
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 비율 (투자관리·백테스트 분석 차트) */
|
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
|
||||||
private _paneAreaRatio: { candle: number; aux: number } | null = null;
|
private _paneAreaRatio: { candle: number; aux: number } | null = null;
|
||||||
|
|
||||||
|
/** 보조지표 전용 패널 — 캔들 pane 최소화·선형 지표만 표시 (시그널 상세 등) */
|
||||||
|
private _auxIndicatorOnlyLayout = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
@@ -502,16 +505,24 @@ export class ChartManager {
|
|||||||
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||||
} catch { /* ok */ }
|
} catch { /* ok */ }
|
||||||
}
|
}
|
||||||
} else if (panesInit[0]) {
|
} else if (panesInit[0] && !this._auxIndicatorOnlyLayout) {
|
||||||
panesInit[0].setStretchFactor(1);
|
panesInit[0].setStretchFactor(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._reapplyAllPatternMarkers();
|
this._reapplyAllPatternMarkers();
|
||||||
|
|
||||||
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
||||||
|
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
|
||||||
|
try { this.chart.timeScale().fitContent(); } catch { /* ok */ }
|
||||||
|
} else {
|
||||||
this._applyDefaultVisibleRange(200, 0);
|
this._applyDefaultVisibleRange(200, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._auxIndicatorOnlyLayout) {
|
||||||
|
this.syncChartOverlayVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 표시용 visible range 설정.
|
* 표시용 visible range 설정.
|
||||||
* bar < displayCount 이면 fitContent (음수 logical index 사용 금지).
|
* 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 {
|
private _volumeFrac(H: number): number {
|
||||||
if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0;
|
if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0;
|
||||||
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
||||||
@@ -4808,6 +4837,51 @@ export class ChartManager {
|
|||||||
return H;
|
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 {
|
resetPaneHeights(availableHeight?: number): number {
|
||||||
const panes = this.chart.panes();
|
const panes = this.chart.panes();
|
||||||
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
||||||
@@ -4820,6 +4894,10 @@ export class ChartManager {
|
|||||||
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._auxIndicatorOnlyLayout) {
|
||||||
|
return this._resetPaneHeightsAuxIndicatorOnly(availableHeight);
|
||||||
|
}
|
||||||
|
|
||||||
const H = (availableHeight && availableHeight > 0)
|
const H = (availableHeight && availableHeight > 0)
|
||||||
? availableHeight
|
? availableHeight
|
||||||
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
||||||
|
|||||||
@@ -51,3 +51,57 @@ export async function loadAnalysisCandles(
|
|||||||
|
|
||||||
return data.sort((a, b) => a.time - b.time);
|
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 type LiveTimelineEventKind = 'SIGNAL' | 'ORDER_PENDING' | 'FILL';
|
||||||
|
|
||||||
|
export interface LiveTimelineSignalMeta {
|
||||||
|
signalId: number;
|
||||||
|
candleTime: number;
|
||||||
|
candleType: string;
|
||||||
|
executionType: string;
|
||||||
|
strategyId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveTimelineFilter {
|
export interface LiveTimelineFilter {
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
strategyId?: number | null;
|
strategyId?: number | null;
|
||||||
@@ -25,6 +33,8 @@ export interface LiveTimelineEvent {
|
|||||||
strategyName?: string | null;
|
strategyName?: string | null;
|
||||||
sourceLabel?: string;
|
sourceLabel?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
/** kind === 'SIGNAL' 일 때만 채워짐 */
|
||||||
|
signalMeta?: LiveTimelineSignalMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LIVE_TIMELINE_KIND_LABEL: Record<LiveTimelineEventKind, string> = {
|
export const LIVE_TIMELINE_KIND_LABEL: Record<LiveTimelineEventKind, string> = {
|
||||||
@@ -79,6 +89,13 @@ export function buildLiveTradeTimeline(
|
|||||||
price: s.price,
|
price: s.price,
|
||||||
strategyName: s.strategyName,
|
strategyName: s.strategyName,
|
||||||
detail: [s.candleType, s.executionType].filter(Boolean).join(' · ') || undefined,
|
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())} `
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||||
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
+ `${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));
|
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 out: IndicatorRef[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
if (!side || side === 'BUY') {
|
||||||
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
|
walkIndicatorRefs(strategy.buyCondition as LogicNode | null, out, seen);
|
||||||
|
}
|
||||||
|
if (!side || side === 'SELL') {
|
||||||
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
|
walkIndicatorRefs(strategy.sellCondition as LogicNode | null, out, seen);
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +244,11 @@ export function buildStrategyChartIndicators(
|
|||||||
getParams: GetParams,
|
getParams: GetParams,
|
||||||
getVisual: GetVisual,
|
getVisual: GetVisual,
|
||||||
workspaceIndicators?: IndicatorConfig[],
|
workspaceIndicators?: IndicatorConfig[],
|
||||||
|
side?: 'BUY' | 'SELL',
|
||||||
): IndicatorConfig[] {
|
): IndicatorConfig[] {
|
||||||
if (!strategy) return [];
|
if (!strategy) return [];
|
||||||
|
|
||||||
const refs = collectIndicatorRefs(strategy);
|
const refs = collectIndicatorRefs(strategy, side);
|
||||||
const byType = new Map<string, IndicatorRef>();
|
const byType = new Map<string, IndicatorRef>();
|
||||||
|
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
|
|||||||
@@ -54,6 +54,67 @@ export function buildNotificationSignalMarkers(
|
|||||||
return [{ time, type: marker.signalType, price: marker.price }];
|
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(
|
export function applyNotificationSignalMarkers(
|
||||||
mgr: ChartManager,
|
mgr: ChartManager,
|
||||||
marker: TradeSignalChartMarker | undefined,
|
marker: TradeSignalChartMarker | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user