투자관리 차트탭 기능 수정
This commit is contained in:
@@ -1131,7 +1131,6 @@ html.theme-blue {
|
|||||||
border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85));
|
border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85));
|
||||||
background: color-mix(in srgb, var(--bg, #131722) 88%, transparent);
|
background: color-mix(in srgb, var(--bg, #131722) 88%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
.candle-pane-time-axis__label {
|
.candle-pane-time-axis__label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -77,22 +77,6 @@ const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, contai
|
|||||||
const root = axisRef.current;
|
const root = axisRef.current;
|
||||||
if (!root || !containerEl.isConnected) return;
|
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 = '';
|
root.style.transform = '';
|
||||||
|
|
||||||
const state = manager.getCandlePaneTimeAxisOverlay();
|
const state = manager.getCandlePaneTimeAxisOverlay();
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
|
|||||||
import BuilderPageShell from './layout/BuilderPageShell';
|
import BuilderPageShell from './layout/BuilderPageShell';
|
||||||
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
|
||||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
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 HistoryTab = 'open' | 'recent' | 'ledger';
|
||||||
type CenterTab = 'invest' | 'chart';
|
type CenterTab = 'invest' | 'chart';
|
||||||
@@ -95,10 +98,27 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
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 [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const orderAnchorRef = useRef<HTMLDivElement>(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);
|
const tickersRef = useRef(tickers);
|
||||||
tickersRef.current = tickers;
|
tickersRef.current = tickers;
|
||||||
const summaryRef = useRef(summary);
|
const summaryRef = useRef(summary);
|
||||||
@@ -129,6 +149,12 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
|
|
||||||
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
|
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 priceKey = positionPriceKey(summary?.positions, tickers);
|
||||||
const lastPriceKeyRef = useRef('');
|
const lastPriceKeyRef = useRef('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,11 +185,33 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
return coerceFiniteNumber(p?.quantity) ?? 0;
|
return coerceFiniteNumber(p?.quantity) ?? 0;
|
||||||
}, [s?.positions, selectedMarket]);
|
}, [s?.positions, selectedMarket]);
|
||||||
|
|
||||||
const selectedAlloc = useMemo(
|
const investmentTargets = useMemo(
|
||||||
() => s?.allocations?.find(a => a.symbol === selectedMarket),
|
() => s?.allocations ?? [],
|
||||||
[s?.allocations, selectedMarket],
|
[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 orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
|
||||||
|
|
||||||
const tradePrice = useMemo(
|
const tradePrice = useMemo(
|
||||||
@@ -277,12 +325,40 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
centerHead={(
|
centerHead={(
|
||||||
<div className="ptd-center-head">
|
<div className={`ptd-center-head${centerTab === 'chart' ? ' ptd-center-head--chart' : ''}`}>
|
||||||
<div className="ptd-tabs ptd-tabs--center">
|
<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 === 'invest' ? ' active' : ''}`} onClick={() => setCenterTab('invest')}>투자금 관리</button>
|
||||||
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}>차트</button>
|
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}>차트</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
center={centerTab === 'invest' ? (
|
center={centerTab === 'invest' ? (
|
||||||
@@ -306,7 +382,11 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="ptd-card ptd-chart-card ptd-center-fill">
|
<div className="ptd-card ptd-chart-card ptd-center-fill">
|
||||||
<PaperAnalysisChart market={selectedMarket} theme={theme} />
|
<PaperAnalysisChart
|
||||||
|
market={selectedMarket}
|
||||||
|
strategyId={chartStrategyId}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
rightTabs={rightTabs}
|
rightTabs={rightTabs}
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ interface TradingChartProps {
|
|||||||
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
paneSeparatorOptions?: ChartPaneSeparatorOptions;
|
||||||
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
|
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
|
||||||
paneLayoutClamp?: boolean;
|
paneLayoutClamp?: boolean;
|
||||||
|
/** 캔들+거래량 vs 보조 pane 높이 비율 (aux 0 이면 미적용) */
|
||||||
|
paneAreaRatio?: { candle: number; aux: number } | null;
|
||||||
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */
|
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */
|
||||||
showPaneLegend?: boolean;
|
showPaneLegend?: boolean;
|
||||||
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
|
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
|
||||||
@@ -195,6 +197,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
chartVisible = true,
|
chartVisible = true,
|
||||||
paneSeparatorOptions,
|
paneSeparatorOptions,
|
||||||
paneLayoutClamp = false,
|
paneLayoutClamp = false,
|
||||||
|
paneAreaRatio = null,
|
||||||
showPaneLegend = true,
|
showPaneLegend = true,
|
||||||
crosshairInfoVisible = true,
|
crosshairInfoVisible = true,
|
||||||
dataLoading = false,
|
dataLoading = false,
|
||||||
@@ -227,6 +230,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const seriesDblSuppressRef = useRef(false);
|
const seriesDblSuppressRef = useRef(false);
|
||||||
const toggleCandleOnlyRef = useRef<() => void>(() => {});
|
const toggleCandleOnlyRef = useRef<() => void>(() => {});
|
||||||
const candleOnlyModeRef = useRef(false);
|
const candleOnlyModeRef = useRef(false);
|
||||||
|
const paneAreaRatioRef = useRef(paneAreaRatio);
|
||||||
|
paneAreaRatioRef.current = paneAreaRatio;
|
||||||
const lastCrosshairPriceRef = useRef<number | null>(null);
|
const lastCrosshairPriceRef = useRef<number | null>(null);
|
||||||
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
|
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
|
||||||
onTradeOrderRequestRef.current = onTradeOrderRequest;
|
onTradeOrderRequestRef.current = onTradeOrderRequest;
|
||||||
@@ -387,6 +392,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
if (mgr.isCandleOnlyLayout()) {
|
if (mgr.isCandleOnlyLayout()) {
|
||||||
mgr.restoreFromCandleFullscreen(wrapperH);
|
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);
|
required = mgr.resetPaneHeights(wrapperH);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +419,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
} catch { /* ok */ }
|
} catch { /* ok */ }
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [paneLayoutClamp]);
|
}, [paneLayoutClamp, paneAreaRatio]);
|
||||||
|
|
||||||
/** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
|
/** pane 레이아웃 + 가격·시간축 정상화 (느린 네트워크·레이아웃 지연 후 복구용) */
|
||||||
const normalizeChartViewport = useCallback((mgr: ChartManager) => {
|
const normalizeChartViewport = useCallback((mgr: ChartManager) => {
|
||||||
@@ -938,8 +949,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
if (!panStateRef.current.active) return;
|
if (!panStateRef.current.active) return;
|
||||||
if (panStateRef.current.moved) {
|
if (panStateRef.current.moved) {
|
||||||
suppressClickRef.current = true;
|
suppressClickRef.current = true;
|
||||||
|
managerRef.current?.notifyViewportChanged();
|
||||||
}
|
}
|
||||||
managerRef.current?.endCandleAxisPan();
|
|
||||||
panStateRef.current.active = false;
|
panStateRef.current.active = false;
|
||||||
container.style.cursor = canPanRef.current ? 'grab' : '';
|
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 TradingChart from '../TradingChart';
|
||||||
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
import type { BacktestSignal, StrategyDto } from '../../utils/backendApi';
|
||||||
import { loadStrategy } from '../../utils/backendApi';
|
import { loadStrategy } from '../../utils/backendApi';
|
||||||
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types';
|
||||||
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
import { loadAnalysisCandles } from '../../utils/analysisChartData';
|
||||||
import type { ChartManager } from '../../utils/ChartManager';
|
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 { isUpbitMarket } from '../../utils/upbitApi';
|
||||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||||
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
import { normalizeChartTimeframe, timeframeBarSeconds } from '../../utils/backtestUiUtils';
|
||||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||||
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
import { enrichIndicatorConfig, getIndicatorDef } from '../../utils/indicatorRegistry';
|
||||||
|
import {
|
||||||
|
chartPaneFlexRatio,
|
||||||
|
countNonOverlayIndicatorPanes,
|
||||||
|
} from '../../utils/strategyOscillatorSeries';
|
||||||
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
import { normalizeSmaConfig } from '../../utils/smaConfig';
|
||||||
import {
|
import {
|
||||||
buildVirtualTradingChartIndicators,
|
buildVirtualTradingChartIndicators,
|
||||||
resolveStrategyPrimaryTimeframe,
|
resolveStrategyPrimaryTimeframe,
|
||||||
} from '../../utils/strategyToChartIndicators';
|
} from '../../utils/strategyToChartIndicators';
|
||||||
import BacktestOscillatorPanes from './BacktestOscillatorPanes';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -28,6 +31,10 @@ interface Props {
|
|||||||
signals: BacktestSignal[];
|
signals: BacktestSignal[];
|
||||||
strategyId?: number | null;
|
strategyId?: number | null;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
|
/** 과거 캔들 추가 로드 후 (차트에 반영된 전체 봉) */
|
||||||
|
onHistoryBarsLoaded?: (bars: OHLCVBar[]) => void;
|
||||||
|
/** 과거 로드·시그널 재계산 중 UI */
|
||||||
|
onHistoryLoadingChange?: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -59,6 +66,11 @@ function defaultOverlayIndicators(
|
|||||||
return [cfg];
|
return [cfg];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백테스트·투자관리 차트 — TradingChart 단일 엔진(실시간 차트와 동일 크로스헤어·시간축·우측 가격)
|
||||||
|
*/
|
||||||
const BacktestAnalysisChart: React.FC<Props> = ({
|
const BacktestAnalysisChart: React.FC<Props> = ({
|
||||||
symbol,
|
symbol,
|
||||||
timeframe: timeframeRaw,
|
timeframe: timeframeRaw,
|
||||||
@@ -68,8 +80,9 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
signals,
|
signals,
|
||||||
strategyId,
|
strategyId,
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
|
onHistoryBarsLoaded,
|
||||||
|
onHistoryLoadingChange,
|
||||||
}) => {
|
}) => {
|
||||||
const timeframe = normalizeChartTimeframe(timeframeRaw);
|
|
||||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||||
const { defaults: appDefaults } = useAppSettings();
|
const { defaults: appDefaults } = useAppSettings();
|
||||||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||||||
@@ -78,10 +91,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
const [chartType] = useState<ChartType>('candlestick');
|
const [chartType] = useState<ChartType>('candlestick');
|
||||||
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
|
||||||
const managerRef = useRef<ChartManager | null>(null);
|
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);
|
const signalsRef = useRef(signals);
|
||||||
signalsRef.current = 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 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(() => {
|
useEffect(() => {
|
||||||
if (!strategyId) {
|
if (!strategyId) {
|
||||||
setStrategy(undefined);
|
setStrategy(undefined);
|
||||||
@@ -97,10 +121,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
const indicators = useMemo(() => {
|
const indicators = useMemo(() => {
|
||||||
if (strategy) {
|
if (strategy) {
|
||||||
const inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
|
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);
|
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 effectiveBarCount = useMemo(() => {
|
||||||
const barSec = timeframeBarSeconds(timeframeRaw);
|
const barSec = timeframeBarSeconds(timeframeRaw);
|
||||||
@@ -114,7 +149,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
void loadAnalysisCandles(market, timeframe, toTimeSec, effectiveBarCount)
|
setLoadedBarCount(null);
|
||||||
|
void loadAnalysisCandles(market, chartTimeframe, toTimeSec, effectiveBarCount)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBars(data);
|
setBars(data);
|
||||||
@@ -128,7 +164,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [market, timeframe, toTimeSec, effectiveBarCount]);
|
}, [market, chartTimeframe, toTimeSec, effectiveBarCount]);
|
||||||
|
|
||||||
const applyMarkers = useCallback((attempt = 0) => {
|
const applyMarkers = useCallback((attempt = 0) => {
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
@@ -139,51 +175,127 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
mgr.setBacktestMarkers(signalsRef.current, true);
|
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) => {
|
const onManagerReady = useCallback((mgr: ChartManager) => {
|
||||||
managerRef.current = mgr;
|
managerRef.current = mgr;
|
||||||
|
setupHistoryScroll(mgr);
|
||||||
|
setLoadedBarCount(mgr.getRawBarsLength());
|
||||||
|
applyPaneRatio(mgr);
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
}, [applyMarkers]);
|
}, [applyMarkers, applyPaneRatio, setupHistoryScroll]);
|
||||||
|
|
||||||
const onCandlesReady = useCallback(() => {
|
const onCandlesReady = useCallback(() => {
|
||||||
|
const mgr = managerRef.current;
|
||||||
|
if (!mgr) return;
|
||||||
|
setupHistoryScroll(mgr);
|
||||||
|
applyPaneRatio(mgr);
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
}, [applyMarkers]);
|
setLoadedBarCount(mgr.getRawBarsLength());
|
||||||
|
}, [applyMarkers, applyPaneRatio, setupHistoryScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
}, [signals, bars, applyMarkers]);
|
}, [signals, bars, applyMarkers]);
|
||||||
|
|
||||||
useHistoryLoader({
|
useEffect(() => {
|
||||||
symbol: market,
|
const mgr = managerRef.current;
|
||||||
timeframe,
|
if (mgr) applyPaneRatio(mgr);
|
||||||
isUpbit: isUpbitMarket(market),
|
}, [paneAreaRatio, applyPaneRatio]);
|
||||||
managerRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
const symLabel = market.replace(/^KRW-/, '');
|
const symLabel = market.replace(/^KRW-/, '');
|
||||||
const stratTf = strategy ? resolveStrategyPrimaryTimeframe(strategy) : timeframe;
|
const stratTf = chartTimeframe;
|
||||||
|
|
||||||
|
const priceLabels = appDefaults.chartSeriesPriceLabels !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="btd-analysis-chart">
|
<div className="btd-analysis-chart">
|
||||||
<div className="btd-analysis-toolbar">
|
<div className="btd-analysis-toolbar">
|
||||||
<div className="btd-analysis-toolbar-left">
|
<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">{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>
|
<span className="btd-analysis-select btd-analysis-select--static">Candle</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="btd-analysis-toolbar-right">
|
<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-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>
|
</div>
|
||||||
<div className="btd-analysis-main">
|
<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>}
|
{loading && <div className="btd-analysis-loading">차트 로딩…</div>}
|
||||||
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
{error && !loading && <div className="btd-analysis-error">{error}</div>}
|
||||||
{!loading && !error && bars.length > 0 && (
|
{!loading && !error && bars.length > 0 && (
|
||||||
<TradingChart
|
<TradingChart
|
||||||
key={`${market}-${timeframe}-${toTimeSec}-${effectiveBarCount}`}
|
key={`${market}-${chartTimeframe}-${toTimeSec}-${effectiveBarCount}-${indicators.map(i => i.id).join(',')}`}
|
||||||
bars={bars}
|
bars={bars}
|
||||||
barsMarket={market}
|
barsMarket={market}
|
||||||
market={market}
|
market={market}
|
||||||
@@ -201,13 +313,20 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
onAddDrawing={noop}
|
onAddDrawing={noop}
|
||||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||||
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
paneSeparatorOptions={appDefaults.chartPaneSeparator}
|
||||||
|
paneLayoutClamp
|
||||||
|
paneAreaRatio={paneAreaRatio}
|
||||||
|
showPaneLegend
|
||||||
|
crosshairInfoVisible
|
||||||
|
candleAreaPriceLabelsEnabled={priceLabels}
|
||||||
|
indicatorAreaPriceLabelsEnabled={priceLabels}
|
||||||
|
seriesPriceLabelsEnabled={priceLabels}
|
||||||
|
showHoverToolbar
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && bars.length === 0 && (
|
{!loading && !error && bars.length === 0 && (
|
||||||
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!loading && bars.length > 0 && <BacktestOscillatorPanes bars={bars} />}
|
|
||||||
</div>
|
</div>
|
||||||
</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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Theme, Timeframe } from '../../types';
|
import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart';
|
||||||
import PaperMiniChart from './PaperMiniChart';
|
import {
|
||||||
import PaperIndicatorPanel from './PaperIndicatorPanel';
|
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 {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
|
strategyId?: number | null;
|
||||||
theme?: Theme;
|
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 (
|
return (
|
||||||
<div className="ptd-analysis-chart">
|
<div className="ptd-analysis-chart ptd-analysis-chart--backtest">
|
||||||
<div className="ptd-main-pane">
|
{statusBusy && (
|
||||||
<PaperMiniChart
|
<div className="ptd-analysis-chart-status">
|
||||||
market={market}
|
{historyLoading && !running ? '과거 캔들 로드 중…' : '전략 시그널 계산 중…'}
|
||||||
theme={theme}
|
</div>
|
||||||
timeframe={timeframe}
|
)}
|
||||||
onTimeframeChange={setTimeframe}
|
{error && !statusBusy && (
|
||||||
/>
|
<div className="ptd-analysis-chart-status ptd-analysis-chart-status--err">{error}</div>
|
||||||
</div>
|
)}
|
||||||
<div className="ptd-sub-panes">
|
<BacktestAnalysisChart
|
||||||
<PaperIndicatorPanel market={market} timeframe={timeframe} stacked />
|
symbol={market}
|
||||||
</div>
|
timeframe={timeframe}
|
||||||
|
toTimeSec={toTimeSec}
|
||||||
|
barCount={BAR_COUNT}
|
||||||
|
signals={signals}
|
||||||
|
strategyId={strategyId}
|
||||||
|
theme={theme}
|
||||||
|
onHistoryBarsLoaded={handleHistoryBarsLoaded}
|
||||||
|
onHistoryLoadingChange={setHistoryLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import type { RefObject, MutableRefObject } from 'react';
|
import type { RefObject, MutableRefObject } from 'react';
|
||||||
import type { ChartManager } from '../utils/ChartManager';
|
import type { ChartManager } from '../utils/ChartManager';
|
||||||
|
import { toHistoryApiIso } from '../utils/analysisChartData';
|
||||||
|
import { timeframeToCandleType } from '../utils/chartCandleType';
|
||||||
import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache';
|
import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache';
|
||||||
import type { OHLCVBar, Timeframe } from '../types';
|
import type { OHLCVBar, Timeframe } from '../types';
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ interface UseHistoryLoaderOptions {
|
|||||||
timeframe: Timeframe;
|
timeframe: Timeframe;
|
||||||
isUpbit: boolean;
|
isUpbit: boolean;
|
||||||
managerRef: RefObject<ChartManager | null>;
|
managerRef: RefObject<ChartManager | null>;
|
||||||
|
/** 과거 캔들 prepend 완료 후 (추가된 봉 수, 전체 봉 수) */
|
||||||
|
onBarsPrepended?: (addedCount: number, totalBars: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseHistoryLoaderReturn {
|
interface UseHistoryLoaderReturn {
|
||||||
@@ -91,6 +95,7 @@ export function useHistoryLoader({
|
|||||||
timeframe,
|
timeframe,
|
||||||
isUpbit,
|
isUpbit,
|
||||||
managerRef,
|
managerRef,
|
||||||
|
onBarsPrepended,
|
||||||
}: UseHistoryLoaderOptions): UseHistoryLoaderReturn {
|
}: UseHistoryLoaderOptions): UseHistoryLoaderReturn {
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
|
||||||
@@ -103,6 +108,8 @@ export function useHistoryLoader({
|
|||||||
/** false 가 되면 더 이상 API 를 호출하지 않음 */
|
/** false 가 되면 더 이상 API 를 호출하지 않음 */
|
||||||
const hasMoreRef = useRef(true);
|
const hasMoreRef = useRef(true);
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const onPrependedRef = useRef(onBarsPrepended);
|
||||||
|
onPrependedRef.current = onBarsPrepended;
|
||||||
|
|
||||||
// 심볼·타임프레임이 바뀌면 상태 초기화
|
// 심볼·타임프레임이 바뀌면 상태 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,10 +132,15 @@ export function useHistoryLoader({
|
|||||||
const oldestTime = mgr.getOldestBarTime();
|
const oldestTime = mgr.getOldestBarTime();
|
||||||
if (oldestTime === null) return;
|
if (oldestTime === null) return;
|
||||||
|
|
||||||
// Unix 타임스탬프(초) → ISO 8601 UTC ('Z' 없이 전달, 백엔드에서 처리)
|
// exclusive to — 가장 오래된 봉 시각 그대로 쓰면 중복·0건이 될 수 있어 한 봉 이전으로
|
||||||
const beforeIso = new Date(oldestTime * 1000)
|
const raw = mgr.getRawBars();
|
||||||
.toISOString()
|
let beforeSec = oldestTime;
|
||||||
.replace('.000Z', ''); // '2026-05-20T10:00:00'
|
if (raw.length >= 2) {
|
||||||
|
const step = Math.max(1, (raw[1].time as number) - (raw[0].time as number));
|
||||||
|
beforeSec = oldestTime - step;
|
||||||
|
}
|
||||||
|
const beforeIso = toHistoryApiIso(beforeSec);
|
||||||
|
const candleType = timeframeToCandleType(timeframe);
|
||||||
|
|
||||||
// ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단
|
// ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
@@ -139,8 +151,8 @@ export function useHistoryLoader({
|
|||||||
|
|
||||||
// ── 1. 백엔드 /api/candles/history 우선 시도 ──────────────────────────
|
// ── 1. 백엔드 /api/candles/history 우선 시도 ──────────────────────────
|
||||||
try {
|
try {
|
||||||
olderBars = await fetchHistoryFromBackend(symbol, timeframe, beforeIso, LOAD_BATCH);
|
olderBars = await fetchHistoryFromBackend(symbol, candleType, beforeIso, LOAD_BATCH);
|
||||||
console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신`);
|
console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신 (${candleType})`);
|
||||||
} catch (backendErr) {
|
} catch (backendErr) {
|
||||||
// ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ───────────────────────
|
// ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ───────────────────────
|
||||||
console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr);
|
console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr);
|
||||||
@@ -153,7 +165,10 @@ export function useHistoryLoader({
|
|||||||
hasMoreRef.current = false; // 더 이상 데이터 없음
|
hasMoreRef.current = false; // 더 이상 데이터 없음
|
||||||
} else {
|
} else {
|
||||||
// ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ──────────────────────
|
// ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ──────────────────────
|
||||||
await mgr.prependBars(olderBars);
|
const added = await mgr.prependBars(olderBars);
|
||||||
|
if (added > 0) {
|
||||||
|
onPrependedRef.current?.(added, mgr.getRawBarsLength());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e);
|
console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e);
|
||||||
|
|||||||
@@ -1216,36 +1216,69 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btd-analysis-canvas-wrap {
|
.btd-analysis-canvas-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btd-analysis-canvas-wrap--live {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-analysis-canvas-wrap--live .tv-chart-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btd-analysis-canvas-wrap .tv-chart-wrap {
|
.btd-analysis-canvas-wrap .tv-chart-wrap {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
min-height: 320px;
|
min-height: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btd-analysis-canvas-wrap .chart-container {
|
.btd-analysis-canvas-wrap .chart-container {
|
||||||
min-height: 320px;
|
min-height: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── RSI / MACD pane ── */
|
/* ── 보조지표 pane ── */
|
||||||
|
|
||||||
.btd-osc-stack {
|
.btd-osc-stack {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-top: 1px solid var(--btd-divider);
|
border-top: 1px solid var(--btd-divider);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-osc-stack--fill {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btd-osc-pane {
|
.btd-osc-pane {
|
||||||
padding: 6px 10px 4px;
|
padding: 6px 10px 4px;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--btd-divider) 70%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--btd-divider) 70%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-osc-pane--fill {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-osc-pane--fill .btd-osc-svg {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 36px;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btd-osc-pane:last-child { border-bottom: none; }
|
.btd-osc-pane:last-child { border-bottom: none; }
|
||||||
|
|||||||
@@ -72,6 +72,54 @@
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ptd-analysis-chart--backtest {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-analysis-chart--backtest .btd-analysis-chart {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-analysis-chart--backtest .btd-analysis-main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-analysis-chart--empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-analysis-chart-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 4;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg3) 92%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-analysis-chart-status--err {
|
||||||
|
color: var(--down, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
.ptd-ind-stack {
|
.ptd-ind-stack {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -2805,6 +2805,59 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ptd-center-head--chart {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-center-head--chart .ptd-tabs--center {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-center-head--chart .ptd-center-head-center {
|
||||||
|
justify-self: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-center-head--chart .bps-center-head-title {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-chart-market-select-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-chart-market-select {
|
||||||
|
min-width: min(220px, 42vw);
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 5px 28px 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border, var(--border));
|
||||||
|
background: var(--gc-surface-2, rgba(255, 255, 255, 0.06));
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%2394a3b8' d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-chart-market-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-chart-market-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 55%, var(--border));
|
||||||
|
}
|
||||||
.ptd-tabs--center {
|
.ptd-tabs--center {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ export class ChartManager {
|
|||||||
/** 알림 목록 미니 차트 등 고정 높이 — pane 최소 px·비율 완화 */
|
/** 알림 목록 미니 차트 등 고정 높이 — pane 최소 px·비율 완화 */
|
||||||
private _compactPaneLayout = false;
|
private _compactPaneLayout = false;
|
||||||
|
|
||||||
|
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
|
||||||
|
private _paneAreaRatio: { candle: number; aux: number } | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
@@ -2054,10 +2057,6 @@ export class ChartManager {
|
|||||||
private _viewportLwcHooked = false;
|
private _viewportLwcHooked = false;
|
||||||
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
|
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
|
||||||
|
|
||||||
/** 캔들 pane 시간축 — 드래그 패닝 중 transform 동기화용 */
|
|
||||||
private _candleAxisPanActive = false;
|
|
||||||
private _candleAxisPanOffsetPx = 0;
|
|
||||||
|
|
||||||
private _notifyViewport(): void {
|
private _notifyViewport(): void {
|
||||||
for (const cb of this._viewportListeners) {
|
for (const cb of this._viewportListeners) {
|
||||||
try { cb(); } catch { /* ok */ }
|
try { cb(); } catch { /* ok */ }
|
||||||
@@ -2096,29 +2095,6 @@ export class ChartManager {
|
|||||||
this._notifyViewport();
|
this._notifyViewport();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 캔들 pane 시간축 드래그 패닝 종료 — 라벨 위치를 최종 논리 범위로 재정렬 */
|
|
||||||
endCandleAxisPan(): void {
|
|
||||||
if (!this._candleAxisPanActive) return;
|
|
||||||
this._candleAxisPanActive = false;
|
|
||||||
this._candleAxisPanOffsetPx = 0;
|
|
||||||
this._notifyViewport();
|
|
||||||
}
|
|
||||||
|
|
||||||
isCandleAxisPanActive(): boolean {
|
|
||||||
return this._candleAxisPanActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCandleAxisPanOffsetPx(): number {
|
|
||||||
return this._candleAxisPanOffsetPx;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _ensureCandleAxisPan(): void {
|
|
||||||
if (this._candleAxisPanActive) return;
|
|
||||||
this._candleAxisPanActive = true;
|
|
||||||
this._candleAxisPanOffsetPx = 0;
|
|
||||||
this._notifyViewport();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
||||||
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
|
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
|
||||||
const span = lr.to - lr.from;
|
const span = lr.to - lr.from;
|
||||||
@@ -3040,9 +3016,6 @@ export class ChartManager {
|
|||||||
options?: { allowVerticalPan?: boolean },
|
options?: { allowVerticalPan?: boolean },
|
||||||
): void {
|
): void {
|
||||||
if (deltaX !== 0) {
|
if (deltaX !== 0) {
|
||||||
this._ensureCandleAxisPan();
|
|
||||||
this._candleAxisPanOffsetPx += deltaX;
|
|
||||||
|
|
||||||
const ts = this.chart.timeScale();
|
const ts = this.chart.timeScale();
|
||||||
const lr = ts.getVisibleLogicalRange();
|
const lr = ts.getVisibleLogicalRange();
|
||||||
if (lr) {
|
if (lr) {
|
||||||
@@ -3272,6 +3245,11 @@ export class ChartManager {
|
|||||||
return this.rawBars.length;
|
return this.rawBars.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 차트에 로드된 캔들 스냅샷 (과거 추가 로드·백테스트 재계산용) */
|
||||||
|
getRawBars(): OHLCVBar[] {
|
||||||
|
return this.rawBars.slice();
|
||||||
|
}
|
||||||
|
|
||||||
/** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
|
/** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
|
||||||
countBarsBetweenTime(t0: number, t1: number): number {
|
countBarsBetweenTime(t0: number, t1: number): number {
|
||||||
const lo = Math.min(t0, t1);
|
const lo = Math.min(t0, t1);
|
||||||
@@ -3296,15 +3274,15 @@ export class ChartManager {
|
|||||||
* 사용자가 보고 있던 구간이 유지되도록 합니다.
|
* 사용자가 보고 있던 구간이 유지되도록 합니다.
|
||||||
* - 모든 보조지표를 전체 데이터 기준으로 재계산합니다.
|
* - 모든 보조지표를 전체 데이터 기준으로 재계산합니다.
|
||||||
*/
|
*/
|
||||||
async prependBars(olderBars: OHLCVBar[]): Promise<void> {
|
async prependBars(olderBars: OHLCVBar[]): Promise<number> {
|
||||||
if (!this.mainSeries || olderBars.length === 0) return;
|
if (!this.mainSeries || olderBars.length === 0) return 0;
|
||||||
|
|
||||||
const firstExistingTime = this.rawBars[0]?.time as number | undefined;
|
const firstExistingTime = this.rawBars[0]?.time as number | undefined;
|
||||||
const newBars = firstExistingTime !== undefined
|
const newBars = firstExistingTime !== undefined
|
||||||
? olderBars.filter(b => (b.time as number) < firstExistingTime)
|
? olderBars.filter(b => (b.time as number) < firstExistingTime)
|
||||||
: olderBars;
|
: olderBars;
|
||||||
|
|
||||||
if (newBars.length === 0) return;
|
if (newBars.length === 0) return 0;
|
||||||
|
|
||||||
// 현재 가시 논리 범위 저장 (prepend 후 복원용)
|
// 현재 가시 논리 범위 저장 (prepend 후 복원용)
|
||||||
const logicalRange = this.chart.timeScale().getVisibleLogicalRange();
|
const logicalRange = this.chart.timeScale().getVisibleLogicalRange();
|
||||||
@@ -3349,6 +3327,7 @@ export class ChartManager {
|
|||||||
|
|
||||||
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
||||||
await this._refreshAllIndicatorsData();
|
await this._refreshAllIndicatorsData();
|
||||||
|
return newBars.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */
|
/** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */
|
||||||
@@ -3628,6 +3607,21 @@ export class ChartManager {
|
|||||||
*
|
*
|
||||||
* @param availableHeight 외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용.
|
* @param availableHeight 외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 캔들 영역 vs 보조지표 영역 높이 비율 (예: 보조 1개 → 2:1, 2~5개 → 1:1, 6+ → 1:2)
|
||||||
|
* auxWeight 가 0 이면 비율 해제.
|
||||||
|
*/
|
||||||
|
setPaneAreaRatio(candleWeight: number, auxWeight: number): void {
|
||||||
|
if (auxWeight <= 0) {
|
||||||
|
this._paneAreaRatio = null;
|
||||||
|
} else {
|
||||||
|
this._paneAreaRatio = {
|
||||||
|
candle: Math.max(0.1, candleWeight),
|
||||||
|
aux: Math.max(0.1, auxWeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _volumeFrac(H: number): number {
|
private _volumeFrac(H: number): number {
|
||||||
if (!this._volumeVisible || this._candleOnlyLayout) return 0;
|
if (!this._volumeVisible || this._candleOnlyLayout) return 0;
|
||||||
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
||||||
@@ -3750,7 +3744,15 @@ export class ChartManager {
|
|||||||
? this._lastLayoutAvailableHeight
|
? this._lastLayoutAvailableHeight
|
||||||
: this.container.clientHeight);
|
: this.container.clientHeight);
|
||||||
const N = this._activeIndicatorPaneIndices().size;
|
const N = this._activeIndicatorPaneIndices().size;
|
||||||
const { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H), H);
|
const volFrac = this._volumeFrac(H);
|
||||||
|
let mainFrac: number;
|
||||||
|
if (this._paneAreaRatio && N > 0) {
|
||||||
|
const total = this._paneAreaRatio.candle + this._paneAreaRatio.aux;
|
||||||
|
const candleGroupFrac = this._paneAreaRatio.candle / total;
|
||||||
|
mainFrac = Math.max(0.12, candleGroupFrac - volFrac);
|
||||||
|
} else {
|
||||||
|
mainFrac = this._paneHeightFractions(N, volFrac, H).mainFrac;
|
||||||
|
}
|
||||||
|
|
||||||
return this._applyPaneStretchFactors(mainFrac, availableHeight);
|
return this._applyPaneStretchFactors(mainFrac, availableHeight);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { OHLCVBar } from '../types';
|
||||||
|
import type { StrategyDto } from './backendApi';
|
||||||
|
import { extractVirtualConditions } from './virtualStrategyConditions';
|
||||||
|
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
|
||||||
|
|
||||||
|
const DSL_TO_REGISTRY: Record<string, string> = {
|
||||||
|
RSI: 'RSI',
|
||||||
|
MACD: 'MACD',
|
||||||
|
MA: 'SMA',
|
||||||
|
EMA: 'EMA',
|
||||||
|
BOLLINGER: 'BollingerBands',
|
||||||
|
STOCHASTIC: 'Stochastic',
|
||||||
|
WILLIAMS_R: 'WilliamsPercentRange',
|
||||||
|
CCI: 'CCI',
|
||||||
|
ADX: 'ADX',
|
||||||
|
DMI: 'DMI',
|
||||||
|
OBV: 'OBV',
|
||||||
|
TRIX: 'TRIX',
|
||||||
|
VOLUME_OSC: 'VolumeOscillator',
|
||||||
|
VR: 'VR',
|
||||||
|
DISPARITY: 'Disparity',
|
||||||
|
PSYCHOLOGICAL: 'Psychological',
|
||||||
|
NEW_PSYCHOLOGICAL: 'NewPsychological',
|
||||||
|
INVEST_PSYCHOLOGICAL: 'InvestPsychological',
|
||||||
|
ICHIMOKU: 'IchimokuCloud',
|
||||||
|
ATR: 'ATR',
|
||||||
|
MFI: 'MFI',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 메인 캔들 pane 오버레이 — 하단 스파크 pane 제외 */
|
||||||
|
const OVERLAY_DSL = new Set([
|
||||||
|
'MA', 'EMA', 'BOLLINGER', 'ICHIMOKU', 'DONCHIAN', 'NEW_HIGH', 'NEW_LOW', 'DISPARITY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface OscillatorPaneSpec {
|
||||||
|
key: string;
|
||||||
|
registryType: string;
|
||||||
|
label: string;
|
||||||
|
period?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OscillatorPaneData {
|
||||||
|
spec: OscillatorPaneSpec;
|
||||||
|
values: number[];
|
||||||
|
refLines?: number[];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANE_COLORS: Record<string, string> = {
|
||||||
|
RSI: '#a78bfa',
|
||||||
|
MACD: '#38bdf8',
|
||||||
|
CCI: '#fbbf24',
|
||||||
|
Stochastic: '#f472b6',
|
||||||
|
WilliamsPercentRange: '#c084fc',
|
||||||
|
ADX: '#34d399',
|
||||||
|
MFI: '#fb923c',
|
||||||
|
OBV: '#94a3b8',
|
||||||
|
TRIX: '#22d3ee',
|
||||||
|
VolumeOscillator: '#86efac',
|
||||||
|
VR: '#fcd34d',
|
||||||
|
Psychological: '#e879f9',
|
||||||
|
NewPsychological: '#e879f9',
|
||||||
|
InvestPsychological: '#e879f9',
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeRsi(closes: number[], period = 14): number[] {
|
||||||
|
if (closes.length < period + 1) return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
let avgGain = 0;
|
||||||
|
let avgLoss = 0;
|
||||||
|
for (let i = 1; i <= period; i++) {
|
||||||
|
const d = closes[i] - closes[i - 1];
|
||||||
|
if (d >= 0) avgGain += d; else avgLoss -= d;
|
||||||
|
}
|
||||||
|
avgGain /= period;
|
||||||
|
avgLoss /= period;
|
||||||
|
for (let i = period; i < closes.length; i++) {
|
||||||
|
const d = closes[i] - closes[i - 1];
|
||||||
|
const gain = d > 0 ? d : 0;
|
||||||
|
const loss = d < 0 ? -d : 0;
|
||||||
|
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||||
|
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||||
|
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||||
|
out.push(100 - 100 / (1 + rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMacd(closes: number[]): number[] {
|
||||||
|
const ema = (arr: number[], p: number) => {
|
||||||
|
const k = 2 / (p + 1);
|
||||||
|
let v = arr[0];
|
||||||
|
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
|
||||||
|
};
|
||||||
|
const e12 = ema(closes, 12);
|
||||||
|
const e26 = ema(closes, 26);
|
||||||
|
return e12.map((v, i) => v - e26[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeCci(bars: OHLCVBar[], period = 20): number[] {
|
||||||
|
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = period; i < tp.length; i++) {
|
||||||
|
const slice = tp.slice(i - period, i);
|
||||||
|
const sma = slice.reduce((a, b) => a + b, 0) / slice.length;
|
||||||
|
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
|
||||||
|
out.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSeries(bars: OHLCVBar[], registryType: string, period?: number): number[] {
|
||||||
|
const closes = bars.map(b => b.close);
|
||||||
|
switch (registryType) {
|
||||||
|
case 'RSI':
|
||||||
|
return computeRsi(closes, period ?? 14);
|
||||||
|
case 'MACD':
|
||||||
|
return computeMacd(closes);
|
||||||
|
case 'CCI':
|
||||||
|
return computeCci(bars, period ?? 20);
|
||||||
|
default:
|
||||||
|
return computeRsi(closes, 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refLinesFor(registryType: string): number[] | undefined {
|
||||||
|
if (registryType === 'RSI') return [30, 50, 70];
|
||||||
|
if (registryType === 'CCI') return [-100, 0, 100];
|
||||||
|
if (registryType === 'MACD') return [0];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectStrategyOscillatorPanes(strategy: StrategyDto | undefined): OscillatorPaneSpec[] {
|
||||||
|
if (!strategy) return [];
|
||||||
|
const rows = extractVirtualConditions(strategy);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: OscillatorPaneSpec[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (OVERLAY_DSL.has(row.indicatorType)) continue;
|
||||||
|
const registryType = DSL_TO_REGISTRY[row.indicatorType] ?? row.indicatorType;
|
||||||
|
if (OVERLAY_DSL.has(registryType) || registryType === 'SMA' || registryType === 'EMA'
|
||||||
|
|| registryType === 'BollingerBands' || registryType === 'IchimokuCloud') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(registryType)) continue;
|
||||||
|
seen.add(registryType);
|
||||||
|
out.push({
|
||||||
|
key: registryType,
|
||||||
|
registryType,
|
||||||
|
label: formatIndicatorDisplayLabel(row.indicatorType),
|
||||||
|
period: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOscillatorPaneData(
|
||||||
|
bars: OHLCVBar[],
|
||||||
|
specs: OscillatorPaneSpec[],
|
||||||
|
): OscillatorPaneData[] {
|
||||||
|
if (bars.length < 20) return [];
|
||||||
|
const out: OscillatorPaneData[] = [];
|
||||||
|
for (const spec of specs) {
|
||||||
|
const values = computeSeries(bars, spec.registryType, spec.period).slice(-80);
|
||||||
|
if (values.length < 2) continue;
|
||||||
|
out.push({
|
||||||
|
spec,
|
||||||
|
values,
|
||||||
|
refLines: refLinesFor(spec.registryType),
|
||||||
|
color: PANE_COLORS[spec.registryType] ?? '#94a3b8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultOscillatorPaneSpecs(): OscillatorPaneSpec[] {
|
||||||
|
return [
|
||||||
|
{ key: 'RSI', registryType: 'RSI', label: 'RSI (14)', period: 14 },
|
||||||
|
{ key: 'MACD', registryType: 'MACD', label: 'MACD (12, 26, 9)' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** StrategyOscillatorPanes 에 실제로 그려지는 보조 pane 개수 */
|
||||||
|
export function countAuxiliaryOscillatorPaneCount(strategy: StrategyDto | undefined): number {
|
||||||
|
const specs = collectStrategyOscillatorPanes(strategy);
|
||||||
|
return (specs.length > 0 ? specs : defaultOscillatorPaneSpecs()).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TradingChart 보조 pane(비오버레이 지표) 개수 */
|
||||||
|
export function countNonOverlayIndicatorPanes(
|
||||||
|
indicators: Array<{ type: string }>,
|
||||||
|
isOverlay: (type: string) => boolean,
|
||||||
|
): number {
|
||||||
|
return indicators.filter(ind => !isOverlay(ind.type)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캔들 vs 보조지표 영역 flex 비율
|
||||||
|
* - 보조 1개: 2:1
|
||||||
|
* - 보조 2~5개: 1:1
|
||||||
|
* - 보조 6개+: 1:2
|
||||||
|
*/
|
||||||
|
export function chartPaneFlexRatio(auxPaneCount: number): { candle: number; aux: number } {
|
||||||
|
const n = Math.max(0, auxPaneCount);
|
||||||
|
if (n === 0) return { candle: 1, aux: 0 };
|
||||||
|
if (n === 1) return { candle: 2, aux: 1 };
|
||||||
|
if (n <= 5) return { candle: 1, aux: 1 };
|
||||||
|
return { candle: 1, aux: 2 };
|
||||||
|
}
|
||||||
@@ -23,3 +23,13 @@ export function resolveVirtualTargetNames(
|
|||||||
: getKoreanName(market);
|
: getKoreanName(market);
|
||||||
return { koreanName: ko, englishName: en };
|
return { koreanName: ko, englishName: en };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 투자대상·차트 종목 선택 — 한글명(영문명) */
|
||||||
|
export function formatVirtualTargetOptionLabel(
|
||||||
|
market: string,
|
||||||
|
koreanName?: string | null,
|
||||||
|
englishName?: string | null,
|
||||||
|
): string {
|
||||||
|
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(market, koreanName, englishName);
|
||||||
|
return `${ko}(${en})`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user