전략평가 화면 전략 일괄평가 기능

This commit is contained in:
Macbook
2026-06-19 20:26:59 +09:00
parent 994a2024c7
commit cc30d317ec
8 changed files with 683 additions and 21 deletions
+27
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-virtual": "^3.14.2",
"@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0",
@@ -1155,6 +1156,32 @@
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0"
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-virtual": "^3.14.2",
"@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0",
@@ -7,6 +7,7 @@ import '../styles/strategyEditorTheme.css';
import '../styles/strategyEditor.css';
import '../styles/strategyEvaluation.css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Theme, Timeframe } from '../types';
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
import {
@@ -72,6 +73,8 @@ import {
buildAiPanelSelectionSummary,
} from '../utils/strategyEvaluationAiVerify';
import type { StrategyEvaluationAiVerifyResponse } from '../utils/backendApi';
import { useStrategyBulkEvaluation } from '../hooks/useStrategyBulkEvaluation';
import StrategyBulkEvalBadge, { StrategyBulkEvalToolbar } from './strategyEvaluation/StrategyBulkEvalControls';
const LEFT_KEY = 'seval-left-width';
const LEFT_OPEN_KEY = 'seval-left-open';
@@ -91,7 +94,26 @@ interface Props {
theme?: Theme;
}
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const strategyEvaluationQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 10 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export default function StrategyEvaluationPage(props: Props) {
return (
<QueryClientProvider client={strategyEvaluationQueryClient}>
<StrategyEvaluationPageInner {...props} />
</QueryClientProvider>
);
}
function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
const {
getParams: baseGetParams,
getVisualConfig,
@@ -413,6 +435,72 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
});
}, [selectedStrategy, backtestSignals, initialCapital, market]);
const bulkEval = useStrategyBulkEvaluation({
strategies,
market,
timeframe: chartTimeframe,
bars,
evaluationBarCount: evalWindowMeta.evaluationBarCount,
windowEndTimeSec: evalWindowMeta.windowEndTimeSec,
initialCapital,
selectedStrategyId,
getParams: getEvalParams,
appliedIndicatorParams,
});
const autoBulkEvalTriggerKey = useMemo(() => {
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
const strategyIds = strategies.map(s => s.id).filter(Boolean).join(',');
return [
market,
chartTimeframe,
selectedStrategyId ?? 0,
evalWindowMeta.windowEndTimeSec ?? 'latest',
evalWindowMeta.evaluationBarCount,
bars.length,
lastBarTime,
paramsRevision,
strategyIds,
].join('|');
}, [
market,
chartTimeframe,
selectedStrategyId,
evalWindowMeta.windowEndTimeSec,
evalWindowMeta.evaluationBarCount,
bars,
paramsRevision,
strategies,
]);
const lastAutoBulkEvalKeyRef = useRef<string | null>(null);
useEffect(() => {
if (chartLoading) {
bulkEval.cancelBulkEvaluation();
}
}, [chartLoading, bulkEval.cancelBulkEvaluation]);
useEffect(() => {
const waitingForSelectedSignals = selectedStrategyId != null && signalScanRunning;
if (chartLoading || waitingForSelectedSignals) return;
if (bars.length < 10 || !strategies.some(s => s.id != null)) return;
const key = autoBulkEvalTriggerKey;
if (lastAutoBulkEvalKeyRef.current === key) return;
lastAutoBulkEvalKeyRef.current = key;
void bulkEval.startBulkEvaluation({ restart: true });
}, [
autoBulkEvalTriggerKey,
chartLoading,
signalScanRunning,
selectedStrategyId,
bars.length,
strategies,
bulkEval.startBulkEvaluation,
]);
const refreshEvaluation = useCallback(async () => {
if (!selectedStrategyId || !selectedStrategy) {
setSnapshot(undefined);
@@ -687,11 +775,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
return (
<div className={`btd-page se-page se-page--${theme}`}>
<header className="btd-header">
<div className="btd-header-brand">
<h1 className="btd-header-title"> </h1>
<span className="btd-header-sub">Strategy Match Analysis</span>
</div>
{selectedStrategy && (signalScanRunning || analysisSummary) && (
<header className="btd-header btd-header--seval-summary">
<div className="btd-header-actions seval-header-analysis">
{selectedStrategy && signalScanRunning && (
<span className="seval-header-analysis-busy"> </span>
@@ -732,6 +817,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
)}
</div>
</header>
)}
<div className="btd-body">
<div
@@ -740,6 +826,28 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
>
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
<div className="btd-exec-list seval-left-panel">
<div className="seval-left-panel-header">
<h1 className="seval-left-panel-title"> </h1>
<div className="seval-left-panel-toolbar">
<button
type="button"
className="seval-strategy-add-btn seval-strategy-add-btn--panel"
title="전략 추가·편집"
aria-label="전략 추가·편집"
onClick={() => setEditorModalOpen(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
<StrategyBulkEvalToolbar
isRunning={bulkEval.isRunning}
canStart={bulkEval.canStart}
onStart={() => { void bulkEval.startBulkEvaluation({ restart: true }); }}
onCancel={bulkEval.cancelBulkEvaluation}
/>
</div>
</div>
<div className="seval-left-tabs-row">
<div className="btd-exec-tabs" role="tablist">
<button
@@ -771,19 +879,6 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
</button>
</div>
{leftTab === 'strategy' && (
<button
type="button"
className="seval-strategy-add-btn"
title="전략 추가·편집"
aria-label="전략 추가·편집"
onClick={() => setEditorModalOpen(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{leftTab === 'strategy' ? (
@@ -823,6 +918,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
>
<div className="btd-exec-item-top">
<span className="btd-exec-strategy">{name}</span>
{s.id != null && (
<StrategyBulkEvalBadge
strategyId={s.id}
scanKey={bulkEval.scanKey}
isRunning={bulkEval.isRunning}
isCurrent={bulkEval.currentStrategyId === s.id}
/>
)}
</div>
{s.description && (
<p className="btd-exec-meta">{repairUtf8Mojibake(s.description)}</p>
@@ -0,0 +1,106 @@
import React from 'react';
import { formatEvaluationReturnPct } from '../../utils/strategyEvaluationReport';
import { useStrategyBulkEvalReturns } from '../../hooks/useStrategyBulkEvaluation';
function ReturnValue({ pct, label }: { pct: number; label: string }) {
const pos = pct > 0;
const neg = pct < 0;
const zero = !pos && !neg;
return (
<span
className={`seval-bulk-item-return${
pos ? ' seval-bulk-item-return--pos' : neg ? ' seval-bulk-item-return--neg' : zero ? ' seval-bulk-item-return--zero' : ''
}`}
title={`${label} 수익률`}
>
<span className="seval-bulk-item-return-label">{label}</span>
{formatEvaluationReturnPct(pct)}
</span>
);
}
interface Props {
strategyId: number;
scanKey: string;
isRunning: boolean;
isCurrent: boolean;
}
export default function StrategyBulkEvalBadge({
strategyId,
scanKey,
isRunning,
isCurrent,
}: Props) {
const { data: returns, isFetching } = useStrategyBulkEvalReturns(strategyId, scanKey);
if (isRunning && isCurrent) {
return (
<span className="seval-bulk-item-badge seval-bulk-item-badge--loading" aria-label="평가 중">
<span className="btd-run-spinner seval-bulk-item-spinner" aria-hidden />
</span>
);
}
if (!returns) {
if (isFetching) {
return (
<span className="seval-bulk-item-badge seval-bulk-item-badge--loading" aria-hidden>
<span className="btd-run-spinner seval-bulk-item-spinner" />
</span>
);
}
return null;
}
return (
<span className="seval-bulk-item-returns" title="일괄매수 · 분할매수 수익률">
<ReturnValue pct={returns.fullBuyReturnPct} label="일괄" />
<ReturnValue pct={returns.splitBuyReturnPct} label="분할" />
</span>
);
}
interface ToolbarProps {
isRunning: boolean;
canStart: boolean;
onStart: () => void;
onCancel?: () => void;
}
export function StrategyBulkEvalToolbar({
isRunning,
canStart,
onStart,
onCancel,
}: ToolbarProps) {
return (
<div className="seval-bulk-eval-toolbar">
<button
type="button"
className={`seval-bulk-eval-btn${isRunning ? ' seval-bulk-eval-btn--running' : ''}`}
title={isRunning ? '전략 일괄 평가 중… (클릭 시 중지)' : '전략 목록 일괄 평가 (일괄·분할매수)'}
aria-label={isRunning ? '전략 일괄 평가 중지' : '전략 일괄 평가'}
disabled={!isRunning && !canStart}
onClick={() => {
if (isRunning) {
onCancel?.();
} else {
void onStart();
}
}}
>
{isRunning ? (
<span className="btd-run-spinner seval-bulk-eval-btn-spinner" aria-hidden />
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
)}
</button>
</div>
);
}
@@ -0,0 +1,145 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { OHLCVBar } from '../types';
import type { StrategyDto } from '../utils/backendApi';
import {
buildBarsFingerprint,
buildStrategyBulkEvalScanKey,
evaluateStrategyBulkReturns,
strategyBulkEvalKeys,
type StrategyBulkEvalResult,
} from '../utils/strategyBulkEvaluation';
import { mergeEvalGetParams } from '../utils/strategyEvaluationParams';
interface UseStrategyBulkEvaluationOpts {
strategies: StrategyDto[];
market: string;
timeframe: string;
bars: OHLCVBar[];
evaluationBarCount: number;
windowEndTimeSec: number | null;
initialCapital: number;
selectedStrategyId: number | null;
getParams: (type: string) => Record<string, number | string | boolean>;
appliedIndicatorParams: Record<string, Record<string, number | string | boolean>> | null;
}
export function useStrategyBulkEvaluation({
strategies,
market,
timeframe,
bars,
evaluationBarCount,
windowEndTimeSec,
initialCapital,
selectedStrategyId,
getParams,
appliedIndicatorParams,
}: UseStrategyBulkEvaluationOpts) {
const queryClient = useQueryClient();
const [isRunning, setIsRunning] = useState(false);
const [currentStrategyId, setCurrentStrategyId] = useState<number | null>(null);
const runGenRef = useRef(0);
const scanKey = useMemo(
() => buildStrategyBulkEvalScanKey({
market,
timeframe,
windowEndTimeSec,
evaluationBarCount,
barsFingerprint: buildBarsFingerprint(bars),
}),
[market, timeframe, windowEndTimeSec, evaluationBarCount, bars],
);
const getParamsForStrategy = useCallback((strategy: StrategyDto) => {
if (strategy.id === selectedStrategyId && appliedIndicatorParams) {
return mergeEvalGetParams(getParams, appliedIndicatorParams);
}
return getParams;
}, [selectedStrategyId, appliedIndicatorParams, getParams]);
const startBulkEvaluation = useCallback(async (options?: { restart?: boolean }) => {
if (!options?.restart && isRunning) return;
if (bars.length < 10) return;
const list = strategies.filter((s): s is StrategyDto & { id: number } => s.id != null);
if (list.length === 0) return;
const gen = ++runGenRef.current;
setIsRunning(true);
setCurrentStrategyId(null);
for (const strategy of list) {
if (gen !== runGenRef.current) break;
setCurrentStrategyId(strategy.id);
const queryKey = strategyBulkEvalKeys.returns(scanKey, strategy.id);
await queryClient.fetchQuery({
queryKey,
queryFn: () => evaluateStrategyBulkReturns({
strategy,
market,
timeframe,
bars,
evaluationBarCount,
initialCapital,
getParams: getParamsForStrategy(strategy),
}),
staleTime: 0,
gcTime: 10 * 60_000,
}).catch(() => {
queryClient.setQueryData(queryKey, null);
});
await new Promise<void>(resolve => { setTimeout(resolve, 0); });
}
if (gen === runGenRef.current) {
setCurrentStrategyId(null);
setIsRunning(false);
}
}, [
isRunning,
bars,
strategies,
queryClient,
scanKey,
market,
timeframe,
evaluationBarCount,
initialCapital,
getParamsForStrategy,
]);
const cancelBulkEvaluation = useCallback(() => {
runGenRef.current += 1;
setCurrentStrategyId(null);
setIsRunning(false);
}, []);
return {
isRunning,
currentStrategyId,
scanKey,
startBulkEvaluation,
cancelBulkEvaluation,
canStart: bars.length >= 10 && strategies.some(s => s.id != null),
};
}
/** 목록 아이템 — React Query 캐시 구독 */
export function useStrategyBulkEvalReturns(
strategyId: number | null | undefined,
scanKey: string,
) {
return useQuery<StrategyBulkEvalResult | null>({
queryKey: strategyId != null
? strategyBulkEvalKeys.returns(scanKey, strategyId)
: ['strategyBulkEval', 'idle'],
queryFn: async () => null,
enabled: false,
staleTime: 60_000,
gcTime: 10 * 60_000,
placeholderData: (prev) => prev,
});
}
+176 -1
View File
@@ -167,6 +167,9 @@
}
.seval-left-panel {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
@@ -213,6 +216,54 @@
overflow-y: auto;
}
.seval-left-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
padding: 2px 2px 10px;
margin-bottom: 2px;
border-bottom: 1px solid var(--btd-divider, var(--se-border));
}
.seval-left-panel-title {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
line-height: 1.2;
background: var(--se-title-gradient, linear-gradient(90deg, var(--se-gold), #ffe566));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
white-space: nowrap;
}
.seval-left-panel-toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
flex-shrink: 0;
}
.seval-strategy-add-btn--panel {
margin-bottom: 0;
width: 30px;
height: 30px;
}
.btd-header--seval-summary {
justify-content: flex-end;
min-height: 44px;
}
.btd-header--seval-summary:empty,
.btd-header--seval-summary .seval-header-analysis:empty {
display: none;
}
.seval-left-tabs-row {
display: flex;
align-items: flex-end;
@@ -220,6 +271,15 @@
flex-shrink: 0;
}
.seval-left-tabs-row-end {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
flex-shrink: 0;
margin-bottom: 6px;
}
.seval-left-tabs-row .btd-exec-tabs {
flex: 1;
min-width: 0;
@@ -948,9 +1008,123 @@
.seval-header-analysis {
flex: 1;
justify-content: flex-end;
align-items: center;
gap: 10px;
min-width: 0;
}
.seval-bulk-eval-toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.seval-bulk-eval-mode {
font-size: 0.68rem;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--se-border, #30363d);
background: var(--se-input-bg, var(--bg2));
color: var(--se-text, var(--text));
cursor: pointer;
}
.seval-bulk-eval-mode:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.seval-bulk-eval-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border-radius: 6px;
border: 1px solid var(--se-border, #30363d);
background: var(--se-input-bg, var(--bg2));
color: var(--se-text-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.seval-bulk-eval-btn:hover:not(:disabled) {
border-color: var(--btd-gold, var(--se-gold));
color: var(--btd-gold, var(--se-gold));
}
.seval-bulk-eval-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.seval-bulk-eval-btn--running {
border-color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 45%, var(--se-border));
}
.seval-bulk-eval-btn-spinner,
.seval-bulk-item-spinner {
width: 14px;
height: 14px;
}
.seval-bulk-item-badge {
flex-shrink: 0;
font-size: 0.62rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.seval-bulk-item-returns {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.seval-bulk-item-return-label {
font-size: 0.58rem;
font-weight: 600;
opacity: 0.75;
margin-right: 3px;
}
.seval-bulk-item-return {
flex-shrink: 0;
font-size: 0.68rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
background: none;
padding: 0;
border: none;
line-height: 1.2;
}
.seval-bulk-item-return--zero {
color: #ffffff;
}
.seval-bulk-item-return--pos {
color: #b6ff5a;
}
.seval-bulk-item-return--neg {
color: #ff4444;
}
.seval-bulk-item-badge--loading {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 18px;
}
.seval-header-analysis-busy {
font-size: 0.72rem;
color: var(--se-text-muted);
@@ -985,7 +1159,8 @@
}
.seval-analysis-return--pos strong {
color: #3fb950;
color: #b6ff5a;
text-shadow: 0 0 8px color-mix(in srgb, #b6ff5a 35%, transparent);
}
.seval-analysis-return--neg strong {
@@ -0,0 +1,78 @@
import type { OHLCVBar } from '../types';
import type { StrategyDto } from './backendApi';
import { fetchLiveConditionScanSignals } from './backendApi';
import { buildEvalParamsFromStrategy } from './strategyEvaluationParams';
import { buildStrategyEvaluationToolbarSummary } from './strategyEvaluationReport';
export interface StrategyBulkEvalResult {
fullBuyReturnPct: number;
splitBuyReturnPct: number;
}
export interface StrategyBulkEvalContext {
market: string;
timeframe: string;
windowEndTimeSec: number | null;
evaluationBarCount: number;
barsFingerprint: string;
}
export function buildStrategyBulkEvalScanKey(ctx: StrategyBulkEvalContext): string {
const windowKey = ctx.windowEndTimeSec ?? 'latest';
return `${ctx.market}|${ctx.timeframe}|${windowKey}|${ctx.evaluationBarCount}|${ctx.barsFingerprint}`;
}
export function buildBarsFingerprint(bars: OHLCVBar[]): string {
if (bars.length === 0) return '0';
const first = bars[0];
const last = bars[bars.length - 1];
return `${bars.length}:${first.time}:${last.time}`;
}
export const strategyBulkEvalKeys = {
all: ['strategyBulkEval'] as const,
returns: (scanKey: string, strategyId: number) =>
[...strategyBulkEvalKeys.all, scanKey, strategyId] as const,
};
export async function evaluateStrategyBulkReturns(opts: {
strategy: StrategyDto;
market: string;
timeframe: string;
bars: OHLCVBar[];
evaluationBarCount: number;
initialCapital: number;
getParams: (type: string) => Record<string, number | string | boolean>;
}): Promise<StrategyBulkEvalResult> {
const strategyId = opts.strategy.id;
if (!strategyId || opts.bars.length < 10) {
throw new Error('평가 데이터 부족');
}
const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams);
const signals = await fetchLiveConditionScanSignals(
opts.market,
strategyId,
opts.bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
opts.timeframe,
indicatorParams,
opts.evaluationBarCount,
);
const summary = buildStrategyEvaluationToolbarSummary(signals, {
initialCapital: opts.initialCapital,
symbol: opts.market.replace(/^KRW-/, ''),
});
return {
fullBuyReturnPct: summary.fullBuyReturnPct,
splitBuyReturnPct: summary.splitBuyReturnPct,
};
}
+28 -1
View File
@@ -45,7 +45,7 @@
},
"desktop": {
"name": "@goldenchart/desktop",
"version": "0.1.48",
"version": "0.1.59",
"dependencies": {
"@goldenchart/shared": "*",
"@stomp/stompjs": "^7.3.0",
@@ -81,6 +81,7 @@
"version": "0.1.0",
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-virtual": "^3.14.2",
"@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0",
@@ -1534,6 +1535,32 @@
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0"
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",