투자관리 차트탭 기능 수정
This commit is contained in:
@@ -77,22 +77,6 @@ const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, contai
|
||||
const root = axisRef.current;
|
||||
if (!root || !containerEl.isConnected) return;
|
||||
|
||||
const panActive = manager.isCandleAxisPanActive();
|
||||
const panOffset = manager.getCandleAxisPanOffsetPx();
|
||||
|
||||
if (panActive) {
|
||||
const state = manager.getCandlePaneTimeAxisOverlay();
|
||||
if (!state || state.labels.length === 0) {
|
||||
root.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (panOffset === 0) {
|
||||
applyOverlayLayout(root, labelPoolRef.current, state);
|
||||
}
|
||||
root.style.transform = `translate3d(${panOffset}px, 0, 0)`;
|
||||
return;
|
||||
}
|
||||
|
||||
root.style.transform = '';
|
||||
|
||||
const state = manager.getCandlePaneTimeAxisOverlay();
|
||||
|
||||
@@ -31,6 +31,9 @@ import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||
import { loadVirtualSession, loadVirtualTargets } from '../utils/virtualTradingStorage';
|
||||
import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy';
|
||||
import { formatVirtualTargetOptionLabel } from '../utils/virtualTargetNames';
|
||||
|
||||
type HistoryTab = 'open' | 'recent' | 'ledger';
|
||||
type CenterTab = 'invest' | 'chart';
|
||||
@@ -95,10 +98,27 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||||
const [virtualTargets, setVirtualTargets] = useState(() => loadVirtualTargets());
|
||||
const [virtualGlobalStrategyId, setVirtualGlobalStrategyId] = useState(
|
||||
() => loadVirtualSession().globalStrategyId,
|
||||
);
|
||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshVirtual = () => {
|
||||
setVirtualTargets(loadVirtualTargets());
|
||||
setVirtualGlobalStrategyId(loadVirtualSession().globalStrategyId);
|
||||
};
|
||||
window.addEventListener('gc_virtual_session_changed', refreshVirtual);
|
||||
window.addEventListener('storage', refreshVirtual);
|
||||
return () => {
|
||||
window.removeEventListener('gc_virtual_session_changed', refreshVirtual);
|
||||
window.removeEventListener('storage', refreshVirtual);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tickersRef = useRef(tickers);
|
||||
tickersRef.current = tickers;
|
||||
const summaryRef = useRef(summary);
|
||||
@@ -129,6 +149,12 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
|
||||
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoading) return;
|
||||
setVirtualTargets(loadVirtualTargets());
|
||||
setVirtualGlobalStrategyId(loadVirtualSession().globalStrategyId);
|
||||
}, [initialLoading, dataRefreshKey]);
|
||||
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
const lastPriceKeyRef = useRef('');
|
||||
useEffect(() => {
|
||||
@@ -159,11 +185,33 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
return coerceFiniteNumber(p?.quantity) ?? 0;
|
||||
}, [s?.positions, selectedMarket]);
|
||||
|
||||
const selectedAlloc = useMemo(
|
||||
() => s?.allocations?.find(a => a.symbol === selectedMarket),
|
||||
[s?.allocations, selectedMarket],
|
||||
const investmentTargets = useMemo(
|
||||
() => s?.allocations ?? [],
|
||||
[s?.allocations],
|
||||
);
|
||||
|
||||
const selectedAlloc = useMemo(
|
||||
() => investmentTargets.find(a => a.symbol === selectedMarket),
|
||||
[investmentTargets, selectedMarket],
|
||||
);
|
||||
|
||||
/** 가상매매 투자대상 종목별 전략(없으면 기본 전략) — virtualLiveStrategySync 와 동일 */
|
||||
const chartStrategyId = useMemo(() => {
|
||||
if (!selectedAlloc) return null;
|
||||
const vt = virtualTargets.find(t => t.market === selectedAlloc.symbol);
|
||||
if (vt) {
|
||||
return resolveVirtualTargetStrategyId(vt, virtualGlobalStrategyId);
|
||||
}
|
||||
return selectedAlloc.strategyId ?? null;
|
||||
}, [selectedAlloc, virtualTargets, virtualGlobalStrategyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (centerTab !== 'chart' || investmentTargets.length === 0) return;
|
||||
if (!investmentTargets.some(a => a.symbol === selectedMarket)) {
|
||||
setSelectedMarket(investmentTargets[0].symbol);
|
||||
}
|
||||
}, [centerTab, investmentTargets, selectedMarket]);
|
||||
|
||||
const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
|
||||
|
||||
const tradePrice = useMemo(
|
||||
@@ -277,12 +325,40 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
centerHead={(
|
||||
<div className="ptd-center-head">
|
||||
<div className={`ptd-center-head${centerTab === 'chart' ? ' ptd-center-head--chart' : ''}`}>
|
||||
<div className="ptd-tabs ptd-tabs--center">
|
||||
<button type="button" className={`ptd-tab${centerTab === 'invest' ? ' active' : ''}`} onClick={() => setCenterTab('invest')}>투자금 관리</button>
|
||||
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}>차트</button>
|
||||
</div>
|
||||
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
|
||||
{centerTab === 'chart' ? (
|
||||
<div className="ptd-center-head-center">
|
||||
<label className="ptd-chart-market-select-wrap">
|
||||
<select
|
||||
className="ptd-chart-market-select"
|
||||
aria-label="종목 선택"
|
||||
value={investmentTargets.some(a => a.symbol === selectedMarket) ? selectedMarket : ''}
|
||||
onChange={e => setSelectedMarket(e.target.value)}
|
||||
disabled={investmentTargets.length === 0}
|
||||
>
|
||||
{investmentTargets.length === 0 ? (
|
||||
<option value="">투자 대상 없음</option>
|
||||
) : (
|
||||
investmentTargets.map(a => (
|
||||
<option key={a.symbol} value={a.symbol}>
|
||||
{formatVirtualTargetOptionLabel(
|
||||
a.symbol,
|
||||
a.koreanName ?? tickers?.get(a.symbol)?.koreanName,
|
||||
coinCode(a.symbol),
|
||||
)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
center={centerTab === 'invest' ? (
|
||||
@@ -306,7 +382,11 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="ptd-card ptd-chart-card ptd-center-fill">
|
||||
<PaperAnalysisChart market={selectedMarket} theme={theme} />
|
||||
<PaperAnalysisChart
|
||||
market={selectedMarket}
|
||||
strategyId={chartStrategyId}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
|
||||
@@ -149,6 +149,8 @@ interface TradingChartProps {
|
||||
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
||||
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
|
||||
paneLayoutClamp?: boolean;
|
||||
/** 캔들+거래량 vs 보조 pane 높이 비율 (aux 0 이면 미적용) */
|
||||
paneAreaRatio?: { candle: number; aux: number } | null;
|
||||
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */
|
||||
showPaneLegend?: boolean;
|
||||
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
|
||||
@@ -195,6 +197,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
chartVisible = true,
|
||||
paneSeparatorOptions,
|
||||
paneLayoutClamp = false,
|
||||
paneAreaRatio = null,
|
||||
showPaneLegend = true,
|
||||
crosshairInfoVisible = true,
|
||||
dataLoading = false,
|
||||
@@ -227,6 +230,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const seriesDblSuppressRef = useRef(false);
|
||||
const toggleCandleOnlyRef = useRef<() => void>(() => {});
|
||||
const candleOnlyModeRef = useRef(false);
|
||||
const paneAreaRatioRef = useRef(paneAreaRatio);
|
||||
paneAreaRatioRef.current = paneAreaRatio;
|
||||
const lastCrosshairPriceRef = useRef<number | null>(null);
|
||||
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
|
||||
onTradeOrderRequestRef.current = onTradeOrderRequest;
|
||||
@@ -387,6 +392,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
if (mgr.isCandleOnlyLayout()) {
|
||||
mgr.restoreFromCandleFullscreen(wrapperH);
|
||||
}
|
||||
const ratio = paneAreaRatioRef.current;
|
||||
if (ratio && ratio.aux > 0) {
|
||||
mgr.setPaneAreaRatio(ratio.candle, ratio.aux);
|
||||
} else {
|
||||
mgr.setPaneAreaRatio(0, 0);
|
||||
}
|
||||
required = mgr.resetPaneHeights(wrapperH);
|
||||
}
|
||||
|
||||
@@ -408,7 +419,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paneLayoutClamp]);
|
||||
}, [paneLayoutClamp, paneAreaRatio]);
|
||||
|
||||
/** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
|
||||
const normalizeChartViewport = useCallback((mgr: ChartManager) => {
|
||||
@@ -938,8 +949,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
if (!panStateRef.current.active) return;
|
||||
if (panStateRef.current.moved) {
|
||||
suppressClickRef.current = true;
|
||||
managerRef.current?.notifyViewportChanged();
|
||||
}
|
||||
managerRef.current?.endCandleAxisPan();
|
||||
panStateRef.current.active = false;
|
||||
container.style.cursor = canPanRef.current ? 'grab' : '';
|
||||
};
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||
import { loadStrategy } from '../../utils/backendApi';
|
||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../../hooks/useHistoryLoader';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||
import {
|
||||
chartPaneFlexRatio,
|
||||
countNonOverlayIndicatorPanes,
|
||||
} from '../../utils/strategyOscillatorSeries';
|
||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||
import {
|
||||
buildVirtualTradingChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import BacktestOscillatorPanes from './BacktestOscillatorPanes';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
@@ -28,6 +31,10 @@ interface Props {
|
||||
signals: BacktestSignal[];
|
||||
strategyId?: number | null;
|
||||
theme?: Theme;
|
||||
/** 과거 캔들 추가 로드 후 (차트에 반영된 전체 봉) */
|
||||
onHistoryBarsLoaded?: (bars: OHLCVBar[]) => void;
|
||||
/** 과거 로드·시그널 재계산 중 UI */
|
||||
onHistoryLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -59,6 +66,11 @@ function defaultOverlayIndicators(
|
||||
return [cfg];
|
||||
}
|
||||
|
||||
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||
|
||||
/**
|
||||
* 백테스트·투자관리 차트 — TradingChart 단일 엔진(실시간 차트와 동일 크로스헤어·시간축·우측 가격)
|
||||
*/
|
||||
const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
symbol,
|
||||
timeframe: timeframeRaw,
|
||||
@@ -68,8 +80,9 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
signals,
|
||||
strategyId,
|
||||
theme = 'dark',
|
||||
onHistoryBarsLoaded,
|
||||
onHistoryLoadingChange,
|
||||
}) => {
|
||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const { defaults: appDefaults } = useAppSettings();
|
||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||
@@ -78,10 +91,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
const [chartType] = useState<ChartType>('candlestick');
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const logicalRangeUnsubRef = useRef<(() => void) | null>(null);
|
||||
const viewportUnsubRef = useRef<(() => void) | null>(null);
|
||||
const loadMoreRefRef = useRef<MutableRefObject<() => void>>({ current: () => {} });
|
||||
const signalsRef = useRef(signals);
|
||||
signalsRef.current = signals;
|
||||
const onHistoryBarsLoadedRef = useRef(onHistoryBarsLoaded);
|
||||
onHistoryBarsLoadedRef.current = onHistoryBarsLoaded;
|
||||
const [loadedBarCount, setLoadedBarCount] = useState<number | null>(null);
|
||||
const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
|
||||
|
||||
const chartTimeframe = useMemo((): Timeframe => {
|
||||
const tf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframeRaw;
|
||||
return normalizeChartTimeframe(tf) as Timeframe;
|
||||
}, [strategy, timeframeRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId) {
|
||||
setStrategy(undefined);
|
||||
@@ -97,10 +121,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
const indicators = useMemo(() => {
|
||||
if (strategy) {
|
||||
const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
|
||||
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[timeframe] !== false);
|
||||
if (inds.length) return inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
|
||||
}
|
||||
return defaultOverlayIndicators(getParams);
|
||||
}, [strategy, getParams, getVisualConfig, timeframe]);
|
||||
}, [strategy, getParams, getVisualConfig, chartTimeframe]);
|
||||
|
||||
const auxPaneCount = useMemo(
|
||||
() => countNonOverlayIndicatorPanes(indicators, isOverlayType),
|
||||
[indicators],
|
||||
);
|
||||
|
||||
const paneAreaRatio = useMemo(() => {
|
||||
if (auxPaneCount <= 0) return null;
|
||||
const flex = chartPaneFlexRatio(auxPaneCount);
|
||||
return { candle: flex.candle, aux: flex.aux };
|
||||
}, [auxPaneCount]);
|
||||
|
||||
const effectiveBarCount = useMemo(() => {
|
||||
const barSec = timeframeBarSeconds(timeframeRaw);
|
||||
@@ -114,7 +149,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void loadAnalysisCandles(market, timeframe, toTimeSec, effectiveBarCount)
|
||||
setLoadedBarCount(null);
|
||||
void loadAnalysisCandles(market, chartTimeframe, toTimeSec, effectiveBarCount)
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setBars(data);
|
||||
@@ -128,7 +164,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, toTimeSec, effectiveBarCount]);
|
||||
}, [market, chartTimeframe, toTimeSec, effectiveBarCount]);
|
||||
|
||||
const applyMarkers = useCallback((attempt = 0) => {
|
||||
const mgr = managerRef.current;
|
||||
@@ -139,51 +175,127 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
mgr.setBacktestMarkers(signalsRef.current, true);
|
||||
}, []);
|
||||
|
||||
const applyPaneRatio = useCallback((mgr: ChartManager) => {
|
||||
if (paneAreaRatio && paneAreaRatio.aux > 0) {
|
||||
mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux);
|
||||
} else {
|
||||
mgr.setPaneAreaRatio(0, 0);
|
||||
}
|
||||
}, [paneAreaRatio]);
|
||||
|
||||
const handleBarsPrepended = useCallback((_added: number, total: number) => {
|
||||
setLoadedBarCount(total);
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
onHistoryBarsLoadedRef.current?.(mgr.getRawBars());
|
||||
// 왼쪽 끝에 붙어 있으면 연속 로드
|
||||
requestAnimationFrame(() => {
|
||||
const lr = mgr.getVisibleLogicalRange();
|
||||
if (lr && lr.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRefRef.current.current();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { isLoadingMore, loadMoreRef } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe: chartTimeframe,
|
||||
isUpbit: isUpbitMarket(market),
|
||||
managerRef,
|
||||
onBarsPrepended: handleBarsPrepended,
|
||||
});
|
||||
|
||||
loadMoreRefRef.current = loadMoreRef;
|
||||
|
||||
const setupHistoryScroll = useCallback((mgr: ChartManager) => {
|
||||
logicalRangeUnsubRef.current?.();
|
||||
viewportUnsubRef.current?.();
|
||||
|
||||
const tryLoadMore = () => {
|
||||
if (!mgr.hasMainSeries()) return;
|
||||
const lr = mgr.getVisibleLogicalRange();
|
||||
if (lr && lr.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRefRef.current.current();
|
||||
}
|
||||
};
|
||||
|
||||
logicalRangeUnsubRef.current = mgr.subscribeVisibleLogicalRange(r => {
|
||||
if (r && r.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRefRef.current.current();
|
||||
}
|
||||
});
|
||||
// 커스텀 패닝은 LWC 논리범위 이벤트를 안 낼 수 있어 뷰포트 알림에도 연결
|
||||
viewportUnsubRef.current = mgr.subscribeViewport(tryLoadMore);
|
||||
requestAnimationFrame(tryLoadMore);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onHistoryLoadingChange?.(isLoadingMore);
|
||||
}, [isLoadingMore, onHistoryLoadingChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
logicalRangeUnsubRef.current?.();
|
||||
logicalRangeUnsubRef.current = null;
|
||||
viewportUnsubRef.current?.();
|
||||
viewportUnsubRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onManagerReady = useCallback((mgr: ChartManager) => {
|
||||
managerRef.current = mgr;
|
||||
setupHistoryScroll(mgr);
|
||||
setLoadedBarCount(mgr.getRawBarsLength());
|
||||
applyPaneRatio(mgr);
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
}, [applyMarkers, applyPaneRatio, setupHistoryScroll]);
|
||||
|
||||
const onCandlesReady = useCallback(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
setupHistoryScroll(mgr);
|
||||
applyPaneRatio(mgr);
|
||||
applyMarkers();
|
||||
}, [applyMarkers]);
|
||||
setLoadedBarCount(mgr.getRawBarsLength());
|
||||
}, [applyMarkers, applyPaneRatio, setupHistoryScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
}, [signals, bars, applyMarkers]);
|
||||
|
||||
useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: isUpbitMarket(market),
|
||||
managerRef,
|
||||
});
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (mgr) applyPaneRatio(mgr);
|
||||
}, [paneAreaRatio, applyPaneRatio]);
|
||||
|
||||
const symLabel = market.replace(/^KRW-/, '');
|
||||
const stratTf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframe;
|
||||
const stratTf = chartTimeframe;
|
||||
|
||||
const priceLabels = appDefaults.chartSeriesPriceLabels !== false;
|
||||
|
||||
return (
|
||||
<div className="btd-analysis-chart">
|
||||
<div className="btd-analysis-toolbar">
|
||||
<div className="btd-analysis-toolbar-left">
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{symLabel}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{timeframe}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">{chartTimeframe}</span>
|
||||
<span className="btd-analysis-select btd-analysis-select--static">Candle</span>
|
||||
</div>
|
||||
<div className="btd-analysis-toolbar-right">
|
||||
<span className="btd-analysis-tool" title="자석 모드">⊕</span>
|
||||
<span className="btd-analysis-tool" title="드로잉">✎</span>
|
||||
<span className="btd-analysis-tool" title="삭제">⌫</span>
|
||||
<span className="btd-analysis-count">{effectiveBarCount.toLocaleString()}봉</span>
|
||||
<span className="btd-analysis-count">
|
||||
{(loadedBarCount ?? effectiveBarCount).toLocaleString()}봉
|
||||
{isLoadingMore && ' · 과거 로드…'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="btd-analysis-main">
|
||||
<div className="btd-analysis-canvas-wrap">
|
||||
<div className="btd-analysis-canvas-wrap btd-analysis-canvas-wrap--live">
|
||||
{loading && <div className="btd-analysis-loading">차트 로딩…</div>}
|
||||
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
||||
{!loading && !error && bars.length > 0 && (
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${toTimeSec}-${effectiveBarCount}`}
|
||||
key={`${market}-${chartTimeframe}-${toTimeSec}-${effectiveBarCount}-${indicators.map(i => i.id).join(',')}`}
|
||||
bars={bars}
|
||||
barsMarket={market}
|
||||
market={market}
|
||||
@@ -201,13 +313,20 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
||||
onAddDrawing={noop}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
||||
paneLayoutClamp
|
||||
paneAreaRatio={paneAreaRatio}
|
||||
showPaneLegend
|
||||
crosshairInfoVisible
|
||||
candleAreaPriceLabelsEnabled={priceLabels}
|
||||
indicatorAreaPriceLabelsEnabled={priceLabels}
|
||||
seriesPriceLabelsEnabled={priceLabels}
|
||||
showHoverToolbar
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && bars.length === 0 && (
|
||||
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
{!loading && bars.length > 0 && <BacktestOscillatorPanes bars={bars} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { OHLCVBar } from '../../types';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import {
|
||||
buildOscillatorPaneData,
|
||||
collectStrategyOscillatorPanes,
|
||||
defaultOscillatorPaneSpecs,
|
||||
} from '../../utils/strategyOscillatorSeries';
|
||||
|
||||
function spark(values: number[], w: number, h: number, min?: number, max?: number): string {
|
||||
if (!values.length) return '';
|
||||
const lo = min ?? Math.min(...values);
|
||||
const hi = max ?? Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
return values.map((v, i) => {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * w;
|
||||
const y = h - ((v - lo) / range) * (h - 6) - 3;
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
interface Props {
|
||||
bars: OHLCVBar[];
|
||||
strategy?: StrategyDto;
|
||||
/** 부모 flex 영역 높이에 맞춰 pane 균등 분배 */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
/** 전략 조건에 사용된 보조지표 하단 pane (없으면 RSI·MACD 기본) */
|
||||
const StrategyOscillatorPanes: React.FC<Props> = ({ bars, strategy, fillHeight = false }) => {
|
||||
const panes = useMemo(() => {
|
||||
const specs = collectStrategyOscillatorPanes(strategy);
|
||||
const useSpecs = specs.length > 0 ? specs : defaultOscillatorPaneSpecs();
|
||||
return buildOscillatorPaneData(bars, useSpecs);
|
||||
}, [bars, strategy]);
|
||||
|
||||
if (panes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`btd-osc-stack${fillHeight ? ' btd-osc-stack--fill' : ''}`}>
|
||||
{panes.map(pane => {
|
||||
const last = pane.values[pane.values.length - 1];
|
||||
const lo = pane.refLines?.[0];
|
||||
const hi = pane.refLines?.[pane.refLines.length - 1];
|
||||
const path = spark(
|
||||
pane.values,
|
||||
280,
|
||||
52,
|
||||
lo != null && hi != null && pane.spec.registryType === 'RSI' ? 0 : undefined,
|
||||
lo != null && hi != null && pane.spec.registryType === 'RSI' ? 100 : undefined,
|
||||
);
|
||||
return (
|
||||
<div key={pane.spec.key} className={`btd-osc-pane${fillHeight ? ' btd-osc-pane--fill' : ''}`}>
|
||||
<div className="btd-osc-head">
|
||||
<span className="btd-osc-label">{pane.spec.label}</span>
|
||||
<span className="btd-osc-val">
|
||||
{last != null
|
||||
? (pane.spec.registryType === 'RSI' ? last.toFixed(1) : last.toFixed(2))
|
||||
: '–'}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="btd-osc-svg" viewBox="0 0 280 52" preserveAspectRatio="none">
|
||||
{pane.refLines?.map((r, i) => {
|
||||
const min = pane.refLines![0];
|
||||
const max = pane.refLines![pane.refLines!.length - 1];
|
||||
const y = 52 - ((r - min) / (max - min || 1)) * 44 - 4;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1="0"
|
||||
y1={y}
|
||||
x2="280"
|
||||
y2={y}
|
||||
className={`btd-osc-ref${i === 0 || i === pane.refLines!.length - 1 ? ' btd-osc-ref--hi' : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!pane.refLines?.length && (
|
||||
<line x1="0" y1="26" x2="280" y2="26" className="btd-osc-ref" />
|
||||
)}
|
||||
<path
|
||||
d={path}
|
||||
className="btd-osc-line"
|
||||
fill="none"
|
||||
stroke={pane.color}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyOscillatorPanes;
|
||||
@@ -1,30 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Theme, Timeframe } from '../../types';
|
||||
import PaperMiniChart from './PaperMiniChart';
|
||||
import PaperIndicatorPanel from './PaperIndicatorPanel';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart';
|
||||
import {
|
||||
DEFAULT_BACKTEST_SETTINGS,
|
||||
loadStrategy,
|
||||
runBacktest,
|
||||
type BacktestSignal,
|
||||
type StrategyDto,
|
||||
} from '../../utils/backendApi';
|
||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||
import { normalizeChartTimeframe } from '../../utils/backtestUiUtils';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import type { OHLCVBar, Theme } from '../../types';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
|
||||
const BAR_COUNT = 300;
|
||||
const HISTORY_BACKTEST_DEBOUNCE_MS = 450;
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategyId?: number | null;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
/** 모의투자 중앙 — 상단 캔들 + 하단 보조지표 (실시간 차트 레이아웃) */
|
||||
const PaperAnalysisChart: React.FC<Props> = ({ market, theme = 'dark' }) => {
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
|
||||
/**
|
||||
* 모의투자 차트 탭 — 전략 백테스트 시그널 + 캔들·보조지표 (백테스팅 분석 차트와 동일)
|
||||
* 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산.
|
||||
*/
|
||||
const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark' }) => {
|
||||
const { getParams } = useIndicatorSettings();
|
||||
const [signals, setSignals] = useState<BacktestSignal[]>([]);
|
||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||
const [timeframe, setTimeframe] = useState('1h');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]);
|
||||
const backtestGenRef = useRef(0);
|
||||
const historyDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const strategyRef = useRef(strategy);
|
||||
const strategyIdRef = useRef(strategyId);
|
||||
strategyRef.current = strategy;
|
||||
strategyIdRef.current = strategyId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId) {
|
||||
setStrategy(undefined);
|
||||
setSignals([]);
|
||||
setError('가상매매 투자대상에 종목 전략 또는 기본 전략이 설정되어 있지 않습니다.');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
void loadStrategy(strategyId).then(s => {
|
||||
if (!cancelled) setStrategy(s ?? undefined);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [strategyId]);
|
||||
|
||||
const runSignalsForBars = useCallback(async (
|
||||
bars: OHLCVBar[],
|
||||
tf: string,
|
||||
strat: StrategyDto,
|
||||
sid: number,
|
||||
) => {
|
||||
const sym = market.startsWith('KRW-') ? market : `KRW-${market}`;
|
||||
if (bars.length < 10) {
|
||||
setSignals([]);
|
||||
setError('캔들 데이터가 부족합니다.');
|
||||
return;
|
||||
}
|
||||
const gen = ++backtestGenRef.current;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await runBacktest({
|
||||
strategyId: sid,
|
||||
bars: bars.map(b => ({
|
||||
time: b.time,
|
||||
open: b.open,
|
||||
high: b.high,
|
||||
low: b.low,
|
||||
close: b.close,
|
||||
volume: b.volume,
|
||||
})),
|
||||
timeframe: tf,
|
||||
symbol: sym,
|
||||
strategyName: strat.name,
|
||||
settings: DEFAULT_BACKTEST_SETTINGS,
|
||||
indicatorParams: Object.fromEntries(
|
||||
['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [
|
||||
t,
|
||||
getParams(t) as Record<string, unknown>,
|
||||
]),
|
||||
),
|
||||
});
|
||||
if (gen !== backtestGenRef.current) return;
|
||||
if (!res) {
|
||||
setSignals([]);
|
||||
setError('시그널 계산에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
setSignals(res.signals);
|
||||
} catch (e) {
|
||||
if (gen !== backtestGenRef.current) return;
|
||||
setSignals([]);
|
||||
setError(e instanceof Error ? e.message : '차트 분석 실패');
|
||||
} finally {
|
||||
if (gen === backtestGenRef.current) setRunning(false);
|
||||
}
|
||||
}, [market, getParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategyId || !strategy) {
|
||||
setSignals([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy));
|
||||
setTimeframe(tf);
|
||||
setError(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const sym = market.startsWith('KRW-') ? market : `KRW-${market}`;
|
||||
const bars = await loadAnalysisCandles(sym, tf, toTimeSec, BAR_COUNT);
|
||||
if (cancelled) return;
|
||||
await runSignalsForBars(bars, tf, strategy, strategyId);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setSignals([]);
|
||||
setError(e instanceof Error ? e.message : '차트 분석 실패');
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
++backtestGenRef.current;
|
||||
};
|
||||
}, [market, strategyId, strategy, toTimeSec, runSignalsForBars]);
|
||||
|
||||
const handleHistoryBarsLoaded = useCallback((bars: OHLCVBar[]) => {
|
||||
const strat = strategyRef.current;
|
||||
const sid = strategyIdRef.current;
|
||||
if (!strat || !sid) return;
|
||||
const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strat));
|
||||
if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current);
|
||||
historyDebounceRef.current = setTimeout(() => {
|
||||
historyDebounceRef.current = null;
|
||||
void runSignalsForBars(bars, tf, strat, sid);
|
||||
}, HISTORY_BACKTEST_DEBOUNCE_MS);
|
||||
}, [runSignalsForBars]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current);
|
||||
}, []);
|
||||
|
||||
if (!strategyId) {
|
||||
return (
|
||||
<div className="ptd-analysis-chart ptd-analysis-chart--empty">
|
||||
<p className="ptd-muted">
|
||||
가상매매 투자대상 목록에서 종목별 전략 또는 기본 전략을 설정하면 차트에 매매 시그널이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusBusy = running || historyLoading;
|
||||
|
||||
return (
|
||||
<div className="ptd-analysis-chart">
|
||||
<div className="ptd-main-pane">
|
||||
<PaperMiniChart
|
||||
market={market}
|
||||
theme={theme}
|
||||
timeframe={timeframe}
|
||||
onTimeframeChange={setTimeframe}
|
||||
/>
|
||||
</div>
|
||||
<div className="ptd-sub-panes">
|
||||
<PaperIndicatorPanel market={market} timeframe={timeframe} stacked />
|
||||
</div>
|
||||
<div className="ptd-analysis-chart ptd-analysis-chart--backtest">
|
||||
{statusBusy && (
|
||||
<div className="ptd-analysis-chart-status">
|
||||
{historyLoading && !running ? '과거 캔들 로드 중…' : '전략 시그널 계산 중…'}
|
||||
</div>
|
||||
)}
|
||||
{error && !statusBusy && (
|
||||
<div className="ptd-analysis-chart-status ptd-analysis-chart-status--err">{error}</div>
|
||||
)}
|
||||
<BacktestAnalysisChart
|
||||
symbol={market}
|
||||
timeframe={timeframe}
|
||||
toTimeSec={toTimeSec}
|
||||
barCount={BAR_COUNT}
|
||||
signals={signals}
|
||||
strategyId={strategyId}
|
||||
theme={theme}
|
||||
onHistoryBarsLoaded={handleHistoryBarsLoaded}
|
||||
onHistoryLoadingChange={setHistoryLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user