가상투자 메뉴 기능 구현

This commit is contained in:
Macbook
2026-05-25 06:41:45 +09:00
parent 0cfe7fc84c
commit 8b373b11e3
33 changed files with 3897 additions and 99 deletions
@@ -0,0 +1,206 @@
/**
* 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표)
*/
import React, {
useCallback, useEffect, useMemo, useRef, useState,
} from 'react';
import AppPopup from '../AppPopup';
import TradingChart from '../TradingChart';
import type { StrategyDto } from '../../utils/backendApi';
import { pinCandleWatch } from '../../utils/backendApi';
import type {
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
} from '../../types';
import { getKoreanName } from '../../utils/marketNameCache';
import { isUpbitMarket } from '../../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../../hooks/useChartRealtimeData';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import type { ChartManager } from '../../utils/ChartManager';
import {
buildStrategyChartIndicators,
resolveStrategyPrimaryTimeframe,
resolveStrategyTimeframes,
} from '../../utils/strategyToChartIndicators';
import { timeframeToCandleType } from '../../utils/chartCandleType';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
strategy: StrategyDto | undefined;
theme?: Theme;
running?: boolean;
onClose: () => void;
}
const noop = () => {};
function WsBadge({ status }: { status: WsStatus }) {
const map: Record<WsStatus, { cls: string; label: string }> = {
connecting: { cls: 'vtd-chart-ws--connecting', label: '연결 중' },
connected: { cls: 'vtd-chart-ws--live', label: '실시간' },
disconnected: { cls: 'vtd-chart-ws--off', label: '연결 끊김' },
error: { cls: 'vtd-chart-ws--off', label: '오류' },
};
const { cls, label } = map[status];
return (
<span className={`vtd-chart-ws ${cls}`}>
<span className="vtd-chart-ws-dot" aria-hidden />
{label}
</span>
);
}
const VirtualStrategyChartPopup: React.FC<Props> = ({
market,
strategy,
theme = 'dark',
running = false,
onClose,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
useEffect(() => {
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
}, [strategy, market]);
const indicators = useMemo(
() => buildStrategyChartIndicators(strategy, getParams, getVisualConfig),
[strategy, getParams, getVisualConfig],
);
const managerRef = useRef<ChartManager | null>(null);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const chartLiveReadyRef = useRef(false);
const marketRef = useRef(market);
marketRef.current = market;
const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true;
const pending = pendingBarRef.current;
const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
pendingBarRef.current = null;
mgr.updateBar(pending);
}, []);
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, wsStatus, isLoading } = useChartRealtimeData(
market,
timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
}), [handleTickUpdate, handleNewCandle, useUpbit]),
);
barsMarketRef.current = barsMarket;
useEffect(() => {
chartLiveReadyRef.current = false;
pendingBarRef.current = null;
}, [market, timeframe]);
useEffect(() => {
if (!useUpbit) return;
void pinCandleWatch(market, timeframeToCandleType(timeframe));
if (timeframeToCandleType(timeframe) !== '1m') {
void pinCandleWatch(market, '1m');
}
}, [market, timeframe, useUpbit, running]);
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
const stratName = strategy?.name ?? '전략 미지정';
return (
<AppPopup
onClose={onClose}
title={sym}
titleKo={ko}
badge="CHART"
width={920}
maxWidth="96vw"
centered
backdrop
closeOnBackdrop
bodyClassName="app-popup-body vtd-chart-popup-body"
headerExtra={running ? <WsBadge status={wsStatus} /> : undefined}
>
<div className="vtd-chart-popup-meta">
<span className="vtd-chart-popup-strat">{stratName}</span>
{indicators.length > 0 && (
<span className="vtd-chart-popup-ind-count"> {indicators.length}</span>
)}
</div>
<div className="vtd-chart-popup-tf-row">
{tfOptions.map(tf => (
<button
key={tf}
type="button"
className={`vtd-chart-popup-tf${timeframe === tf ? ' vtd-chart-popup-tf--on' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
<div className="vtd-chart-popup-canvas-wrap">
{isLoading && <div className="vtd-chart-popup-loading"> </div>}
<TradingChart
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={timeframe}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
/>
</div>
{indicators.length === 0 && (
<p className="vtd-muted vtd-chart-popup-empty">
.
</p>
)}
</AppPopup>
);
};
export default VirtualStrategyChartPopup;