From 7a2f65088c552d395dd1c48202f9a598270af445 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 01:59:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=84=EC=A0=AF=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backtest/BacktestAnalysisChart.tsx | 5 +- .../components/paper/PaperAnalysisChart.tsx | 7 +- frontend/src/styles/floatingWidget.css | 27 ++++ frontend/src/styles/widgetDashboard.css | 58 +++++++++ .../widgets/hooks/useWidgetBacktestSignals.ts | 123 ++++++++++++++++++ .../hooks/useWidgetSelectedStrategy.ts | 45 +++++++ .../hosts/BacktestSignalsWidgetHost.tsx | 33 +++++ .../hosts/IndicatorStatusWidgetHost.tsx | 38 ++++++ .../widgets/hosts/SignalMatchWidgetHost.tsx | 42 ++++++ .../widgets/hosts/SignalSummaryWidgetHost.tsx | 31 +++++ .../widgets/hosts/StrategyChartWidgetHost.tsx | 1 + .../hosts/StrategyConditionWidgetHost.tsx | 29 +++++ frontend/src/widgets/widgetRegistry.ts | 49 ++++++- 13 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 frontend/src/widgets/hooks/useWidgetBacktestSignals.ts create mode 100644 frontend/src/widgets/hooks/useWidgetSelectedStrategy.ts create mode 100644 frontend/src/widgets/hosts/BacktestSignalsWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/IndicatorStatusWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/SignalMatchWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/SignalSummaryWidgetHost.tsx create mode 100644 frontend/src/widgets/hosts/StrategyConditionWidgetHost.tsx diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index fb07f4e..1fad8dd 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -59,6 +59,8 @@ interface Props { reportDisabled?: boolean; /** 거래 로그 클릭 시 해당 시점으로 차트 이동 */ focusTimeSec?: number | null; + /** 하단 확대·축소·좌우 이동 툴바 (위젯 등에서는 false) */ + showHoverToolbar?: boolean; } const noop = () => {}; @@ -114,6 +116,7 @@ const BacktestAnalysisChart: React.FC = ({ onOpenReport, reportDisabled = false, focusTimeSec = null, + showHoverToolbar = true, }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); @@ -474,7 +477,7 @@ const BacktestAnalysisChart: React.FC = ({ candleAreaPriceLabelsEnabled={priceLabels} indicatorAreaPriceLabelsEnabled={priceLabels} seriesPriceLabelsEnabled={priceLabels} - showHoverToolbar + showHoverToolbar={showHoverToolbar} candleOverlayToggles={candleOverlayToggles} onToggleCandleOverlay={handleToggleCandleOverlay} showCandleOverlayControls diff --git a/frontend/src/components/paper/PaperAnalysisChart.tsx b/frontend/src/components/paper/PaperAnalysisChart.tsx index 24cadeb..04180bf 100644 --- a/frontend/src/components/paper/PaperAnalysisChart.tsx +++ b/frontend/src/components/paper/PaperAnalysisChart.tsx @@ -26,13 +26,17 @@ interface Props { market: string; strategyId?: number | null; theme?: Theme; + /** 위젯 임베드 시 하단 줌/패닝 툴바 숨김 */ + showHoverToolbar?: boolean; } /** * 모의투자 차트 탭 — 전략 백테스트 시그널 + 캔들·보조지표 (백테스팅 분석 차트와 동일) * 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산. */ -const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark' }) => { +const PaperAnalysisChart: React.FC = ({ + market, strategyId, theme = 'dark', showHoverToolbar = true, +}) => { const { getParams } = useIndicatorSettings(); const [signals, setSignals] = useState([]); const [strategy, setStrategy] = useState(); @@ -214,6 +218,7 @@ const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark onHistoryLoadingChange={setHistoryLoading} overlayVisibility={overlayVisibility} onToggleOverlay={handleToggleOverlay} + showHoverToolbar={showHoverToolbar} /> ); diff --git a/frontend/src/styles/floatingWidget.css b/frontend/src/styles/floatingWidget.css index 9284c8d..0fe0a99 100644 --- a/frontend/src/styles/floatingWidget.css +++ b/frontend/src/styles/floatingWidget.css @@ -452,6 +452,10 @@ flex: 1; } +.fw-window-cell .ptd-analysis-chart .chart-hover-toolbar { + display: none !important; +} + .fw-window-cell .wd-panel-body { flex: 1; min-height: 0; @@ -464,6 +468,29 @@ overflow: auto; } +.fw-window-cell .wd-widget-host--analysis { + overflow: auto; + flex: 1; + min-height: 0; +} + +.fw-window-cell .wd-widget-host--signal-summary { + padding: 0; +} + +.fw-window-cell .wd-widget-host--signal-match .seval-signal-split { + flex-direction: column; + gap: 10px; +} + +.fw-window-cell .wd-widget-host--backtest-signals { + padding: 0; +} + +.fw-window-cell .wd-widget-host--backtest-signals .brd-sig-scroll { + max-height: none; +} + /* 레이아웃 선택 팝업 */ .fw-layout-picker-grid { display: grid; diff --git a/frontend/src/styles/widgetDashboard.css b/frontend/src/styles/widgetDashboard.css index 3588759..51601ef 100644 --- a/frontend/src/styles/widgetDashboard.css +++ b/frontend/src/styles/widgetDashboard.css @@ -1325,6 +1325,11 @@ flex: 1; } +/* 위젯 — 전략 분석 차트 하단 줌/패닝 툴바 숨김 */ +.wd-widget-host--chart .ptd-analysis-chart .chart-hover-toolbar { + display: none !important; +} + /* 미니 차트 */ .bps-page--wd .wd-mini-chart-wrap { min-height: 0; @@ -1390,3 +1395,56 @@ padding: 0; min-height: 0; } + +/* 매매 시그널·분석 위젯 */ +.wd-widget-host--analysis { + overflow: auto; + flex: 1; + min-height: 0; + padding: 8px 10px; +} + +.wd-widget-host--signal-summary { + padding: 0; +} + +.wd-widget-host--signal-summary .vtd-card { + border: none; + box-shadow: none; +} + +.wd-widget-host--signal-match .seval-signal-split { + flex-direction: column; + gap: 10px; +} + +.wd-widget-host--strategy-condition .scv-root { + min-height: 0; +} + +.wd-widget-host--backtest-signals { + padding: 0; +} + +.wd-widget-host--backtest-signals .wd-backtest-sig-table { + border: none; + box-shadow: none; + height: 100%; +} + +.wd-widget-host--backtest-signals .brd-sig-scroll { + max-height: none; +} + +.wd-widget-host--indicator-status .vtd-sig-panel-wrap--detail { + min-height: 0; +} + +.bps-page--wd .wd-widget-host--analysis { + overflow: auto; +} + +.wd-widget-empty--loading { + color: var(--text3, var(--se-text-muted, #6272a4)); + font-style: italic; +} diff --git a/frontend/src/widgets/hooks/useWidgetBacktestSignals.ts b/frontend/src/widgets/hooks/useWidgetBacktestSignals.ts new file mode 100644 index 0000000..0dbf19b --- /dev/null +++ b/frontend/src/widgets/hooks/useWidgetBacktestSignals.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + loadBacktestSettings, + runBacktest, + type BacktestSignal, + type StrategyDto, +} from '../../utils/backendApi'; +import { loadAnalysisCandles } from '../../utils/analysisChartData'; +import { normalizeChartTimeframe } from '../../utils/backtestUiUtils'; +import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; +import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup'; +import type { OHLCVBar } from '../../types'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; + +const BAR_COUNT = 300; + +/** 선택 종목·전략에 대한 백테스트 매매 시그널 목록 */ +export function useWidgetBacktestSignals( + market: string, + strategyId: number | null, + strategy: StrategyDto | undefined, +) { + const { getParams } = useIndicatorSettings(); + const [signals, setSignals] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const genRef = useRef(0); + const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]); + + const runSignals = useCallback(async ( + bars: OHLCVBar[], + tf: string, + strat: StrategyDto, + sid: number, + ) => { + const sym = market.startsWith('KRW-') ? market : `KRW-${market}`; + if (bars.length < 10) { + setSignals([]); + setError('캔들 데이터가 부족합니다.'); + return; + } + const gen = ++genRef.current; + setLoading(true); + setError(null); + try { + const btSettings = await loadBacktestSettings(); + const indicatorParams = Object.fromEntries( + ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ + t, + getParams(t) as Record, + ]), + ); + const { evaluationBarCount } = resolveEvaluationFromLoadedBars( + bars.length, + strat.buyCondition, + strat.sellCondition, + indicatorParams, + ); + const res = await runBacktest({ + strategyId: sid, + bars: bars.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })), + timeframe: tf, + symbol: sym, + strategyName: strat.name, + settings: btSettings, + indicatorParams, + evaluationBarCount, + }); + if (gen !== genRef.current) return; + if (!res) { + setSignals([]); + setError('시그널 계산에 실패했습니다.'); + return; + } + setSignals(res.signals); + } catch (e) { + if (gen !== genRef.current) return; + setSignals([]); + setError(e instanceof Error ? e.message : '시그널 계산 실패'); + } finally { + if (gen === genRef.current) setLoading(false); + } + }, [market, getParams]); + + useEffect(() => { + if (!strategyId || !strategy) { + setSignals([]); + setError(strategyId ? null : '전략을 선택하세요.'); + return; + } + let cancelled = false; + const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy)); + + void (async () => { + try { + const sym = market.startsWith('KRW-') ? market : `KRW-${market}`; + const bars = await loadAnalysisCandles(sym, tf, toTimeSec, BAR_COUNT); + if (cancelled) return; + await runSignals(bars, tf, strategy, strategyId); + } catch (e) { + if (!cancelled) { + setSignals([]); + setError(e instanceof Error ? e.message : '시그널 계산 실패'); + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + ++genRef.current; + }; + }, [market, strategyId, strategy, toTimeSec, runSignals]); + + return { signals, loading, error }; +} diff --git a/frontend/src/widgets/hooks/useWidgetSelectedStrategy.ts b/frontend/src/widgets/hooks/useWidgetSelectedStrategy.ts new file mode 100644 index 0000000..fb8767f --- /dev/null +++ b/frontend/src/widgets/hooks/useWidgetSelectedStrategy.ts @@ -0,0 +1,45 @@ +import { useEffect, useMemo, useState } from 'react'; +import { loadStrategy, type StrategyDto } from '../../utils/backendApi'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; + +function strategyHasConditions(s: StrategyDto | undefined): boolean { + return Boolean(s?.buyCondition || s?.sellCondition); +} + +/** 위젯 대시보드 — config·컨텍스트에서 전략 ID·전체 DTO 해석 */ +export function useWidgetSelectedStrategy(configStrategyId?: unknown) { + const { strategies, selectedStrategyId: ctxStrategyId } = useWidgetDashboard(); + + const strategyId = useMemo(() => { + if (typeof configStrategyId === 'number' && configStrategyId > 0) return configStrategyId; + if (ctxStrategyId != null && ctxStrategyId > 0) return ctxStrategyId; + return strategies[0]?.id ?? null; + }, [configStrategyId, ctxStrategyId, strategies]); + + const listStrategy = useMemo( + () => (strategyId != null ? strategies.find(s => s.id === strategyId) : undefined), + [strategies, strategyId], + ); + + const [strategy, setStrategy] = useState(); + + useEffect(() => { + if (strategyId == null) { + setStrategy(undefined); + return; + } + if (listStrategy && strategyHasConditions(listStrategy)) { + setStrategy(listStrategy); + return; + } + let cancelled = false; + void loadStrategy(strategyId).then(s => { + if (!cancelled) setStrategy(s ?? listStrategy); + }); + return () => { cancelled = true; }; + }, [strategyId, listStrategy]); + + const strategyLabel = strategy?.name ?? listStrategy?.name; + + return { strategyId, strategy, strategies, strategyLabel }; +} diff --git a/frontend/src/widgets/hosts/BacktestSignalsWidgetHost.tsx b/frontend/src/widgets/hosts/BacktestSignalsWidgetHost.tsx new file mode 100644 index 0000000..7cfca9a --- /dev/null +++ b/frontend/src/widgets/hosts/BacktestSignalsWidgetHost.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import BacktestSignalTable from '../../components/backtest/BacktestSignalTable'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy'; +import { useWidgetBacktestSignals } from '../hooks/useWidgetBacktestSignals'; +import '../../styles/backtestDashboard.css'; + +/** 백테스트 매매 시그널 목록 — BacktestSignalTable */ +const BacktestSignalsWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { selectedMarket } = useWidgetDashboard(); + const { strategyId, strategy } = useWidgetSelectedStrategy(config?.strategyId); + const { signals, loading, error } = useWidgetBacktestSignals(selectedMarket, strategyId, strategy); + + if (!strategyId) { + return

전략을 선택하세요.

; + } + + if (loading && signals.length === 0 && !error) { + return

시그널 계산 중…

; + } + + if (error && signals.length === 0) { + return

{error}

; + } + + return ( +
+ +
+ ); +}; + +export default BacktestSignalsWidgetHost; diff --git a/frontend/src/widgets/hosts/IndicatorStatusWidgetHost.tsx b/frontend/src/widgets/hosts/IndicatorStatusWidgetHost.tsx new file mode 100644 index 0000000..1b569fa --- /dev/null +++ b/frontend/src/widgets/hosts/IndicatorStatusWidgetHost.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import VirtualTargetSignalPanel from '../../components/virtual/VirtualTargetSignalPanel'; +import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy'; +import '../../styles/virtualTradingDashboard.css'; + +/** 지표별 충족 상태 — VirtualTargetSignalPanel detail */ +const IndicatorStatusWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { selectedMarket } = useWidgetDashboard(); + const { strategyId, strategy, strategies, strategyLabel } = useWidgetSelectedStrategy(config?.strategyId); + + const { snapshot, loading } = useTradeNotificationSignalSnapshot( + selectedMarket, + strategyId, + strategy, + strategyLabel, + strategyId != null, + strategies, + ); + + if (!strategyId) { + return

전략을 선택하세요.

; + } + + return ( +
+ +
+ ); +}; + +export default IndicatorStatusWidgetHost; diff --git a/frontend/src/widgets/hosts/SignalMatchWidgetHost.tsx b/frontend/src/widgets/hosts/SignalMatchWidgetHost.tsx new file mode 100644 index 0000000..060e490 --- /dev/null +++ b/frontend/src/widgets/hosts/SignalMatchWidgetHost.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import StrategyEvaluationSignalPanel from '../../components/strategyEvaluation/StrategyEvaluationSignalPanel'; +import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy'; +import '../../styles/strategyEvaluation.css'; +import '../../styles/virtualTradingDashboard.css'; + +/** 매수·매도 조건 일치율 — StrategyEvaluationSignalPanel */ +const SignalMatchWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { selectedMarket } = useWidgetDashboard(); + const { strategyId, strategy, strategies, strategyLabel } = useWidgetSelectedStrategy(config?.strategyId); + const { getParams } = useIndicatorSettings(); + + const { snapshot, loading } = useTradeNotificationSignalSnapshot( + selectedMarket, + strategyId, + strategy, + strategyLabel, + strategyId != null, + strategies, + ); + + if (!strategyId) { + return

전략을 선택하세요.

; + } + + return ( +
+ +
+ ); +}; + +export default SignalMatchWidgetHost; diff --git a/frontend/src/widgets/hosts/SignalSummaryWidgetHost.tsx b/frontend/src/widgets/hosts/SignalSummaryWidgetHost.tsx new file mode 100644 index 0000000..24a25f4 --- /dev/null +++ b/frontend/src/widgets/hosts/SignalSummaryWidgetHost.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import TradeNotificationSignalSummary from '../../components/tradeNotification/TradeNotificationSignalSummary'; +import { useWidgetDashboard } from '../WidgetDashboardContext'; +import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy'; +import '../../styles/virtualTradingDashboard.css'; + +/** 실시간 매매 시그널 요약 — VirtualTargetCard 요약형 */ +const SignalSummaryWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { theme, selectedMarket, tickers, strategies } = useWidgetDashboard(); + const { strategyId, strategy, strategyLabel } = useWidgetSelectedStrategy(config?.strategyId); + + if (!strategyId) { + return

전략을 선택하세요.

; + } + + return ( +
+ +
+ ); +}; + +export default SignalSummaryWidgetHost; diff --git a/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx b/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx index 3147545..c9d8bf4 100644 --- a/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx +++ b/frontend/src/widgets/hosts/StrategyChartWidgetHost.tsx @@ -22,6 +22,7 @@ const StrategyChartWidgetHost: React.FC<{ config?: Record }> = market={selectedMarket} strategyId={strategyId} theme={theme} + showHoverToolbar={false} /> ); diff --git a/frontend/src/widgets/hosts/StrategyConditionWidgetHost.tsx b/frontend/src/widgets/hosts/StrategyConditionWidgetHost.tsx new file mode 100644 index 0000000..eaac077 --- /dev/null +++ b/frontend/src/widgets/hosts/StrategyConditionWidgetHost.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import StrategyConditionView from '../../components/tradeNotification/StrategyConditionView'; +import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy'; +import '../../styles/appPopup.css'; + +/** 전략 매수·매도 조건 트리 — StrategyConditionView */ +const StrategyConditionWidgetHost: React.FC<{ config?: Record }> = ({ config }) => { + const { strategyId, strategy } = useWidgetSelectedStrategy(config?.strategyId); + + if (!strategyId) { + return

전략을 선택하세요.

; + } + + if (!strategy) { + return

전략 조건 불러오는 중…

; + } + + if (!strategy.buyCondition && !strategy.sellCondition) { + return

등록된 매수·매도 조건이 없습니다.

; + } + + return ( +
+ +
+ ); +}; + +export default StrategyConditionWidgetHost; diff --git a/frontend/src/widgets/widgetRegistry.ts b/frontend/src/widgets/widgetRegistry.ts index 49da6fc..57ab8b3 100644 --- a/frontend/src/widgets/widgetRegistry.ts +++ b/frontend/src/widgets/widgetRegistry.ts @@ -22,6 +22,11 @@ import NotificationListWidgetHost from './hosts/NotificationListWidgetHost'; import StrategyListWidgetHost from './hosts/StrategyListWidgetHost'; import BacktestListWidgetHost from './hosts/BacktestListWidgetHost'; import PaperKpiWidgetHost from './hosts/PaperKpiWidgetHost'; +import SignalSummaryWidgetHost from './hosts/SignalSummaryWidgetHost'; +import SignalMatchWidgetHost from './hosts/SignalMatchWidgetHost'; +import StrategyConditionWidgetHost from './hosts/StrategyConditionWidgetHost'; +import BacktestSignalsWidgetHost from './hosts/BacktestSignalsWidgetHost'; +import IndicatorStatusWidgetHost from './hosts/IndicatorStatusWidgetHost'; export type WidgetGroupId = | 'chart' @@ -29,6 +34,7 @@ export type WidgetGroupId = | 'trade' | 'portfolio' | 'strategy' + | 'analysis' | 'alert'; export interface WidgetDefinition { @@ -47,6 +53,7 @@ export const WIDGET_GROUP_LABELS: Record = { trade: '매매·호가', portfolio: '계좌·포트폴리오', strategy: '전략·분석', + analysis: '매매 시그널', alert: '알림', }; @@ -227,6 +234,46 @@ export const WIDGET_REGISTRY: WidgetDefinition[] = [ group: 'strategy', Host: BacktestListWidgetHost, }, + { + type: 'analysis.signalSummary', + label: '매매 시그널 요약', + description: '선택 종목·전략 실시간 신호 카드', + category: 'side', + group: 'analysis', + Host: SignalSummaryWidgetHost, + }, + { + type: 'analysis.signalMatch', + label: '조건 일치율', + description: '매수·매도 조건별 지표 충족률', + category: 'side', + group: 'analysis', + Host: SignalMatchWidgetHost, + }, + { + type: 'analysis.strategyCondition', + label: '전략 조건', + description: '매수·매도 DSL 조건 트리', + category: 'side', + group: 'analysis', + Host: StrategyConditionWidgetHost, + }, + { + type: 'analysis.backtestSignals', + label: '백테스트 시그널', + description: '최근 캔들 구간 매매 시그널 목록', + category: 'side', + group: 'analysis', + Host: BacktestSignalsWidgetHost, + }, + { + type: 'analysis.indicatorDetail', + label: '지표별 상태', + description: '조건별 현재값·충족 여부 상세', + category: 'side', + group: 'analysis', + Host: IndicatorStatusWidgetHost, + }, ]; const byType = new Map(WIDGET_REGISTRY.map(w => [w.type, w])); @@ -244,7 +291,7 @@ export function widgetsByGroup(category: WidgetCategory): { group: WidgetGroupId const items = widgetsForCategory(category); const order: WidgetGroupId[] = category === 'center' ? ['chart'] - : ['market', 'trade', 'portfolio', 'strategy', 'alert']; + : ['market', 'trade', 'portfolio', 'strategy', 'analysis', 'alert']; return order .map(group => ({ group,