실시간 차트 신고가,신저가 표시

This commit is contained in:
Macbook
2026-06-12 21:00:52 +09:00
parent d54dc6e2ff
commit 046bcc6379
15 changed files with 509 additions and 39 deletions
+12 -1
View File
@@ -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')}
+25 -1
View File
@@ -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,
+32 -3
View File
@@ -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"
+17
View File
@@ -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 && (
+37 -9
View File
@@ -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();
+22
View File
@@ -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,
); );
}; };
+8
View File
@@ -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
+16 -1
View File
@@ -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;
+92 -8
View File
@@ -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,
}));
}