위젯 기능 추가
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 위젯 대시보드 — 종목 검색 + 좌·우 세로 툴바 + 실시간 캔들 차트
|
||||
*/
|
||||
import React, {
|
||||
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import TradingChart from '../components/TradingChart';
|
||||
import DrawingToolbar from '../components/DrawingToolbar';
|
||||
import { MarketSearchPanel } from '../components/MarketSearchPanel';
|
||||
import IndicatorDropdownPanel from '../components/IndicatorDropdownPanel';
|
||||
import type { StrategyDto } from '../utils/backendApi';
|
||||
import { pinCandleWatch } from '../utils/backendApi';
|
||||
import type {
|
||||
ChartMode, ChartType, Drawing, IndicatorConfig, LegendData, OHLCVBar, Theme, Timeframe,
|
||||
} from '../types';
|
||||
import { isUpbitMarket } from '../utils/upbitApi';
|
||||
import { useChartRealtimeData, type ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
import { useHistoryLoader } from '../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import {
|
||||
buildVirtualTradingChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
resolveStrategyTimeframes,
|
||||
} from '../utils/strategyToChartIndicators';
|
||||
import { timeframeToCandleType } from '../utils/chartCandleType';
|
||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import {
|
||||
DEFAULT_CHART_OVERLAY_VISIBILITY,
|
||||
listChartOverlayToggleItems,
|
||||
chartOverlayKeyForType,
|
||||
type ChartOverlayToggleKey,
|
||||
type ChartOverlayVisibility,
|
||||
} from '../utils/chartOverlayVisibility';
|
||||
import {
|
||||
duplicateIndicatorInList,
|
||||
mergeIndicators,
|
||||
removeIndicatorFromList,
|
||||
removeIndicatorTypeFromList,
|
||||
replaceIndicatorConfigsFromTypes,
|
||||
reorderIndicatorGroup,
|
||||
splitIndicatorPane,
|
||||
} from '../utils/indicatorPaneMerge';
|
||||
import { getIndicatorDef, type IndicatorDef } from '../utils/indicatorRegistry';
|
||||
import { sortIndicatorsByTypeOrder } from '../utils/indicatorListOrder';
|
||||
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
|
||||
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
|
||||
|
||||
let _widgetIndCounter = 0;
|
||||
function newIndId() { return `wind_${++_widgetIndCounter}_${Date.now()}`; }
|
||||
|
||||
const COMPACT_MIN_BAR_PX = 5;
|
||||
|
||||
function resolveCompactDisplayCount(width: number): number {
|
||||
if (width <= 0) return DISPLAY_COUNT;
|
||||
return Math.max(40, Math.min(DISPLAY_COUNT, Math.floor(width / COMPACT_MIN_BAR_PX)));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
onMarketChange: (market: string) => void;
|
||||
strategy: StrategyDto | undefined;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const WidgetCandleChartPanel: React.FC<Props> = ({
|
||||
market,
|
||||
onMarketChange,
|
||||
strategy,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(
|
||||
() => resolveStrategyPrimaryTimeframe(strategy),
|
||||
);
|
||||
const [mode, setMode] = useState<ChartMode>('chart');
|
||||
const [drawingTool, setDrawingTool] = useState('cursor');
|
||||
const [drawings, setDrawings] = useState<Drawing[]>([]);
|
||||
const [drawingsLocked, setDrawingsLocked] = useState(false);
|
||||
const [drawingsVisible, setDrawingsVisible] = useState(true);
|
||||
const [indicatorsVisible, setIndicatorsVisible] = useState(true);
|
||||
const [magnetMode, setMagnetMode] = useState<'off' | 'weak' | 'strong'>('off');
|
||||
const [snapToIndicator, setSnapToIndicator] = useState(false);
|
||||
const [showMarketSearch, setShowMarketSearch] = useState(false);
|
||||
const [showIndicatorPanel, setShowIndicatorPanel] = useState(false);
|
||||
const [leftToolbarVisible, setLeftToolbarVisible] = useState(true);
|
||||
const [rightToolbarVisible, setRightToolbarVisible] = useState(true);
|
||||
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
|
||||
const [overlayVisibility, setOverlayVisibility] = useState<ChartOverlayVisibility>(
|
||||
() => ({ ...DEFAULT_CHART_OVERLAY_VISIBILITY }),
|
||||
);
|
||||
|
||||
const marketBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const indicatorsRef = useRef<IndicatorConfig[]>([]);
|
||||
const [chartReloadTick, setChartReloadTick] = useState(0);
|
||||
const chartReloadTriggeredRef = useRef(false);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
|
||||
}, [strategy, market]);
|
||||
|
||||
const baseIndicators = useMemo(
|
||||
() => buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig),
|
||||
[strategy, getParams, getVisualConfig],
|
||||
);
|
||||
|
||||
const [indicators, setIndicators] = useState<IndicatorConfig[]>(baseIndicators);
|
||||
useEffect(() => {
|
||||
setIndicators(prev => {
|
||||
const baseTypes = new Set(baseIndicators.map(i => i.type));
|
||||
const userExtras = prev.filter(i => !baseTypes.has(i.type));
|
||||
return sortIndicatorsByTypeOrder([...baseIndicators, ...userExtras]);
|
||||
});
|
||||
}, [baseIndicators]);
|
||||
|
||||
indicatorsRef.current = indicators;
|
||||
|
||||
const effectiveIndicators = useMemo(() => {
|
||||
const tfFiltered = indicators.filter(ind =>
|
||||
ind.timeframeVisibility?.[timeframe] !== false,
|
||||
);
|
||||
if (!focusedIndicatorId) return tfFiltered;
|
||||
return tfFiltered.filter(ind => {
|
||||
const def = getIndicatorDef(ind.type);
|
||||
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
|
||||
});
|
||||
}, [focusedIndicatorId, indicators, timeframe]);
|
||||
|
||||
const candleOverlayToggles = useMemo(
|
||||
() => listChartOverlayToggleItems(overlayVisibility),
|
||||
[overlayVisibility],
|
||||
);
|
||||
|
||||
const resolveInitialDisplayCount = useCallback(
|
||||
(plotWidth: number) => resolveCompactDisplayCount(plotWidth),
|
||||
[],
|
||||
);
|
||||
|
||||
const syncChartLayout = useCallback(() => {
|
||||
const wrap = canvasWrapRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!wrap || !mgr?.hasMainSeries()) return;
|
||||
const chartWrap = wrap.querySelector<HTMLElement>('.tv-chart-wrap');
|
||||
const w = chartWrap?.clientWidth ?? wrap.clientWidth;
|
||||
const h = chartWrap?.clientHeight ?? wrap.clientHeight;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
mgr.syncLayout(w, h);
|
||||
}, []);
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (pending && mgr && barsMarketRef.current === marketRef.current) {
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
}
|
||||
syncChartLayout();
|
||||
}, [syncChartLayout]);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, false);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, true);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
timeframe,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: useUpbit,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
const barsReady = bars.length >= 2 && barsMarket === market;
|
||||
|
||||
const requestChartReload = useCallback(() => {
|
||||
chartReloadTriggeredRef.current = true;
|
||||
setChartReloadTick(t => t + 1);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
chartReloadTriggeredRef.current = false;
|
||||
}, [market, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = canvasWrapRef.current;
|
||||
if (!wrap) return;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleLayoutSync = () => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => syncChartLayout(), 120);
|
||||
};
|
||||
const ro = new ResizeObserver(() => scheduleLayoutSync());
|
||||
ro.observe(wrap);
|
||||
return () => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [market, timeframe, syncChartLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => syncChartLayout());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [leftToolbarVisible, rightToolbarVisible, syncChartLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!barsReady) return;
|
||||
if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) {
|
||||
requestChartReload();
|
||||
}
|
||||
}, [barsReady, requestChartReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(timeframe));
|
||||
if (timeframeToCandleType(timeframe) !== '1m') {
|
||||
void pinCandleWatch(market, '1m');
|
||||
}
|
||||
}, [market, timeframe, useUpbit]);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setCrosshairMagnet(magnetMode !== 'off');
|
||||
}, [magnetMode]);
|
||||
|
||||
const handleToggleHidden = useCallback((id: string) => {
|
||||
setIndicators(prev => prev.map(i => (i.id === id ? { ...i, hidden: !i.hidden } : i)));
|
||||
}, []);
|
||||
|
||||
const handleRemoveIndicator = useCallback((id: string) => {
|
||||
setIndicators(prev => removeIndicatorFromList(prev, id));
|
||||
setFocusedIndicatorId(prev => (prev === id ? null : prev));
|
||||
}, []);
|
||||
|
||||
const buildIndicatorConfig = useCallback((
|
||||
def: IndicatorDef,
|
||||
paramOverrides?: Record<string, number | string | boolean>,
|
||||
): IndicatorConfig => {
|
||||
const params = { ...getParams(def.type, def.defaultParams), ...paramOverrides };
|
||||
const { plots, hlines, cloudColors, bandBackground } = getVisualConfig(def.type, def.plots, def.hlines);
|
||||
let cfg: IndicatorConfig = {
|
||||
id: newIndId(),
|
||||
type: def.type,
|
||||
params,
|
||||
plots,
|
||||
hlines,
|
||||
...(cloudColors ? { cloudColors } : {}),
|
||||
...(bandBackground ? { bandBackground } : {}),
|
||||
};
|
||||
if (def.type === 'SMA') {
|
||||
cfg = normalizeSmaConfig({
|
||||
...cfg,
|
||||
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
|
||||
});
|
||||
} else if (def.type === 'IchimokuCloud') {
|
||||
cfg = normalizeIchimokuConfig(cfg);
|
||||
}
|
||||
return cfg;
|
||||
}, [getParams, getVisualConfig]);
|
||||
|
||||
const handleAddIndicator = useCallback((type: string) => {
|
||||
setIndicators(prev => {
|
||||
if (prev.some(i => i.type === type)) return prev;
|
||||
const def = getIndicatorDef(type);
|
||||
if (!def) return prev;
|
||||
return sortIndicatorsByTypeOrder([...prev, buildIndicatorConfig(def)]);
|
||||
});
|
||||
}, [buildIndicatorConfig]);
|
||||
|
||||
const handleAddIndicators = useCallback((types: string[]) => {
|
||||
if (!types.length) return;
|
||||
const orderedTypes = types.filter(t => getIndicatorDef(t));
|
||||
if (orderedTypes.length === 0) return;
|
||||
setIndicators(
|
||||
sortIndicatorsByTypeOrder(
|
||||
replaceIndicatorConfigsFromTypes(orderedTypes, getParams, getVisualConfig, newIndId),
|
||||
orderedTypes,
|
||||
),
|
||||
);
|
||||
}, [getParams, getVisualConfig]);
|
||||
|
||||
const handleIndicatorListOrderChange = useCallback((orderedTypes: string[]) => {
|
||||
setIndicators(prev => sortIndicatorsByTypeOrder(prev, orderedTypes));
|
||||
}, []);
|
||||
|
||||
const handleRemoveIndicatorByType = useCallback((type: string) => {
|
||||
setIndicators(prev => removeIndicatorTypeFromList(prev, type));
|
||||
setFocusedIndicatorId(prev => {
|
||||
if (!prev) return prev;
|
||||
const focused = indicatorsRef.current.find(i => i.id === prev);
|
||||
return focused?.type === type ? null : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
|
||||
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
|
||||
}, []);
|
||||
|
||||
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
|
||||
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
|
||||
}, []);
|
||||
|
||||
const handleSplitIndicatorPane = useCallback((hostId: string) => {
|
||||
setIndicators(prev => splitIndicatorPane(hostId, prev));
|
||||
}, []);
|
||||
|
||||
const handleExpandIndicator = useCallback((id: string) => {
|
||||
setFocusedIndicatorId(id);
|
||||
}, []);
|
||||
|
||||
const handleRestoreIndicators = useCallback(() => {
|
||||
setFocusedIndicatorId(null);
|
||||
}, []);
|
||||
|
||||
const handleDuplicateIndicator = useCallback((sourceId: string) => {
|
||||
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
||||
}, []);
|
||||
|
||||
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
|
||||
setOverlayVisibility(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
const mgr = managerRef.current;
|
||||
mgr?.setChartOverlayGroupVisible(key, next[key]);
|
||||
if (mgr) {
|
||||
for (const info of mgr.getIndicatorPaneInfo()) {
|
||||
if (chartOverlayKeyForType(info.type) !== key) continue;
|
||||
const reactInd = indicatorsRef.current.find(i => i.id === info.id);
|
||||
mgr.applyIndicatorStyle(reactInd
|
||||
? { ...info.config, ...reactInd, id: info.id, type: info.type }
|
||||
: info.config);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddDrawing = useCallback((d: Drawing) => {
|
||||
setDrawings(prev => [...prev, d]);
|
||||
}, []);
|
||||
|
||||
const handleClearDrawings = useCallback(() => {
|
||||
setDrawings([]);
|
||||
managerRef.current?.clearDrawings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="wd-candle-chart">
|
||||
<div className="wd-candle-chart-header">
|
||||
<button
|
||||
ref={marketBtnRef}
|
||||
type="button"
|
||||
className="wd-candle-chart-market-btn"
|
||||
title="종목 검색"
|
||||
onClick={() => setShowMarketSearch(v => !v)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
|
||||
<circle cx="6" cy="6" r="4" />
|
||||
<line x1="9.5" y1="9.5" x2="13" y2="13" />
|
||||
</svg>
|
||||
<span className="wd-candle-chart-market-ko">{getKoreanName(market)}</span>
|
||||
<span className="wd-candle-chart-market-sym">{market}</span>
|
||||
</button>
|
||||
|
||||
{tfOptions.length > 1 && (
|
||||
<div className="wd-candle-chart-tf-row">
|
||||
{tfOptions.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`wd-candle-chart-tf${timeframe === tf ? ' wd-candle-chart-tf--on' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="wd-candle-chart-header-actions">
|
||||
<div className="wd-candle-chart-toolbar-toggles" role="group" aria-label="차트 툴바 표시">
|
||||
<button
|
||||
type="button"
|
||||
className={`wd-candle-chart-toolbar-toggle${leftToolbarVisible ? ' wd-candle-chart-toolbar-toggle--on' : ''}`}
|
||||
title={leftToolbarVisible ? '좌측 툴바 숨기기' : '좌측 툴바 표시'}
|
||||
aria-label={leftToolbarVisible ? '좌측 툴바 숨기기' : '좌측 툴바 표시'}
|
||||
aria-pressed={leftToolbarVisible}
|
||||
onClick={() => setLeftToolbarVisible(v => !v)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
|
||||
<rect x="1.5" y="2" width="3.5" height="10" rx="0.8" fill="currentColor" fillOpacity={leftToolbarVisible ? 0.35 : 0.12} />
|
||||
<rect x="6" y="2" width="6.5" height="10" rx="0.8" strokeOpacity={0.55} />
|
||||
</svg>
|
||||
<span>좌</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`wd-candle-chart-toolbar-toggle${rightToolbarVisible ? ' wd-candle-chart-toolbar-toggle--on' : ''}`}
|
||||
title={rightToolbarVisible ? '우측 툴바 숨기기' : '우측 툴바 표시'}
|
||||
aria-label={rightToolbarVisible ? '우측 툴바 숨기기' : '우측 툴바 표시'}
|
||||
aria-pressed={rightToolbarVisible}
|
||||
onClick={() => setRightToolbarVisible(v => !v)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
|
||||
<rect x="6" y="2" width="6.5" height="10" rx="0.8" fill="currentColor" fillOpacity={rightToolbarVisible ? 0.35 : 0.12} />
|
||||
<rect x="1.5" y="2" width="3.5" height="10" rx="0.8" strokeOpacity={0.55} />
|
||||
</svg>
|
||||
<span>우</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`wd-candle-chart-ind-btn${showIndicatorPanel ? ' wd-candle-chart-ind-btn--on' : ''}`}
|
||||
title="보조지표 추가·제거"
|
||||
onClick={() => setShowIndicatorPanel(v => !v)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
||||
<polyline points="2,10 5,4 8,8 12,2" />
|
||||
</svg>
|
||||
<span>지표</span>
|
||||
{indicators.length > 0 && (
|
||||
<span className="wd-candle-chart-ind-badge">{indicators.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="wd-candle-chart-mode-btn"
|
||||
title={mode === 'chart' ? '매매 모드' : '차트 모드'}
|
||||
onClick={() => setMode(m => (m === 'chart' ? 'trading' : 'chart'))}
|
||||
>
|
||||
{mode === 'chart' ? '차트' : '매매'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wd-candle-chart-main">
|
||||
{leftToolbarVisible && (
|
||||
<DrawingToolbar
|
||||
activeTool={drawingTool}
|
||||
mode={mode}
|
||||
onTool={setDrawingTool}
|
||||
onClearAll={handleClearDrawings}
|
||||
onUndo={() => setDrawings(prev => prev.slice(0, -1))}
|
||||
locked={drawingsLocked}
|
||||
visible={drawingsVisible}
|
||||
onToggleLock={() => setDrawingsLocked(v => !v)}
|
||||
onToggleEye={() => setDrawingsVisible(v => !v)}
|
||||
indicatorsVisible={indicatorsVisible}
|
||||
magnetMode={magnetMode}
|
||||
snapToIndicator={snapToIndicator}
|
||||
drawingCount={drawings.length}
|
||||
indicatorCount={indicators.length}
|
||||
onMagnetMode={setMagnetMode}
|
||||
onSnapToIndicator={setSnapToIndicator}
|
||||
onHideDrawings={() => setDrawingsVisible(v => !v)}
|
||||
onHideIndicators={() => setIndicatorsVisible(v => !v)}
|
||||
onHideAll={() => { setDrawingsVisible(false); setIndicatorsVisible(false); }}
|
||||
onClearIndicators={() => setIndicators([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={canvasWrapRef}
|
||||
className="wd-candle-chart-canvas slot-chart-wrap vtd-card-chart-canvas--fill"
|
||||
>
|
||||
{isLoading && <div className="vtd-card-chart-loading">차트 로딩…</div>}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||
<span>과거 데이터 로딩…</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !barsReady && (
|
||||
<div className="vtd-card-chart-loading">캔들 데이터 수집 중…</div>
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${chartReloadTick}-${effectiveIndicators.map(i => i.id).join(',')}`}
|
||||
chartVisible
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={timeframe}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={mode}
|
||||
indicators={effectiveIndicators}
|
||||
drawingTool={drawingTool}
|
||||
drawings={drawings}
|
||||
logScale={false}
|
||||
drawingsLocked={drawingsLocked}
|
||||
drawingsVisible={drawingsVisible && mode === 'trading'}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => {
|
||||
managerRef.current = mgr;
|
||||
mgr.setChartOverlayVisibility(overlayVisibility);
|
||||
}}
|
||||
onAddDrawing={handleAddDrawing}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
resolveInitialDisplayCount={resolveInitialDisplayCount}
|
||||
skipViewportRecovery
|
||||
magnifierEnabled={false}
|
||||
volumeVisible={false}
|
||||
paneLayoutClamp
|
||||
showHoverToolbar={false}
|
||||
showPaneLegend
|
||||
showChartRightToolbar={rightToolbarVisible}
|
||||
crosshairInfoVisible={false}
|
||||
onReorderIndicators={handleReorderIndicators}
|
||||
onMergeIndicators={handleMergeIndicators}
|
||||
onSplitIndicatorPane={handleSplitIndicatorPane}
|
||||
onExpandIndicator={handleExpandIndicator}
|
||||
onRemoveIndicator={handleRemoveIndicator}
|
||||
onDuplicateIndicator={handleDuplicateIndicator}
|
||||
focusedIndicatorId={focusedIndicatorId}
|
||||
onRestoreIndicators={handleRestoreIndicators}
|
||||
onToggleIndicatorHidden={handleToggleHidden}
|
||||
candleOverlayToggles={candleOverlayToggles}
|
||||
onToggleCandleOverlay={handleToggleCandleOverlay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(
|
||||
<MarketSearchPanel
|
||||
currentMarket={market}
|
||||
onSelect={m => {
|
||||
onMarketChange(m);
|
||||
setShowMarketSearch(false);
|
||||
}}
|
||||
onClose={() => setShowMarketSearch(false)}
|
||||
anchorRect={marketBtnRef.current.getBoundingClientRect()}
|
||||
anchorPlacement="dropdown"
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{showIndicatorPanel && ReactDOM.createPortal(
|
||||
<IndicatorDropdownPanel
|
||||
activeIndicators={indicators}
|
||||
onAdd={handleAddIndicator}
|
||||
onAddMany={handleAddIndicators}
|
||||
onIndicatorOrderChange={handleIndicatorListOrderChange}
|
||||
onRemove={handleRemoveIndicatorByType}
|
||||
onClose={() => setShowIndicatorPanel(false)}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetCandleChartPanel;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 위젯 대시보드 — 종목·매매 상태 공유
|
||||
*/
|
||||
import React, {
|
||||
createContext, useCallback, useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import type { PaperSummaryDto, PaperTradeDto, StrategyDto } from '../utils/backendApi';
|
||||
import type { TickerData, MarketInfo } from '../hooks/useMarketTicker';
|
||||
import type { TradeOrderFillRequest } from '../types';
|
||||
import type { Theme } from '../types';
|
||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
|
||||
export interface WidgetDashboardContextValue {
|
||||
theme: Theme;
|
||||
selectedMarket: string;
|
||||
setSelectedMarket: (m: string) => void;
|
||||
tickers: Map<string, TickerData>;
|
||||
marketInfos: MarketInfo[];
|
||||
marketLoading: boolean;
|
||||
usdRate: number;
|
||||
strategies: StrategyDto[];
|
||||
selectedStrategyId: number | null;
|
||||
setSelectedStrategyId: (id: number | null) => void;
|
||||
summary: PaperSummaryDto | null;
|
||||
trades: PaperTradeDto[];
|
||||
refreshPaperData: () => void;
|
||||
tradePrice: number | null;
|
||||
fillBuy: TradeOrderFillRequest | null;
|
||||
fillSell: TradeOrderFillRequest | null;
|
||||
setFillBuy: (r: TradeOrderFillRequest | null) => void;
|
||||
setFillSell: (r: TradeOrderFillRequest | null) => void;
|
||||
paperTradingEnabled: boolean;
|
||||
paperAutoTradeEnabled: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
chartRealtimeSource: ChartRealtimeSource;
|
||||
}
|
||||
|
||||
const WidgetDashboardContext = createContext<WidgetDashboardContextValue | null>(null);
|
||||
|
||||
export function useWidgetDashboard(): WidgetDashboardContextValue {
|
||||
const ctx = useContext(WidgetDashboardContext);
|
||||
if (!ctx) throw new Error('useWidgetDashboard must be used within WidgetDashboardProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useWidgetDashboardOptional(): WidgetDashboardContextValue | null {
|
||||
return useContext(WidgetDashboardContext);
|
||||
}
|
||||
|
||||
interface ProviderProps {
|
||||
theme: Theme;
|
||||
tickers: Map<string, TickerData>;
|
||||
marketInfos: MarketInfo[];
|
||||
marketLoading: boolean;
|
||||
usdRate: number;
|
||||
defaultMarket: string;
|
||||
strategies: StrategyDto[];
|
||||
summary: PaperSummaryDto | null;
|
||||
trades: PaperTradeDto[];
|
||||
refreshPaperData: () => void;
|
||||
paperTradingEnabled: boolean;
|
||||
paperAutoTradeEnabled: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
chartRealtimeSource: ChartRealtimeSource;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetDashboardProvider: React.FC<ProviderProps> = ({
|
||||
theme,
|
||||
tickers,
|
||||
marketInfos,
|
||||
marketLoading,
|
||||
usdRate,
|
||||
defaultMarket,
|
||||
strategies,
|
||||
summary,
|
||||
trades,
|
||||
refreshPaperData,
|
||||
paperTradingEnabled,
|
||||
paperAutoTradeEnabled,
|
||||
onPaperOrderFilled,
|
||||
chartRealtimeSource,
|
||||
children,
|
||||
}) => {
|
||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultMarket) setSelectedMarket(defaultMarket);
|
||||
}, [defaultMarket]);
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
|
||||
const tradePrice = useMemo(() => {
|
||||
const t = tickers.get(selectedMarket);
|
||||
return t?.tradePrice ?? null;
|
||||
}, [tickers, selectedMarket]);
|
||||
|
||||
const value = useMemo<WidgetDashboardContextValue>(() => ({
|
||||
theme,
|
||||
selectedMarket,
|
||||
setSelectedMarket,
|
||||
tickers,
|
||||
marketInfos,
|
||||
marketLoading,
|
||||
usdRate,
|
||||
strategies,
|
||||
selectedStrategyId,
|
||||
setSelectedStrategyId,
|
||||
summary,
|
||||
trades,
|
||||
refreshPaperData,
|
||||
tradePrice,
|
||||
fillBuy,
|
||||
fillSell,
|
||||
setFillBuy,
|
||||
setFillSell,
|
||||
paperTradingEnabled,
|
||||
paperAutoTradeEnabled,
|
||||
onPaperOrderFilled,
|
||||
chartRealtimeSource,
|
||||
}), [
|
||||
theme, selectedMarket, tickers, marketInfos, marketLoading, usdRate,
|
||||
strategies, selectedStrategyId, summary, trades, refreshPaperData,
|
||||
tradePrice, fillBuy, fillSell, paperTradingEnabled, paperAutoTradeEnabled,
|
||||
onPaperOrderFilled, chartRealtimeSource,
|
||||
]);
|
||||
|
||||
return (
|
||||
<WidgetDashboardContext.Provider value={value}>
|
||||
{children}
|
||||
</WidgetDashboardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useWidgetOrderbookFillHandler(): (price: number, rowType: 'ask' | 'bid') => void {
|
||||
const ctx = useWidgetDashboard();
|
||||
return useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
const side = rowType === 'bid' ? 'buy' : 'sell';
|
||||
const req: TradeOrderFillRequest = { market: ctx.selectedMarket, price, side, seq: Date.now() };
|
||||
if (side === 'buy') ctx.setFillBuy(req);
|
||||
else ctx.setFillSell(req);
|
||||
}, [ctx]);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
|
||||
const AccountBalanceWidgetHost: React.FC = () => {
|
||||
const { summary } = useWidgetDashboard();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!summary) return [];
|
||||
return [
|
||||
{ label: '총자산', value: summary.totalAsset, highlight: true },
|
||||
{ label: '예수금', value: summary.cashBalance },
|
||||
{ label: '주식평가', value: summary.stockEvalAmount },
|
||||
{ label: '주문가능', value: summary.orderableCash ?? summary.cashBalance },
|
||||
{ label: '평가손익', value: summary.unrealizedPnl, signed: true },
|
||||
{ label: '실현손익', value: summary.realizedPnl, signed: true },
|
||||
{ label: '총수익률', value: summary.totalReturnPct, pct: true },
|
||||
];
|
||||
}, [summary]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader title="계좌 잔고" />
|
||||
<div className="wd-panel-body">
|
||||
{!summary && <p className="wd-widget-empty">계좌 정보를 불러오는 중…</p>}
|
||||
{summary && (
|
||||
<dl className="wd-balance-grid">
|
||||
{rows.map(row => {
|
||||
let val = '-';
|
||||
if (row.pct) {
|
||||
val = `${row.value >= 0 ? '+' : ''}${row.value.toFixed(2)}%`;
|
||||
} else if (typeof row.value === 'number') {
|
||||
val = `${row.signed && row.value >= 0 ? '+' : ''}${row.value.toLocaleString('ko-KR')}원`;
|
||||
}
|
||||
const cls = row.signed || row.pct
|
||||
? (row.value >= 0 ? 'wd-up' : 'wd-down')
|
||||
: row.highlight ? 'wd-balance-total' : '';
|
||||
return (
|
||||
<div key={row.label} className={row.highlight ? 'wd-balance-row wd-balance-row--total' : 'wd-balance-row'}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd className={cls}>{val}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountBalanceWidgetHost;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BacktestExecutionList from '../../components/backtest/BacktestExecutionList';
|
||||
import { loadBacktestResults, loadPaperTrades } from '../../utils/backendApi';
|
||||
import type { BacktestResultRecord } from '../../utils/backendApi';
|
||||
import { buildLiveExecutionItems } from '../../utils/liveExecutionGroups';
|
||||
|
||||
/** 백테스트·실매매 목록 — BacktestExecutionList 재사용 (축소) */
|
||||
const BacktestListWidgetHost: React.FC = () => {
|
||||
const [tab, setTab] = useState<'backtest' | 'live'>('backtest');
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [liveItems, setLiveItems] = useState<ReturnType<typeof buildLiveExecutionItems>>([]);
|
||||
const [selectedBacktestId, setSelectedBacktestId] = useState<number | null>(null);
|
||||
const [selectedLiveId, setSelectedLiveId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([loadBacktestResults(), loadPaperTrades()]).then(([bt, trades]) => {
|
||||
if (cancelled) return;
|
||||
setRecords(bt ?? []);
|
||||
setLiveItems(buildLiveExecutionItems(trades ?? [], null));
|
||||
}).catch(() => { /* ignore */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--backtest-list">
|
||||
<BacktestExecutionList
|
||||
tab={tab}
|
||||
onTabChange={setTab}
|
||||
backtestRecords={records}
|
||||
liveItems={liveItems}
|
||||
selectedBacktestId={selectedBacktestId}
|
||||
selectedLiveId={selectedLiveId}
|
||||
onSelectBacktest={r => setSelectedBacktestId(r.id)}
|
||||
onSelectLive={item => setSelectedLiveId(item.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestListWidgetHost;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import TradeSplitOrderPanel from '../../components/trade/TradeSplitOrderPanel';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 매수·매도 — TradeSplitOrderPanel 재사용 */
|
||||
const BuySellWidgetHost: React.FC = () => {
|
||||
const {
|
||||
selectedMarket, tradePrice, summary, fillBuy, fillSell, setSelectedMarket,
|
||||
paperTradingEnabled, paperAutoTradeEnabled, onPaperOrderFilled, refreshPaperData,
|
||||
} = useWidgetDashboard();
|
||||
|
||||
const availableKrw = summary?.orderableCash ?? summary?.cashBalance ?? 0;
|
||||
const holding = useMemo(
|
||||
() => summary?.positions?.find(h => h.symbol === selectedMarket),
|
||||
[summary, selectedMarket],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--trade">
|
||||
<TradeSplitOrderPanel
|
||||
market={selectedMarket}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={availableKrw}
|
||||
availableCoinQty={holding?.quantity ?? 0}
|
||||
fillBuy={fillBuy}
|
||||
fillSell={fillSell}
|
||||
onMarketSelect={setSelectedMarket}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={() => {
|
||||
refreshPaperData();
|
||||
onPaperOrderFilled?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuySellWidgetHost;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { loadStrategyForNotification } from '../../utils/backendApi';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetCandleChartPanel from '../WidgetCandleChartPanel';
|
||||
|
||||
/** 실시간 캔들 차트 — 종목 검색 + 좌·우 세로 툴바 */
|
||||
const CandleChartWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
|
||||
const {
|
||||
theme, selectedMarket, setSelectedMarket, strategies, selectedStrategyId, chartRealtimeSource,
|
||||
} = useWidgetDashboard();
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
|
||||
const strategyId = useMemo(() => {
|
||||
const fromConfig = config?.strategyId;
|
||||
if (typeof fromConfig === 'number' && fromConfig > 0) return fromConfig;
|
||||
if (selectedStrategyId != null && selectedStrategyId > 0) return selectedStrategyId;
|
||||
return strategies[0]?.id ?? null;
|
||||
}, [config?.strategyId, selectedStrategyId, strategies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId) {
|
||||
setStrategy(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadStrategyForNotification(strategyId).then(s => {
|
||||
if (!cancelled) setStrategy(s ?? undefined);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [strategyId]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--chart">
|
||||
<WidgetCandleChartPanel
|
||||
market={selectedMarket}
|
||||
onMarketChange={setSelectedMarket}
|
||||
strategy={strategy}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CandleChartWidgetHost;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { resolveName } from '../widgetMarketUtils';
|
||||
|
||||
const DepthChartWidgetHost: React.FC = () => {
|
||||
const { selectedMarket, tickers } = useWidgetDashboard();
|
||||
const { orderbook, spread } = useUpbitOrderbook(selectedMarket);
|
||||
const ticker = tickers.get(selectedMarket);
|
||||
|
||||
const { askBars, bidBars, maxSize } = useMemo(() => {
|
||||
const asks = orderbook.asks.slice(0, 12).reverse();
|
||||
const bids = orderbook.bids.slice(0, 12);
|
||||
const max = Math.max(
|
||||
...asks.map(a => a.size),
|
||||
...bids.map(b => b.size),
|
||||
1,
|
||||
);
|
||||
return { askBars: asks, bidBars: bids, maxSize: max };
|
||||
}, [orderbook.asks, orderbook.bids]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader
|
||||
title="호가 깊이"
|
||||
right={spread.bestAsk != null && spread.bestBid != null ? (
|
||||
<span className="wd-panel-meta">
|
||||
스프레드 {spread.spread?.toLocaleString('ko-KR') ?? '-'}
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)}</div>
|
||||
<div className="wd-panel-body wd-depth-body">
|
||||
<div className="wd-depth-side wd-depth-side--ask">
|
||||
{askBars.map(u => (
|
||||
<div key={`a-${u.price}`} className="wd-depth-row">
|
||||
<span className="wd-depth-price wd-down">{u.price.toLocaleString('ko-KR')}</span>
|
||||
<div className="wd-depth-bar-wrap">
|
||||
<div
|
||||
className="wd-depth-bar wd-depth-bar--ask"
|
||||
style={{ width: `${(u.size / maxSize) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="wd-depth-size">{u.size.toFixed(4)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="wd-depth-mid">
|
||||
{ticker?.tradePrice?.toLocaleString('ko-KR') ?? '-'}
|
||||
</div>
|
||||
<div className="wd-depth-side wd-depth-side--bid">
|
||||
{bidBars.map(u => (
|
||||
<div key={`b-${u.price}`} className="wd-depth-row">
|
||||
<span className="wd-depth-price wd-up">{u.price.toLocaleString('ko-KR')}</span>
|
||||
<div className="wd-depth-bar-wrap">
|
||||
<div
|
||||
className="wd-depth-bar wd-depth-bar--bid"
|
||||
style={{ width: `${(u.size / maxSize) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="wd-depth-size">{u.size.toFixed(4)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepthChartWidgetHost;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { fmtPct, heatmapColor, resolveName, tickerListFromMap } from '../widgetMarketUtils';
|
||||
|
||||
const HEATMAP_SIZE = 48;
|
||||
|
||||
const MarketHeatmapWidgetHost: React.FC = () => {
|
||||
const { tickers, selectedMarket, setSelectedMarket } = useWidgetDashboard();
|
||||
|
||||
const cells = useMemo(() => {
|
||||
return tickerListFromMap(tickers)
|
||||
.filter(t => t.changeRate != null)
|
||||
.sort((a, b) => (b.accTradePrice24 ?? 0) - (a.accTradePrice24 ?? 0))
|
||||
.slice(0, HEATMAP_SIZE);
|
||||
}, [tickers]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader title="등락률 히트맵" />
|
||||
<div className="wd-panel-body">
|
||||
{cells.length === 0 && <p className="wd-widget-empty">히트맵 데이터 수집 중…</p>}
|
||||
<div className="wd-heatmap-grid">
|
||||
{cells.map(t => (
|
||||
<button
|
||||
key={t.market}
|
||||
type="button"
|
||||
className={`wd-heatmap-cell${t.market === selectedMarket ? ' wd-heatmap-cell--on' : ''}`}
|
||||
style={{ background: heatmapColor(t.changeRate) }}
|
||||
title={`${resolveName(t.market, t)} ${fmtPct(t.changeRate)}`}
|
||||
onClick={() => setSelectedMarket(t.market)}
|
||||
>
|
||||
<span className="wd-heatmap-sym">{t.market.replace('KRW-', '')}</span>
|
||||
<span className="wd-heatmap-pct">{fmtPct(t.changeRate)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketHeatmapWidgetHost;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import MarketPanel from '../../components/MarketPanel';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 종목 목록 — MarketPanel 재사용 */
|
||||
const MarketListWidgetHost: React.FC = () => {
|
||||
const {
|
||||
selectedMarket, setSelectedMarket, tickers, marketInfos, marketLoading, usdRate,
|
||||
} = useWidgetDashboard();
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--market">
|
||||
<MarketPanel
|
||||
open
|
||||
tickers={tickers}
|
||||
marketInfos={marketInfos}
|
||||
loading={marketLoading}
|
||||
usdRate={usdRate}
|
||||
activeSymbol={selectedMarket}
|
||||
onSymbolSelect={setSelectedMarket}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketListWidgetHost;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import WidgetSymbolList from '../panels/WidgetSymbolList';
|
||||
import { sortTickers, tickerListFromMap, type MoverSortKey } from '../widgetMarketUtils';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'gainers', label: '상승' },
|
||||
{ id: 'losers', label: '하락' },
|
||||
{ id: 'volume', label: '거래대금' },
|
||||
];
|
||||
|
||||
const MarketMoversWidgetHost: React.FC = () => {
|
||||
const { tickers, selectedMarket, setSelectedMarket, marketLoading } = useWidgetDashboard();
|
||||
const [tab, setTab] = useState<MoverSortKey>('gainers');
|
||||
|
||||
const items = useMemo(() => {
|
||||
const list = tickerListFromMap(tickers);
|
||||
return sortTickers(list, tab, 20);
|
||||
}, [tickers, tab]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader tabs={TABS} activeTab={tab} onTabChange={id => setTab(id as MoverSortKey)} />
|
||||
<div className="wd-panel-body">
|
||||
{marketLoading && items.length === 0 && (
|
||||
<p className="wd-widget-empty">시세 로딩 중…</p>
|
||||
)}
|
||||
<WidgetSymbolList
|
||||
items={items}
|
||||
activeMarket={selectedMarket}
|
||||
onSelect={setSelectedMarket}
|
||||
showVolume={tab === 'volume'}
|
||||
rank
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketMoversWidgetHost;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { changeColorClass, fmtKrwShort, fmtPct, tickerListFromMap } from '../widgetMarketUtils';
|
||||
|
||||
const INDEX_SYMBOLS = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-USDT'];
|
||||
|
||||
const MarketSummaryWidgetHost: React.FC = () => {
|
||||
const { tickers, setSelectedMarket } = useWidgetDashboard();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = tickerListFromMap(tickers);
|
||||
const rising = list.filter(t => (t.changeRate ?? 0) > 0).length;
|
||||
const falling = list.filter(t => (t.changeRate ?? 0) < 0).length;
|
||||
const flat = list.length - rising - falling;
|
||||
const totalVol = list.reduce((s, t) => s + (t.accTradePrice24 ?? 0), 0);
|
||||
return { rising, falling, flat, total: list.length, totalVol };
|
||||
}, [tickers]);
|
||||
|
||||
const indices = useMemo(
|
||||
() => INDEX_SYMBOLS.map(sym => tickers.get(sym)).filter(Boolean),
|
||||
[tickers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader title="시장 요약" />
|
||||
<div className="wd-panel-body">
|
||||
<div className="wd-summary-stats">
|
||||
<div className="wd-summary-stat wd-up">
|
||||
<span className="wd-summary-stat-label">상승</span>
|
||||
<span className="wd-summary-stat-val">{stats.rising}</span>
|
||||
</div>
|
||||
<div className="wd-summary-stat wd-down">
|
||||
<span className="wd-summary-stat-label">하락</span>
|
||||
<span className="wd-summary-stat-val">{stats.falling}</span>
|
||||
</div>
|
||||
<div className="wd-summary-stat wd-muted">
|
||||
<span className="wd-summary-stat-label">보합</span>
|
||||
<span className="wd-summary-stat-val">{stats.flat}</span>
|
||||
</div>
|
||||
<div className="wd-summary-stat">
|
||||
<span className="wd-summary-stat-label">24h 거래대금</span>
|
||||
<span className="wd-summary-stat-val">{fmtKrwShort(stats.totalVol)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="wd-index-list">
|
||||
{indices.map(t => {
|
||||
if (!t) return null;
|
||||
const cls = changeColorClass(t.changeRate);
|
||||
return (
|
||||
<li key={t.market}>
|
||||
<button type="button" className="wd-index-row" onClick={() => setSelectedMarket(t.market)}>
|
||||
<span className="wd-index-name">{t.koreanName || t.market.replace('KRW-', '')}</span>
|
||||
<span className={`wd-index-price ${cls}`}>{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}</span>
|
||||
<span className={`wd-index-chg ${cls}`}>{fmtPct(t.changeRate)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketSummaryWidgetHost;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import PaperMiniChart from '../../components/paper/PaperMiniChart';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { resolveName } from '../widgetMarketUtils';
|
||||
|
||||
const MiniChartWidgetHost: React.FC = () => {
|
||||
const { selectedMarket, theme, tickers } = useWidgetDashboard();
|
||||
const ticker = tickers.get(selectedMarket);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel wd-widget-host--mini-chart">
|
||||
<WidgetPanelHeader title="미니 차트" />
|
||||
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)} · {selectedMarket}</div>
|
||||
<div className="wd-mini-chart-wrap">
|
||||
<PaperMiniChart market={selectedMarket} theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniChartWidgetHost;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import { VirtualScroll } from '../../components/common/VirtualScroll';
|
||||
import TradeNotificationListRow from '../../components/tradeNotification/TradeNotificationListRow';
|
||||
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 알림 목록 — TradeNotificationListRow 재사용 */
|
||||
const NotificationListWidgetHost: React.FC = () => {
|
||||
const { allNotifications, markAsRead, deleteNotification, openDetail } = useTradeNotification();
|
||||
const { theme, tickers, strategies, setSelectedMarket } = useWidgetDashboard();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
if (allNotifications.length === 0) {
|
||||
return <p className="wd-widget-empty">알림이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--notifications">
|
||||
<VirtualScroll
|
||||
className="wd-notification-scroll vl-scroll"
|
||||
items={allNotifications}
|
||||
estimateSize={120}
|
||||
measureDynamic
|
||||
overscan={3}
|
||||
getItemKey={i => i.id}
|
||||
innerClassName="vl-scroll-inner"
|
||||
aria-label="알림 목록"
|
||||
>
|
||||
{item => (
|
||||
<TradeNotificationListRow
|
||||
item={item}
|
||||
theme={theme}
|
||||
isSelected={selectedId === item.id}
|
||||
layoutMode="list"
|
||||
chartsEnabled={false}
|
||||
ticker={tickers.get(item.market)}
|
||||
onSelect={() => {
|
||||
if (!item.isRead) markAsRead(item.id);
|
||||
setSelectedMarket(item.market);
|
||||
setSelectedId(item.id);
|
||||
}}
|
||||
onDelete={e => {
|
||||
e.stopPropagation();
|
||||
void deleteNotification(item.id);
|
||||
}}
|
||||
onDetail={() => openDetail(item)}
|
||||
onGoToChart={() => setSelectedMarket(item.market)}
|
||||
itemTag="div"
|
||||
prefetchedStrategies={strategies}
|
||||
/>
|
||||
)}
|
||||
</VirtualScroll>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationListWidgetHost;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import TradeOrderbookPanelContent from '../../components/trade/TradeOrderbookPanelContent';
|
||||
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
|
||||
import { useUpbitRecentTrades } from '../../hooks/useUpbitRecentTrades';
|
||||
import { useWidgetDashboard, useWidgetOrderbookFillHandler } from '../WidgetDashboardContext';
|
||||
|
||||
/** 호가 — TradeOrderbookPanelContent 재사용 */
|
||||
const OrderbookWidgetHost: React.FC = () => {
|
||||
const { selectedMarket, tickers } = useWidgetDashboard();
|
||||
const onRowClick = useWidgetOrderbookFillHandler();
|
||||
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
|
||||
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
|
||||
const selectedTicker = tickers.get(selectedMarket);
|
||||
const orderbookPrevClose = selectedTicker
|
||||
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
|
||||
: 0;
|
||||
const orderbookTickerInfo = selectedTicker ? {
|
||||
tradePrice: selectedTicker.tradePrice,
|
||||
changeRate: selectedTicker.changeRate,
|
||||
changePrice: selectedTicker.changePrice,
|
||||
accTradePrice24: selectedTicker.accTradePrice24,
|
||||
accTradeVolume24: selectedTicker.accTradeVolume24,
|
||||
highPrice: selectedTicker.highPrice,
|
||||
lowPrice: selectedTicker.lowPrice,
|
||||
openingPrice: selectedTicker.openingPrice,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--orderbook">
|
||||
<TradeOrderbookPanelContent
|
||||
market={selectedMarket}
|
||||
asks={orderbook.asks}
|
||||
bids={orderbook.bids}
|
||||
totalAskSize={orderbook.totalAskSize}
|
||||
totalBidSize={orderbook.totalBidSize}
|
||||
wsStatus={obWsStatus}
|
||||
bestAsk={spread.bestAsk}
|
||||
bestBid={spread.bestBid}
|
||||
spread={spread.spread}
|
||||
spreadPct={spread.pct}
|
||||
prevClose={orderbookPrevClose}
|
||||
tickerInfo={orderbookTickerInfo}
|
||||
recentTrades={recentTrades}
|
||||
tradeStrength={tradeStrength ?? undefined}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderbookWidgetHost;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import PaperInvestmentKpi from '../../components/paper/PaperInvestmentKpi';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 투자 KPI — PaperInvestmentKpi 재사용 */
|
||||
const PaperKpiWidgetHost: React.FC = () => {
|
||||
const { summary } = useWidgetDashboard();
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--kpi">
|
||||
<PaperInvestmentKpi summary={summary} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperKpiWidgetHost;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { cancelPaperOrder, loadPaperPendingOrders, type PaperOrderDto } from '../../utils/backendApi';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { resolveName } from '../widgetMarketUtils';
|
||||
|
||||
const PendingOrdersWidgetHost: React.FC = () => {
|
||||
const { tickers, refreshPaperData, setSelectedMarket } = useWidgetDashboard();
|
||||
const [orders, setOrders] = useState<PaperOrderDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await loadPaperPendingOrders();
|
||||
setOrders(list ?? []);
|
||||
} catch {
|
||||
setOrders([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => { void load(); }, 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const handleCancel = async (id: number) => {
|
||||
await cancelPaperOrder(id);
|
||||
refreshPaperData();
|
||||
void load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader title="미체결 주문" right={<span className="wd-panel-count">{orders.length}건</span>} />
|
||||
<div className="wd-panel-body">
|
||||
{loading && orders.length === 0 && <p className="wd-widget-empty">주문 조회 중…</p>}
|
||||
{!loading && orders.length === 0 && <p className="wd-widget-empty">미체결 주문이 없습니다.</p>}
|
||||
<ul className="wd-orders-list">
|
||||
{orders.map(o => {
|
||||
const ticker = tickers.get(o.symbol);
|
||||
const isBuy = o.side === 'BUY';
|
||||
return (
|
||||
<li key={o.id} className="wd-order-item">
|
||||
<button type="button" className="wd-order-main" onClick={() => setSelectedMarket(o.symbol)}>
|
||||
<span className={`wd-order-side ${isBuy ? 'wd-up' : 'wd-down'}`}>{isBuy ? '매수' : '매도'}</span>
|
||||
<span className="wd-order-sym">{resolveName(o.symbol, ticker)}</span>
|
||||
<span className="wd-order-price">{(o.limitPrice ?? 0).toLocaleString('ko-KR')}</span>
|
||||
<span className="wd-order-qty">{o.quantity}</span>
|
||||
</button>
|
||||
<button type="button" className="wd-order-cancel" onClick={() => void handleCancel(o.id)}>
|
||||
취소
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingOrdersWidgetHost;
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { changeColorClass, resolveName } from '../widgetMarketUtils';
|
||||
|
||||
const PositionsWidgetHost: React.FC = () => {
|
||||
const { summary, tickers, setSelectedMarket } = useWidgetDashboard();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const positions = summary?.positions ?? [];
|
||||
return positions
|
||||
.filter(p => p.quantity > 0)
|
||||
.map(p => {
|
||||
const ticker = tickers.get(p.symbol);
|
||||
const mark = p.markPrice ?? ticker?.tradePrice ?? p.avgPrice;
|
||||
const evalAmt = p.evalAmount ?? mark * p.quantity;
|
||||
const pnl = p.profitLoss ?? evalAmt - p.avgPrice * p.quantity;
|
||||
const pnlPct = p.profitLossPct ?? (p.avgPrice * p.quantity > 0
|
||||
? (pnl / (p.avgPrice * p.quantity)) * 100
|
||||
: 0);
|
||||
return { ...p, mark, evalAmt, pnl, pnlPct };
|
||||
})
|
||||
.sort((a, b) => b.evalAmt - a.evalAmt);
|
||||
}, [summary, tickers]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader
|
||||
title="보유 종목"
|
||||
right={summary ? (
|
||||
<span className="wd-panel-meta">
|
||||
평가 {summary.stockEvalAmount.toLocaleString('ko-KR')}원
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
<div className="wd-panel-body">
|
||||
{rows.length === 0 && (
|
||||
<p className="wd-widget-empty">보유 중인 종목이 없습니다.</p>
|
||||
)}
|
||||
<ul className="wd-positions-list">
|
||||
{rows.map(row => {
|
||||
const cls = changeColorClass(row.pnlPct / 100);
|
||||
const ticker = tickers.get(row.symbol);
|
||||
return (
|
||||
<li key={row.symbol}>
|
||||
<button type="button" className="wd-position-row" onClick={() => setSelectedMarket(row.symbol)}>
|
||||
<div className="wd-position-top">
|
||||
<span className="wd-position-name">{resolveName(row.symbol, ticker)}</span>
|
||||
<span className={`wd-position-pnl ${cls}`}>
|
||||
{row.pnl >= 0 ? '+' : ''}{row.pnl.toLocaleString('ko-KR')}
|
||||
{' '}({row.pnlPct >= 0 ? '+' : ''}{row.pnlPct.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="wd-position-bottom">
|
||||
<span>{row.quantity.toFixed(4)} @ {row.avgPrice.toLocaleString('ko-KR')}</span>
|
||||
<span>평가 {row.evalAmt.toLocaleString('ko-KR')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionsWidgetHost;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import VirtualTargetQuote from '../../components/virtual/VirtualTargetQuote';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import { changeColorClass, fmtKrwShort, fmtPct, resolveName } from '../widgetMarketUtils';
|
||||
|
||||
const QuoteWidgetHost: React.FC = () => {
|
||||
const { selectedMarket, tickers, setSelectedMarket } = useWidgetDashboard();
|
||||
const ticker = tickers.get(selectedMarket);
|
||||
const cls = changeColorClass(ticker?.changeRate);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<div className="wd-panel-header">
|
||||
<span className="wd-panel-title">{resolveName(selectedMarket, ticker)}</span>
|
||||
<span className="wd-panel-sub">{selectedMarket}</span>
|
||||
</div>
|
||||
<div className="wd-panel-body wd-quote-body">
|
||||
<VirtualTargetQuote market={selectedMarket} ticker={ticker} />
|
||||
<dl className="wd-quote-grid">
|
||||
<div>
|
||||
<dt>시가</dt>
|
||||
<dd>{ticker?.openingPrice?.toLocaleString('ko-KR') ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>고가</dt>
|
||||
<dd className="wd-up">{ticker?.highPrice?.toLocaleString('ko-KR') ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>저가</dt>
|
||||
<dd className="wd-down">{ticker?.lowPrice?.toLocaleString('ko-KR') ?? '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>전일대비</dt>
|
||||
<dd className={cls}>
|
||||
{ticker?.changePrice != null
|
||||
? `${ticker.changePrice >= 0 ? '+' : ''}${ticker.changePrice.toLocaleString('ko-KR')} (${fmtPct(ticker.changeRate)})`
|
||||
: '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>24h 거래대금</dt>
|
||||
<dd>{fmtKrwShort(ticker?.accTradePrice24)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>24h 거래량</dt>
|
||||
<dd>{ticker?.accTradeVolume24?.toLocaleString('ko-KR', { maximumFractionDigits: 2 }) ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button type="button" className="wd-quote-link-btn" onClick={() => setSelectedMarket(selectedMarket)}>
|
||||
차트·호가 연동 종목
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteWidgetHost;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PaperAnalysisChart from '../../components/paper/PaperAnalysisChart';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 전략 분석 차트 — PaperAnalysisChart 재사용 */
|
||||
const StrategyChartWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
|
||||
const { theme, selectedMarket, strategies, selectedStrategyId } = useWidgetDashboard();
|
||||
const strategyId = useMemo(() => {
|
||||
const fromConfig = config?.strategyId;
|
||||
if (typeof fromConfig === 'number' && fromConfig > 0) return fromConfig;
|
||||
if (selectedStrategyId != null && selectedStrategyId > 0) return selectedStrategyId;
|
||||
return strategies[0]?.id ?? null;
|
||||
}, [config?.strategyId, selectedStrategyId, strategies]);
|
||||
|
||||
if (!strategyId) {
|
||||
return <p className="wd-widget-empty">전략을 선택하세요.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--chart">
|
||||
<PaperAnalysisChart
|
||||
market={selectedMarket}
|
||||
strategyId={strategyId}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyChartWidgetHost;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { VirtualScroll } from '../../components/common/VirtualScroll';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 전략 목록 — API 로드된 strategies 재사용 */
|
||||
const StrategyListWidgetHost: React.FC = () => {
|
||||
const { strategies, selectedStrategyId, setSelectedStrategyId } = useWidgetDashboard();
|
||||
|
||||
if (strategies.length === 0) {
|
||||
return <p className="wd-widget-empty">등록된 전략이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--strategy-list">
|
||||
<VirtualScroll
|
||||
className="wd-strategy-scroll vl-scroll"
|
||||
items={strategies}
|
||||
estimateSize={44}
|
||||
overscan={5}
|
||||
getItemKey={s => String(s.id)}
|
||||
innerClassName="vl-scroll-inner wd-strategy-list"
|
||||
aria-label="전략 목록"
|
||||
>
|
||||
{s => (
|
||||
<button
|
||||
type="button"
|
||||
className={`wd-strategy-item${selectedStrategyId === s.id ? ' wd-strategy-item--on' : ''}`}
|
||||
onClick={() => setSelectedStrategyId(s.id ?? null)}
|
||||
>
|
||||
<span className="wd-strategy-item-name">{s.name}</span>
|
||||
{s.description && (
|
||||
<span className="wd-strategy-item-desc">{s.description}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</VirtualScroll>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyListWidgetHost;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import { changeColorClass, fmtPct, resolveName, tickerListFromMap } from '../widgetMarketUtils';
|
||||
|
||||
const TAPE_COUNT = 40;
|
||||
|
||||
const TickerTapeWidgetHost: React.FC = () => {
|
||||
const { tickers, setSelectedMarket } = useWidgetDashboard();
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const tapeItems = useMemo(() => {
|
||||
const list = tickerListFromMap(tickers)
|
||||
.sort((a, b) => (b.accTradePrice24 ?? 0) - (a.accTradePrice24 ?? 0))
|
||||
.slice(0, TAPE_COUNT);
|
||||
return [...list, ...list];
|
||||
}, [tickers]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el || tapeItems.length === 0) return;
|
||||
let frame = 0;
|
||||
let offset = 0;
|
||||
const step = () => {
|
||||
if (!paused) {
|
||||
offset -= 0.6;
|
||||
if (Math.abs(offset) >= el.scrollWidth / 2) offset = 0;
|
||||
el.style.transform = `translateX(${offset}px)`;
|
||||
}
|
||||
frame = requestAnimationFrame(step);
|
||||
};
|
||||
frame = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [tapeItems, paused]);
|
||||
|
||||
if (tapeItems.length === 0) {
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--tape">
|
||||
<p className="wd-widget-empty">티커 데이터 수집 중…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wd-widget-host wd-widget-host--tape"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="wd-tape-viewport">
|
||||
<div ref={trackRef} className="wd-tape-track">
|
||||
{tapeItems.map((t, i) => {
|
||||
const cls = changeColorClass(t.changeRate);
|
||||
return (
|
||||
<button
|
||||
key={`${t.market}-${i}`}
|
||||
type="button"
|
||||
className="wd-tape-item"
|
||||
onClick={() => setSelectedMarket(t.market)}
|
||||
>
|
||||
<span className="wd-tape-name">{resolveName(t.market, t)}</span>
|
||||
<span className="wd-tape-price">{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}</span>
|
||||
<span className={`wd-tape-chg ${cls}`}>{fmtPct(t.changeRate)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TickerTapeWidgetHost;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useUpbitRecentTrades } from '../../hooks/useUpbitRecentTrades';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
import { resolveName } from '../widgetMarketUtils';
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
const TimeSalesWidgetHost: React.FC = () => {
|
||||
const { selectedMarket, tickers } = useWidgetDashboard();
|
||||
const { trades, strength } = useUpbitRecentTrades(selectedMarket, true, 50);
|
||||
const ticker = tickers.get(selectedMarket);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader
|
||||
title="체결"
|
||||
right={strength != null ? (
|
||||
<span className="wd-panel-meta">체결강도 {strength.toFixed(0)}%</span>
|
||||
) : undefined}
|
||||
/>
|
||||
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)} · {selectedMarket}</div>
|
||||
<div className="wd-panel-body wd-timesales-body">
|
||||
<table className="wd-timesales-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>체결가</th>
|
||||
<th>체결량</th>
|
||||
<th>구분</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 && (
|
||||
<tr><td colSpan={4} className="wd-widget-empty">체결 데이터 수집 중…</td></tr>
|
||||
)}
|
||||
{trades.map((t, i) => (
|
||||
<tr key={`${t.time}-${i}`} className={t.side === 'bid' ? 'wd-ts-buy' : 'wd-ts-sell'}>
|
||||
<td>{fmtTime(t.time)}</td>
|
||||
<td>{t.price.toLocaleString('ko-KR')}</td>
|
||||
<td>{t.volume >= 1 ? t.volume.toFixed(4) : t.volume.toFixed(6)}</td>
|
||||
<td>{t.side === 'bid' ? '매수' : '매도'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeSalesWidgetHost;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PaperTradeHistoryList from '../../components/paper/PaperTradeHistoryList';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
|
||||
/** 거래 내역 — PaperTradeHistoryList 재사용 */
|
||||
const TradeHistoryWidgetHost: React.FC = () => {
|
||||
const { trades, strategies, tickers, setSelectedMarket } = useWidgetDashboard();
|
||||
const strategyNames = useMemo(
|
||||
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
|
||||
[strategies],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--history">
|
||||
<PaperTradeHistoryList
|
||||
trades={trades}
|
||||
strategyNames={strategyNames}
|
||||
tickers={tickers}
|
||||
onSelectMarket={setSelectedMarket}
|
||||
className="ptd-trade-history--fill"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeHistoryWidgetHost;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FAVORITES_CHANGED_EVENT, getFavorites, toggleFavorite } from '../../utils/marketStorage';
|
||||
import { useWidgetDashboard } from '../WidgetDashboardContext';
|
||||
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
|
||||
|
||||
const WatchlistWidgetHost: React.FC = () => {
|
||||
const { tickers, selectedMarket, setSelectedMarket } = useWidgetDashboard();
|
||||
const [favorites, setFavorites] = useState<string[]>(() => getFavorites());
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (e: Event) => {
|
||||
setFavorites((e as CustomEvent<string[]>).detail ?? getFavorites());
|
||||
};
|
||||
window.addEventListener(FAVORITES_CHANGED_EVENT, onChange);
|
||||
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(async (market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const row = tickers.get(market);
|
||||
try {
|
||||
await toggleFavorite(market, { koreanName: row?.koreanName });
|
||||
setFavorites(getFavorites());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [tickers]);
|
||||
|
||||
return (
|
||||
<div className="wd-widget-host wd-widget-host--panel">
|
||||
<WidgetPanelHeader title="관심종목" right={<span className="wd-panel-count">{favorites.length}종목</span>} />
|
||||
<div className="wd-panel-body">
|
||||
{favorites.length === 0 && (
|
||||
<p className="wd-widget-empty">관심종목이 없습니다. 종목 목록에서 ★를 눌러 추가하세요.</p>
|
||||
)}
|
||||
{favorites.length > 0 && (
|
||||
<ul className="wd-symbol-list">
|
||||
{favorites.map(market => {
|
||||
const t = tickers.get(market);
|
||||
if (!t) {
|
||||
return (
|
||||
<li key={market}>
|
||||
<button type="button" className="wd-symbol-row" onClick={() => setSelectedMarket(market)}>
|
||||
<span className="wd-symbol-name">{market}</span>
|
||||
<span className="wd-widget-empty-inline">시세 대기</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const rate = t.changeRate;
|
||||
const cls = rate != null && rate > 0 ? 'wd-up' : rate != null && rate < 0 ? 'wd-down' : 'wd-muted';
|
||||
return (
|
||||
<li key={market}>
|
||||
<div
|
||||
className={`wd-symbol-row${market === selectedMarket ? ' wd-symbol-row--on' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="wd-watch-star"
|
||||
title="관심 해제"
|
||||
onClick={e => void handleToggle(market, e)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wd-symbol-row-main"
|
||||
onClick={() => setSelectedMarket(market)}
|
||||
>
|
||||
<span className="wd-symbol-name">{t.koreanName || market.replace('KRW-', '')}</span>
|
||||
<span className={`wd-symbol-price ${cls}`}>{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}</span>
|
||||
<span className={`wd-symbol-chg ${cls}`}>
|
||||
{rate != null ? `${(rate * 100).toFixed(2)}%` : '-'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchlistWidgetHost;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
tabs?: Tab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
const WidgetPanelHeader: React.FC<Props> = ({
|
||||
title,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
right,
|
||||
}) => (
|
||||
<div className="wd-panel-header">
|
||||
{title && <span className="wd-panel-title">{title}</span>}
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className="wd-panel-tabs" role="tablist">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
className={`wd-panel-tab${activeTab === tab.id ? ' wd-panel-tab--on' : ''}`}
|
||||
onClick={() => onTabChange?.(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{right && <div className="wd-panel-header-right">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default WidgetPanelHeader;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { changeColorClass, fmtKrwShort, fmtPct, resolveName } from '../widgetMarketUtils';
|
||||
|
||||
interface Props {
|
||||
items: TickerData[];
|
||||
activeMarket?: string;
|
||||
onSelect?: (market: string) => void;
|
||||
showVolume?: boolean;
|
||||
rank?: boolean;
|
||||
}
|
||||
|
||||
const WidgetSymbolList: React.FC<Props> = ({
|
||||
items,
|
||||
activeMarket,
|
||||
onSelect,
|
||||
showVolume = false,
|
||||
rank = false,
|
||||
}) => {
|
||||
if (items.length === 0) {
|
||||
return <p className="wd-widget-empty">표시할 종목이 없습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="wd-symbol-list">
|
||||
{items.map((t, i) => {
|
||||
const rate = t.changeRate;
|
||||
const cls = changeColorClass(rate);
|
||||
return (
|
||||
<li key={t.market}>
|
||||
<button
|
||||
type="button"
|
||||
className={`wd-symbol-row${t.market === activeMarket ? ' wd-symbol-row--on' : ''}`}
|
||||
onClick={() => onSelect?.(t.market)}
|
||||
>
|
||||
{rank && <span className="wd-symbol-rank">{i + 1}</span>}
|
||||
<span className="wd-symbol-name">{resolveName(t.market, t)}</span>
|
||||
<span className="wd-symbol-code">{t.market.replace('KRW-', '')}</span>
|
||||
<span className={`wd-symbol-price ${cls}`}>
|
||||
{t.tradePrice?.toLocaleString('ko-KR') ?? '-'}
|
||||
</span>
|
||||
<span className={`wd-symbol-chg ${cls}`}>{fmtPct(rate)}</span>
|
||||
{showVolume && (
|
||||
<span className="wd-symbol-vol">{fmtKrwShort(t.accTradePrice24)}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetSymbolList;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TickerData } from '../hooks/useMarketTicker';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { safePercentFromRate } from '../utils/safeFormat';
|
||||
|
||||
export type MoverSortKey = 'gainers' | 'losers' | 'volume';
|
||||
|
||||
export function tickerListFromMap(tickers: Map<string, TickerData>): TickerData[] {
|
||||
return [...tickers.values()].filter(t => t.market.startsWith('KRW-') && t.tradePrice != null);
|
||||
}
|
||||
|
||||
export function sortTickers(items: TickerData[], key: MoverSortKey, limit = 15): TickerData[] {
|
||||
const copy = [...items];
|
||||
if (key === 'gainers') {
|
||||
return copy
|
||||
.filter(t => (t.changeRate ?? 0) > 0)
|
||||
.sort((a, b) => (b.changeRate ?? 0) - (a.changeRate ?? 0))
|
||||
.slice(0, limit);
|
||||
}
|
||||
if (key === 'losers') {
|
||||
return copy
|
||||
.filter(t => (t.changeRate ?? 0) < 0)
|
||||
.sort((a, b) => (a.changeRate ?? 0) - (b.changeRate ?? 0))
|
||||
.slice(0, limit);
|
||||
}
|
||||
return copy
|
||||
.sort((a, b) => (b.accTradePrice24 ?? 0) - (a.accTradePrice24 ?? 0))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function changeColorClass(rate: number | null | undefined): string {
|
||||
if (rate == null || rate === 0) return 'wd-muted';
|
||||
return rate > 0 ? 'wd-up' : 'wd-down';
|
||||
}
|
||||
|
||||
export function fmtPct(rate: number | null | undefined): string {
|
||||
return safePercentFromRate(rate, 2).replace('—', '-');
|
||||
}
|
||||
|
||||
export function fmtKrwShort(n: number | null | undefined): string {
|
||||
if (n == null || !Number.isFinite(n)) return '-';
|
||||
if (n >= 1_0000_0000) return `${(n / 1_0000_0000).toFixed(1)}억`;
|
||||
if (n >= 1_0000) return `${Math.round(n / 1_0000).toLocaleString('ko-KR')}만`;
|
||||
return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
export function resolveName(market: string, ticker?: TickerData): string {
|
||||
return ticker?.koreanName || getKoreanName(market) || market.replace('KRW-', '');
|
||||
}
|
||||
|
||||
export function heatmapColor(rate: number | null | undefined): string {
|
||||
if (rate == null || rate === 0) return 'rgba(120, 130, 150, 0.35)';
|
||||
const intensity = Math.min(1, Math.abs(rate) * 8);
|
||||
if (rate > 0) return `rgba(231, 76, 60, ${0.25 + intensity * 0.55})`;
|
||||
return `rgba(52, 152, 219, ${0.25 + intensity * 0.55})`;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import type React from 'react';
|
||||
import type { WidgetCategory } from '../types/widgetDashboard';
|
||||
import CandleChartWidgetHost from './hosts/CandleChartWidgetHost';
|
||||
import StrategyChartWidgetHost from './hosts/StrategyChartWidgetHost';
|
||||
import MiniChartWidgetHost from './hosts/MiniChartWidgetHost';
|
||||
import MarketListWidgetHost from './hosts/MarketListWidgetHost';
|
||||
import MarketMoversWidgetHost from './hosts/MarketMoversWidgetHost';
|
||||
import TickerTapeWidgetHost from './hosts/TickerTapeWidgetHost';
|
||||
import QuoteWidgetHost from './hosts/QuoteWidgetHost';
|
||||
import WatchlistWidgetHost from './hosts/WatchlistWidgetHost';
|
||||
import MarketHeatmapWidgetHost from './hosts/MarketHeatmapWidgetHost';
|
||||
import MarketSummaryWidgetHost from './hosts/MarketSummaryWidgetHost';
|
||||
import BuySellWidgetHost from './hosts/BuySellWidgetHost';
|
||||
import OrderbookWidgetHost from './hosts/OrderbookWidgetHost';
|
||||
import DepthChartWidgetHost from './hosts/DepthChartWidgetHost';
|
||||
import TimeSalesWidgetHost from './hosts/TimeSalesWidgetHost';
|
||||
import TradeHistoryWidgetHost from './hosts/TradeHistoryWidgetHost';
|
||||
import PendingOrdersWidgetHost from './hosts/PendingOrdersWidgetHost';
|
||||
import PositionsWidgetHost from './hosts/PositionsWidgetHost';
|
||||
import AccountBalanceWidgetHost from './hosts/AccountBalanceWidgetHost';
|
||||
import NotificationListWidgetHost from './hosts/NotificationListWidgetHost';
|
||||
import StrategyListWidgetHost from './hosts/StrategyListWidgetHost';
|
||||
import BacktestListWidgetHost from './hosts/BacktestListWidgetHost';
|
||||
import PaperKpiWidgetHost from './hosts/PaperKpiWidgetHost';
|
||||
|
||||
export type WidgetGroupId =
|
||||
| 'chart'
|
||||
| 'market'
|
||||
| 'trade'
|
||||
| 'portfolio'
|
||||
| 'strategy'
|
||||
| 'alert';
|
||||
|
||||
export interface WidgetDefinition {
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: WidgetCategory;
|
||||
group: WidgetGroupId;
|
||||
defaultConfig?: Record<string, unknown>;
|
||||
Host: React.FC<{ config?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
export const WIDGET_GROUP_LABELS: Record<WidgetGroupId, string> = {
|
||||
chart: '차트',
|
||||
market: '시세·종목',
|
||||
trade: '매매·호가',
|
||||
portfolio: '계좌·포트폴리오',
|
||||
strategy: '전략·분석',
|
||||
alert: '알림',
|
||||
};
|
||||
|
||||
export const WIDGET_REGISTRY: WidgetDefinition[] = [
|
||||
{
|
||||
type: 'chart.candle',
|
||||
label: '캔들 차트',
|
||||
description: '종목 검색 + 좌·우 툴바 실시간 캔들',
|
||||
category: 'center',
|
||||
group: 'chart',
|
||||
Host: CandleChartWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'chart.strategy',
|
||||
label: '전략 분석 차트',
|
||||
description: '선택 전략 백테스트 시그널 차트',
|
||||
category: 'center',
|
||||
group: 'chart',
|
||||
Host: StrategyChartWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'chart.mini',
|
||||
label: '미니 차트',
|
||||
description: '선택 종목 소형 캔들 차트',
|
||||
category: 'center',
|
||||
group: 'chart',
|
||||
Host: MiniChartWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.list',
|
||||
label: '종목 목록',
|
||||
description: 'KRW·BTC·보유·관심 종목',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: MarketListWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.watchlist',
|
||||
label: '관심종목',
|
||||
description: '즐겨찾기 종목 컴팩트 목록',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: WatchlistWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.movers',
|
||||
label: '급등·급락',
|
||||
description: '상승·하락·거래대금 TOP 종목',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: MarketMoversWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.tickerTape',
|
||||
label: '티커 테이프',
|
||||
description: '주요 종목 시세 흐름 스크롤',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: TickerTapeWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.quote',
|
||||
label: '종목 시세',
|
||||
description: '선택 종목 상세 시세·OHLC',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: QuoteWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.heatmap',
|
||||
label: '히트맵',
|
||||
description: '등락률 색상 그리드',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: MarketHeatmapWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'market.summary',
|
||||
label: '시장 요약',
|
||||
description: '상승/하락 종목 수·주요 지수',
|
||||
category: 'side',
|
||||
group: 'market',
|
||||
Host: MarketSummaryWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'trade.buySell',
|
||||
label: '매수·매도',
|
||||
description: '모의 매매 주문 패널',
|
||||
category: 'side',
|
||||
group: 'trade',
|
||||
Host: BuySellWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'trade.orderbook',
|
||||
label: '호가',
|
||||
description: '실시간 호가·체결',
|
||||
category: 'side',
|
||||
group: 'trade',
|
||||
Host: OrderbookWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'trade.depth',
|
||||
label: '호가 깊이',
|
||||
description: '매수·매도 호가 깊이 차트',
|
||||
category: 'side',
|
||||
group: 'trade',
|
||||
Host: DepthChartWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'trade.timeSales',
|
||||
label: '체결',
|
||||
description: '실시간 체결(Time & Sales)',
|
||||
category: 'side',
|
||||
group: 'trade',
|
||||
Host: TimeSalesWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'trade.history',
|
||||
label: '거래 내역',
|
||||
description: '모의·가상 체결 이력',
|
||||
category: 'side',
|
||||
group: 'trade',
|
||||
Host: TradeHistoryWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'trade.pendingOrders',
|
||||
label: '미체결 주문',
|
||||
description: '대기 중인 모의 주문',
|
||||
category: 'side',
|
||||
group: 'trade',
|
||||
Host: PendingOrdersWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'paper.kpi',
|
||||
label: '투자 KPI',
|
||||
description: '총자산·수익률·예수금',
|
||||
category: 'side',
|
||||
group: 'portfolio',
|
||||
Host: PaperKpiWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'paper.balance',
|
||||
label: '계좌 잔고',
|
||||
description: '예수금·평가·손익 상세',
|
||||
category: 'side',
|
||||
group: 'portfolio',
|
||||
Host: AccountBalanceWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'paper.positions',
|
||||
label: '보유 종목',
|
||||
description: '포지션·평가손익',
|
||||
category: 'side',
|
||||
group: 'portfolio',
|
||||
Host: PositionsWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'notification.list',
|
||||
label: '알림 목록',
|
||||
description: '매매 시그널 알림',
|
||||
category: 'side',
|
||||
group: 'alert',
|
||||
Host: NotificationListWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'strategy.list',
|
||||
label: '전략 목록',
|
||||
description: '등록된 투자 전략',
|
||||
category: 'side',
|
||||
group: 'strategy',
|
||||
Host: StrategyListWidgetHost,
|
||||
},
|
||||
{
|
||||
type: 'backtest.list',
|
||||
label: '백테스트 목록',
|
||||
description: '백테스팅·실시간 매매 실행',
|
||||
category: 'side',
|
||||
group: 'strategy',
|
||||
Host: BacktestListWidgetHost,
|
||||
},
|
||||
];
|
||||
|
||||
const byType = new Map(WIDGET_REGISTRY.map(w => [w.type, w]));
|
||||
|
||||
export function getWidgetDefinition(type: string | null | undefined): WidgetDefinition | undefined {
|
||||
if (!type) return undefined;
|
||||
return byType.get(type);
|
||||
}
|
||||
|
||||
export function widgetsForCategory(category: WidgetCategory): WidgetDefinition[] {
|
||||
return WIDGET_REGISTRY.filter(w => w.category === category);
|
||||
}
|
||||
|
||||
export function widgetsByGroup(category: WidgetCategory): { group: WidgetGroupId; label: string; items: WidgetDefinition[] }[] {
|
||||
const items = widgetsForCategory(category);
|
||||
const order: WidgetGroupId[] = category === 'center'
|
||||
? ['chart']
|
||||
: ['market', 'trade', 'portfolio', 'strategy', 'alert'];
|
||||
return order
|
||||
.map(group => ({
|
||||
group,
|
||||
label: WIDGET_GROUP_LABELS[group],
|
||||
items: items.filter(w => w.group === group),
|
||||
}))
|
||||
.filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
/** 플로팅 위젯 — 메인·패널 위젯 전체 */
|
||||
export function widgetsByGroupAll(): { group: WidgetGroupId; label: string; items: WidgetDefinition[] }[] {
|
||||
return [
|
||||
...widgetsByGroup('center'),
|
||||
...widgetsByGroup('side'),
|
||||
];
|
||||
}
|
||||
|
||||
export function widgetLabel(type: string | null | undefined): string {
|
||||
return getWidgetDefinition(type)?.label ?? '위젯 선택';
|
||||
}
|
||||
Reference in New Issue
Block a user