diff --git a/backend/src/main/java/com/goldenchart/controller/AdminController.java b/backend/src/main/java/com/goldenchart/controller/AdminController.java index 0293d04..3f92a05 100644 --- a/backend/src/main/java/com/goldenchart/controller/AdminController.java +++ b/backend/src/main/java/com/goldenchart/controller/AdminController.java @@ -14,7 +14,7 @@ import java.util.Map; @RequestMapping("/admin") @RequiredArgsConstructor public class AdminController { - + private final AdminService adminService; private final RolePermissionService rolePermissionService; diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 5ba575e..19cf123 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ "minWidth": 960, "minHeight": 640, "center": true, - "visible": true + "visible": true, + "acceptFirstMouse": true } ], "security": { diff --git a/desktop/src/bridge/pickerWindow.ts b/desktop/src/bridge/pickerWindow.ts index 5a895fa..22f8ac8 100644 --- a/desktop/src/bridge/pickerWindow.ts +++ b/desktop/src/bridge/pickerWindow.ts @@ -41,6 +41,7 @@ export async function openWidgetPickerWindow(params: { center: true, decorations: true, alwaysOnTop: true, + acceptFirstMouse: true, backgroundColor: '#1a1b26', }); diff --git a/desktop/src/bridge/widgetWindows.ts b/desktop/src/bridge/widgetWindows.ts index d35b8ad..e2123c4 100644 --- a/desktop/src/bridge/widgetWindows.ts +++ b/desktop/src/bridge/widgetWindows.ts @@ -37,6 +37,7 @@ export async function openWidgetWindow(instance: FloatingWidgetInstance): Promis minHeight: 240, decorations: true, resizable: true, + acceptFirstMouse: true, center: false, x: instance.position.x, y: instance.position.y, diff --git a/frontend/src/components/TopMenuBarNavGroups.tsx b/frontend/src/components/TopMenuBarNavGroups.tsx index 3ad7b46..cedc92d 100644 --- a/frontend/src/components/TopMenuBarNavGroups.tsx +++ b/frontend/src/components/TopMenuBarNavGroups.tsx @@ -102,7 +102,11 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({ className={`tmb-nav-btn tmb-nav-group-btn${isActive ? ' tmb-nav-btn--active' : ''}${isOpen ? ' tmb-nav-group-btn--open' : ''}`} aria-expanded={isOpen} aria-haspopup="menu" - onClick={() => setOpenGroupId(prev => (prev === group.id ? null : group.id))} + onPointerDown={e => { + e.preventDefault(); + e.stopPropagation(); + setOpenGroupId(prev => (prev === group.id ? null : group.id)); + }} > {group.icon} {group.label} diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index 1fad8dd..db9749a 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -37,6 +37,8 @@ import { ensurePaperChartOverlays, } from '../../utils/strategyToChartIndicators'; import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility'; +import { WIDGET_CHART_TF_OPTIONS } from '../../utils/chartWidgetTimeframes'; +import WidgetTimeframePicker from '../../widgets/panels/WidgetTimeframePicker'; interface Props { symbol: string; @@ -61,6 +63,9 @@ interface Props { focusTimeSec?: number | null; /** 하단 확대·축소·좌우 이동 툴바 (위젯 등에서는 false) */ showHoverToolbar?: boolean; + /** 종목 우측 시간봉 선택 (위젯 전략 차트) */ + timeframeOptions?: Timeframe[]; + onTimeframeChange?: (tf: Timeframe) => void; } const noop = () => {}; @@ -117,6 +122,8 @@ const BacktestAnalysisChart: React.FC = ({ reportDisabled = false, focusTimeSec = null, showHoverToolbar = true, + timeframeOptions, + onTimeframeChange, }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); @@ -414,7 +421,16 @@ const BacktestAnalysisChart: React.FC = ({
{koName} - {tfKo} + {onTimeframeChange ? ( + + ) : ( + {tfKo} + )} {chartTypeKo}
diff --git a/frontend/src/components/paper/PaperAnalysisChart.tsx b/frontend/src/components/paper/PaperAnalysisChart.tsx index 04180bf..c3d98e1 100644 --- a/frontend/src/components/paper/PaperAnalysisChart.tsx +++ b/frontend/src/components/paper/PaperAnalysisChart.tsx @@ -9,9 +9,10 @@ import { } from '../../utils/backendApi'; import { loadAnalysisCandles } from '../../utils/analysisChartData'; import { normalizeChartTimeframe } from '../../utils/backtestUiUtils'; +import { mergeWidgetTimeframeOptions } from '../../utils/chartWidgetTimeframes'; import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup'; -import type { OHLCVBar, Theme } from '../../types'; +import type { OHLCVBar, Theme, Timeframe } from '../../types'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { DEFAULT_PAPER_OVERLAY_VISIBILITY, @@ -28,6 +29,8 @@ interface Props { theme?: Theme; /** 위젯 임베드 시 하단 줌/패닝 툴바 숨김 */ showHoverToolbar?: boolean; + /** 위젯 — 종목 우측 시간봉 선택 */ + enableTimeframePicker?: boolean; } /** @@ -35,12 +38,16 @@ interface Props { * 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산. */ const PaperAnalysisChart: React.FC = ({ - market, strategyId, theme = 'dark', showHoverToolbar = true, + market, + strategyId, + theme = 'dark', + showHoverToolbar = true, + enableTimeframePicker = false, }) => { const { getParams } = useIndicatorSettings(); const [signals, setSignals] = useState([]); const [strategy, setStrategy] = useState(); - const [timeframe, setTimeframe] = useState('1h'); + const [timeframe, setTimeframe] = useState('1h'); const [running, setRunning] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [error, setError] = useState(null); @@ -53,8 +60,15 @@ const PaperAnalysisChart: React.FC = ({ const historyDebounceRef = useRef | null>(null); const strategyRef = useRef(strategy); const strategyIdRef = useRef(strategyId); + const timeframeRef = useRef(timeframe); strategyRef.current = strategy; strategyIdRef.current = strategyId; + timeframeRef.current = timeframe; + + const timeframeOptions = useMemo( + () => mergeWidgetTimeframeOptions(strategy), + [strategy], + ); useEffect(() => { if (!strategyId) { @@ -71,6 +85,11 @@ const PaperAnalysisChart: React.FC = ({ return () => { cancelled = true; }; }, [strategyId]); + useEffect(() => { + if (!strategy) return; + setTimeframe(normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy))); + }, [strategyId, strategy?.id]); + const runSignalsForBars = useCallback(async ( bars: OHLCVBar[], tf: string, @@ -139,8 +158,7 @@ const PaperAnalysisChart: React.FC = ({ return; } let cancelled = false; - const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy)); - setTimeframe(tf); + const tf = normalizeChartTimeframe(timeframe); setError(null); void (async () => { @@ -162,7 +180,7 @@ const PaperAnalysisChart: React.FC = ({ cancelled = true; ++backtestGenRef.current; }; - }, [market, strategyId, strategy, toTimeSec, runSignalsForBars]); + }, [market, strategyId, strategy, timeframe, toTimeSec, runSignalsForBars]); const handleToggleOverlay = useCallback((key: PaperOverlayToggleKey) => { setOverlayVisibility(prev => ({ ...prev, [key]: !prev[key] })); @@ -172,7 +190,7 @@ const PaperAnalysisChart: React.FC = ({ const strat = strategyRef.current; const sid = strategyIdRef.current; if (!strat || !sid) return; - const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strat)); + const tf = normalizeChartTimeframe(timeframeRef.current); if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current); historyDebounceRef.current = setTimeout(() => { historyDebounceRef.current = null; @@ -180,6 +198,10 @@ const PaperAnalysisChart: React.FC = ({ }, HISTORY_BACKTEST_DEBOUNCE_MS); }, [runSignalsForBars]); + const handleTimeframeChange = useCallback((tf: Timeframe) => { + setTimeframe(tf); + }, []); + useEffect(() => () => { if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current); }, []); @@ -195,6 +217,7 @@ const PaperAnalysisChart: React.FC = ({ } const statusBusy = running || historyLoading; + const chartTimeframe = normalizeChartTimeframe(timeframe); return (
@@ -208,7 +231,7 @@ const PaperAnalysisChart: React.FC = ({ )} = ({ overlayVisibility={overlayVisibility} onToggleOverlay={handleToggleOverlay} showHoverToolbar={showHoverToolbar} + timeframeOptions={enableTimeframePicker ? timeframeOptions : undefined} + onTimeframeChange={enableTimeframePicker ? handleTimeframeChange : undefined} />
); diff --git a/frontend/src/components/paper/PaperMiniChart.tsx b/frontend/src/components/paper/PaperMiniChart.tsx index 2b8bf6c..e9bf868 100644 --- a/frontend/src/components/paper/PaperMiniChart.tsx +++ b/frontend/src/components/paper/PaperMiniChart.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts'; import type { Theme, Timeframe } from '../../types'; import { fetchUpbitCandles } from '../../utils/upbitApi'; - -const TF_OPTIONS: Timeframe[] = ['1m', '5m', '10m', '15m', '1h', '4h', '1D']; +import { WIDGET_CHART_TF_OPTIONS } from '../../utils/chartWidgetTimeframes'; interface Props { market: string; @@ -11,6 +10,10 @@ interface Props { wsLabel?: string; timeframe?: Timeframe; onTimeframeChange?: (tf: Timeframe) => void; + /** 위젯 헤더에 시간봉이 있을 때 내부 행 숨김 */ + hideTimeframeRow?: boolean; + /** 위젯 임베드 시 상단 타이틀·배지 숨김 */ + hideChartHead?: boolean; } function readChartTheme(container: HTMLElement | null) { @@ -33,6 +36,8 @@ const PaperMiniChart: React.FC = ({ wsLabel = 'WebSocket <100ms', timeframe: timeframeProp, onTimeframeChange, + hideTimeframeRow = false, + hideChartHead = false, }) => { const containerRef = useRef(null); const chartRef = useRef(null); @@ -159,24 +164,28 @@ const PaperMiniChart: React.FC = ({ return (
-
- 실시간 분석 차트 - {wsLabel} 실시간 데이터 반영 -
+ {!hideChartHead && ( +
+ 실시간 분석 차트 + {wsLabel} 실시간 데이터 반영 +
+ )}
{loading &&
차트 로딩…
} -
- {TF_OPTIONS.map(tf => ( - - ))} -
+ {!hideTimeframeRow && ( +
+ {WIDGET_CHART_TF_OPTIONS.map(tf => ( + + ))} +
+ )}
); }; diff --git a/frontend/src/components/widgetDashboard/WidgetSlotView.tsx b/frontend/src/components/widgetDashboard/WidgetSlotView.tsx index e098ab2..6ce2e6b 100644 --- a/frontend/src/components/widgetDashboard/WidgetSlotView.tsx +++ b/frontend/src/components/widgetDashboard/WidgetSlotView.tsx @@ -47,7 +47,7 @@ const WidgetSlotView: React.FC = ({
)} -
+
{Host ? ( ) : ( diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index 77e9afa..dadb432 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -1507,6 +1507,12 @@ display: flex; align-items: center; gap: 8px; + min-width: 0; +} + +.btd-analysis-toolbar-right { + flex-shrink: 1; + justify-content: flex-end; } .btd-analysis-select--static { @@ -1519,6 +1525,14 @@ background: color-mix(in srgb, var(--btd-bg) 60%, transparent); } +.btd-analysis-tf-row { + flex: 1; + min-width: 0; + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: thin; +} + .btd-analysis-tool { width: 28px; height: 28px; diff --git a/frontend/src/styles/widgetDashboard.css b/frontend/src/styles/widgetDashboard.css index d804c9b..b1582f3 100644 --- a/frontend/src/styles/widgetDashboard.css +++ b/frontend/src/styles/widgetDashboard.css @@ -164,16 +164,53 @@ position: absolute; top: 6px; right: 6px; - z-index: 3; + z-index: 10; width: 22px; height: 22px; border: 1px solid var(--wd-slot-border); border-radius: 4px; - background: color-mix(in srgb, var(--bg2) 88%, transparent); + background: color-mix(in srgb, var(--bg2) 92%, transparent); color: var(--se-text-muted); font-size: 0.65rem; cursor: pointer; - opacity: 0.55; + opacity: 0.85; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} + +/* 위젯 ✕ 버튼 — 차트·패널 헤더 텍스트와 겹침 방지 */ +.wd-slot-body--has-clear .wd-candle-chart-header, +.wd-slot-body--has-clear .btd-analysis-toolbar, +.wd-slot-body--has-clear .wd-panel-header, +.wd-slot-body--has-clear .wd-chart-symbol-tf-bar { + padding-right: 36px; + box-sizing: border-box; +} + +.wd-slot-body--has-clear .btd-analysis-toolbar-left { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.wd-slot-body--has-clear .btd-analysis-toolbar-right { + flex-shrink: 1; + min-width: 0; + max-width: 42%; + overflow: hidden; +} + +.wd-slot-body--has-clear .btd-analysis-count { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wd-slot-body--has-clear .wd-candle-chart-header-actions { + flex-shrink: 1; + min-width: 0; + flex-wrap: wrap; + justify-content: flex-end; + max-width: min(100%, 220px); } .wd-slot-overlay-clear:hover { @@ -447,6 +484,33 @@ display: flex; flex-wrap: wrap; gap: 4px; + flex: 1; + min-width: 0; +} + +.wd-chart-symbol-tf-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 4px 10px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 55%, transparent); +} + +.wd-chart-symbol-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--se-text, var(--text)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(220px, 42%); + flex-shrink: 0; +} + +.wd-chart-symbol-tf-bar .wd-candle-chart-tf-row { + flex: 1; + justify-content: flex-start; } .wd-candle-chart-tf { diff --git a/frontend/src/utils/chartWidgetTimeframes.ts b/frontend/src/utils/chartWidgetTimeframes.ts new file mode 100644 index 0000000..d0f5fd0 --- /dev/null +++ b/frontend/src/utils/chartWidgetTimeframes.ts @@ -0,0 +1,23 @@ +import type { Timeframe } from '../types'; +import type { StrategyDto } from './backendApi'; +import { resolveStrategyTimeframes } from './strategyToChartIndicators'; + +/** 위젯 차트 — 종목 우측 시간봉 선택 기본 옵션 */ +export const WIDGET_CHART_TF_OPTIONS: Timeframe[] = [ + '1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D', +]; + +const TF_ORDER = new Map( + WIDGET_CHART_TF_OPTIONS.map((tf, i) => [tf, i]), +); + +/** 전략 DSL 시간봉 + 위젯 기본 옵션 병합 (중복 제거, 표준 순서) */ +export function mergeWidgetTimeframeOptions(strategy?: StrategyDto): Timeframe[] { + const merged = new Set([...WIDGET_CHART_TF_OPTIONS, ...resolveStrategyTimeframes(strategy)]); + return [...merged].sort((a, b) => { + const ai = TF_ORDER.get(a) ?? 999; + const bi = TF_ORDER.get(b) ?? 999; + if (ai !== bi) return ai - bi; + return a.localeCompare(b); + }); +} diff --git a/frontend/src/widgets/WidgetCandleChartPanel.tsx b/frontend/src/widgets/WidgetCandleChartPanel.tsx index c910aa5..b0f5a6c 100644 --- a/frontend/src/widgets/WidgetCandleChartPanel.tsx +++ b/frontend/src/widgets/WidgetCandleChartPanel.tsx @@ -22,8 +22,9 @@ import type { ChartManager } from '../utils/ChartManager'; import { buildVirtualTradingChartIndicators, resolveStrategyPrimaryTimeframe, - resolveStrategyTimeframes, } from '../utils/strategyToChartIndicators'; +import { mergeWidgetTimeframeOptions } from '../utils/chartWidgetTimeframes'; +import WidgetTimeframePicker from './panels/WidgetTimeframePicker'; import { timeframeToCandleType } from '../utils/chartCandleType'; import { DISPLAY_COUNT } from '../hooks/useUpbitData'; import { getKoreanName } from '../utils/marketNameCache'; @@ -76,7 +77,7 @@ const WidgetCandleChartPanel: React.FC = ({ chartRealtimeSource = 'BACKEND_STOMP', }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); - const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]); + const tfOptions = useMemo(() => mergeWidgetTimeframeOptions(strategy), [strategy]); const [timeframe, setTimeframe] = useState( () => resolveStrategyPrimaryTimeframe(strategy), ); @@ -402,20 +403,11 @@ const WidgetCandleChartPanel: React.FC = ({ {market} - {tfOptions.length > 1 && ( -
- {tfOptions.map(tf => ( - - ))} -
- )} +
diff --git a/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx b/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx index 0206c38..ca67753 100644 --- a/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx +++ b/frontend/src/widgets/hosts/MiniChartWidgetHost.tsx @@ -1,19 +1,39 @@ -import React from 'react'; +import React, { useState } from 'react'; import PaperMiniChart from '../../components/paper/PaperMiniChart'; import { useWidgetDashboard } from '../WidgetDashboardContext'; import WidgetPanelHeader from '../panels/WidgetPanelHeader'; +import WidgetTimeframePicker from '../panels/WidgetTimeframePicker'; +import { WIDGET_CHART_TF_OPTIONS } from '../../utils/chartWidgetTimeframes'; import { resolveName } from '../widgetMarketUtils'; +import type { Timeframe } from '../../types'; const MiniChartWidgetHost: React.FC = () => { const { selectedMarket, theme, tickers } = useWidgetDashboard(); const ticker = tickers.get(selectedMarket); + const [timeframe, setTimeframe] = useState('1h'); return (
-
{resolveName(selectedMarket, ticker)} · {selectedMarket}
+
+ + {resolveName(selectedMarket, ticker)} · {selectedMarket} + + +
- +
); diff --git a/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx b/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx index c9d8bf4..4fc4dcb 100644 --- a/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx +++ b/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx @@ -23,6 +23,7 @@ const StrategyChartWidgetHost: React.FC<{ config?: Record }> = strategyId={strategyId} theme={theme} showHoverToolbar={false} + enableTimeframePicker />
); diff --git a/frontend/src/widgets/panels/WidgetTimeframePicker.tsx b/frontend/src/widgets/panels/WidgetTimeframePicker.tsx new file mode 100644 index 0000000..56d61a2 --- /dev/null +++ b/frontend/src/widgets/panels/WidgetTimeframePicker.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type { Timeframe } from '../../types'; + +interface Props { + options: Timeframe[]; + value: Timeframe; + onChange: (tf: Timeframe) => void; + className?: string; + 'aria-label'?: string; +} + +/** 위젯 차트 — 종목 우측 시간봉 버튼 그룹 */ +const WidgetTimeframePicker: React.FC = ({ + options, + value, + onChange, + className = '', + 'aria-label': ariaLabel = '시간봉', +}) => ( +
+ {options.map(tf => ( + + ))} +
+); + +export default WidgetTimeframePicker;