전략평가 화면 수정
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석
|
||||
*/
|
||||
import '../styles/strategyEvaluation.css';
|
||||
import '../styles/backtestDashboard.css';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
import '../styles/strategyEditorTheme.css';
|
||||
import '../styles/strategyEvaluation.css';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Theme, Timeframe } from '../types';
|
||||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||
|
||||
@@ -69,18 +69,16 @@ function indexFromClientX(
|
||||
return findNearestBarIndex(bars, time);
|
||||
}
|
||||
|
||||
/** 캔들 pane 클릭 좌표 → 봉 index (전략선택봉 이동) */
|
||||
/** 차트 클릭 좌표 → 봉 index (전략선택봉 이동, 전략선택봉 영역 제외는 호출측) */
|
||||
export function resolveBarIndexAtChartClick(
|
||||
manager: ChartManager,
|
||||
bars: OHLCVBar[],
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): number {
|
||||
const rect = manager.getContainer().getBoundingClientRect();
|
||||
const clickY = clientY - rect.top;
|
||||
const main = manager.getPaneLayouts().find(l => l.paneIndex === 0);
|
||||
if (!main || clickY < main.topY || clickY >= main.topY + main.height) return -1;
|
||||
return indexFromClientX(manager, bars, clientX);
|
||||
const idx = manager.resolveNearestBarIndexAtClientPoint(clientX, clientY);
|
||||
if (idx < 0 || idx >= bars.length) return -1;
|
||||
return idx;
|
||||
}
|
||||
|
||||
const StrategyEvaluationBarSelector: React.FC<Props> = ({
|
||||
|
||||
@@ -306,21 +306,53 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
const mgr = chartMgr;
|
||||
if (!mgr || bars.length === 0) return;
|
||||
|
||||
const onChartClick = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-no-chart-pan]')) return;
|
||||
const SELECT_DRAG_PX = 8;
|
||||
let pointerStart: { x: number; y: number; onSelector: boolean } | null = null;
|
||||
|
||||
const idx = resolveBarIndexAtChartClick(mgr, bars, e.clientX, e.clientY);
|
||||
if (idx >= 0 && idx !== selectedBarIndex) {
|
||||
onSelectedBarIndexChange(idx);
|
||||
}
|
||||
const isSelectorTarget = (target: EventTarget | null) =>
|
||||
target instanceof Element && Boolean(target.closest('[data-no-chart-pan]'));
|
||||
|
||||
const applyBarSelect = (clientX: number, clientY: number) => {
|
||||
const idx = resolveBarIndexAtChartClick(mgr, bars, clientX, clientY);
|
||||
if (idx >= 0) onSelectedBarIndexChange(idx);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
pointerStart = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
onSelector: isSelectorTarget(e.target),
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.button !== 0 || !pointerStart) return;
|
||||
const start = pointerStart;
|
||||
pointerStart = null;
|
||||
if (start.onSelector || isSelectorTarget(e.target)) return;
|
||||
|
||||
const moved = Math.hypot(e.clientX - start.x, e.clientY - start.y);
|
||||
if (moved > SELECT_DRAG_PX) return;
|
||||
|
||||
applyBarSelect(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const unsubLwc = mgr.subscribeBarSelectClick(idx => {
|
||||
if (idx >= 0 && idx < bars.length) onSelectedBarIndexChange(idx);
|
||||
});
|
||||
|
||||
const container = mgr.getContainer();
|
||||
container.addEventListener('click', onChartClick);
|
||||
return () => container.removeEventListener('click', onChartClick);
|
||||
}, [chartMgr, bars, selectedBarIndex, onSelectedBarIndexChange]);
|
||||
container.addEventListener('pointerdown', onPointerDown, true);
|
||||
container.addEventListener('pointerup', onPointerUp, true);
|
||||
container.addEventListener('pointercancel', onPointerUp, true);
|
||||
return () => {
|
||||
unsubLwc();
|
||||
container.removeEventListener('pointerdown', onPointerDown, true);
|
||||
container.removeEventListener('pointerup', onPointerUp, true);
|
||||
container.removeEventListener('pointercancel', onPointerUp, true);
|
||||
};
|
||||
}, [chartMgr, bars, onSelectedBarIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
|
||||
import { resolveHeatTier } from '../../utils/virtualSignalMetrics';
|
||||
import { coerceFiniteNumber } from '../../utils/safeFormat';
|
||||
import { formatVirtualConditionListLabel } from '../../utils/virtualStrategyConditions';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
/** 3열 레이아웃 가운데 pane — 외곽 박스 생략 */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const IconMismatch = () => (
|
||||
<svg className="seval-cond-icon-svg" width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPending = () => (
|
||||
<svg className="seval-cond-icon-svg" width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M12 9v4" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMatch = () => (
|
||||
<svg className="seval-cond-icon-svg" width="10" height="10" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<polyline points="20 6 9 17 4 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 전략평가 전용 — vtd-heat-* 와 분리된 고정 높이 지표 목록 */
|
||||
const StrategyEvaluationConditionList: React.FC<Props> = ({ metrics, embedded = false }) => {
|
||||
if (metrics.length === 0) {
|
||||
return <p className="seval-cond-empty">조건 없음</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={['seval-cond-list-wrap', embedded ? 'seval-cond-list-wrap--embedded' : ''].filter(Boolean).join(' ')}>
|
||||
<ul className="seval-cond-list" aria-label="전략 지표 매치율">
|
||||
{metrics.map(({ row, status, matchRate }) => {
|
||||
const tier = resolveHeatTier(matchRate, status);
|
||||
const pct = Math.min(100, Math.max(0, coerceFiniteNumber(matchRate) ?? 0));
|
||||
const minFill = pct > 0 && tier === 'nomatch' ? 6 : pct > 0 ? 6 : 0;
|
||||
const label = formatVirtualConditionListLabel(row);
|
||||
return (
|
||||
<li key={row.id} className={`seval-cond-row seval-cond-row--${tier}`}>
|
||||
<div
|
||||
className="seval-cond-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
aria-label={`${label} 매치율 ${pct}%`}
|
||||
>
|
||||
<span className="seval-cond-label" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="seval-cond-fill"
|
||||
style={{ width: `${Math.max(pct, minFill)}%` }}
|
||||
/>
|
||||
<span className="seval-cond-pct">{pct}%</span>
|
||||
</div>
|
||||
<span className="seval-cond-icon" aria-hidden>
|
||||
{tier === 'match' ? <IconMatch /> : tier === 'pending' ? <IconPending /> : <IconMismatch />}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyEvaluationConditionList;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSignalVisualRailHeight } from '../../hooks/useSignalVisualRailHeight';
|
||||
import {
|
||||
computeMatchRate,
|
||||
getTrafficLightState,
|
||||
resolveSideHeadlineMatchRate,
|
||||
resolveSideTrafficState,
|
||||
type ConditionMetric,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalEqualizer from '../virtual/VirtualSignalEqualizer';
|
||||
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
|
||||
import StrategyEvaluationConditionList from './StrategyEvaluationConditionList';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
overallMet?: boolean | null;
|
||||
}
|
||||
|
||||
/** 전략평가 — 이퀄라이저 | 지표 목록 | 신호등 (3열, 가상매매와 동일) */
|
||||
const StrategyEvaluationMatchVisual: React.FC<Props> = ({ metrics, overallMet }) => {
|
||||
const matchRate = useMemo(
|
||||
() => (overallMet != null
|
||||
? resolveSideHeadlineMatchRate(metrics, overallMet)
|
||||
: computeMatchRate(metrics)),
|
||||
[metrics, overallMet],
|
||||
);
|
||||
const trafficState = useMemo(
|
||||
() => (overallMet != null
|
||||
? resolveSideTrafficState(metrics, overallMet)
|
||||
: getTrafficLightState(matchRate, metrics)),
|
||||
[matchRate, metrics, overallMet],
|
||||
);
|
||||
const { visualRef, lightRef } = useSignalVisualRailHeight(
|
||||
[metrics.length, matchRate, trafficState],
|
||||
'--seval-gauge-rail-height',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="seval-match-visual">
|
||||
<div className="seval-match-visual-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="seval-match-visual-row" ref={visualRef}>
|
||||
<div className="seval-match-pane seval-match-pane-eq">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
</div>
|
||||
<div className="seval-match-pane seval-match-pane-list">
|
||||
<StrategyEvaluationConditionList metrics={metrics} embedded />
|
||||
</div>
|
||||
<div className="seval-match-pane seval-match-pane-light">
|
||||
<div ref={lightRef} className="seval-match-light-rail">
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyEvaluationMatchVisual;
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
buildConditionMetrics,
|
||||
type ConditionMetric,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualSignalMatchVisual from '../virtual/VirtualSignalMatchVisual';
|
||||
import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual';
|
||||
import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals';
|
||||
|
||||
interface Props {
|
||||
@@ -52,12 +52,7 @@ const SideCard: React.FC<SideCardProps> = ({ side, metrics, overallMet, signalAc
|
||||
</div>
|
||||
</header>
|
||||
<div className="seval-side-card-body">
|
||||
<VirtualSignalMatchVisual
|
||||
metrics={metrics}
|
||||
title="SIGNAL INTELLIGENCE & MATCH RATES"
|
||||
overallMet={overallMet}
|
||||
panelClassName="seval-side-sig-panel"
|
||||
/>
|
||||
<StrategyEvaluationMatchVisual metrics={metrics} overallMet={overallMet} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -92,18 +87,18 @@ const StrategyEvaluationSignalPanel: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className={['seval-signal-split', className].filter(Boolean).join(' ')}>
|
||||
<SideCard
|
||||
side="buy"
|
||||
metrics={buyMetrics}
|
||||
overallMet={snapshot?.overallEntryMet}
|
||||
signalActive={barSignalHighlight?.buy ?? false}
|
||||
/>
|
||||
<SideCard
|
||||
side="sell"
|
||||
metrics={sellMetrics}
|
||||
overallMet={snapshot?.overallExitMet}
|
||||
signalActive={barSignalHighlight?.sell ?? false}
|
||||
/>
|
||||
<SideCard
|
||||
side="buy"
|
||||
metrics={buyMetrics}
|
||||
overallMet={snapshot?.overallEntryMet}
|
||||
signalActive={barSignalHighlight?.buy ?? false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ import { coerceFiniteNumber } from '../../utils/safeFormat';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
/** 전략평가 등 — 고정 높이 컴팩트 목록 */
|
||||
listClassName?: string;
|
||||
scrollWrap?: boolean;
|
||||
}
|
||||
|
||||
const IconMismatch = () => (
|
||||
@@ -33,13 +36,16 @@ const IconMatch = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
|
||||
const VirtualConditionList: React.FC<Props> = ({ metrics, listClassName = '', scrollWrap = false }) => {
|
||||
if (metrics.length === 0) {
|
||||
return <p className="vtd-heat-list-empty vtd-muted">조건 없음</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="vtd-heat-list" aria-label="전략 지표 매치율">
|
||||
const list = (
|
||||
<ul
|
||||
className={['vtd-heat-list', listClassName].filter(Boolean).join(' ')}
|
||||
aria-label="전략 지표 매치율"
|
||||
>
|
||||
{metrics.map(({ row, status, matchRate }) => {
|
||||
const tier = resolveHeatTier(matchRate, status);
|
||||
const pct = Math.min(100, Math.max(0, coerceFiniteNumber(matchRate) ?? 0));
|
||||
@@ -68,6 +74,12 @@ const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (scrollWrap) {
|
||||
return <div className="seval-heat-list-scroll">{list}</div>;
|
||||
}
|
||||
|
||||
return list;
|
||||
};
|
||||
|
||||
export default VirtualConditionList;
|
||||
|
||||
@@ -19,6 +19,8 @@ interface Props {
|
||||
backendMatchRate?: number | null;
|
||||
/** DSL 전체 Rule 충족 — 헤드라인 일치율·신호등 우선 */
|
||||
overallMet?: boolean | null;
|
||||
/** 전략평가 — 지표 목록 고정 높이·스크롤 */
|
||||
compactIndicatorList?: boolean;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
}
|
||||
@@ -28,6 +30,7 @@ const VirtualSignalMatchVisual: React.FC<Props> = ({
|
||||
title,
|
||||
backendMatchRate,
|
||||
overallMet,
|
||||
compactIndicatorList = false,
|
||||
className = '',
|
||||
panelClassName = '',
|
||||
}) => {
|
||||
@@ -60,7 +63,11 @@ const VirtualSignalMatchVisual: React.FC<Props> = ({
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-heat">
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualConditionList
|
||||
metrics={metrics}
|
||||
listClassName={compactIndicatorList ? 'vtd-heat-list--seval-compact' : ''}
|
||||
scrollWrap={compactIndicatorList}
|
||||
/>
|
||||
</div>
|
||||
<div className="vtd-sig-visual-pane vtd-sig-visual-light">
|
||||
<div ref={lightRef} className="vtd-sig-light-rail">
|
||||
|
||||
@@ -2,9 +2,12 @@ import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 신호등+하단 %/문구(.vtd-sig-light-rail) 높이를 측정해
|
||||
* 이퀄라이저 pane 눈금·막대 높이(--vtd-sig-rail-height)와 동기화
|
||||
* 이퀄라이저 pane 눈금·막대 높이 CSS 변수와 동기화
|
||||
*/
|
||||
export function useSignalVisualRailHeight(deps: unknown[] = []) {
|
||||
export function useSignalVisualRailHeight(
|
||||
deps: unknown[] = [],
|
||||
railHeightVar = '--vtd-sig-rail-height',
|
||||
) {
|
||||
const visualRef = useRef<HTMLDivElement>(null);
|
||||
const lightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -16,7 +19,7 @@ export function useSignalVisualRailHeight(deps: unknown[] = []) {
|
||||
const sync = () => {
|
||||
const h = Math.ceil(light.getBoundingClientRect().height);
|
||||
if (h > 0) {
|
||||
visual.style.setProperty('--vtd-sig-rail-height', `${h}px`);
|
||||
visual.style.setProperty(railHeightVar, `${h}px`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -441,11 +441,15 @@
|
||||
}
|
||||
|
||||
.seval-side-card--buy {
|
||||
border-top-color: color-mix(in srgb, #3fb950 55%, var(--se-border));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 42%, var(--se-border, var(--btd-divider)));
|
||||
border-top: 2px solid var(--gc-trade-buy, #ef5350);
|
||||
background: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 7%, var(--vtd-sig-panel-bg, #0d111f));
|
||||
}
|
||||
|
||||
.seval-side-card--sell {
|
||||
border-top-color: color-mix(in srgb, #f85149 55%, var(--se-border));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 42%, var(--se-border, var(--btd-divider)));
|
||||
border-top: 2px solid var(--gc-trade-sell, #4dabf7);
|
||||
background: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 7%, var(--vtd-sig-panel-bg, #0d111f));
|
||||
}
|
||||
|
||||
.seval-side-card-head {
|
||||
@@ -458,6 +462,14 @@
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent);
|
||||
}
|
||||
|
||||
.seval-side-card--buy .seval-side-card-head {
|
||||
border-bottom-color: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 28%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-side-card--sell .seval-side-card-head {
|
||||
border-bottom-color: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 28%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-side-card-head-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -472,11 +484,11 @@
|
||||
}
|
||||
|
||||
.seval-side-card-badge--buy {
|
||||
color: #3fb950;
|
||||
color: var(--gc-trade-buy, #ef5350);
|
||||
}
|
||||
|
||||
.seval-side-card-badge--sell {
|
||||
color: #f85149;
|
||||
color: var(--gc-trade-sell, #4dabf7);
|
||||
}
|
||||
|
||||
.seval-side-overall {
|
||||
@@ -489,10 +501,16 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.seval-side-overall--met {
|
||||
color: #3fb950;
|
||||
background: color-mix(in srgb, #3fb950 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #3fb950 35%, transparent);
|
||||
.seval-side-card--buy .seval-side-overall--met {
|
||||
color: var(--gc-trade-buy, #ef5350);
|
||||
background: color-mix(in srgb, var(--gc-trade-buy, #ef5350) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--gc-trade-buy, #ef5350) 35%, transparent);
|
||||
}
|
||||
|
||||
.seval-side-card--sell .seval-side-overall--met {
|
||||
color: var(--gc-trade-sell, #4dabf7);
|
||||
background: color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--gc-trade-sell, #4dabf7) 35%, transparent);
|
||||
}
|
||||
|
||||
.seval-side-overall--unmet {
|
||||
@@ -510,50 +528,406 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.seval-side-card-body .seval-side-sig-panel {
|
||||
/* ── 전략평가 전용 매치 비주얼 (vtd-heat-* 미사용) ─────────────────────────── */
|
||||
.seval-match-visual {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.seval-side-card-body .seval-side-sig-panel .vtd-sig-panel-title {
|
||||
.seval-match-visual-title {
|
||||
flex-shrink: 0;
|
||||
font-size: 8px;
|
||||
padding: 2px 0 0;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--vtd-sig-gold-text, var(--se-gold, #e6c200));
|
||||
text-align: center;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-sig-visual {
|
||||
--vtd-sig-rail-height: 120px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 6px 8px 8px;
|
||||
gap: 8px 10px;
|
||||
/* 이퀄라이저 | 지표 목록 | 신호등 — 3열 (가상매매 vtd-sig-visual 동일) */
|
||||
.seval-match-visual-row {
|
||||
--seval-gauge-rail-height: 108px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 32%, var(--se-border));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 72%, transparent);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-sig-visual-heat {
|
||||
.seval-match-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 4px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 28%, var(--se-border));
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 55%, transparent);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq {
|
||||
flex-shrink: 0;
|
||||
justify-content: stretch;
|
||||
height: var(--seval-gauge-rail-height);
|
||||
min-height: var(--seval-gauge-rail-height);
|
||||
max-height: var(--seval-gauge-rail-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-sig-visual-heat .vtd-heat-list {
|
||||
max-height: min(140px, 100%);
|
||||
overflow-y: auto;
|
||||
.seval-match-pane-list {
|
||||
justify-content: flex-start;
|
||||
height: var(--seval-gauge-rail-height);
|
||||
min-height: var(--seval-gauge-rail-height);
|
||||
max-height: var(--seval-gauge-rail-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-sig-visual-eq {
|
||||
height: var(--vtd-sig-rail-height);
|
||||
min-height: var(--vtd-sig-rail-height);
|
||||
max-height: var(--vtd-sig-rail-height);
|
||||
.seval-match-pane-light {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: var(--seval-gauge-rail-height);
|
||||
min-height: var(--seval-gauge-rail-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-sig-visual-light .vtd-sig-light-rate {
|
||||
.seval-match-pane-eq .vtd-sig-eq,
|
||||
.seval-match-visual-eq .vtd-sig-eq {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-scale,
|
||||
.seval-match-visual-eq .vtd-sig-eq-scale {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
font-size: 8px;
|
||||
color: var(--se-text-muted);
|
||||
padding: 2px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-tick,
|
||||
.seval-match-visual-eq .vtd-sig-eq-tick {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-bar-wrap,
|
||||
.seval-match-visual-eq .vtd-sig-eq-bar-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-bar,
|
||||
.seval-match-visual-eq .vtd-sig-eq-bar {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold) 40%, var(--se-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vtd-sig-eq-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-seg,
|
||||
.seval-match-visual-eq .vtd-sig-eq-seg {
|
||||
flex: 1;
|
||||
min-height: 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--se-text-muted) 15%, transparent);
|
||||
transition: background 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--red,
|
||||
.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--red {
|
||||
background: linear-gradient(90deg, #c62828, #ff8a80);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #ef5350 50%, transparent);
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue,
|
||||
.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--blue {
|
||||
background: linear-gradient(90deg, #1e6fd9, #4fc3f7);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #4fc3f7 50%, transparent);
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--gold,
|
||||
.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--gold {
|
||||
background: linear-gradient(90deg, #b8860b, #ffd54f);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #ffd54f 45%, transparent);
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--peak,
|
||||
.seval-match-visual-eq .vtd-sig-eq-seg--lit.vtd-sig-eq-seg--peak {
|
||||
background: linear-gradient(90deg, #1565c0, #82b1ff);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, #82b1ff 70%, transparent);
|
||||
}
|
||||
|
||||
.seval-match-pane-eq .vtd-sig-eq-badge,
|
||||
.seval-match-visual-eq .vtd-sig-eq-badge {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: #82b1ff;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.seval-match-pane-light,
|
||||
.seval-match-visual-light {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.seval-match-light-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.seval-match-pane-light .vtd-sig-light--card-right,
|
||||
.seval-match-visual-light .vtd-sig-light--card-right {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.seval-match-pane-light .vtd-sig-light-rate,
|
||||
.seval-match-visual-light .vtd-sig-light-rate {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-sig-visual-light .vtd-sig-light-label {
|
||||
.seval-match-pane-light .vtd-sig-light-label,
|
||||
.seval-match-visual-light .vtd-sig-light-label {
|
||||
font-size: 0.58rem;
|
||||
max-width: 76px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 지표 목록 — 행당 22px, 세로 나열 · 3열 pane 안에서는 스크롤 */
|
||||
.seval-cond-list-wrap {
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 2px 3px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold, #e6c200) 32%, var(--se-border));
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--vtd-sig-panel-bg, #0d111f) 72%, transparent);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.seval-cond-list-wrap--embedded {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.seval-cond-list-wrap::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.seval-cond-list-wrap::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--se-gold, #e6c200) 35%, var(--se-border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.seval-cond-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.seval-cond-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
}
|
||||
|
||||
.seval-cond-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.seval-cond-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-border) 70%, var(--se-text-muted));
|
||||
background: var(--vtd-heat-track-bg, color-mix(in srgb, #060a14 85%, #000));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-cond-label {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
max-width: calc(100% - 38px);
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--vtd-heat-label-color, #e6edf3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.seval-cond-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
min-width: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.seval-cond-pct {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 3;
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.seval-cond-icon {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.seval-cond-icon-svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.seval-cond-row--nomatch .seval-cond-track {
|
||||
border-color: color-mix(in srgb, #ef5350 40%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-cond-row--nomatch .seval-cond-fill {
|
||||
background: linear-gradient(90deg, #b71c1c 0%, #ef5350 55%, #ff8a80 100%);
|
||||
}
|
||||
|
||||
.seval-cond-row--nomatch .seval-cond-pct {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.seval-cond-row--nomatch .seval-cond-icon {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.seval-cond-row--pending .seval-cond-track {
|
||||
border-color: color-mix(in srgb, #ffb300 45%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-cond-row--pending .seval-cond-fill {
|
||||
background: linear-gradient(90deg, #e65100 0%, #ffb300 45%, #ffe082 100%);
|
||||
}
|
||||
|
||||
.seval-cond-row--pending .seval-cond-pct {
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
.seval-cond-row--pending .seval-cond-icon {
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
.seval-cond-row--match .seval-cond-track {
|
||||
border-color: color-mix(in srgb, #3f7ef5 45%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-cond-row--match .seval-cond-fill {
|
||||
background: linear-gradient(90deg, #1565c0 0%, #3f7ef5 45%, #82b1ff 100%);
|
||||
}
|
||||
|
||||
.seval-cond-row--match .seval-cond-pct {
|
||||
color: #82b1ff;
|
||||
}
|
||||
|
||||
.seval-cond-row--match .seval-cond-icon {
|
||||
color: #3f7ef5;
|
||||
}
|
||||
|
||||
.seval-cond-empty {
|
||||
margin: 0;
|
||||
padding: 10px 4px;
|
||||
font-size: 0.68rem;
|
||||
text-align: center;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.seval-side-card-body .vtd-heat-list-empty {
|
||||
|
||||
@@ -4653,6 +4653,67 @@ export class ChartManager {
|
||||
return t === null ? null : (t as number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 평가 — 클라이언트 좌표 → rawBars 내 가장 가까운 봉 index.
|
||||
* 캔들·보조지표 pane 클릭 허용, 가격축·시간축·플롯 밖 제외.
|
||||
*/
|
||||
resolveNearestBarIndexAtClientPoint(clientX: number, clientY: number): number {
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
const chartX = clientX - rect.left;
|
||||
const chartY = clientY - rect.top;
|
||||
|
||||
if (
|
||||
chartX < 0 || chartY < 0
|
||||
|| chartX > this.container.clientWidth
|
||||
|| chartY > this.container.clientHeight
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return -1;
|
||||
|
||||
const layouts = this.getPaneLayouts();
|
||||
const inPane = layouts.some(
|
||||
l => chartY >= l.topY && chartY < l.topY + l.height && l.height >= 4,
|
||||
);
|
||||
if (!inPane || this.rawBars.length === 0) return -1;
|
||||
|
||||
const bar = this._barAtChartX(chartX);
|
||||
if (!bar) return -1;
|
||||
|
||||
const idx = this.rawBars.findIndex(b => (b.time as number) === (bar.time as number));
|
||||
return idx >= 0 ? idx : -1;
|
||||
}
|
||||
|
||||
/** LWC 클릭 — 전략선택봉 이동 (캔들 pane 클릭) */
|
||||
subscribeBarSelectClick(onSelect: (barIndex: number) => void): () => void {
|
||||
const handler = (param: MouseEventParams<Time>) => {
|
||||
if (param.time == null || this.rawBars.length === 0) return;
|
||||
const time = param.time as number;
|
||||
let idx = this.rawBars.findIndex(b => (b.time as number) === time);
|
||||
if (idx < 0) {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < this.rawBars.length; i++) {
|
||||
const dist = Math.abs((this.rawBars[i].time as number) - time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
idx = bestIdx;
|
||||
}
|
||||
if (idx >= 0) onSelect(idx);
|
||||
};
|
||||
this.chart.subscribeClick(handler);
|
||||
return () => {
|
||||
try {
|
||||
this.chart.unsubscribeClick(handler);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
yToPrice(y: number): number | null {
|
||||
const p = this.mainSeries?.coordinateToPrice(y);
|
||||
return p === null || p === undefined ? null : p;
|
||||
|
||||
@@ -13,50 +13,47 @@ function liveFallbackKey(r: LiveConditionRowDto): string {
|
||||
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${plotKey}`;
|
||||
}
|
||||
|
||||
function liveRowToVirtual(r: LiveConditionRowDto): VirtualConditionRow & { currentValue: number | null } {
|
||||
const plotKey = r.plotKey ?? r.indicatorType;
|
||||
return normalizeConditionRow({
|
||||
id: r.id,
|
||||
indicatorType: r.indicatorType,
|
||||
displayName: formatIndicatorDisplayLabel(r.indicatorType),
|
||||
conditionType: r.conditionType,
|
||||
conditionLabel: r.conditionLabel,
|
||||
targetValue: r.targetValue,
|
||||
timeframe: r.timeframe,
|
||||
side: r.side,
|
||||
plotKey,
|
||||
satisfied: r.satisfied,
|
||||
thresholdLabel: r.thresholdLabel,
|
||||
currentValue: r.currentValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeRows(
|
||||
base: VirtualConditionRow[],
|
||||
live: LiveConditionRowDto[],
|
||||
): Array<VirtualConditionRow & { currentValue: number | null }> {
|
||||
const liveById = new Map<string, LiveConditionRowDto>();
|
||||
const liveByKey = new Map<string, LiveConditionRowDto>();
|
||||
for (const r of live) {
|
||||
liveById.set(r.id, r);
|
||||
liveByKey.set(liveFallbackKey(r), r);
|
||||
if (live.length === 0) {
|
||||
return base.map(row => normalizeConditionRow({ ...row, currentValue: null, satisfied: null }));
|
||||
}
|
||||
|
||||
if (base.length === 0) {
|
||||
return live.map(liveRowToVirtual);
|
||||
}
|
||||
const baseById = new Map(base.map(r => [r.id, r]));
|
||||
const baseByKey = new Map(base.map(r => [rowFallbackKey(r), r]));
|
||||
const matchedBaseIds = new Set<string>();
|
||||
|
||||
return base.map(row => {
|
||||
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
|
||||
if (!liveRow) {
|
||||
return normalizeConditionRow({ ...row, currentValue: null, satisfied: null });
|
||||
}
|
||||
const merged = live.map(lr => {
|
||||
const baseRow = baseById.get(lr.id) ?? baseByKey.get(liveFallbackKey(lr));
|
||||
if (baseRow) matchedBaseIds.add(baseRow.id);
|
||||
const plotKey = lr.plotKey ?? lr.indicatorType;
|
||||
return normalizeConditionRow({
|
||||
...row,
|
||||
satisfied: liveRow.satisfied,
|
||||
thresholdLabel: liveRow.thresholdLabel,
|
||||
currentValue: liveRow.currentValue,
|
||||
targetValue: liveRow.targetValue ?? row.targetValue,
|
||||
id: lr.id,
|
||||
indicatorType: lr.indicatorType,
|
||||
displayName: baseRow?.displayName ?? formatIndicatorDisplayLabel(lr.indicatorType),
|
||||
conditionType: lr.conditionType,
|
||||
conditionLabel: lr.conditionLabel ?? baseRow?.conditionLabel ?? lr.conditionType,
|
||||
targetValue: lr.targetValue ?? baseRow?.targetValue ?? null,
|
||||
timeframe: lr.timeframe,
|
||||
side: lr.side as 'buy' | 'sell',
|
||||
plotKey,
|
||||
satisfied: lr.satisfied,
|
||||
thresholdLabel: lr.thresholdLabel ?? baseRow?.thresholdLabel,
|
||||
currentValue: lr.currentValue,
|
||||
});
|
||||
});
|
||||
|
||||
for (const row of base) {
|
||||
if (matchedBaseIds.has(row.id)) continue;
|
||||
const hasLive = live.some(
|
||||
lr => lr.id === row.id || liveFallbackKey(lr) === rowFallbackKey(row),
|
||||
);
|
||||
if (!hasLive) {
|
||||
merged.push(normalizeConditionRow({ ...row, currentValue: null, satisfied: null }));
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -51,20 +51,25 @@ function walk(
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'AND' || node.type === 'OR') {
|
||||
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
|
||||
return;
|
||||
}
|
||||
|
||||
// StrategyDslToTa4jAdapter 와 동일: type 미설정(구버전 DSL) 시 CONDITION 으로 처리
|
||||
if ((!node.type || node.type === 'CONDITION') && node.condition) {
|
||||
const c = node.condition;
|
||||
const rowTf = normalizeStartCandleType(c.leftCandleType ?? c.rightCandleType ?? timeframe);
|
||||
const key = `${side}:${rowTf}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const rowId = node.id ? `${node.id}-${side}` : `${side}:${rowTf}:${c.indicatorType}:${out.length}`;
|
||||
if (seen.has(rowId)) return;
|
||||
seen.add(rowId);
|
||||
|
||||
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
|
||||
const target = coerceFiniteNumber(c.targetValue ?? c.compareValue ?? null);
|
||||
const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType;
|
||||
|
||||
out.push({
|
||||
id: `${node.id}-${side}`,
|
||||
id: rowId,
|
||||
indicatorType: c.indicatorType,
|
||||
displayName: formatIndicatorDisplayLabel(c.indicatorType),
|
||||
conditionType: c.conditionType,
|
||||
@@ -80,6 +85,17 @@ function walk(
|
||||
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
|
||||
}
|
||||
|
||||
/** 목록 표시용 — 동일 지표 복수 조건 구분 */
|
||||
export function formatVirtualConditionListLabel(
|
||||
row: Pick<VirtualConditionRow, 'displayName' | 'thresholdLabel' | 'conditionLabel' | 'timeframe'>,
|
||||
): string {
|
||||
const parts = [row.displayName];
|
||||
if (row.thresholdLabel) parts.push(row.thresholdLabel);
|
||||
else if (row.conditionLabel) parts.push(row.conditionLabel);
|
||||
if (row.timeframe && row.timeframe !== '1m') parts.push(row.timeframe);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] {
|
||||
if (!strategy) return [];
|
||||
const out: VirtualConditionRow[] = [];
|
||||
|
||||
Reference in New Issue
Block a user