전략평가 화면 기능 수정
This commit is contained in:
@@ -20,7 +20,8 @@ import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnaps
|
|||||||
import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
|
import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
|
||||||
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
||||||
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
||||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
|
||||||
|
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
import { getKoreanName } from '../utils/marketNameCache';
|
||||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
import type { OHLCVBar } from '../types';
|
import type { OHLCVBar } from '../types';
|
||||||
|
|
||||||
const LEFT_KEY = 'seval-left-width';
|
const LEFT_KEY = 'seval-left-width';
|
||||||
|
const LEFT_OPEN_KEY = 'seval-left-open';
|
||||||
const RIGHT_KEY = 'seval-right-width';
|
const RIGHT_KEY = 'seval-right-width';
|
||||||
const LEFT_DEFAULT = 380;
|
const LEFT_DEFAULT = 380;
|
||||||
const RIGHT_DEFAULT = 440;
|
const RIGHT_DEFAULT = 440;
|
||||||
@@ -68,6 +70,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const [evalError, setEvalError] = useState<string | null>(null);
|
const [evalError, setEvalError] = useState<string | null>(null);
|
||||||
const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null);
|
const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null);
|
||||||
const [paramsRevision, setParamsRevision] = useState(0);
|
const [paramsRevision, setParamsRevision] = useState(0);
|
||||||
|
const [chartLoading, setChartLoading] = useState(false);
|
||||||
|
const evalSessionRef = useRef(0);
|
||||||
|
const paramsRevisionRef = useRef(paramsRevision);
|
||||||
|
|
||||||
const getEvalParams = useMemo(
|
const getEvalParams = useMemo(
|
||||||
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
|
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
|
||||||
@@ -75,6 +80,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||||
|
const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true));
|
||||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
||||||
const leftRef = useRef(leftWidth);
|
const leftRef = useRef(leftWidth);
|
||||||
const rightRef = useRef(rightWidth);
|
const rightRef = useRef(rightWidth);
|
||||||
@@ -82,6 +88,19 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
rightRef.current = rightWidth;
|
rightRef.current = rightWidth;
|
||||||
|
|
||||||
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
|
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
|
||||||
|
|
||||||
|
const toggleLeftPanel = useCallback(() => {
|
||||||
|
setLeftOpen(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
storeBool(LEFT_OPEN_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const leftWrapStyle = useMemo(
|
||||||
|
() => ({ '--seval-left-width': `${leftWidth}px` }) as React.CSSProperties,
|
||||||
|
[leftWidth],
|
||||||
|
);
|
||||||
const onRightSplit = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
const onRightSplit = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const start = rightRef.current;
|
const start = rightRef.current;
|
||||||
@@ -121,6 +140,27 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
[strategyPrimaryTimeframe],
|
[strategyPrimaryTimeframe],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 종목·시간봉·전략 변경 — 차트·평가 컨텍스트 초기화 */
|
||||||
|
useEffect(() => {
|
||||||
|
evalSessionRef.current += 1;
|
||||||
|
setSnapshot(undefined);
|
||||||
|
setEvalError(null);
|
||||||
|
setEvalLoading(true);
|
||||||
|
setBars([]);
|
||||||
|
setSelectedBarIndex(0);
|
||||||
|
}, [market, chartTimeframe, selectedStrategyId]);
|
||||||
|
|
||||||
|
/** 전략설정 파라미터 저장·초기화 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (paramsRevisionRef.current === paramsRevision) return;
|
||||||
|
paramsRevisionRef.current = paramsRevision;
|
||||||
|
if (paramsRevision === 0) return;
|
||||||
|
evalSessionRef.current += 1;
|
||||||
|
setSnapshot(undefined);
|
||||||
|
setEvalError(null);
|
||||||
|
setEvalLoading(true);
|
||||||
|
}, [paramsRevision]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadStrategies().then(list => setStrategies(list ?? []));
|
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -150,10 +190,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
|
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
|
||||||
|
|
||||||
const refreshEvaluation = useCallback(async () => {
|
const refreshEvaluation = useCallback(async () => {
|
||||||
if (!selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) {
|
if (!selectedStrategyId || !selectedStrategy) {
|
||||||
|
setSnapshot(undefined);
|
||||||
|
setEvalLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chartLoading || bars.length === 0 || selectedBarTimeSec == null) {
|
||||||
setSnapshot(undefined);
|
setSnapshot(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = evalSessionRef.current;
|
||||||
setEvalLoading(true);
|
setEvalLoading(true);
|
||||||
setEvalError(null);
|
setEvalError(null);
|
||||||
try {
|
try {
|
||||||
@@ -164,14 +211,26 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
selectedBarTimeSec,
|
selectedBarTimeSec,
|
||||||
appliedIndicatorParams,
|
appliedIndicatorParams,
|
||||||
);
|
);
|
||||||
|
if (session !== evalSessionRef.current) return;
|
||||||
setSnapshot(snap ?? undefined);
|
setSnapshot(snap ?? undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (session !== evalSessionRef.current) return;
|
||||||
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
|
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
|
||||||
setSnapshot(undefined);
|
setSnapshot(undefined);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (session === evalSessionRef.current) {
|
||||||
setEvalLoading(false);
|
setEvalLoading(false);
|
||||||
}
|
}
|
||||||
}, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, appliedIndicatorParams]);
|
}
|
||||||
|
}, [
|
||||||
|
chartLoading,
|
||||||
|
market,
|
||||||
|
selectedStrategyId,
|
||||||
|
selectedStrategy,
|
||||||
|
selectedBarTimeSec,
|
||||||
|
bars.length,
|
||||||
|
appliedIndicatorParams,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshEvaluation();
|
void refreshEvaluation();
|
||||||
@@ -225,7 +284,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="btd-body">
|
<div className="btd-body">
|
||||||
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}>
|
<div
|
||||||
|
className={`seval-left-wrap${leftOpen ? ' seval-left-wrap--open' : ''}`}
|
||||||
|
style={leftWrapStyle}
|
||||||
|
>
|
||||||
|
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
|
||||||
<div className="btd-exec-list seval-left-panel">
|
<div className="btd-exec-list seval-left-panel">
|
||||||
<div className="btd-exec-tabs" role="tablist">
|
<div className="btd-exec-tabs" role="tablist">
|
||||||
<button
|
<button
|
||||||
@@ -307,7 +370,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<SePanelCollapseHandle
|
||||||
|
side="left"
|
||||||
|
open={leftOpen}
|
||||||
|
onToggle={toggleLeftPanel}
|
||||||
|
label="전략 패널"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leftOpen && (
|
||||||
<div
|
<div
|
||||||
className="btd-splitter btd-splitter--v"
|
className="btd-splitter btd-splitter--v"
|
||||||
role="separator"
|
role="separator"
|
||||||
@@ -315,8 +386,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
aria-label="좌측 패널 너비"
|
aria-label="좌측 패널 너비"
|
||||||
onPointerDown={onLeftSplit}
|
onPointerDown={onLeftSplit}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="btd-main">
|
<main className={`btd-main${leftOpen ? '' : ' seval-main--left-collapsed'}`}>
|
||||||
<div className="btd-main-content seval-main-content">
|
<div className="btd-main-content seval-main-content">
|
||||||
<StrategyEvaluationChart
|
<StrategyEvaluationChart
|
||||||
market={market}
|
market={market}
|
||||||
@@ -328,6 +400,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
onStrategySelect={handleSelectStrategyById}
|
onStrategySelect={handleSelectStrategyById}
|
||||||
onMarketChange={setMarket}
|
onMarketChange={setMarket}
|
||||||
onTimeframeChoiceChange={setTimeframeChoice}
|
onTimeframeChoiceChange={setTimeframeChoice}
|
||||||
|
onChartLoadingChange={setChartLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
strategy={selectedStrategy}
|
strategy={selectedStrategy}
|
||||||
selectedBarIndex={selectedBarIndex}
|
selectedBarIndex={selectedBarIndex}
|
||||||
@@ -336,6 +409,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
paramsRevision={paramsRevision}
|
paramsRevision={paramsRevision}
|
||||||
getParamsOverride={getEvalParams}
|
getParamsOverride={getEvalParams}
|
||||||
getVisualConfigOverride={getVisualConfig}
|
getVisualConfigOverride={getVisualConfig}
|
||||||
|
evalLoading={evalLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -351,19 +425,33 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
<aside className="btd-right seval-right-panel" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
|
<aside className="btd-right seval-right-panel" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
|
||||||
<div className="seval-signal-head">
|
<div className="seval-signal-head">
|
||||||
<span className="seval-signal-market">{getKoreanName(market)}</span>
|
<span className="seval-signal-market">{getKoreanName(market)}</span>
|
||||||
<span className="seval-live-dot" title="선택 봉 기준 평가" />
|
<span
|
||||||
<span className="seval-live-label">봉 평가</span>
|
className={`seval-live-dot${evalLoading ? ' seval-live-dot--loading' : ''}`}
|
||||||
|
title={evalLoading ? '전략 조건 확인 중' : '선택 봉 기준 평가'}
|
||||||
|
/>
|
||||||
|
<span className={`seval-live-label${evalLoading ? ' seval-live-label--loading' : ''}`}>
|
||||||
|
{evalLoading ? '조건 확인 중…' : '봉 평가'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{evalError && <p className="seval-eval-error">{evalError}</p>}
|
{evalError && <p className="seval-eval-error">{evalError}</p>}
|
||||||
|
|
||||||
|
<div className="seval-signal-body">
|
||||||
|
{evalLoading && (
|
||||||
|
<div className="seval-eval-loading" aria-live="polite" aria-busy="true">
|
||||||
|
<span className="btd-run-spinner seval-eval-loading-spinner" aria-hidden />
|
||||||
|
<span className="seval-eval-loading-text">전략 조건 확인 중…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<VirtualTargetSignalPanel
|
<VirtualTargetSignalPanel
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
viewMode="summary"
|
viewMode="summary"
|
||||||
loading={evalLoading}
|
loading={evalLoading}
|
||||||
|
loadingMessage="전략 조건 확인 중…"
|
||||||
hasStrategy={selectedStrategyId != null}
|
hasStrategy={selectedStrategyId != null}
|
||||||
className="seval-signal-panel"
|
className={`seval-signal-panel${evalLoading ? ' seval-signal-panel--loading' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer className="seval-signal-footer">
|
<footer className="seval-signal-footer">
|
||||||
<span className="seval-updated">
|
<span className="seval-updated">
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface Props {
|
|||||||
onStrategySelect: (strategyId: number) => void;
|
onStrategySelect: (strategyId: number) => void;
|
||||||
onMarketChange: (market: string) => void;
|
onMarketChange: (market: string) => void;
|
||||||
onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void;
|
onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void;
|
||||||
|
onChartLoadingChange?: (loading: boolean) => void;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
strategy: StrategyDto | null;
|
strategy: StrategyDto | null;
|
||||||
selectedBarIndex: number;
|
selectedBarIndex: number;
|
||||||
@@ -55,6 +56,8 @@ interface Props {
|
|||||||
hlines: import('../../utils/indicatorRegistry').HLineDef[];
|
hlines: import('../../utils/indicatorRegistry').HLineDef[];
|
||||||
cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors;
|
cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors;
|
||||||
};
|
};
|
||||||
|
/** 선택 봉 조건 평가 진행 중 */
|
||||||
|
evalLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||||
@@ -76,6 +79,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
onStrategySelect,
|
onStrategySelect,
|
||||||
onMarketChange,
|
onMarketChange,
|
||||||
onTimeframeChoiceChange,
|
onTimeframeChoiceChange,
|
||||||
|
onChartLoadingChange,
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
strategy,
|
strategy,
|
||||||
selectedBarIndex,
|
selectedBarIndex,
|
||||||
@@ -84,6 +88,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
paramsRevision = 0,
|
paramsRevision = 0,
|
||||||
getParamsOverride,
|
getParamsOverride,
|
||||||
getVisualConfigOverride,
|
getVisualConfigOverride,
|
||||||
|
evalLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
|
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
|
||||||
const getParams = getParamsOverride ?? baseGetParams;
|
const getParams = getParamsOverride ?? baseGetParams;
|
||||||
@@ -206,14 +211,23 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
void runBacktestForBars(bars);
|
void runBacktestForBars(bars);
|
||||||
return () => { ++backtestGenRef.current; };
|
return () => { ++backtestGenRef.current; };
|
||||||
}, [strategy?.id, bars, loading, runBacktestForBars]);
|
}, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runBacktestForBars]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyMarkers();
|
applyMarkers();
|
||||||
}, [signals, applyMarkers]);
|
}, [signals, applyMarkers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChartLoadingChange?.(loading);
|
||||||
|
}, [loading, onChartLoadingChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
++backtestGenRef.current;
|
||||||
|
setSignals([]);
|
||||||
|
setBacktestError(null);
|
||||||
|
setBacktestRunning(false);
|
||||||
|
managerRef.current?.clearBacktestMarkers();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const toTimeSec = Math.floor(Date.now() / 1000);
|
const toTimeSec = Math.floor(Date.now() / 1000);
|
||||||
@@ -235,7 +249,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [market, timeframe, onBarsLoaded, onSelectedBarIndexChange]);
|
}, [market, timeframe, strategy?.id, onBarsLoaded, onSelectedBarIndexChange]);
|
||||||
|
|
||||||
const scrollToBar = useCallback((barIdx: number) => {
|
const scrollToBar = useCallback((barIdx: number) => {
|
||||||
const mgr = chartMgr;
|
const mgr = chartMgr;
|
||||||
@@ -351,7 +365,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{selectedBar && (
|
{selectedBar && (
|
||||||
<span className="seval-chart-bar-time">
|
<span className={`seval-chart-bar-time${evalLoading ? ' seval-chart-bar-time--loading' : ''}`}>
|
||||||
|
{evalLoading && (
|
||||||
|
<span className="btd-run-spinner seval-chart-bar-spinner" aria-hidden />
|
||||||
|
)}
|
||||||
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
|
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface Props {
|
|||||||
receiving?: boolean;
|
receiving?: boolean;
|
||||||
/** 업비트 캔들 warm-up · live-conditions 수집 중 */
|
/** 업비트 캔들 warm-up · live-conditions 수집 중 */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** loading=true 일 때 빈 패널 문구 (미지정 시 기본) */
|
||||||
|
loadingMessage?: string;
|
||||||
/** false — 전략 미지정 안내 */
|
/** false — 전략 미지정 안내 */
|
||||||
hasStrategy?: boolean;
|
hasStrategy?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -28,6 +30,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
|||||||
viewMode = 'summary',
|
viewMode = 'summary',
|
||||||
receiving = false,
|
receiving = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
loadingMessage,
|
||||||
hasStrategy = true,
|
hasStrategy = true,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
@@ -51,7 +54,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
|
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
|
||||||
{loading
|
{loading
|
||||||
? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)'
|
? (loadingMessage ?? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)')
|
||||||
: !hasStrategy
|
: !hasStrategy
|
||||||
? '투자전략을 선택하세요 (상단 또는 종목별 전략)'
|
? '투자전략을 선택하세요 (상단 또는 종목별 전략)'
|
||||||
: '전략 조건·지표 데이터 없음'}
|
: '전략 조건·지표 데이터 없음'}
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
/* ── 전략 평가 — btd-page 레이아웃 위 보조 스타일 ─────────────────────────── */
|
/* ── 전략 평가 — btd-page 레이아웃 위 보조 스타일 ─────────────────────────── */
|
||||||
|
|
||||||
|
/* ── 좌측 패널 접기 ───────────────────────────────────────────────────────── */
|
||||||
|
.seval-left-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-sidebar {
|
||||||
|
width: 0;
|
||||||
|
flex: 0 0 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-right: none;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-sidebar--open {
|
||||||
|
width: var(--seval-left-width, 380px);
|
||||||
|
flex: 0 0 var(--seval-left-width, 380px);
|
||||||
|
max-width: var(--seval-left-width, 380px);
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-right: 1px solid var(--btd-divider, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-wrap .se-panel-handle {
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
width: 16px;
|
||||||
|
height: 56px;
|
||||||
|
border: 1px solid var(--se-border);
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-wrap .se-panel-handle--left {
|
||||||
|
margin-left: -1px;
|
||||||
|
background: var(--se-bg-elevated, var(--bg2));
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-wrap .se-panel-handle--left:hover {
|
||||||
|
color: var(--se-text);
|
||||||
|
background: var(--se-bg-muted, var(--bg3));
|
||||||
|
box-shadow: 3px 0 14px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-wrap--open .se-panel-handle--left,
|
||||||
|
.seval-left-wrap .se-panel-handle--left.se-panel-handle--open {
|
||||||
|
border-color: color-mix(in srgb, var(--se-accent, #3d9be9) 30%, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-main--left-collapsed {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-left-panel {
|
.seval-left-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -55,6 +131,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seval-chart-bar-time {
|
.seval-chart-bar-time {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--btd-gold, var(--se-gold));
|
color: var(--btd-gold, var(--se-gold));
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -64,6 +143,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-chart-bar-time--loading {
|
||||||
|
border-color: color-mix(in srgb, var(--se-accent, #3d9be9) 45%, var(--se-border));
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-chart-bar-spinner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-chart-hint {
|
.seval-chart-hint {
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
@@ -230,6 +318,12 @@
|
|||||||
box-shadow: 0 0 8px #3fb950;
|
box-shadow: 0 0 8px #3fb950;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-live-dot--loading {
|
||||||
|
background: var(--se-accent, #3d9be9);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--se-accent, #3d9be9) 55%, transparent);
|
||||||
|
animation: seval-live-pulse 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-live-label {
|
.seval-live-label {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
@@ -237,6 +331,50 @@
|
|||||||
color: #3fb950;
|
color: #3fb950;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-live-label--loading {
|
||||||
|
color: var(--se-accent, #3d9be9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes seval-live-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.55; transform: scale(0.88); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-signal-body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-eval-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: color-mix(in srgb, var(--se-bg, #0d1117) 78%, transparent);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-eval-loading-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-width: 2px;
|
||||||
|
color: var(--se-accent, #3d9be9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-eval-loading-text {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.seval-signal-panel {
|
.seval-signal-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -244,6 +382,12 @@
|
|||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-signal-panel--loading {
|
||||||
|
opacity: 0.42;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-eval-error {
|
.seval-eval-error {
|
||||||
margin: 0 4px 8px;
|
margin: 0 4px 8px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user