투자관리 화면 수정

This commit is contained in:
Macbook
2026-05-31 17:02:39 +09:00
parent 78f00dbaa5
commit d6c2f3e451
8 changed files with 251 additions and 13 deletions
+5
View File
@@ -1082,6 +1082,11 @@ html.theme-blue {
cursor: grabbing;
}
/* 캔들 pane 상단 — 표시 설정 그룹 (⋯ 메뉴, 투자관리 차트 탭) */
.chart-right-toolbar-cluster--overlay {
z-index: 4;
}
/* 우측 툴바 플라이아웃 — 버튼 왼쪽으로 펼침 */
.tv-side-flyout--right-anchor {
transform: translateX(-100%);
+72 -1
View File
@@ -7,12 +7,25 @@ import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { getPaneHostId } from '../utils/indicatorPaneMerge';
import { buildPaneItems, resolvePaneItemLayout } from './paneLegendLayout';
import { PAPER_OVERLAY_MENU_TITLE } from '../utils/paperChartOverlayVisibility';
/** 캔들 오버레이 표시 설정 그룹 메뉴 id */
export const CANDLE_OVERLAY_MENU_ID = '__candle_overlay_visibility__';
export interface CandleOverlayToggleItem {
key: string;
label: string;
visible: boolean;
}
export interface ChartRightToolbarProps {
manager: ChartManager;
indicators: IndicatorConfig[];
chartHeight: number;
paused?: boolean;
/** 캔들 pane 상단 — 이동평균·일목·볼린저 표시/숨김 (투자관리 차트 탭) */
candleOverlayToggles?: CandleOverlayToggleItem[];
onToggleCandleOverlay?: (key: string) => void;
onSplit?: (hostId: string) => void;
onRemove?: (id: string) => void;
onDuplicate?: (id: string) => void;
@@ -210,6 +223,8 @@ function buildMenuItems(
const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
manager, indicators, chartHeight, paused = false,
candleOverlayToggles,
onToggleCandleOverlay,
onSplit, onRemove, onDuplicate,
onExpand, onRestore, focusedId,
onDragStart, onToggleHidden, onSettings,
@@ -279,16 +294,50 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
const allowDrag = !!(onDragStart && paneItems.length > 1);
const clusterHeight = (allowDrag ? BTN_SIZE + BTN_GAP : 0) + BTN_SIZE;
const openItem = openMenuId
const openItem = openMenuId && openMenuId !== CANDLE_OVERLAY_MENU_ID
? paneItems.find(p => p.id === openMenuId)
: null;
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
const showOverlayMenu = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
const overlayMenuOpen = openMenuId === CANDLE_OVERLAY_MENU_ID;
const overlayMenuItems: MenuItemDef[] = showOverlayMenu
? candleOverlayToggles!.map(item => ({
key: item.key,
title: item.visible ? `${item.label} 숨기기` : `${item.label} 표시`,
icon: <IconEye hidden={!item.visible} />,
active: !item.visible,
onClick: () => {
onToggleCandleOverlay?.(item.key);
},
}))
: [];
return (
<aside className="chart-right-toolbar" aria-label="차트 우측 도구">
<div
className="chart-right-toolbar-inner"
style={{ height: Math.max(innerHeight, chartHeight) }}
>
{showOverlayMenu && (
<div
className="chart-right-toolbar-cluster chart-right-toolbar-cluster--overlay"
style={{ top: candlePane!.topY + 4 }}
>
<button
type="button"
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--menu${overlayMenuOpen ? ' active' : ''}`}
title="캔들 지표 표시 설정"
onMouseEnter={e => openFlyout(CANDLE_OVERLAY_MENU_ID, e.currentTarget)}
onMouseLeave={scheduleClose}
>
<IconMore />
<span className="tv-side-expand-arrow" />
</button>
</div>
)}
{paneLayouts.map(layout => (
<div
key={layout.paneIndex}
@@ -348,6 +397,28 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
})}
</div>
{overlayMenuOpen && overlayMenuItems.length > 0 && (
<div
className="tv-side-flyout tv-side-flyout--right-anchor"
style={{ top: flyoutPos.top, left: flyoutPos.left }}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
<div className="tv-side-flyout-header">{PAPER_OVERLAY_MENU_TITLE}</div>
{overlayMenuItems.map(mi => (
<button
key={mi.key}
type="button"
className={`tv-side-flyout-item${mi.active ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); mi.onClick(); }}
>
<span className="tv-side-flyout-icon">{mi.icon}</span>
<span className="tv-side-flyout-title">{mi.title}</span>
</button>
))}
</div>
)}
{openItem && openMenuId && (() => {
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
+16 -6
View File
@@ -50,7 +50,7 @@ function canApplyChartBars(
}
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
import ChartRightToolbar from './ChartRightToolbar';
import ChartRightToolbar, { type CandleOverlayToggleItem } from './ChartRightToolbar';
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
@@ -161,6 +161,9 @@ interface TradingChartProps {
defaultCandleOnly?: boolean;
/** 캔들 pane 좌하단 전체보기/복원 버튼 (기본 true) */
showCandlePaneControls?: boolean;
/** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
candleOverlayToggles?: CandleOverlayToggleItem[];
onToggleCandleOverlay?: (key: string) => void;
}
const TradingChart: React.FC<TradingChartProps> = ({
@@ -203,6 +206,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
dataLoading = false,
defaultCandleOnly = false,
showCandlePaneControls = true,
candleOverlayToggles,
onToggleCandleOverlay,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -401,21 +406,24 @@ const TradingChart: React.FC<TradingChartProps> = ({
required = mgr.resetPaneHeights(wrapperH);
}
const w = wrapper.clientWidth;
// LWC는 chart-container에 마운트 — 우측 툴바(44px) 제외 너비로 맞춰 가격 라벨이 가리지 않음
const plotW = Math.max(0, container.clientWidth);
const plotH = Math.max(0, container.clientHeight > 0 ? container.clientHeight : wrapperH);
if (candleOnly || paneLayoutClamp) {
// 전체보기·미니차트: 래퍼 높이에 고정 resize (autoSize 피드백 루프·무한 스크롤 방지)
try {
if (w > 0 && wrapperH > 0) mgr.resize(w, wrapperH);
} catch { /* ok */ }
container.style.height = `${wrapperH}px`;
const clampH = wrapperH > 0 ? wrapperH : plotH;
try {
if (plotW > 0 && clampH > 0) mgr.resize(plotW, clampH);
} catch { /* ok */ }
if (candleOnly) wrapper.scrollTop = 0;
} else if (required > wrapperH + 4) {
container.style.height = `${required}px`;
} else {
container.style.height = '';
try {
if (w > 0 && wrapperH > 0) mgr.resize(w, wrapperH);
if (plotW > 0 && plotH > 0) mgr.resize(plotW, plotH);
} catch { /* ok */ }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1560,6 +1568,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
indicators={indicators}
chartHeight={chartBodyHeight}
paused={paneLegendPaused}
candleOverlayToggles={candleOverlayToggles}
onToggleCandleOverlay={onToggleCandleOverlay}
onSplit={onSplitIndicatorPane}
onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator}
@@ -17,8 +17,15 @@ import {
countNonOverlayIndicatorPanes,
} from '../../utils/strategyOscillatorSeries';
import { normalizeSmaConfig } from '../../utils/smaConfig';
import {
applyPaperOverlayVisibility,
listPaperOverlayToggleItems,
type PaperOverlayToggleKey,
type PaperOverlayVisibility,
} from '../../utils/paperChartOverlayVisibility';
import {
buildVirtualTradingChartIndicators,
ensurePaperChartOverlays,
resolveStrategyPrimaryTimeframe,
} from '../../utils/strategyToChartIndicators';
@@ -35,6 +42,9 @@ interface Props {
onHistoryBarsLoaded?: (bars: OHLCVBar[]) => void;
/** 과거 로드·시그널 재계산 중 UI */
onHistoryLoadingChange?: (loading: boolean) => void;
/** 투자관리 차트 탭 — 캔들 오버레이 표시/숨김 (세션만, DB 미저장) */
overlayVisibility?: PaperOverlayVisibility;
onToggleOverlay?: (key: PaperOverlayToggleKey) => void;
}
const noop = () => {};
@@ -82,6 +92,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
theme = 'dark',
onHistoryBarsLoaded,
onHistoryLoadingChange,
overlayVisibility,
onToggleOverlay,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const { defaults: appDefaults } = useAppSettings();
@@ -118,13 +130,29 @@ const BacktestAnalysisChart: React.FC<Props> = ({
return () => { cancelled = true; };
}, [strategyId]);
const indicators = useMemo(() => {
const baseIndicators = useMemo(() => {
let inds: IndicatorConfig[];
if (strategy) {
const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
} else {
inds = defaultOverlayIndicators(getParams);
}
return defaultOverlayIndicators(getParams);
}, [strategy, getParams, getVisualConfig, chartTimeframe]);
if (overlayVisibility) {
inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig);
}
return inds;
}, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility]);
const indicators = useMemo(() => {
if (!overlayVisibility) return baseIndicators;
return applyPaperOverlayVisibility(baseIndicators, overlayVisibility);
}, [baseIndicators, overlayVisibility]);
const candleOverlayToggles = useMemo(() => {
if (!overlayVisibility || !onToggleOverlay) return undefined;
return listPaperOverlayToggleItems(baseIndicators, overlayVisibility);
}, [baseIndicators, overlayVisibility, onToggleOverlay]);
const auxPaneCount = useMemo(
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
@@ -321,6 +349,10 @@ const BacktestAnalysisChart: React.FC<Props> = ({
indicatorAreaPriceLabelsEnabled={priceLabels}
seriesPriceLabelsEnabled={priceLabels}
showHoverToolbar
candleOverlayToggles={candleOverlayToggles}
onToggleCandleOverlay={onToggleOverlay
? (key) => onToggleOverlay(key as PaperOverlayToggleKey)
: undefined}
/>
)}
{!loading && !error && bars.length === 0 && (
@@ -12,6 +12,11 @@ import { normalizeChartTimeframe } from '../../utils/backtestUiUtils';
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
import type { OHLCVBar, Theme } from '../../types';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import {
DEFAULT_PAPER_OVERLAY_VISIBILITY,
type PaperOverlayToggleKey,
type PaperOverlayVisibility,
} from '../../utils/paperChartOverlayVisibility';
const BAR_COUNT = 300;
const HISTORY_BACKTEST_DEBOUNCE_MS = 450;
@@ -34,6 +39,10 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
const [running, setRunning] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** 종목 변경해도 유지 — 투자관리 차트 탭 세션 전용 (DB·설정 저장 없음) */
const [overlayVisibility, setOverlayVisibility] = useState<PaperOverlayVisibility>(
() => ({ ...DEFAULT_PAPER_OVERLAY_VISIBILITY }),
);
const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]);
const backtestGenRef = useRef(0);
const historyDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -141,6 +150,10 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
};
}, [market, strategyId, strategy, toTimeSec, runSignalsForBars]);
const handleToggleOverlay = useCallback((key: PaperOverlayToggleKey) => {
setOverlayVisibility(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
const handleHistoryBarsLoaded = useCallback((bars: OHLCVBar[]) => {
const strat = strategyRef.current;
const sid = strategyIdRef.current;
@@ -189,6 +202,8 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
theme={theme}
onHistoryBarsLoaded={handleHistoryBarsLoaded}
onHistoryLoadingChange={setHistoryLoading}
overlayVisibility={overlayVisibility}
onToggleOverlay={handleToggleOverlay}
/>
</div>
);
+2 -1
View File
@@ -113,8 +113,9 @@ export function areIchimokuSpanLinesVisible(config: IndicatorConfig): boolean {
);
}
/** 구름 영역을 그릴 수 있는지 (선행스팬 + 상승/하락 구름 중 하나 이상 on) */
/** 구름 영역을 그릴 수 있는지 (지표 표시 on + 선행스팬 + 구름 색상 설정) */
export function isIchimokuCloudVisible(config: IndicatorConfig): boolean {
if (config.hidden === true) return false;
if (!areIchimokuSpanLinesVisible(config)) return false;
const colors = resolveIchimokuCloudColors(config.cloudColors);
return colors.bullishVisible !== false || colors.bearishVisible !== false;
@@ -0,0 +1,69 @@
import type { IndicatorConfig } from '../types';
/** 투자관리 차트 탭 — 캔들 오버레이 표시/숨김 (저장 없음, 화면 세션만) */
export type PaperOverlayToggleKey = 'ma' | 'ichimoku' | 'bollinger';
export type PaperOverlayVisibility = Record<PaperOverlayToggleKey, boolean>;
export const DEFAULT_PAPER_OVERLAY_VISIBILITY: PaperOverlayVisibility = {
ma: true,
ichimoku: true,
bollinger: true,
};
const MA_TYPES = new Set(['SMA', 'EMA']);
export const PAPER_OVERLAY_TOGGLE_ORDER: PaperOverlayToggleKey[] = [
'ma',
'ichimoku',
'bollinger',
];
export const PAPER_OVERLAY_LABELS: Record<PaperOverlayToggleKey, string> = {
ma: '이동평균선',
ichimoku: '일목균형표',
bollinger: '볼린저 밴드',
};
/** 우측 툴바 표시 설정 그룹 메뉴 헤더 */
export const PAPER_OVERLAY_MENU_TITLE = '표시 설정';
export function paperOverlayKeyForType(type: string): PaperOverlayToggleKey | null {
if (MA_TYPES.has(type)) return 'ma';
if (type === 'IchimokuCloud') return 'ichimoku';
if (type === 'BollingerBands') return 'bollinger';
return null;
}
export function applyPaperOverlayVisibility(
indicators: IndicatorConfig[],
visibility: PaperOverlayVisibility,
): IndicatorConfig[] {
return indicators.map(ind => {
const key = paperOverlayKeyForType(ind.type);
if (!key) return ind;
const visible = visibility[key];
return { ...ind, hidden: !visible };
});
}
/** 투자관리 차트 — 표시 설정 메뉴에 항상 노출할 오버레이 (지표 인스턴스 유무와 무관) */
const PAPER_OVERLAY_ALWAYS_MENU: PaperOverlayToggleKey[] = ['ma', 'bollinger'];
export function listPaperOverlayToggleItems(
indicators: IndicatorConfig[],
visibility: PaperOverlayVisibility,
): Array<{ key: PaperOverlayToggleKey; label: string; visible: boolean }> {
const present = new Set<PaperOverlayToggleKey>(PAPER_OVERLAY_ALWAYS_MENU);
for (const ind of indicators) {
const key = paperOverlayKeyForType(ind.type);
if (key) present.add(key);
}
return PAPER_OVERLAY_TOGGLE_ORDER
.filter(key => present.has(key))
.map(key => ({
key,
label: PAPER_OVERLAY_LABELS[key],
visible: visibility[key],
}));
}
@@ -248,6 +248,41 @@ export function buildStrategyChartIndicators(
return result;
}
const MA_OVERLAY_TYPES = new Set(['SMA', 'EMA']);
/**
* 투자관리 차트 탭 — 캔들 영역에 이동평균(SMA)·볼린저밴드를 기본 포함
* (전략 조건에 없어도 표시 설정 메뉴로 표시/숨김 가능)
*/
export function ensurePaperChartOverlays(
indicators: IndicatorConfig[],
getParams: GetParams,
getVisual: GetVisual,
): IndicatorConfig[] {
const hasMa = indicators.some(i => MA_OVERLAY_TYPES.has(i.type));
const hasBb = indicators.some(i => i.type === 'BollingerBands');
const prepend: IndicatorConfig[] = [];
if (!hasMa) {
const sma = buildOneIndicator(
{ dslType: 'MA', registryType: 'SMA' },
getParams,
getVisual,
);
if (sma) prepend.push(sma);
}
if (!hasBb) {
const bb = buildOneIndicator(
{ dslType: 'BOLLINGER', registryType: 'BollingerBands' },
getParams,
getVisual,
);
if (bb) prepend.push(bb);
}
if (prepend.length === 0) return indicators;
return [...prepend, ...indicators];
}
/** 가상투자 차트 — 전략 지표 + 기본 일목균형표 */
export function buildVirtualTradingChartIndicators(
strategy: StrategyDto | undefined,