실시간 차트 신고가,신저가 표시
This commit is contained in:
@@ -138,6 +138,7 @@ export interface ChartWorkspaceViewProps {
|
|||||||
chartCandleAreaPriceLabels: boolean;
|
chartCandleAreaPriceLabels: boolean;
|
||||||
chartSeriesPriceLabels: boolean;
|
chartSeriesPriceLabels: boolean;
|
||||||
chartVolumeVisible: boolean;
|
chartVolumeVisible: boolean;
|
||||||
|
chartPriceExtremeLabelsVisible: boolean;
|
||||||
chartPaneSeparator: ChartPaneSeparatorOptions;
|
chartPaneSeparator: ChartPaneSeparatorOptions;
|
||||||
chartCrosshairInfoVisible: boolean;
|
chartCrosshairInfoVisible: boolean;
|
||||||
chartHoverToolbarVisible: boolean;
|
chartHoverToolbarVisible: boolean;
|
||||||
@@ -291,6 +292,7 @@ export interface ChartWorkspaceViewProps {
|
|||||||
handleExpandIndicator: (id: string) => void;
|
handleExpandIndicator: (id: string) => void;
|
||||||
handleRestoreIndicators: () => void;
|
handleRestoreIndicators: () => void;
|
||||||
handleToggleCandleOverlay: (key: ChartOverlayToggleKey) => void;
|
handleToggleCandleOverlay: (key: ChartOverlayToggleKey) => void;
|
||||||
|
handleTogglePriceExtremeLabels: () => void;
|
||||||
handleCustomOverlayActiveChange: (active: boolean) => void;
|
handleCustomOverlayActiveChange: (active: boolean) => void;
|
||||||
handleCustom1OverlayActiveChange: (active: boolean) => void;
|
handleCustom1OverlayActiveChange: (active: boolean) => void;
|
||||||
handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void;
|
handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void;
|
||||||
@@ -325,7 +327,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
overlayVisibilityRef, candleOverlayToggles, customOverlaySelection,
|
overlayVisibilityRef, candleOverlayToggles, customOverlaySelection,
|
||||||
custom1OverlaySelection, customOverlaySettingsSlot, customOverlayActive,
|
custom1OverlaySelection, customOverlaySettingsSlot, customOverlayActive,
|
||||||
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
|
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
|
||||||
chartVolumeVisible, chartPaneSeparator, chartCrosshairInfoVisible,
|
chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible,
|
||||||
chartHoverToolbarVisible, chartLiveReceiveHighlight, chartRealtimeSource,
|
chartHoverToolbarVisible, chartLiveReceiveHighlight, chartRealtimeSource,
|
||||||
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, wsStatus, bars, barsMarket,
|
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, wsStatus, bars, barsMarket,
|
||||||
barsMarketRef, chartMountKey, chartLiveReadyRef, pendingRealtimeBarRef,
|
barsMarketRef, chartMountKey, chartLiveReadyRef, pendingRealtimeBarRef,
|
||||||
@@ -363,6 +365,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
|
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
|
||||||
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
|
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
|
||||||
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
|
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
|
||||||
|
handleTogglePriceExtremeLabels,
|
||||||
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
|
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
|
||||||
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
|
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
|
||||||
handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
|
handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
|
||||||
@@ -505,6 +508,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
chartLiveQuote={liveQuote}
|
chartLiveQuote={liveQuote}
|
||||||
chartQuoteLoading={isLoading}
|
chartQuoteLoading={isLoading}
|
||||||
chartQuoteError={error}
|
chartQuoteError={error}
|
||||||
|
priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
||||||
|
onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isMobile && (showMarketPanel || mobileRightOpen) && (
|
{isMobile && (showMarketPanel || mobileRightOpen) && (
|
||||||
@@ -616,6 +621,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
chartPaneSeparator={chartPaneSeparator}
|
chartPaneSeparator={chartPaneSeparator}
|
||||||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||||||
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
||||||
|
chartPriceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
||||||
|
onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels}
|
||||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
@@ -659,6 +666,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
chartPaneSeparator={chartPaneSeparator}
|
chartPaneSeparator={chartPaneSeparator}
|
||||||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||||||
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
chartHoverToolbarVisible={chartHoverToolbarVisible}
|
||||||
|
chartPriceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
||||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
@@ -723,6 +731,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||||
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
|
||||||
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight);
|
||||||
|
mgr.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
|
||||||
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
|
||||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||||
@@ -773,6 +782,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) {
|
|||||||
paneSeparatorOptions={chartPaneSeparator}
|
paneSeparatorOptions={chartPaneSeparator}
|
||||||
candleOverlayToggles={candleOverlayToggles}
|
candleOverlayToggles={candleOverlayToggles}
|
||||||
onToggleCandleOverlay={handleToggleCandleOverlay}
|
onToggleCandleOverlay={handleToggleCandleOverlay}
|
||||||
|
priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
||||||
|
onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels}
|
||||||
customOverlayActive={customOverlayActive}
|
customOverlayActive={customOverlayActive}
|
||||||
onCustomOverlayActiveChange={handleCustomOverlayActiveChange}
|
onCustomOverlayActiveChange={handleCustomOverlayActiveChange}
|
||||||
onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')}
|
onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ import {
|
|||||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||||
listChartOverlayToggleItems,
|
listChartOverlayToggleItems,
|
||||||
} from '../utils/chartOverlayVisibility';
|
} from '../utils/chartOverlayVisibility';
|
||||||
|
import {
|
||||||
|
loadChartPriceExtremeLabelsVisible,
|
||||||
|
saveChartPriceExtremeLabelsVisible,
|
||||||
|
} from '../utils/chartPriceExtremeLabelsPref';
|
||||||
import {
|
import {
|
||||||
type ChartCustomOverlaySelection,
|
type ChartCustomOverlaySelection,
|
||||||
type ChartCustomOverlaySlotId,
|
type ChartCustomOverlaySlotId,
|
||||||
@@ -1396,6 +1400,15 @@ export function useChartWorkspace({
|
|||||||
[overlayVisibility],
|
[overlayVisibility],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [chartPriceExtremeLabelsVisible, setChartPriceExtremeLabelsVisible] = useState(
|
||||||
|
() => loadChartPriceExtremeLabelsVisible(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
managerRef.current?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
|
||||||
|
slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible));
|
||||||
|
}, [chartPriceExtremeLabelsVisible]);
|
||||||
|
|
||||||
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
|
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
|
||||||
() => createDefaultChartCustomOverlaySelection(),
|
() => createDefaultChartCustomOverlaySelection(),
|
||||||
);
|
);
|
||||||
@@ -1481,6 +1494,16 @@ export function useChartWorkspace({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePriceExtremeLabels = useCallback(() => {
|
||||||
|
setChartPriceExtremeLabelsVisible(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
saveChartPriceExtremeLabelsVisible(next);
|
||||||
|
managerRef.current?.setPriceExtremeLabelsVisible(next);
|
||||||
|
slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동.
|
* 보조지표 순서 변경 (드래그 핸들): fromId 를 indicators 배열에서 이동.
|
||||||
* overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨.
|
* overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨.
|
||||||
@@ -1908,7 +1931,7 @@ export function useChartWorkspace({
|
|||||||
candleOverlayToggles, customOverlaySelection, custom1OverlaySelection,
|
candleOverlayToggles, customOverlaySelection, custom1OverlaySelection,
|
||||||
activeCustomOverlaySlot, customOverlaySettingsSlot, customOverlayActive,
|
activeCustomOverlaySlot, customOverlaySettingsSlot, customOverlayActive,
|
||||||
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
|
custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels,
|
||||||
chartVolumeVisible, chartPaneSeparator, chartCrosshairInfoVisible,
|
chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible,
|
||||||
chartHoverToolbarVisible, chartLiveReceiveHighlight,
|
chartHoverToolbarVisible, chartLiveReceiveHighlight,
|
||||||
chartRealtimeSource: (chartRealtimeSource ?? 'BACKEND_STOMP') as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
|
chartRealtimeSource: (chartRealtimeSource ?? 'BACKEND_STOMP') as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
|
||||||
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit,
|
displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit,
|
||||||
@@ -1948,6 +1971,7 @@ export function useChartWorkspace({
|
|||||||
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
|
handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot,
|
||||||
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
|
handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane,
|
||||||
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
|
handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay,
|
||||||
|
handleTogglePriceExtremeLabels,
|
||||||
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
|
handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange,
|
||||||
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
|
handleSaveCustomOverlaySelection, syncCustomOverlaysToManager,
|
||||||
handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
|
handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount,
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export interface ChartRightToolbarProps {
|
|||||||
onOpenCustom1OverlaySettings?: () => void;
|
onOpenCustom1OverlaySettings?: () => void;
|
||||||
/** true — 캔들 pane 상단 오버레이 토글만 (미니 차트 등) */
|
/** true — 캔들 pane 상단 오버레이 토글만 (미니 차트 등) */
|
||||||
overlayControlsOnly?: boolean;
|
overlayControlsOnly?: boolean;
|
||||||
|
/** 9·20일 신고가·신저가 라벨 표시 (실시간 차트) */
|
||||||
|
priceExtremeLabelsVisible?: boolean;
|
||||||
|
onTogglePriceExtremeLabels?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BTN_SIZE = 32;
|
const BTN_SIZE = 32;
|
||||||
@@ -150,6 +153,14 @@ const IconIchimoku = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 신고가(상향)·신저가(하향) */
|
||||||
|
const IconPriceExtreme = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
|
||||||
|
<path d="M7 1.5 L10.5 6 H3.5 Z" fill="#4dabf7"/>
|
||||||
|
<path d="M7 12.5 L3.5 8 H10.5 Z" fill="#ef5350"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const OVERLAY_ICONS: Record<ChartOverlayToggleKey, React.ReactNode> = {
|
const OVERLAY_ICONS: Record<ChartOverlayToggleKey, React.ReactNode> = {
|
||||||
ma: <IconMa />,
|
ma: <IconMa />,
|
||||||
bollinger: <IconBollinger />,
|
bollinger: <IconBollinger />,
|
||||||
@@ -360,6 +371,8 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
onExpand, onRestore, focusedId,
|
onExpand, onRestore, focusedId,
|
||||||
onDragStart, onToggleHidden, onSettings,
|
onDragStart, onToggleHidden, onSettings,
|
||||||
overlayControlsOnly = false,
|
overlayControlsOnly = false,
|
||||||
|
priceExtremeLabelsVisible = false,
|
||||||
|
onTogglePriceExtremeLabels,
|
||||||
}) => {
|
}) => {
|
||||||
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
|
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
|
||||||
const [paneItems, setPaneItems] = useState<ReturnType<typeof buildPaneItems>>([]);
|
const [paneItems, setPaneItems] = useState<ReturnType<typeof buildPaneItems>>([]);
|
||||||
@@ -418,9 +431,11 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
|
|
||||||
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
|
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
|
||||||
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
|
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
|
||||||
|
const showPriceExtremeBtn = !!(candlePane && onTogglePriceExtremeLabels);
|
||||||
|
const showOverlayCluster = showOverlayIcons || showPriceExtremeBtn;
|
||||||
|
|
||||||
if (paused || chartHeight <= 0) return null;
|
if (paused || chartHeight <= 0) return null;
|
||||||
if (overlayControlsOnly && !showOverlayIcons) return null;
|
if (overlayControlsOnly && !showOverlayCluster) return null;
|
||||||
|
|
||||||
const innerHeight = paneLayouts.reduce(
|
const innerHeight = paneLayouts.reduce(
|
||||||
(max, l) => Math.max(max, l.topY + l.height),
|
(max, l) => Math.max(max, l.topY + l.height),
|
||||||
@@ -475,12 +490,12 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showOverlayIcons && (
|
{showOverlayCluster && (
|
||||||
<div
|
<div
|
||||||
className="chart-right-toolbar-cluster chart-right-toolbar-cluster--overlay-icons"
|
className="chart-right-toolbar-cluster chart-right-toolbar-cluster--overlay-icons"
|
||||||
style={{ top: candlePane!.topY + 4 }}
|
style={{ top: candlePane!.topY + 4 }}
|
||||||
>
|
>
|
||||||
{candleOverlayToggles!.map((item, idx) => (
|
{candleOverlayToggles?.map((item, idx) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -495,6 +510,20 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
|
|||||||
{OVERLAY_ICONS[item.key]}
|
{OVERLAY_ICONS[item.key]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{showPriceExtremeBtn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--overlay chart-right-toolbar-btn--price-extreme${priceExtremeLabelsVisible ? '' : ' chart-right-toolbar-btn--overlay-off'}`}
|
||||||
|
title={priceExtremeLabelsVisible ? '9·20일 신고가·신저가 숨기기' : '9·20일 신고가·신저가 표시'}
|
||||||
|
style={{ marginTop: (candleOverlayToggles?.length ?? 0) > 0 ? BTN_GAP : 0 }}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTogglePriceExtremeLabels?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPriceExtreme />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{showCustomBtn && (
|
{showCustomBtn && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export interface ChartSlotHandle {
|
|||||||
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => void;
|
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => void;
|
||||||
/** 거래량 pane on/off */
|
/** 거래량 pane on/off */
|
||||||
setVolumeVisible: (visible: boolean) => void;
|
setVolumeVisible: (visible: boolean) => void;
|
||||||
|
/** 9·20일 신고가·신저가 표시 */
|
||||||
|
setPriceExtremeLabelsVisible: (visible: boolean) => void;
|
||||||
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void;
|
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void;
|
||||||
/** 차트 시간축·크로스헤어 날짜 형식 */
|
/** 차트 시간축·크로스헤어 날짜 형식 */
|
||||||
setChartTimeFormat: (format: string, timeframe?: Timeframe) => void;
|
setChartTimeFormat: (format: string, timeframe?: Timeframe) => void;
|
||||||
@@ -210,6 +212,9 @@ export interface ChartSlotProps {
|
|||||||
chartCrosshairInfoVisible?: boolean;
|
chartCrosshairInfoVisible?: boolean;
|
||||||
/** 차트 마우스오버 줌·이동 플로팅 툴바 */
|
/** 차트 마우스오버 줌·이동 플로팅 툴바 */
|
||||||
chartHoverToolbarVisible?: boolean;
|
chartHoverToolbarVisible?: boolean;
|
||||||
|
/** 9·20일 신고가·신저가 차트 표시 */
|
||||||
|
chartPriceExtremeLabelsVisible?: boolean;
|
||||||
|
onTogglePriceExtremeLabels?: () => void;
|
||||||
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
|
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
|
||||||
marketTickers?: Map<string, TickerData>;
|
marketTickers?: Map<string, TickerData>;
|
||||||
}
|
}
|
||||||
@@ -239,6 +244,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
chartPaneSeparator,
|
chartPaneSeparator,
|
||||||
chartCrosshairInfoVisible = true,
|
chartCrosshairInfoVisible = true,
|
||||||
chartHoverToolbarVisible = true,
|
chartHoverToolbarVisible = true,
|
||||||
|
chartPriceExtremeLabelsVisible = false,
|
||||||
|
onTogglePriceExtremeLabels,
|
||||||
marketTickers,
|
marketTickers,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||||
@@ -364,6 +371,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
setVolumeVisible: (visible: boolean) => {
|
setVolumeVisible: (visible: boolean) => {
|
||||||
managerRef.current?.setVolumeVisible(visible);
|
managerRef.current?.setVolumeVisible(visible);
|
||||||
},
|
},
|
||||||
|
setPriceExtremeLabelsVisible: (visible: boolean) => {
|
||||||
|
managerRef.current?.setPriceExtremeLabelsVisible(visible);
|
||||||
|
},
|
||||||
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
|
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
|
||||||
managerRef.current?.setPaneSeparatorOptions(opts);
|
managerRef.current?.setPaneSeparatorOptions(opts);
|
||||||
managerRef.current?.refreshPaneSeparatorOverlay();
|
managerRef.current?.refreshPaneSeparatorOverlay();
|
||||||
@@ -391,6 +401,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
managerRef.current?.setVolumeVisible(chartVolumeVisible);
|
managerRef.current?.setVolumeVisible(chartVolumeVisible);
|
||||||
}, [chartVolumeVisible]);
|
}, [chartVolumeVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
managerRef.current?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
|
||||||
|
}, [chartPriceExtremeLabelsVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartPaneSeparator) return;
|
if (!chartPaneSeparator) return;
|
||||||
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
@@ -1010,6 +1024,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
|
||||||
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||||
mgr.setVolumeVisible(chartVolumeVisible);
|
mgr.setVolumeVisible(chartVolumeVisible);
|
||||||
|
mgr.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible ?? false);
|
||||||
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
|
||||||
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
|
||||||
syncCustomOverlaysToManager(mgr);
|
syncCustomOverlaysToManager(mgr);
|
||||||
@@ -1099,6 +1114,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
custom1OverlayActive={compactMode ? undefined : custom1OverlayActive}
|
custom1OverlayActive={compactMode ? undefined : custom1OverlayActive}
|
||||||
onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange}
|
onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange}
|
||||||
onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')}
|
onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')}
|
||||||
|
priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
|
||||||
|
onTogglePriceExtremeLabels={onTogglePriceExtremeLabels}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{customOverlaySettingsSlot === 'custom' && !compactMode && (
|
{customOverlaySettingsSlot === 'custom' && !compactMode && (
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ import {
|
|||||||
loadPaletteItems,
|
loadPaletteItems,
|
||||||
type PaletteItem,
|
type PaletteItem,
|
||||||
} from '../utils/strategyPaletteStorage';
|
} from '../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
buildCompositePaletteTemplateRows,
|
||||||
|
buildSidewaysPaletteTemplateRows,
|
||||||
|
} from '../utils/strategyEditorTemplatePaletteRows';
|
||||||
import {
|
import {
|
||||||
buildPriceExtremeBreakoutTree,
|
buildPriceExtremeBreakoutTree,
|
||||||
isPriceExtremeBreakoutPairPaletteValue,
|
isPriceExtremeBreakoutPairPaletteValue,
|
||||||
@@ -1307,22 +1311,22 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||||
|
|
||||||
const templateRows = useMemo(() => {
|
const templateRows = useMemo(() => {
|
||||||
const userRows = userTemplates.map(t => ({
|
const userRows: TemplatePaletteRow[] = userTemplates.map(t => ({
|
||||||
key: t.id,
|
key: t.id,
|
||||||
source: 'user' as const,
|
source: 'user',
|
||||||
label: t.label,
|
label: t.label,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
signal: t.signal,
|
signal: t.signal,
|
||||||
user: t,
|
user: t,
|
||||||
}));
|
}));
|
||||||
const hidden = new Set(hiddenBuiltinTemplateIds);
|
const hidden = new Set(hiddenBuiltinTemplateIds);
|
||||||
const builtinRows = templates
|
const builtinRows: TemplatePaletteRow[] = templates
|
||||||
.filter(t => !hidden.has(getBuiltinTemplateId(t)))
|
.filter(t => !hidden.has(getBuiltinTemplateId(t)))
|
||||||
.map(t => {
|
.map(t => {
|
||||||
const builtinId = getBuiltinTemplateId(t);
|
const builtinId = getBuiltinTemplateId(t);
|
||||||
return {
|
return {
|
||||||
key: builtinId,
|
key: builtinId,
|
||||||
source: 'builtin' as const,
|
source: 'builtin',
|
||||||
label: t.label,
|
label: t.label,
|
||||||
description: 'description' in t ? t.description : undefined,
|
description: 'description' in t ? t.description : undefined,
|
||||||
signal: t.signal,
|
signal: t.signal,
|
||||||
@@ -1330,19 +1334,26 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
builtin: t,
|
builtin: t,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return [...userRows, ...builtinRows];
|
const compositeRows: TemplatePaletteRow[] = buildCompositePaletteTemplateRows(compositePalette, hidden);
|
||||||
}, [userTemplates, templates, hiddenBuiltinTemplateIds]);
|
const sidewaysRows: TemplatePaletteRow[] = buildSidewaysPaletteTemplateRows(sidewaysPalette, hidden);
|
||||||
|
return [...userRows, ...builtinRows, ...compositeRows, ...sidewaysRows];
|
||||||
|
}, [userTemplates, templates, hiddenBuiltinTemplateIds, compositePalette, sidewaysPalette]);
|
||||||
|
|
||||||
const filteredTemplateRows = useMemo(() => {
|
const filteredTemplateRows = useMemo(() => {
|
||||||
const q = templateSearch.trim().toLowerCase();
|
const q = templateSearch.trim().toLowerCase();
|
||||||
if (!q) return templateRows;
|
if (!q) return templateRows;
|
||||||
return templateRows.filter(row => {
|
return templateRows.filter(row => {
|
||||||
const signalLabel = row.signal === 'buy' ? '매수' : '매도';
|
const signalLabel = row.signal === 'buy' ? '매수' : '매도';
|
||||||
|
const badge = row.source === 'user' ? '내 템플릿'
|
||||||
|
: row.source === 'composite-palette' ? '복합지표'
|
||||||
|
: row.source === 'sideways-palette' ? '횡보지표'
|
||||||
|
: '';
|
||||||
return (
|
return (
|
||||||
row.label.toLowerCase().includes(q)
|
row.label.toLowerCase().includes(q)
|
||||||
|| row.description?.toLowerCase().includes(q)
|
|| row.description?.toLowerCase().includes(q)
|
||||||
|| signalLabel.includes(q)
|
|| signalLabel.includes(q)
|
||||||
|| row.signal.includes(q)
|
|| row.signal.includes(q)
|
||||||
|
|| badge.includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [templateRows, templateSearch]);
|
}, [templateRows, templateSearch]);
|
||||||
@@ -1468,14 +1479,31 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
|||||||
handleDeleteUserTemplate(row.user.id, row.label);
|
handleDeleteUserTemplate(row.user.id, row.label);
|
||||||
} else if (row.source === 'builtin' && row.builtinId) {
|
} else if (row.source === 'builtin' && row.builtinId) {
|
||||||
handleDeleteBuiltinTemplate(row.builtinId, row.label);
|
handleDeleteBuiltinTemplate(row.builtinId, row.label);
|
||||||
|
} else if (row.source === 'composite-palette' || row.source === 'sideways-palette') {
|
||||||
|
handleDeleteBuiltinTemplate(row.key, row.label);
|
||||||
}
|
}
|
||||||
setSelectedTemplateKey(null);
|
setSelectedTemplateKey(null);
|
||||||
}, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]);
|
}, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]);
|
||||||
|
|
||||||
const handleTemplateApply = useCallback((row: TemplatePaletteRow) => {
|
const handleTemplateApply = useCallback((row: TemplatePaletteRow) => {
|
||||||
if (row.source === 'user' && row.user) applyUserTemplate(row.user);
|
if (row.source === 'user' && row.user) {
|
||||||
else if (row.builtin) handleTemplate(row.builtin);
|
applyUserTemplate(row.user);
|
||||||
}, [applyUserTemplate, handleTemplate]);
|
return;
|
||||||
|
}
|
||||||
|
if (row.source === 'builtin' && row.builtin) {
|
||||||
|
handleTemplate(row.builtin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.source === 'composite-palette' && row.paletteItem) {
|
||||||
|
applyPaletteItem(row.paletteItem);
|
||||||
|
showSnack(`"${row.label}" 조건이 추가됐습니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.source === 'sideways-palette' && row.sidewaysFilterId) {
|
||||||
|
applySidewaysFilter(row.sidewaysFilterId);
|
||||||
|
showSnack(`"${row.label}" 필터가 추가됐습니다`);
|
||||||
|
}
|
||||||
|
}, [applyUserTemplate, handleTemplate, applyPaletteItem, applySidewaysFilter]);
|
||||||
|
|
||||||
const handleTemplateSaveConfirm = useCallback(() => {
|
const handleTemplateSaveConfirm = useCallback(() => {
|
||||||
const label = templateSaveLabel.trim();
|
const label = templateSaveLabel.trim();
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export interface ToolbarProps {
|
|||||||
chartLiveQuote?: ChartLiveQuote | null;
|
chartLiveQuote?: ChartLiveQuote | null;
|
||||||
chartQuoteLoading?: boolean;
|
chartQuoteLoading?: boolean;
|
||||||
chartQuoteError?: string | null;
|
chartQuoteError?: string | null;
|
||||||
|
/** 9·20일 신고가·신저가 차트 표시 (실시간 차트 상단 툴바) */
|
||||||
|
priceExtremeLabelsVisible?: boolean;
|
||||||
|
onTogglePriceExtremeLabels?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SVG 아이콘 ────────────────────────────────────────────────────────────
|
// ─── SVG 아이콘 ────────────────────────────────────────────────────────────
|
||||||
@@ -240,6 +243,12 @@ const IcTradeSignal = () => (
|
|||||||
<path d="M8.5 1.5 L4 8.5 H7.5 L6 13.5 L11 6 H7.5 Z" fill="currentColor" stroke="none" opacity="0.85"/>
|
<path d="M8.5 1.5 L4 8.5 H7.5 L6 13.5 L11 6 H7.5 Z" fill="currentColor" stroke="none" opacity="0.85"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
const IcPriceExtreme = () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
|
||||||
|
<path d="M7 1.5 L10.5 6 H3.5 Z" fill="#4dabf7"/>
|
||||||
|
<path d="M7 12.5 L3.5 8 H10.5 Z" fill="#ef5350"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
const IcGridIcon = () => (
|
const IcGridIcon = () => (
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.3">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||||
<rect x="1" y="1" width="13" height="13" rx="1"/>
|
<rect x="1" y="1" width="13" height="13" rx="1"/>
|
||||||
@@ -826,6 +835,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
chartLiveQuote = null,
|
chartLiveQuote = null,
|
||||||
chartQuoteLoading = false,
|
chartQuoteLoading = false,
|
||||||
chartQuoteError = null,
|
chartQuoteError = null,
|
||||||
|
priceExtremeLabelsVisible = false,
|
||||||
|
onTogglePriceExtremeLabels,
|
||||||
}) => {
|
}) => {
|
||||||
const [showMarket, setShowMarket] = React.useState(false);
|
const [showMarket, setShowMarket] = React.useState(false);
|
||||||
const [showTfMenu, setShowTfMenu] = React.useState(false);
|
const [showTfMenu, setShowTfMenu] = React.useState(false);
|
||||||
@@ -1097,6 +1108,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<IcFibAuto />
|
<IcFibAuto />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 9·20일 신고가·신저가 표시 */}
|
||||||
|
{onTogglePriceExtremeLabels && (
|
||||||
|
<button
|
||||||
|
className={`tv-icon-btn${priceExtremeLabelsVisible ? ' active' : ''}`}
|
||||||
|
onClick={onTogglePriceExtremeLabels}
|
||||||
|
title={priceExtremeLabelsVisible ? '9·20일 신고가·신저가 숨기기' : '9·20일 신고가·신저가 표시'}
|
||||||
|
>
|
||||||
|
<IcPriceExtreme />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 그리드 토글 */}
|
{/* 그리드 토글 */}
|
||||||
<button
|
<button
|
||||||
className={`tv-icon-btn${showGrid ? ' active' : ''}`}
|
className={`tv-icon-btn${showGrid ? ' active' : ''}`}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type { ChartManager } from '../utils/ChartManager';
|
import type { ChartManager } from '../utils/ChartManager';
|
||||||
import {
|
import {
|
||||||
layoutStackedSignalLabels,
|
layoutStackedSignalLabels,
|
||||||
@@ -95,6 +96,18 @@ function renderTradeSignalLabels(
|
|||||||
|
|
||||||
const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
|
const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
||||||
|
const redrawRafRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = manager.getContainer();
|
||||||
|
if (container) {
|
||||||
|
setPortalTarget(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tid = window.setTimeout(() => setPortalTarget(manager.getContainer()), 100);
|
||||||
|
return () => clearTimeout(tid);
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
const redraw = useCallback(() => {
|
const redraw = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -120,11 +133,18 @@ const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
|
|||||||
}, [manager]);
|
}, [manager]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!portalTarget) return;
|
||||||
|
|
||||||
redraw();
|
redraw();
|
||||||
let layoutRaf = 0;
|
|
||||||
|
/** 패닝·스크롤 중 매 이벤트 즉시 + 다음 프레임 보정 (cancelAnimationFrame 미사용) */
|
||||||
const scheduleRedraw = () => {
|
const scheduleRedraw = () => {
|
||||||
cancelAnimationFrame(layoutRaf);
|
redraw();
|
||||||
layoutRaf = requestAnimationFrame(redraw);
|
if (redrawRafRef.current) return;
|
||||||
|
redrawRafRef.current = requestAnimationFrame(() => {
|
||||||
|
redrawRafRef.current = 0;
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsubs = [
|
const unsubs = [
|
||||||
@@ -137,19 +157,23 @@ const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
|
|||||||
const ro = new ResizeObserver(scheduleRedraw);
|
const ro = new ResizeObserver(scheduleRedraw);
|
||||||
if (container) ro.observe(container);
|
if (container) ro.observe(container);
|
||||||
|
|
||||||
const raf1 = requestAnimationFrame(redraw);
|
const scrollWrap = container?.closest('.tv-chart-wrap') as HTMLElement | null;
|
||||||
const raf2 = requestAnimationFrame(() => requestAnimationFrame(redraw));
|
scrollWrap?.addEventListener('scroll', scheduleRedraw, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubs.forEach(u => u());
|
unsubs.forEach(u => u());
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
cancelAnimationFrame(layoutRaf);
|
scrollWrap?.removeEventListener('scroll', scheduleRedraw);
|
||||||
cancelAnimationFrame(raf1);
|
if (redrawRafRef.current) {
|
||||||
cancelAnimationFrame(raf2);
|
cancelAnimationFrame(redrawRafRef.current);
|
||||||
|
redrawRafRef.current = 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [manager, redraw]);
|
}, [manager, portalTarget, redraw]);
|
||||||
|
|
||||||
return (
|
if (!portalTarget) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="trade-signal-label-overlay"
|
className="trade-signal-label-overlay"
|
||||||
@@ -160,7 +184,8 @@ const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 8,
|
zIndex: 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>,
|
||||||
|
portalTarget,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ interface TradingChartProps {
|
|||||||
custom1OverlayActive?: boolean;
|
custom1OverlayActive?: boolean;
|
||||||
onCustom1OverlayActiveChange?: (active: boolean) => void;
|
onCustom1OverlayActiveChange?: (active: boolean) => void;
|
||||||
onOpenCustom1OverlaySettings?: () => void;
|
onOpenCustom1OverlaySettings?: () => void;
|
||||||
|
/** 9·20일 신고가·신저가 라벨 표시 (실시간 차트 우측 툴바) */
|
||||||
|
priceExtremeLabelsVisible?: boolean;
|
||||||
|
onTogglePriceExtremeLabels?: () => void;
|
||||||
/** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */
|
/** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */
|
||||||
resolveInitialDisplayCount?: (plotWidth: number) => number;
|
resolveInitialDisplayCount?: (plotWidth: number) => number;
|
||||||
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
|
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
|
||||||
@@ -243,6 +246,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
custom1OverlayActive,
|
custom1OverlayActive,
|
||||||
onCustom1OverlayActiveChange,
|
onCustom1OverlayActiveChange,
|
||||||
onOpenCustom1OverlaySettings,
|
onOpenCustom1OverlaySettings,
|
||||||
|
priceExtremeLabelsVisible,
|
||||||
|
onTogglePriceExtremeLabels,
|
||||||
resolveInitialDisplayCount,
|
resolveInitialDisplayCount,
|
||||||
skipViewportRecovery = false,
|
skipViewportRecovery = false,
|
||||||
auxIndicatorOnly = false,
|
auxIndicatorOnly = false,
|
||||||
@@ -1161,6 +1166,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
managerRef.current?.panByPixelDelta(dx, dy, {
|
managerRef.current?.panByPixelDelta(dx, dy, {
|
||||||
allowVerticalPan: allowVertical,
|
allowVerticalPan: allowVertical,
|
||||||
});
|
});
|
||||||
|
managerRef.current?.notifyViewportChanged();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPanPointerUp = (e: PointerEvent) => {
|
const onPanPointerUp = (e: PointerEvent) => {
|
||||||
@@ -1827,6 +1833,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
custom1OverlayActive={custom1OverlayActive}
|
custom1OverlayActive={custom1OverlayActive}
|
||||||
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
|
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
|
||||||
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
|
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
|
||||||
|
priceExtremeLabelsVisible={priceExtremeLabelsVisible}
|
||||||
|
onTogglePriceExtremeLabels={onTogglePriceExtremeLabels}
|
||||||
onSplit={onSplitIndicatorPane}
|
onSplit={onSplitIndicatorPane}
|
||||||
onRemove={onRemoveIndicator}
|
onRemove={onRemoveIndicator}
|
||||||
onDuplicate={onDuplicateIndicator}
|
onDuplicate={onDuplicateIndicator}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { StrategyTemplateDef } from '../../utils/strategyPresets';
|
import type { StrategyTemplateDef } from '../../utils/strategyPresets';
|
||||||
import type { UserStrategyTemplate } from '../../utils/strategyTemplateStorage';
|
import type { UserStrategyTemplate } from '../../utils/strategyTemplateStorage';
|
||||||
|
import type { PaletteItem } from '../../utils/strategyPaletteStorage';
|
||||||
|
import {
|
||||||
|
templateBadgeForSource,
|
||||||
|
type TemplatePaletteSource,
|
||||||
|
} from '../../utils/strategyEditorTemplatePaletteRows';
|
||||||
|
|
||||||
|
export type { TemplatePaletteSource };
|
||||||
|
|
||||||
export type TemplatePaletteRow = {
|
export type TemplatePaletteRow = {
|
||||||
key: string;
|
key: string;
|
||||||
source: 'user' | 'builtin';
|
source: TemplatePaletteSource;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
signal: 'buy' | 'sell';
|
signal: 'buy' | 'sell';
|
||||||
user?: UserStrategyTemplate;
|
user?: UserStrategyTemplate;
|
||||||
builtinId?: string;
|
builtinId?: string;
|
||||||
builtin?: StrategyTemplateDef;
|
builtin?: StrategyTemplateDef;
|
||||||
|
paletteItem?: PaletteItem;
|
||||||
|
sidewaysFilterId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -91,7 +100,9 @@ export default function TemplatePaletteTab({
|
|||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="se-template-empty">검색 결과가 없습니다</div>
|
<div className="se-template-empty">검색 결과가 없습니다</div>
|
||||||
) : (
|
) : (
|
||||||
rows.map(row => (
|
rows.map(row => {
|
||||||
|
const sourceBadge = templateBadgeForSource(row.source);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className={`se-template-row${selectedKey === row.key ? ' se-template-row--selected' : ''}`}
|
className={`se-template-row${selectedKey === row.key ? ' se-template-row--selected' : ''}`}
|
||||||
@@ -108,7 +119,11 @@ export default function TemplatePaletteTab({
|
|||||||
>
|
>
|
||||||
<div className="se-template-item">
|
<div className="se-template-item">
|
||||||
<span className="se-template-label">
|
<span className="se-template-label">
|
||||||
{row.source === 'user' && <span className="se-template-user-badge">내 템플릿</span>}
|
{sourceBadge && (
|
||||||
|
<span className={`se-template-source-badge se-template-source-badge--${row.source}`}>
|
||||||
|
{sourceBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{row.label}
|
{row.label}
|
||||||
</span>
|
</span>
|
||||||
{row.description && (
|
{row.description && (
|
||||||
@@ -119,7 +134,8 @@ export default function TemplatePaletteTab({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
crosshairInfoVisible={false}
|
crosshairInfoVisible={false}
|
||||||
showHoverToolbar={false}
|
showHoverToolbar={false}
|
||||||
showChartRightToolbar={false}
|
showChartRightToolbar={false}
|
||||||
|
showCandlePaneControls={false}
|
||||||
/>
|
/>
|
||||||
{chartMgr && (
|
{chartMgr && (
|
||||||
<StrategyEvaluationBarSelector
|
<StrategyEvaluationBarSelector
|
||||||
|
|||||||
@@ -2296,15 +2296,30 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.se-template-user-badge {
|
.se-template-user-badge,
|
||||||
|
.se-template-source-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-template-user-badge,
|
||||||
|
.se-template-source-badge--user {
|
||||||
color: var(--se-gold);
|
color: var(--se-gold);
|
||||||
background: color-mix(in srgb, var(--se-gold) 14%, transparent);
|
background: color-mix(in srgb, var(--se-gold) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.se-template-source-badge--composite-palette {
|
||||||
|
color: #82b1ff;
|
||||||
|
background: color-mix(in srgb, #82b1ff 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se-template-source-badge--sideways-palette {
|
||||||
|
color: #ffb74d;
|
||||||
|
background: color-mix(in srgb, #ffb74d 14%, transparent);
|
||||||
|
}
|
||||||
.se-template-save-hint {
|
.se-template-save-hint {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
type TradeSignalLabelItem,
|
type TradeSignalLabelItem,
|
||||||
type TradeSignalMarkerWithPrice,
|
type TradeSignalMarkerWithPrice,
|
||||||
} from './tradeSignalLabels';
|
} from './tradeSignalLabels';
|
||||||
|
import { computePriceExtremeChartMarkers } from './priceExtremeChartEvents';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CHART_TIME_FORMAT,
|
DEFAULT_CHART_TIME_FORMAT,
|
||||||
formatLwcTimeWithPattern,
|
formatLwcTimeWithPattern,
|
||||||
@@ -274,6 +275,9 @@ export class ChartManager {
|
|||||||
private backtestMarkers: TradeSignalMarkerWithPrice[] = [];
|
private backtestMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||||
/** 실시간 전략 체크 마커 */
|
/** 실시간 전략 체크 마커 */
|
||||||
private liveStrategyMarkers: TradeSignalMarkerWithPrice[] = [];
|
private liveStrategyMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||||
|
/** 9·20일 신고가·신저가 마커 */
|
||||||
|
private priceExtremeMarkers: TradeSignalMarkerWithPrice[] = [];
|
||||||
|
private _priceExtremeLabelsVisible = false;
|
||||||
private _tradeSignalLabelListeners = new Set<() => void>();
|
private _tradeSignalLabelListeners = new Set<() => void>();
|
||||||
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
||||||
/** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */
|
/** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */
|
||||||
@@ -515,7 +519,7 @@ export class ChartManager {
|
|||||||
panesInit[0].setStretchFactor(1);
|
panesInit[0].setStretchFactor(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._reapplyAllPatternMarkers();
|
this._refreshPriceExtremeMarkers();
|
||||||
|
|
||||||
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
||||||
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
|
if (this._auxIndicatorOnlyLayout && bars.length <= 21) {
|
||||||
@@ -550,7 +554,7 @@ export class ChartManager {
|
|||||||
} else {
|
} else {
|
||||||
ts.fitContent();
|
ts.fitContent();
|
||||||
}
|
}
|
||||||
this._notifyViewport();
|
this._scheduleNotifyViewport();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +567,7 @@ export class ChartManager {
|
|||||||
from: bars[startIdx].time as Time,
|
from: bars[startIdx].time as Time,
|
||||||
to: to as Time,
|
to: to as Time,
|
||||||
});
|
});
|
||||||
this._notifyViewport();
|
this._scheduleNotifyViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
||||||
@@ -1777,7 +1781,14 @@ export class ChartManager {
|
|||||||
color: m.color,
|
color: m.color,
|
||||||
text: '',
|
text: '',
|
||||||
}));
|
}));
|
||||||
const all = [...patternAll, ...backtestAll, ...liveAll];
|
const priceExtremeAll = this.priceExtremeMarkers.map(m => ({
|
||||||
|
time: m.time as Time,
|
||||||
|
position: m.position,
|
||||||
|
shape: m.shape,
|
||||||
|
color: m.color,
|
||||||
|
text: '',
|
||||||
|
}));
|
||||||
|
const all = [...patternAll, ...backtestAll, ...liveAll, ...priceExtremeAll];
|
||||||
if (this.mainMarkersPlugin) {
|
if (this.mainMarkersPlugin) {
|
||||||
try {
|
try {
|
||||||
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
|
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
|
||||||
@@ -1876,9 +1887,50 @@ export class ChartManager {
|
|||||||
return [
|
return [
|
||||||
...this.backtestMarkers.map(toTradeSignalLabelItem),
|
...this.backtestMarkers.map(toTradeSignalLabelItem),
|
||||||
...this.liveStrategyMarkers.map(toTradeSignalLabelItem),
|
...this.liveStrategyMarkers.map(toTradeSignalLabelItem),
|
||||||
|
...this.priceExtremeMarkers.map(toTradeSignalLabelItem),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** rawBars 기준 9·20일 신고가·신저가 마커·라벨 재계산 */
|
||||||
|
private _refreshPriceExtremeMarkers(): void {
|
||||||
|
if (!this._priceExtremeLabelsVisible) {
|
||||||
|
if (this.priceExtremeMarkers.length === 0) return;
|
||||||
|
this.priceExtremeMarkers = [];
|
||||||
|
this._reapplyAllPatternMarkers();
|
||||||
|
this._notifyTradeSignalLabels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = computePriceExtremeChartMarkers(this.rawBars);
|
||||||
|
if (this._samePriceExtremeMarkers(next, this.priceExtremeMarkers)) return;
|
||||||
|
this.priceExtremeMarkers = next;
|
||||||
|
this._reapplyAllPatternMarkers();
|
||||||
|
this._notifyTradeSignalLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실시간 차트 — 9·20일 신고가·신저가 라벨·마커 표시 */
|
||||||
|
setPriceExtremeLabelsVisible(visible: boolean): void {
|
||||||
|
if (this._priceExtremeLabelsVisible === visible) return;
|
||||||
|
this._priceExtremeLabelsVisible = visible;
|
||||||
|
this._refreshPriceExtremeMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPriceExtremeLabelsVisible(): boolean {
|
||||||
|
return this._priceExtremeLabelsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _samePriceExtremeMarkers(
|
||||||
|
a: TradeSignalMarkerWithPrice[],
|
||||||
|
b: TradeSignalMarkerWithPrice[],
|
||||||
|
): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].time !== b[i].time || a[i].text !== b[i].text || a[i].position !== b[i].position) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
subscribeTradeSignalLabels(cb: () => void): () => void {
|
subscribeTradeSignalLabels(cb: () => void): () => void {
|
||||||
this._tradeSignalLabelListeners.add(cb);
|
this._tradeSignalLabelListeners.add(cb);
|
||||||
return () => { this._tradeSignalLabelListeners.delete(cb); };
|
return () => { this._tradeSignalLabelListeners.delete(cb); };
|
||||||
@@ -2281,6 +2333,7 @@ export class ChartManager {
|
|||||||
|
|
||||||
// 4) 보조지표 마지막 포인트 스로틀 갱신
|
// 4) 보조지표 마지막 포인트 스로틀 갱신
|
||||||
this._scheduleIndicatorLastUpdate();
|
this._scheduleIndicatorLastUpdate();
|
||||||
|
this._refreshPriceExtremeMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2621,6 +2674,7 @@ export class ChartManager {
|
|||||||
if (this._pendingIndUpdate) {
|
if (this._pendingIndUpdate) {
|
||||||
this._scheduleIndicatorLastUpdate();
|
this._scheduleIndicatorLastUpdate();
|
||||||
}
|
}
|
||||||
|
this._refreshPriceExtremeMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2652,6 +2706,7 @@ export class ChartManager {
|
|||||||
if (!this._indicatorLoadStale(loadGen)) {
|
if (!this._indicatorLoadStale(loadGen)) {
|
||||||
this._finalizeIndicatorPaneLayout();
|
this._finalizeIndicatorPaneLayout();
|
||||||
}
|
}
|
||||||
|
this._refreshPriceExtremeMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Alerts ──────────────────────────────────────────────────────────────
|
// ─── Alerts ──────────────────────────────────────────────────────────────
|
||||||
@@ -2896,7 +2951,11 @@ export class ChartManager {
|
|||||||
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
|
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
|
||||||
private readonly _viewportListeners = new Set<() => void>();
|
private readonly _viewportListeners = new Set<() => void>();
|
||||||
private _viewportLwcHooked = false;
|
private _viewportLwcHooked = false;
|
||||||
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
|
private _viewportNotifyQueued = false;
|
||||||
|
private readonly _lwcViewportHandler = (): void => {
|
||||||
|
this._notifyViewport();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
|
};
|
||||||
|
|
||||||
private _notifyViewport(): void {
|
private _notifyViewport(): void {
|
||||||
for (const cb of this._viewportListeners) {
|
for (const cb of this._viewportListeners) {
|
||||||
@@ -2904,6 +2963,18 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** LWC timeScale·좌표 변환 반영 후 오버레이 재그리기 (연속 패닝 중 cancel 로 redraw 누락 방지) */
|
||||||
|
private _scheduleNotifyViewport(): void {
|
||||||
|
if (this._viewportNotifyQueued) return;
|
||||||
|
this._viewportNotifyQueued = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this._viewportNotifyQueued = false;
|
||||||
|
this._notifyViewport();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _hookViewportLwc(): void {
|
private _hookViewportLwc(): void {
|
||||||
if (this._viewportLwcHooked) return;
|
if (this._viewportLwcHooked) return;
|
||||||
this._viewportLwcHooked = true;
|
this._viewportLwcHooked = true;
|
||||||
@@ -2923,6 +2994,7 @@ export class ChartManager {
|
|||||||
try {
|
try {
|
||||||
this.chart.timeScale().setVisibleLogicalRange(range);
|
this.chart.timeScale().setVisibleLogicalRange(range);
|
||||||
this._notifyViewport();
|
this._notifyViewport();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
} catch { /* 데이터 로드 전 */ }
|
} catch { /* 데이터 로드 전 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2931,9 +3003,10 @@ export class ChartManager {
|
|||||||
this._notifyPaneLayout();
|
this._notifyPaneLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */
|
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝·스크롤 중) */
|
||||||
notifyViewportChanged(): void {
|
notifyViewportChanged(): void {
|
||||||
this._notifyViewport();
|
this._notifyViewport();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
||||||
@@ -3590,7 +3663,10 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layout ───────────────────────────────────────────────────────────────
|
// ─── Layout ───────────────────────────────────────────────────────────────
|
||||||
fitContent(): void { this.chart.timeScale().fitContent(); }
|
fitContent(): void {
|
||||||
|
this.chart.timeScale().fitContent();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
|
}
|
||||||
|
|
||||||
/** 시간축 확대 (visible logical range 20% 축소) */
|
/** 시간축 확대 (visible logical range 20% 축소) */
|
||||||
zoomIn(): void {
|
zoomIn(): void {
|
||||||
@@ -4167,6 +4243,8 @@ export class ChartManager {
|
|||||||
this._mainPriceVisibleRange = next;
|
this._mainPriceVisibleRange = next;
|
||||||
try {
|
try {
|
||||||
ps.setVisibleRange(next);
|
ps.setVisibleRange(next);
|
||||||
|
this._notifyViewport();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
} catch { /* 범위 미설정 등 */ }
|
} catch { /* 범위 미설정 등 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4223,6 +4301,7 @@ export class ChartManager {
|
|||||||
try {
|
try {
|
||||||
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
|
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
|
||||||
this._notifyViewport();
|
this._notifyViewport();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
} catch { /* 데이터 로딩 전 호출 무시 */ }
|
} catch { /* 데이터 로딩 전 호출 무시 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4262,7 +4341,10 @@ export class ChartManager {
|
|||||||
* 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출
|
* 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출
|
||||||
* (ref: https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates)
|
* (ref: https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates)
|
||||||
*/
|
*/
|
||||||
scrollToRealTime(): void { this.chart.timeScale().scrollToRealTime(); }
|
scrollToRealTime(): void {
|
||||||
|
this.chart.timeScale().scrollToRealTime();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
|
}
|
||||||
|
|
||||||
/** 줌 툴: 두 X 픽셀 위치(canvas 내부)를 시간 범위로 변환하여 확대 */
|
/** 줌 툴: 두 X 픽셀 위치(canvas 내부)를 시간 범위로 변환하여 확대 */
|
||||||
zoomToXRange(x0: number, x1: number): void {
|
zoomToXRange(x0: number, x1: number): void {
|
||||||
@@ -4272,6 +4354,7 @@ export class ChartManager {
|
|||||||
try {
|
try {
|
||||||
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
|
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
|
||||||
this._notifyViewport();
|
this._notifyViewport();
|
||||||
|
this._scheduleNotifyViewport();
|
||||||
} catch { /* ok */ }
|
} catch { /* ok */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4430,6 +4513,7 @@ export class ChartManager {
|
|||||||
|
|
||||||
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
||||||
await this._refreshAllIndicatorsData();
|
await this._refreshAllIndicatorsData();
|
||||||
|
this._refreshPriceExtremeMarkers();
|
||||||
return newBars.length;
|
return newBars.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const STORAGE_KEY = 'gc_chartPriceExtremeLabels';
|
||||||
|
|
||||||
|
/** 실시간 차트 — 9·20일 신고가·신저가 라벨 표시 (기본 off) */
|
||||||
|
export function loadChartPriceExtremeLabelsVisible(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveChartPriceExtremeLabelsVisible(visible: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, visible ? '1' : '0');
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { OHLCVBar } from '../types';
|
||||||
|
import { formatUpbitKrwPrice } from './safeFormat';
|
||||||
|
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
|
||||||
|
import type { TradeSignalMarkerWithPrice } from './tradeSignalLabels';
|
||||||
|
|
||||||
|
const CHART_PERIODS = [9, 20] as const;
|
||||||
|
|
||||||
|
/** 직전 N봉 고가 중 최고값 (현재 봉 제외) — index t 에서 max(high[t-N]..high[t-1]) */
|
||||||
|
export function priorHighestHigh(bars: OHLCVBar[], index: number, period: number): number | null {
|
||||||
|
if (index < period) return null;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (let j = index - period; j < index; j++) {
|
||||||
|
max = Math.max(max, bars[j].high);
|
||||||
|
}
|
||||||
|
return max === -Infinity ? null : max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 직전 N봉 저가 중 최저값 (현재 봉 제외) */
|
||||||
|
export function priorLowestLow(bars: OHLCVBar[], index: number, period: number): number | null {
|
||||||
|
if (index < period) return null;
|
||||||
|
let min = Infinity;
|
||||||
|
for (let j = index - period; j < index; j++) {
|
||||||
|
min = Math.min(min, bars[j].low);
|
||||||
|
}
|
||||||
|
return min === Infinity ? null : min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 종가가 직전 N봉 최고가선을 상향 돌파(CROSS_UP) */
|
||||||
|
export function isNewHighCrossUp(bars: OHLCVBar[], index: number, period: number): boolean {
|
||||||
|
const thresh = priorHighestHigh(bars, index, period);
|
||||||
|
if (thresh == null) return false;
|
||||||
|
const close = bars[index].close;
|
||||||
|
if (close <= thresh) return false;
|
||||||
|
if (index === 0) return true;
|
||||||
|
const prevThresh = priorHighestHigh(bars, index - 1, period);
|
||||||
|
const prevClose = bars[index - 1].close;
|
||||||
|
if (prevThresh == null) return true;
|
||||||
|
return prevClose <= prevThresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 종가가 직전 N봉 최저가선을 하향 이탈(CROSS_DOWN) */
|
||||||
|
export function isNewLowCrossDown(bars: OHLCVBar[], index: number, period: number): boolean {
|
||||||
|
const thresh = priorLowestLow(bars, index, period);
|
||||||
|
if (thresh == null) return false;
|
||||||
|
const close = bars[index].close;
|
||||||
|
if (close >= thresh) return false;
|
||||||
|
if (index === 0) return true;
|
||||||
|
const prevThresh = priorLowestLow(bars, index - 1, period);
|
||||||
|
const prevClose = bars[index - 1].close;
|
||||||
|
if (prevThresh == null) return true;
|
||||||
|
return prevClose >= prevThresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실시간·백테스트 차트 — 9·20일 신고가·신저가 마커·라벨 */
|
||||||
|
export function computePriceExtremeChartMarkers(bars: OHLCVBar[]): TradeSignalMarkerWithPrice[] {
|
||||||
|
const out: TradeSignalMarkerWithPrice[] = [];
|
||||||
|
for (let i = 0; i < bars.length; i++) {
|
||||||
|
const bar = bars[i];
|
||||||
|
for (const period of CHART_PERIODS) {
|
||||||
|
if (isNewHighCrossUp(bars, i, period)) {
|
||||||
|
out.push({
|
||||||
|
time: bar.time,
|
||||||
|
position: 'aboveBar',
|
||||||
|
shape: 'arrowDown',
|
||||||
|
color: TRADE_SELL_COLOR,
|
||||||
|
text: `${period}일 신고가 : ${formatUpbitKrwPrice(bar.close)}`,
|
||||||
|
price: bar.close,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isNewLowCrossDown(bars, i, period)) {
|
||||||
|
out.push({
|
||||||
|
time: bar.time,
|
||||||
|
position: 'belowBar',
|
||||||
|
shape: 'arrowUp',
|
||||||
|
color: TRADE_BUY_COLOR,
|
||||||
|
text: `${period}일 신저가 : ${formatUpbitKrwPrice(bar.close)}`,
|
||||||
|
price: bar.close,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 전략편집기 — 복합지표·횡보 팔레트 → 템플릿 목록 행
|
||||||
|
*/
|
||||||
|
import type { PaletteItem } from './strategyPaletteStorage';
|
||||||
|
import type { SidewaysFilterPaletteEntry } from './sidewaysFilterPaletteStorage';
|
||||||
|
import {
|
||||||
|
isInflection33BuyPaletteValue,
|
||||||
|
isInflection33SellPaletteValue,
|
||||||
|
} from './inflection33Strategy';
|
||||||
|
import {
|
||||||
|
isNewHigh920BuyPaletteValue,
|
||||||
|
isNewLow920SellPaletteValue,
|
||||||
|
} from './priceExtremeBreakoutPair';
|
||||||
|
import { STABLE_STRATEGY_PALETTE_ITEMS } from './stableStrategyPairs';
|
||||||
|
|
||||||
|
export type TemplatePaletteSource =
|
||||||
|
| 'user'
|
||||||
|
| 'builtin'
|
||||||
|
| 'composite-palette'
|
||||||
|
| 'sideways-palette';
|
||||||
|
|
||||||
|
export type TemplatePaletteBadge = '내 템플릿' | '복합지표' | '횡보지표';
|
||||||
|
|
||||||
|
export interface PaletteTemplateRow {
|
||||||
|
key: string;
|
||||||
|
source: 'composite-palette' | 'sideways-palette';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
signal: 'buy' | 'sell';
|
||||||
|
badge: TemplatePaletteBadge;
|
||||||
|
paletteItem?: PaletteItem;
|
||||||
|
sidewaysFilterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateBadgeForSource(source: TemplatePaletteSource): TemplatePaletteBadge | null {
|
||||||
|
switch (source) {
|
||||||
|
case 'user': return '내 템플릿';
|
||||||
|
case 'composite-palette': return '복합지표';
|
||||||
|
case 'sideways-palette': return '횡보지표';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferCompositePaletteSignal(item: PaletteItem): 'buy' | 'sell' {
|
||||||
|
if (isNewHigh920BuyPaletteValue(item.value) || isInflection33BuyPaletteValue(item.value)) {
|
||||||
|
return 'buy';
|
||||||
|
}
|
||||||
|
if (isNewLow920SellPaletteValue(item.value) || isInflection33SellPaletteValue(item.value)) {
|
||||||
|
return 'sell';
|
||||||
|
}
|
||||||
|
const stable = STABLE_STRATEGY_PALETTE_ITEMS.find(s => s.value === item.value);
|
||||||
|
if (stable) return stable.mode;
|
||||||
|
if (item.value.endsWith('_BUY')) return 'buy';
|
||||||
|
if (item.value.endsWith('_SELL')) return 'sell';
|
||||||
|
return 'buy';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompositePaletteTemplateRows(
|
||||||
|
items: PaletteItem[],
|
||||||
|
hiddenIds: ReadonlySet<string>,
|
||||||
|
): PaletteTemplateRow[] {
|
||||||
|
return items
|
||||||
|
.filter(item => item.kind === 'composite')
|
||||||
|
.filter(item => !hiddenIds.has(`composite-palette:${item.id}`))
|
||||||
|
.map(item => ({
|
||||||
|
key: `composite-palette:${item.id}`,
|
||||||
|
source: 'composite-palette' as const,
|
||||||
|
label: item.label,
|
||||||
|
description: item.desc,
|
||||||
|
signal: inferCompositePaletteSignal(item),
|
||||||
|
badge: '복합지표' as const,
|
||||||
|
paletteItem: item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSidewaysPaletteTemplateRows(
|
||||||
|
items: SidewaysFilterPaletteEntry[],
|
||||||
|
hiddenIds: ReadonlySet<string>,
|
||||||
|
): PaletteTemplateRow[] {
|
||||||
|
return items
|
||||||
|
.filter(item => !hiddenIds.has(`sideways-palette:${item.id}`))
|
||||||
|
.map(item => ({
|
||||||
|
key: `sideways-palette:${item.id}`,
|
||||||
|
source: 'sideways-palette' as const,
|
||||||
|
label: item.label,
|
||||||
|
description: item.desc,
|
||||||
|
signal: item.mode === 'exclude' ? 'sell' : 'buy',
|
||||||
|
badge: '횡보지표' as const,
|
||||||
|
sidewaysFilterId: item.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user