From 8de3b77479c9daacbc1a55c45c9bc1fe9c2dc4d2 Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 3 Jun 2026 02:04:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=A1=ED=85=8C=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TradingChart.tsx | 1 + .../src/components/VirtualTradingPage.tsx | 18 ++---- .../virtual/VirtualLeftTargetPanel.tsx | 2 +- .../virtual/VirtualStrategyChartPopup.tsx | 5 +- .../components/virtual/VirtualTargetCard.tsx | 37 +++++++++++-- .../virtual/VirtualTargetCardChart.tsx | 1 + .../virtual/VirtualTargetFocusView.tsx | 29 ++++++++-- .../components/virtual/VirtualTargetGrid.tsx | 4 ++ frontend/src/hooks/useVirtualTradingCore.ts | 21 +++---- .../src/styles/virtualTradingDashboard.css | 13 +++++ frontend/src/utils/ChartManager.ts | 55 +++++++++++-------- frontend/src/utils/virtualTargetMutations.ts | 4 +- frontend/src/utils/virtualTargetNames.ts | 12 ++++ frontend/src/utils/virtualTargetStrategy.ts | 40 +++++++------- 14 files changed, 156 insertions(+), 86 deletions(-) diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index a9f1285..06fc989 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -812,6 +812,7 @@ const TradingChart: React.FC = ({ const mgr = new ChartManager(containerRef.current, theme, { compactPaneLayout: paneLayoutClamp, autoSize: !paneLayoutClamp, + volumePaneEnabled: volumeVisibleRef.current, }); managerRef.current = mgr; setChartMgr(mgr); diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 7603141..ed25f67 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -58,7 +58,7 @@ import { import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { - coalesceTargetsToDefaultStrategy, + freezeTargetsBeforeGlobalStrategyChange, resolveVirtualTargetStrategyId, } from '../utils/virtualTargetStrategy'; import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits'; @@ -165,12 +165,6 @@ const VirtualTradingPage: React.FC = ({ useEffect(() => { saveVirtualTargets(targets); }, [targets]); useEffect(() => { saveVirtualSession(session); }, [session]); - /** 예전 데이터: 종목 strategyId에 전역 ID가 복사된 경우 → 기본 전략 따름(null) */ - useEffect(() => { - if (session.globalStrategyId == null) return; - setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId)); - }, [session.globalStrategyId]); - useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]); @@ -299,13 +293,11 @@ const VirtualTradingPage: React.FC = ({ }); }, []); + /** 상단 기본전략 — 신규 추가 종목 템플릿만 변경, 기존 종목 전략은 유지 */ const handleGlobalStrategyChange = useCallback((strategyId: number | null) => { - const next = { ...session, globalStrategyId: strategyId }; - setSession(next); - if (session.running && strategyId) { - void syncVirtualTargetsToBackend(targets, next, true); - } - }, [session, targets]); + setTargets(prev => freezeTargetsBeforeGlobalStrategyChange(prev, session.globalStrategyId)); + setSession(prev => ({ ...prev, globalStrategyId: strategyId })); + }, [session.globalStrategyId]); const handleStart = useCallback(async () => { if (targets.length === 0) { diff --git a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx index 1785eb3..8c52135 100644 --- a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx +++ b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx @@ -77,7 +77,7 @@ const VirtualLeftTargetPanel: React.FC = ({ ...targets, { market, - strategyId: null, + strategyId: globalStrategyId, koreanName: names.koreanName, englishName: names.englishName, }, diff --git a/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx b/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx index ffefdc3..78e1026 100644 --- a/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx +++ b/frontend/src/components/virtual/VirtualStrategyChartPopup.tsx @@ -33,14 +33,13 @@ const VirtualStrategyChartPopup: React.FC = ({ const chartSeriesPriceLabels = chartDefaults.chartSeriesPriceLabels; const ko = getKoreanName(market); - const sym = market.replace(/^KRW-/, ''); const stratName = strategy?.name ?? '전략 미지정'; return ( = ({ chartPaneSeparator, chartLiveReceiveHighlight = true, ticker, + koreanName, + englishName, readOnlyStrategyLabel, headVariant = 'default', }) => { const inlineHeadQuote = headVariant === 'inline-quote'; - const ko = getKoreanName(market); - const sym = market.replace(/^KRW-/, ''); + const { koreanName: displayKo, englishName: displayEn } = resolveVirtualTargetNames( + market, + koreanName ?? ticker?.koreanName, + englishName, + ); + const strategyDisplayName = resolveVirtualTargetStrategyDisplayName( + strategy, + strategies, + readOnlyStrategyLabel, + ); const rows = snapshot?.rows ?? []; const metrics = useMemo(() => buildConditionMetrics(rows), [rows]); @@ -129,9 +145,18 @@ const VirtualTargetCard: React.FC = ({ >
-
- {ko} - {sym} +
+ {isChart ? ( + <> + {strategyDisplayName} + {displayKo} + + ) : ( + <> + {displayKo} + {displayEn} + + )}
{inlineHeadQuote ? ( diff --git a/frontend/src/components/virtual/VirtualTargetCardChart.tsx b/frontend/src/components/virtual/VirtualTargetCardChart.tsx index fd4d29a..64e65a9 100644 --- a/frontend/src/components/virtual/VirtualTargetCardChart.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardChart.tsx @@ -259,6 +259,7 @@ const VirtualTargetCardChart: React.FC = ({ onCandlesReady={handleCandlesReady} magnifierEnabled={false} volumeVisible={false} + paneLayoutClamp showHoverToolbar={false} showPaneLegend={false} showCandlePaneControls={false} diff --git a/frontend/src/components/virtual/VirtualTargetFocusView.tsx b/frontend/src/components/virtual/VirtualTargetFocusView.tsx index 2c93d40..474940a 100644 --- a/frontend/src/components/virtual/VirtualTargetFocusView.tsx +++ b/frontend/src/components/virtual/VirtualTargetFocusView.tsx @@ -1,6 +1,9 @@ import React, { useMemo } from 'react'; -import { getKoreanName } from '../../utils/marketNameCache'; import type { StrategyDto } from '../../utils/backendApi'; +import { + resolveVirtualTargetNames, + resolveVirtualTargetStrategyDisplayName, +} from '../../utils/virtualTargetNames'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; @@ -37,6 +40,9 @@ interface Props { chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; chartLiveReceiveHighlight?: boolean; ticker?: TickerData; + koreanName?: string; + englishName?: string; + readOnlyStrategyLabel?: string; } /** 전체보기 — 좌 차트 · 우 신호 */ @@ -61,9 +67,20 @@ const VirtualTargetFocusView: React.FC = ({ chartPaneSeparator, chartLiveReceiveHighlight = true, ticker, + koreanName, + englishName, + readOnlyStrategyLabel, }) => { - const ko = getKoreanName(market); - const sym = market.replace(/^KRW-/, ''); + const { koreanName: displayKo } = resolveVirtualTargetNames( + market, + koreanName ?? ticker?.koreanName, + englishName, + ); + const strategyDisplayName = resolveVirtualTargetStrategyDisplayName( + strategy, + strategies, + readOnlyStrategyLabel, + ); const receiveSignal = useMemo(() => { if (!running) return null; @@ -80,9 +97,9 @@ const VirtualTargetFocusView: React.FC = ({
-
- {ko} - {sym} +
+ {strategyDisplayName} + {displayKo}
{running && }
diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index 1475fc4..aa426cb 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -140,6 +140,8 @@ const VirtualTargetGrid: React.FC = ({ chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(focusTarget.market)} + koreanName={focusTarget.koreanName} + englishName={focusTarget.englishName} /> ) : (
= ({ chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(t.market)} + koreanName={t.koreanName} + englishName={t.englishName} /> ); })} diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index 56c0b0e..8230ccb 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -37,7 +37,7 @@ import { import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations'; import { - coalesceTargetsToDefaultStrategy, + freezeTargetsBeforeGlobalStrategyChange, resolveVirtualTargetStrategyId, } from '../utils/virtualTargetStrategy'; import { @@ -186,11 +186,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} }, [session]); useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); - useEffect(() => { - if (session.globalStrategyId == null) return; - setTargets(prev => coalesceTargetsToDefaultStrategy(prev, session.globalStrategyId)); - }, [session.globalStrategyId]); - const strategyNames = useMemo( () => Object.fromEntries(strategies.map(s => [s.id, s.name])), [strategies], @@ -258,13 +253,13 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} const names = resolveVirtualTargetNames(market, meta?.koreanName, meta?.englishName); setTargets(prev => [...prev, { market, - strategyId: null, + strategyId: session.globalStrategyId, koreanName: names.koreanName, englishName: names.englishName, }]); setSelectedMarket(market); return true; - }, [targets, virtualTargetMaxCount]); + }, [targets, session.globalStrategyId, virtualTargetMaxCount]); const handleRemoveTarget = useCallback((market: string) => { setTargets(prev => prev.filter(x => x.market !== market)); @@ -282,13 +277,11 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} }); }, []); + /** 상단 기본전략 — 신규 추가 종목 템플릿만 변경, 기존 종목 전략은 유지 */ const handleGlobalStrategyChange = useCallback((strategyId: number | null) => { - const next = { ...session, globalStrategyId: strategyId }; - setSession(next); - if (session.running && strategyId) { - void syncVirtualTargetsToBackend(targets, next, true); - } - }, [session, targets]); + setTargets(prev => freezeTargetsBeforeGlobalStrategyChange(prev, session.globalStrategyId)); + setSession(prev => ({ ...prev, globalStrategyId: strategyId })); + }, [session.globalStrategyId]); const handleStart = useCallback(async () => { if (targets.length === 0) { diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index f65631f..30692c6 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -1662,6 +1662,19 @@ overflow: hidden; } +.vtd-card-title--chart { + gap: 4px; +} + +.vtd-card-chart-strategy { + font-size: 11px; + font-weight: 600; + color: var(--se-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .vtd-card-ko { font-size: 14px; font-weight: 700; diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index e31d344..670dcf2 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -307,6 +307,8 @@ export class ChartManager { /** 차트 하단 거래량 pane 표시 */ private _volumeVisible = true; + /** false — 거래량 시리즈·pane 1 미생성 (가상매매 카드 등) */ + private _volumePaneEnabled = true; /** pane 간 구분선 (설정 화면) */ private _paneSeparatorOptions: ChartPaneSeparatorOptions = @@ -324,10 +326,12 @@ export class ChartManager { constructor( container: HTMLElement, theme: Theme, - options?: { autoSize?: boolean; compactPaneLayout?: boolean }, + options?: { autoSize?: boolean; compactPaneLayout?: boolean; volumePaneEnabled?: boolean }, ) { this.container = container; this._compactPaneLayout = options?.compactPaneLayout ?? false; + this._volumePaneEnabled = options?.volumePaneEnabled ?? true; + if (!this._volumePaneEnabled) this._volumeVisible = false; const autoSize = options?.autoSize ?? !this._compactPaneLayout; const t = getTheme(theme); this.chart = createChart(container, { @@ -454,28 +458,31 @@ export class ChartManager { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.mainSeries as ISeriesApi).setData(mainData); - // Volume sub-pane (pane index 1) - this.volumeSeries = this.chart.addSeries(HistogramSeries, { - priceFormat: { type: 'volume' }, - priceScaleId: 'volume', - }, 1); - - // 초기 volume pane 크기: resetPaneHeights() 호출 전 임시 설정 - // (setHeight 대신 setStretchFactor - LWC 내부 기준 높이와 무관하게 동작) const panesInit = this.chart.panes(); - if (panesInit[0]) panesInit[0].setStretchFactor(0.88); - if (panesInit[1]) panesInit[1].setStretchFactor(0.12); + if (this._volumePaneEnabled) { + // Volume sub-pane (pane index 1) + this.volumeSeries = this.chart.addSeries(HistogramSeries, { + priceFormat: { type: 'volume' }, + priceScaleId: 'volume', + }, 1); - this.volumeSeries.setData(bars.map(b => ({ - time: b.time as Time, - value: b.volume, - color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`, - }))); + // 초기 volume pane 크기: resetPaneHeights() 호출 전 임시 설정 + if (panesInit[0]) panesInit[0].setStretchFactor(0.88); + if (panesInit[1]) panesInit[1].setStretchFactor(0.12); - if (!this._volumeVisible) { - try { - this.volumeSeries.applyOptions({ visible: false } as Parameters['applyOptions']>[0]); - } catch { /* ok */ } + this.volumeSeries.setData(bars.map(b => ({ + time: b.time as Time, + value: b.volume, + color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`, + }))); + + if (!this._volumeVisible) { + try { + this.volumeSeries.applyOptions({ visible: false } as Parameters['applyOptions']>[0]); + } catch { /* ok */ } + } + } else if (panesInit[0]) { + panesInit[0].setStretchFactor(1); } this._reapplyAllPatternMarkers(); @@ -2621,6 +2628,10 @@ export class ChartManager { /** 차트 설정: 거래량 pane 표시 on/off */ setVolumeVisible(visible: boolean, availableHeight?: number): void { + if (!this._volumePaneEnabled) { + this._volumeVisible = false; + return; + } this._volumeVisible = visible; if (this._candleOnlyLayout) return; this.applyVolumeVisibility(visible, availableHeight); @@ -3954,7 +3965,7 @@ export class ChartManager { } private _volumeFrac(H: number): number { - if (!this._volumeVisible || this._candleOnlyLayout) return 0; + if (!this._volumePaneEnabled || !this._volumeVisible || this._candleOnlyLayout) return 0; return Math.min(Math.max(60 / H, 0.04), 0.12); } @@ -3970,7 +3981,7 @@ export class ChartManager { ? this._lastLayoutAvailableHeight : this.container.clientHeight); - const volumeShown = this._volumeVisible && !this._candleOnlyLayout; + const volumeShown = this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout; const VOL_FRAC = this._volumeFrac(H); const MIN_IND_PX = this._minIndPx(H); const ORPHAN_STRETCH = 0.0001; diff --git a/frontend/src/utils/virtualTargetMutations.ts b/frontend/src/utils/virtualTargetMutations.ts index 066595f..625b1ae 100644 --- a/frontend/src/utils/virtualTargetMutations.ts +++ b/frontend/src/utils/virtualTargetMutations.ts @@ -44,8 +44,8 @@ export async function addVirtualTarget( const en = meta.englishName ?? market.replace(/^KRW-/, ''); const item: VirtualTargetItem = { market, - /** null = 상단 기본 전략 사용 */ - strategyId: null, + /** 신규 추가 시점의 상단 기본전략(템플릿)을 종목에 고정 */ + strategyId: session.globalStrategyId, koreanName: meta.koreanName, englishName: en, }; diff --git a/frontend/src/utils/virtualTargetNames.ts b/frontend/src/utils/virtualTargetNames.ts index 2dd8c8e..193e816 100644 --- a/frontend/src/utils/virtualTargetNames.ts +++ b/frontend/src/utils/virtualTargetNames.ts @@ -33,3 +33,15 @@ export function formatVirtualTargetOptionLabel( const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(market, koreanName, englishName); return `${ko}(${en})`; } + +/** 카드·차트 상단 — 투자전략 표시명 */ +export function resolveVirtualTargetStrategyDisplayName( + strategy: { name?: string } | undefined, + strategies: Array<{ id?: number; name?: string }>, + readOnlyStrategyLabel?: string, +): string { + const ro = readOnlyStrategyLabel?.trim(); + if (ro) return ro; + if (strategy?.name?.trim()) return strategy.name.trim(); + return '전략 미지정'; +} diff --git a/frontend/src/utils/virtualTargetStrategy.ts b/frontend/src/utils/virtualTargetStrategy.ts index fa1477c..ef6566a 100644 --- a/frontend/src/utils/virtualTargetStrategy.ts +++ b/frontend/src/utils/virtualTargetStrategy.ts @@ -3,7 +3,11 @@ import type { VirtualTargetItem } from './virtualTradingStorage'; /** 종목 전략 셀렉트 — 기본(전역) 전략 옵션 값 */ export const VIRTUAL_DEFAULT_STRATEGY_VALUE = '__default__'; -/** 종목에 전략이 지정되지 않았으면 전역(기본) 전략 ID */ +/** + * 종목별 실효 전략 ID. + * strategyId 가 있으면 그대로 사용하고, null 이면 globalStrategyId(레거시·미설정)만 참조. + * 상단 기본전략 변경은 기존 null 종목을 freezeTargetsBeforeGlobalStrategyChange 로 고정한 뒤 session 만 갱신. + */ export function resolveVirtualTargetStrategyId( target: Pick, globalStrategyId: number | null, @@ -12,6 +16,21 @@ export function resolveVirtualTargetStrategyId( return globalStrategyId; } +/** 기본전략 변경 직전 — null(기본 따름) 종목을 이전 기본전략 ID 로 고정 */ +export function freezeTargetsBeforeGlobalStrategyChange( + targets: VirtualTargetItem[], + previousGlobalStrategyId: number | null, +): VirtualTargetItem[] { + if (previousGlobalStrategyId == null) return targets; + let changed = false; + const next = targets.map(t => { + if (t.strategyId != null) return t; + changed = true; + return { ...t, strategyId: previousGlobalStrategyId }; + }); + return changed ? next : targets; +} + export function usesDefaultStrategy(target: Pick): boolean { return target.strategyId == null; } @@ -26,6 +45,7 @@ export function parseTargetStrategySelectValue(value: string): number | null { return Number.isFinite(n) ? n : null; } +/** 종목 셀렉트 — 기본 전략(전역) 옵션 라벨 (신규 추가 시 적용되는 템플릿) */ export function defaultStrategyOptionLabel( globalStrategyId: number | null, strategies: Array<{ id?: number; name?: string }>, @@ -35,21 +55,3 @@ export function defaultStrategyOptionLabel( return name ? `기본 전략 (${name})` : '기본 전략'; } -/** - * 예전에 전역 전략 ID가 종목에 복사 저장된 항목 → null(기본 전략 따름)로 정규화 - */ -export function coalesceTargetsToDefaultStrategy( - targets: VirtualTargetItem[], - globalStrategyId: number | null, -): VirtualTargetItem[] { - if (globalStrategyId == null) return targets; - let changed = false; - const next = targets.map(t => { - if (t.strategyId != null && t.strategyId === globalStrategyId) { - changed = true; - return { ...t, strategyId: null }; - } - return t; - }); - return changed ? next : targets; -}