From 046bcc6379e8c86b9bed1293e1061bd41ccf02bb Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 12 Jun 2026 21:00:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A0=EA=B3=A0=EA=B0=80,=EC=8B=A0=EC=A0=80?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/chart/ChartWorkspaceView.tsx | 13 ++- frontend/src/chart/useChartWorkspace.ts | 26 ++++- frontend/src/components/ChartRightToolbar.tsx | 35 +++++- frontend/src/components/ChartSlot.tsx | 17 +++ .../src/components/StrategyEditorPage.tsx | 46 ++++++-- frontend/src/components/Toolbar.tsx | 22 ++++ .../components/TradeSignalLabelOverlay.tsx | 49 ++++++--- frontend/src/components/TradingChart.tsx | 8 ++ .../strategyEditor/TemplatePaletteTab.tsx | 24 ++++- .../StrategyEvaluationChart.tsx | 1 + frontend/src/styles/strategyEditor.css | 17 ++- frontend/src/utils/ChartManager.ts | 100 ++++++++++++++++-- .../src/utils/chartPriceExtremeLabelsPref.ts | 16 +++ frontend/src/utils/priceExtremeChartEvents.ts | 83 +++++++++++++++ .../strategyEditorTemplatePaletteRows.ts | 91 ++++++++++++++++ 15 files changed, 509 insertions(+), 39 deletions(-) create mode 100644 frontend/src/utils/chartPriceExtremeLabelsPref.ts create mode 100644 frontend/src/utils/priceExtremeChartEvents.ts create mode 100644 frontend/src/utils/strategyEditorTemplatePaletteRows.ts diff --git a/frontend/src/chart/ChartWorkspaceView.tsx b/frontend/src/chart/ChartWorkspaceView.tsx index 39f4af0..2f6bb9f 100644 --- a/frontend/src/chart/ChartWorkspaceView.tsx +++ b/frontend/src/chart/ChartWorkspaceView.tsx @@ -138,6 +138,7 @@ export interface ChartWorkspaceViewProps { chartCandleAreaPriceLabels: boolean; chartSeriesPriceLabels: boolean; chartVolumeVisible: boolean; + chartPriceExtremeLabelsVisible: boolean; chartPaneSeparator: ChartPaneSeparatorOptions; chartCrosshairInfoVisible: boolean; chartHoverToolbarVisible: boolean; @@ -291,6 +292,7 @@ export interface ChartWorkspaceViewProps { handleExpandIndicator: (id: string) => void; handleRestoreIndicators: () => void; handleToggleCandleOverlay: (key: ChartOverlayToggleKey) => void; + handleTogglePriceExtremeLabels: () => void; handleCustomOverlayActiveChange: (active: boolean) => void; handleCustom1OverlayActiveChange: (active: boolean) => void; handleSaveCustomOverlaySelection: (slot: ChartCustomOverlaySlotId, selection: ChartCustomOverlaySelection) => void; @@ -325,7 +327,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { overlayVisibilityRef, candleOverlayToggles, customOverlaySelection, custom1OverlaySelection, customOverlaySettingsSlot, customOverlayActive, custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels, - chartVolumeVisible, chartPaneSeparator, chartCrosshairInfoVisible, + chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible, chartHoverToolbarVisible, chartLiveReceiveHighlight, chartRealtimeSource, displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, wsStatus, bars, barsMarket, barsMarketRef, chartMountKey, chartLiveReadyRef, pendingRealtimeBarRef, @@ -363,6 +365,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot, handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane, handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay, + handleTogglePriceExtremeLabels, handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange, handleSaveCustomOverlaySelection, syncCustomOverlaysToManager, handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount, @@ -505,6 +508,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { chartLiveQuote={liveQuote} chartQuoteLoading={isLoading} chartQuoteError={error} + priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible} + onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels} /> {isMobile && (showMarketPanel || mobileRightOpen) && ( @@ -616,6 +621,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { chartPaneSeparator={chartPaneSeparator} chartCrosshairInfoVisible={chartCrosshairInfoVisible} chartHoverToolbarVisible={chartHoverToolbarVisible} + chartPriceExtremeLabelsVisible={chartPriceExtremeLabelsVisible} + onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -659,6 +666,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { chartPaneSeparator={chartPaneSeparator} chartCrosshairInfoVisible={chartCrosshairInfoVisible} chartHoverToolbarVisible={chartHoverToolbarVisible} + chartPriceExtremeLabelsVisible={chartPriceExtremeLabelsVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -723,6 +731,7 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels); const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); + mgr.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible); mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); mgr.setChartOverlayVisibility(overlayVisibilityRef.current); @@ -773,6 +782,8 @@ function ChartWorkspaceViewInner(props: ChartWorkspaceViewProps) { paneSeparatorOptions={chartPaneSeparator} candleOverlayToggles={candleOverlayToggles} onToggleCandleOverlay={handleToggleCandleOverlay} + priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible} + onTogglePriceExtremeLabels={handleTogglePriceExtremeLabels} customOverlayActive={customOverlayActive} onCustomOverlayActiveChange={handleCustomOverlayActiveChange} onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')} diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts index 5c7c688..9d256b3 100644 --- a/frontend/src/chart/useChartWorkspace.ts +++ b/frontend/src/chart/useChartWorkspace.ts @@ -54,6 +54,10 @@ import { DEFAULT_CHART_OVERLAY_VISIBILITY, listChartOverlayToggleItems, } from '../utils/chartOverlayVisibility'; +import { + loadChartPriceExtremeLabelsVisible, + saveChartPriceExtremeLabelsVisible, +} from '../utils/chartPriceExtremeLabelsPref'; import { type ChartCustomOverlaySelection, type ChartCustomOverlaySlotId, @@ -1396,6 +1400,15 @@ export function useChartWorkspace({ [overlayVisibility], ); + const [chartPriceExtremeLabelsVisible, setChartPriceExtremeLabelsVisible] = useState( + () => loadChartPriceExtremeLabelsVisible(), + ); + + useEffect(() => { + managerRef.current?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible); + slotRefs.current.forEach(slot => slot?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible)); + }, [chartPriceExtremeLabelsVisible]); + const [customOverlaySelection, setCustomOverlaySelection] = useState( () => 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 배열에서 이동. * overlay/markers 지표는 건드리지 않고 비오버레이 지표 위치만 변경됨. @@ -1908,7 +1931,7 @@ export function useChartWorkspace({ candleOverlayToggles, customOverlaySelection, custom1OverlaySelection, activeCustomOverlaySlot, customOverlaySettingsSlot, customOverlayActive, custom1OverlayActive, chartCandleAreaPriceLabels, chartSeriesPriceLabels, - chartVolumeVisible, chartPaneSeparator, chartCrosshairInfoVisible, + chartVolumeVisible, chartPriceExtremeLabelsVisible, chartPaneSeparator, chartCrosshairInfoVisible, chartHoverToolbarVisible, chartLiveReceiveHighlight, chartRealtimeSource: (chartRealtimeSource ?? 'BACKEND_STOMP') as 'BACKEND_STOMP' | 'UPBIT_DIRECT', displayTimezone, chartTimeFormat, chartLegendOptions, useUpbit, @@ -1948,6 +1971,7 @@ export function useChartWorkspace({ handleDuplicateIndicator, handleRemoveIndicatorById, handleScreenshot, handleReorderIndicators, handleMergeIndicators, handleSplitIndicatorPane, handleExpandIndicator, handleRestoreIndicators, handleToggleCandleOverlay, + handleTogglePriceExtremeLabels, handleCustomOverlayActiveChange, handleCustom1OverlayActiveChange, handleSaveCustomOverlaySelection, syncCustomOverlaysToManager, handleCandlesReady, applyBacktestMarkersWhenReady, btRun, refreshPaperAccount, diff --git a/frontend/src/components/ChartRightToolbar.tsx b/frontend/src/components/ChartRightToolbar.tsx index 3c88244..cffcffd 100644 --- a/frontend/src/components/ChartRightToolbar.tsx +++ b/frontend/src/components/ChartRightToolbar.tsx @@ -45,6 +45,9 @@ export interface ChartRightToolbarProps { onOpenCustom1OverlaySettings?: () => void; /** true — 캔들 pane 상단 오버레이 토글만 (미니 차트 등) */ overlayControlsOnly?: boolean; + /** 9·20일 신고가·신저가 라벨 표시 (실시간 차트) */ + priceExtremeLabelsVisible?: boolean; + onTogglePriceExtremeLabels?: () => void; } const BTN_SIZE = 32; @@ -150,6 +153,14 @@ const IconIchimoku = () => ( ); +/** 신고가(상향)·신저가(하향) */ +const IconPriceExtreme = () => ( + + + + +); + const OVERLAY_ICONS: Record = { ma: , bollinger: , @@ -360,6 +371,8 @@ const ChartRightToolbar: React.FC = ({ onExpand, onRestore, focusedId, onDragStart, onToggleHidden, onSettings, overlayControlsOnly = false, + priceExtremeLabelsVisible = false, + onTogglePriceExtremeLabels, }) => { const [paneLayouts, setPaneLayouts] = useState>([]); const [paneItems, setPaneItems] = useState>([]); @@ -418,9 +431,11 @@ const ChartRightToolbar: React.FC = ({ const candlePane = paneLayouts.find(l => l.paneIndex === 0); const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay); + const showPriceExtremeBtn = !!(candlePane && onTogglePriceExtremeLabels); + const showOverlayCluster = showOverlayIcons || showPriceExtremeBtn; if (paused || chartHeight <= 0) return null; - if (overlayControlsOnly && !showOverlayIcons) return null; + if (overlayControlsOnly && !showOverlayCluster) return null; const innerHeight = paneLayouts.reduce( (max, l) => Math.max(max, l.topY + l.height), @@ -475,12 +490,12 @@ const ChartRightToolbar: React.FC = ({ )} - {showOverlayIcons && ( + {showOverlayCluster && (
- {candleOverlayToggles!.map((item, idx) => ( + {candleOverlayToggles?.map((item, idx) => ( ))} + {showPriceExtremeBtn && ( + + )} {showCustomBtn && ( + {/* 9·20일 신고가·신저가 표시 */} + {onTogglePriceExtremeLabels && ( + + )} + {/* 그리드 토글 */}
diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index b1b81be..c603b87 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -481,6 +481,7 @@ const StrategyEvaluationChart: React.FC = ({ crosshairInfoVisible={false} showHoverToolbar={false} showChartRightToolbar={false} + showCandlePaneControls={false} /> {chartMgr && ( void>(); private compareSeriesMap = new Map>(); /** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */ @@ -515,7 +519,7 @@ export class ChartManager { panesInit[0].setStretchFactor(1); } - this._reapplyAllPatternMarkers(); + this._refreshPriceExtremeMarkers(); // 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림 if (this._auxIndicatorOnlyLayout && bars.length <= 21) { @@ -550,7 +554,7 @@ export class ChartManager { } else { ts.fitContent(); } - this._notifyViewport(); + this._scheduleNotifyViewport(); return; } @@ -563,7 +567,7 @@ export class ChartManager { from: bars[startIdx].time as Time, to: to as Time, }); - this._notifyViewport(); + this._scheduleNotifyViewport(); } private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi { @@ -1777,7 +1781,14 @@ export class ChartManager { color: m.color, 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) { try { if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) { @@ -1876,9 +1887,50 @@ export class ChartManager { return [ ...this.backtestMarkers.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 { this._tradeSignalLabelListeners.add(cb); return () => { this._tradeSignalLabelListeners.delete(cb); }; @@ -2281,6 +2333,7 @@ export class ChartManager { // 4) 보조지표 마지막 포인트 스로틀 갱신 this._scheduleIndicatorLastUpdate(); + this._refreshPriceExtremeMarkers(); } /** @@ -2621,6 +2674,7 @@ export class ChartManager { if (this._pendingIndUpdate) { this._scheduleIndicatorLastUpdate(); } + this._refreshPriceExtremeMarkers(); } /** @@ -2652,6 +2706,7 @@ export class ChartManager { if (!this._indicatorLoadStale(loadGen)) { this._finalizeIndicatorPaneLayout(); } + this._refreshPriceExtremeMarkers(); } // ─── Alerts ────────────────────────────────────────────────────────────── @@ -2896,7 +2951,11 @@ export class ChartManager { /** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */ private readonly _viewportListeners = new Set<() => void>(); private _viewportLwcHooked = false; - private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); }; + private _viewportNotifyQueued = false; + private readonly _lwcViewportHandler = (): void => { + this._notifyViewport(); + this._scheduleNotifyViewport(); + }; private _notifyViewport(): void { 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 { if (this._viewportLwcHooked) return; this._viewportLwcHooked = true; @@ -2923,6 +2994,7 @@ export class ChartManager { try { this.chart.timeScale().setVisibleLogicalRange(range); this._notifyViewport(); + this._scheduleNotifyViewport(); } catch { /* 데이터 로드 전 */ } } @@ -2931,9 +3003,10 @@ export class ChartManager { this._notifyPaneLayout(); } - /** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */ + /** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝·스크롤 중) */ notifyViewportChanged(): void { this._notifyViewport(); + this._scheduleNotifyViewport(); } /** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */ @@ -3590,7 +3663,10 @@ export class ChartManager { } // ─── Layout ─────────────────────────────────────────────────────────────── - fitContent(): void { this.chart.timeScale().fitContent(); } + fitContent(): void { + this.chart.timeScale().fitContent(); + this._scheduleNotifyViewport(); + } /** 시간축 확대 (visible logical range 20% 축소) */ zoomIn(): void { @@ -4167,6 +4243,8 @@ export class ChartManager { this._mainPriceVisibleRange = next; try { ps.setVisibleRange(next); + this._notifyViewport(); + this._scheduleNotifyViewport(); } catch { /* 범위 미설정 등 */ } } @@ -4223,6 +4301,7 @@ export class ChartManager { try { this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time }); this._notifyViewport(); + this._scheduleNotifyViewport(); } catch { /* 데이터 로딩 전 호출 무시 */ } } @@ -4262,7 +4341,10 @@ export class ChartManager { * 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출 * (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 내부)를 시간 범위로 변환하여 확대 */ zoomToXRange(x0: number, x1: number): void { @@ -4272,6 +4354,7 @@ export class ChartManager { try { this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time }); this._notifyViewport(); + this._scheduleNotifyViewport(); } catch { /* ok */ } } @@ -4430,6 +4513,7 @@ export class ChartManager { // 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요) await this._refreshAllIndicatorsData(); + this._refreshPriceExtremeMarkers(); return newBars.length; } diff --git a/frontend/src/utils/chartPriceExtremeLabelsPref.ts b/frontend/src/utils/chartPriceExtremeLabelsPref.ts new file mode 100644 index 0000000..297d11e --- /dev/null +++ b/frontend/src/utils/chartPriceExtremeLabelsPref.ts @@ -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 */ } +} diff --git a/frontend/src/utils/priceExtremeChartEvents.ts b/frontend/src/utils/priceExtremeChartEvents.ts new file mode 100644 index 0000000..4ea3b5d --- /dev/null +++ b/frontend/src/utils/priceExtremeChartEvents.ts @@ -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; +} diff --git a/frontend/src/utils/strategyEditorTemplatePaletteRows.ts b/frontend/src/utils/strategyEditorTemplatePaletteRows.ts new file mode 100644 index 0000000..e2c7a2d --- /dev/null +++ b/frontend/src/utils/strategyEditorTemplatePaletteRows.ts @@ -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, +): 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, +): 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, + })); +}