가상 수정

This commit is contained in:
Macbook
2026-05-25 16:02:52 +09:00
parent 3102169541
commit 182b82e990
18 changed files with 747 additions and 105 deletions
@@ -261,6 +261,7 @@ const VirtualTradingPage: React.FC<Props> = ({
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
/>
)}
rightTabs={rightTabs}
@@ -3,24 +3,28 @@ import type { ConditionStatus } from '../../utils/virtualSignalMetrics';
interface Props {
status: ConditionStatus;
/** 조건 매칭율 0~100 */
matchRate?: number | null;
/** @deprecated matchRate 사용 */
progress?: number | null;
compact?: boolean;
}
const LABELS: Record<ConditionStatus, string> = {
match: 'Match',
pending: 'Pending',
nomatch: 'Mismatch',
match: '충족',
pending: '대기',
nomatch: '미충족',
unknown: '—',
};
const ConditionStatusSignal: React.FC<Props> = ({ status, progress, compact = false }) => {
const ConditionStatusSignal: React.FC<Props> = ({ status, matchRate, progress, compact = false }) => {
if (status === 'unknown') {
return <span className="vtd-cond-signal vtd-cond-signal--unknown"></span>;
}
const pct = progress != null && Number.isFinite(progress)
? `${Math.round(progress)}%`
const rate = matchRate ?? progress;
const pct = rate != null && Number.isFinite(rate)
? `${Math.round(rate)}%`
: null;
return (
@@ -1,26 +1,70 @@
import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import ConditionStatusSignal from './ConditionStatusSignal';
import { resolveHeatTier } from '../../utils/virtualSignalMetrics';
interface Props {
metrics: ConditionMetric[];
}
const IconMismatch = () => (
<svg className="vtd-heat-icon-svg" width="12" height="12" 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="vtd-heat-icon-svg" width="12" height="12" 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="vtd-heat-icon-svg" width="12" height="12" 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>
);
const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
if (metrics.length === 0) {
return <p className="vtd-cond-list-empty vtd-muted"> </p>;
return <p className="vtd-heat-list-empty vtd-muted"> </p>;
}
return (
<ul className="vtd-cond-list" aria-label="전략 조건 목록">
{metrics.map(({ row, status, progress }) => (
<li key={row.id} className="vtd-cond-list-item">
<span className="vtd-cond-list-name" title={row.displayName}>
{row.displayName}
</span>
<ConditionStatusSignal status={status} progress={progress} compact />
</li>
))}
<ul className="vtd-heat-list" aria-label="전략 지표 매치율">
{metrics.map(({ row, status, matchRate }) => {
const tier = resolveHeatTier(matchRate, status);
const pct = Math.min(100, Math.max(0, matchRate));
const minFill = pct > 0 && tier === 'nomatch' ? 6 : pct > 0 ? 6 : 0;
return (
<li key={row.id} className={`vtd-heat-row vtd-heat-row--${tier}`}>
<div
className="vtd-heat-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label={`${row.displayName} 매치율 ${pct}%`}
>
<span className="vtd-heat-label" title={row.displayName}>
{row.displayName}
</span>
<div className="vtd-heat-fill" style={{ width: `${Math.max(pct, minFill)}%` }} />
<span className="vtd-heat-pct">{pct}%</span>
</div>
<span className="vtd-heat-icon" aria-hidden>
{tier === 'match' ? <IconMatch /> : tier === 'pending' ? <IconPending /> : <IconMismatch />}
</span>
</li>
);
})}
</ul>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
import { formatStrategyThreshold, resolveHeatTier } from '../../utils/virtualSignalMetrics';
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
import ConditionStatusSignal from './ConditionStatusSignal';
@@ -10,19 +10,28 @@ interface Props {
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
<div className="vtd-sig-table-wrap">
<div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div>
<div className="vtd-sig-table-head"> </div>
<table className="vtd-sig-table">
<colgroup>
<col className="vtd-sig-col vtd-sig-col--ind" />
<col className="vtd-sig-col vtd-sig-col--val" />
<col className="vtd-sig-col vtd-sig-col--threshold" />
<col className="vtd-sig-col vtd-sig-col--progress" />
<col className="vtd-sig-col vtd-sig-col--status" />
</colgroup>
<thead>
<tr>
<th>TECHNICAL INDICATOR</th>
<th>CURRENT VALUE</th>
<th>STRATEGY THRESHOLD</th>
<th>PROGRESS</th>
<th>STATUS</th>
<th className="vtd-sig-table-th--ind"> </th>
<th className="vtd-sig-table-th--val"></th>
<th className="vtd-sig-table-th--threshold"> </th>
<th className="vtd-sig-table-th--progress"></th>
<th className="vtd-sig-table-th--status"></th>
</tr>
</thead>
<tbody>
{metrics.map(({ row, status, progress }) => (
{metrics.map(({ row, status, matchRate }) => {
const tier = resolveHeatTier(matchRate, status);
return (
<tr key={row.id}>
<td className="vtd-sig-table-ind">{row.displayName}</td>
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
@@ -31,17 +40,20 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
</td>
<td className="vtd-sig-table-progress">
<div className="vtd-sig-row-bar">
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
<div
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${status}`}
style={{ width: `${progress ?? 0}%` }}
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${tier}`}
style={{ width: `${matchRate}%` }}
/>
</div>
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
<span className="vtd-sig-table-pct">{`${matchRate}%`}</span>
</td>
<td className="vtd-sig-table-status-cell">
<ConditionStatusSignal status={status} matchRate={matchRate} compact />
</td>
<td><ConditionStatusSignal status={status} progress={progress} /></td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -11,6 +11,8 @@ import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import VirtualTargetCardChart from './VirtualTargetCardChart';
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
import type { Theme } from '../../types';
import type { TickerData } from '../../hooks/useMarketTicker';
import VirtualTargetQuote from './VirtualTargetQuote';
export type VirtualCardDisplayMode = 'signal' | 'chart';
@@ -34,6 +36,7 @@ interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
ticker?: TickerData;
}
const VirtualTargetCard: React.FC<Props> = ({
@@ -56,6 +59,7 @@ const VirtualTargetCard: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
ticker,
}) => {
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
@@ -122,6 +126,7 @@ const VirtualTargetCard: React.FC<Props> = ({
))}
</select>
</label>
<span className="vtd-card-tf"> {tf}</span>
</div>
<div className="vtd-card-head-actions">
{onEnterFocus && (
@@ -162,7 +167,7 @@ const VirtualTargetCard: React.FC<Props> = ({
</div>
<div className="vtd-card-meta">
<span className="vtd-card-tf"> {tf}</span>
<VirtualTargetQuote market={market} ticker={ticker} compact />
{!isChart && isDetail && evalCount > 0 && (
<span className="vtd-card-met-count">{metCount}/{evalCount} </span>
)}
@@ -7,7 +7,9 @@ import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { Theme } from '../../types';
import type { TickerData } from '../../hooks/useMarketTicker';
import VirtualLiveBadge from './VirtualLiveBadge';
import VirtualTargetQuote from './VirtualTargetQuote';
import VirtualTargetCardChart from './VirtualTargetCardChart';
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
@@ -27,6 +29,7 @@ interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
ticker?: TickerData;
}
/** 전체보기 — 좌 차트 · 우 신호 */
@@ -46,6 +49,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
ticker,
}) => {
const ko = getKoreanName(market);
const sym = market.replace(/^KRW-/, '');
@@ -100,6 +104,10 @@ const VirtualTargetFocusView: React.FC<Props> = ({
</button>
</div>
<div className="vtd-focus-meta">
<VirtualTargetQuote market={market} ticker={ticker} compact />
</div>
<div className="vtd-focus-split">
<section className="vtd-focus-pane vtd-focus-pane--chart" aria-label="차트 보기">
<div className="vtd-focus-pane-head"></div>
@@ -9,6 +9,7 @@ import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } fro
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
import type { TickerData } from '../../hooks/useMarketTicker';
interface Props {
targets: VirtualTargetItem[];
@@ -28,6 +29,7 @@ interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
tickers?: Map<string, TickerData>;
}
const VirtualTargetGrid: React.FC<Props> = ({
@@ -39,6 +41,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
tickers,
}) => {
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
@@ -137,6 +140,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
ticker={tickers?.get(focusTarget.market)}
/>
) : (
<div className="vtd-grid">
@@ -164,6 +168,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
ticker={tickers?.get(t.market)}
/>
);
})}
@@ -25,9 +25,11 @@ const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir })
interface Props {
market: string;
ticker?: TickerData;
/** 카드 메타 행 — 현재가만 간략 표시 */
compact?: boolean;
}
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker }) => {
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }) => {
const code = market.replace(/^KRW-/, '');
const change: ChangeDir = ticker?.change ?? 'EVEN';
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
@@ -49,6 +51,20 @@ const VirtualTargetQuote: React.FC<Props> = ({ market, ticker }) => {
prevPriceRef.current = price;
}, [ticker?.tradePrice]);
if (compact) {
return (
<div className={`vtd-target-quote vtd-target-quote--compact ${flashClass}`.trim()} aria-label="현재가">
<span className="vtd-card-price-label"></span>
<span className={`vtd-target-price ${colorCls}`}>
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
</span>
<span className={`vtd-target-change ${colorCls}`}>
{ticker ? fmtPct(ticker.changeRate) : '-'}
</span>
</div>
);
}
return (
<div className={`vtd-target-quote ${flashClass}`.trim()} aria-label="현재가 및 전일 대비">
<div className="vtd-target-quote-left">
@@ -34,8 +34,6 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
[metrics, snapshot?.matchRate],
);
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
const metCount = metrics.filter(m => m.status === 'match').length;
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
if (rows.length === 0) {
return (
@@ -49,17 +47,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
<div
className={[
'vtd-sig-panel-wrap',
isDetail ? '' : 'vtd-sig-panel-wrap--summary',
isDetail ? 'vtd-sig-panel-wrap--detail' : 'vtd-sig-panel-wrap--summary',
receiving ? 'vtd-sig-panel-wrap--receiving' : '',
className,
].filter(Boolean).join(' ')}
>
{isDetail && evalCount > 0 && (
<div className="vtd-focus-sig-meta">
<span className="vtd-card-met-count">{metCount}/{evalCount} </span>
</div>
)}
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<div className="vtd-sig-visual-eq">
@@ -68,8 +61,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
<VirtualConditionList metrics={metrics} />
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
</div>
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
</div>
{isDetail && (
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
<VirtualIndicatorCompareTable metrics={metrics} />
</div>
)}
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {formatUpdatedTime(snapshot?.updatedAt)}</span>
<span className="vtd-card-match-summary"> {matchRate}%</span>
@@ -9,6 +9,7 @@ import {
pinCandleWatch,
type LiveConditionRowDto,
} from '../utils/backendApi';
import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry';
import {
extractVirtualConditions,
type VirtualConditionRow,
@@ -29,7 +30,26 @@ function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'i
}
function liveFallbackKey(r: LiveConditionRowDto): string {
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`;
const plotKey = r.plotKey ?? r.indicatorType;
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 {
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,
};
}
function mergeRows(
@@ -44,26 +64,13 @@ function mergeRows(
}
if (base.length === 0) {
return live.map(r => ({
id: r.id,
indicatorType: r.indicatorType,
displayName: r.displayName,
conditionType: r.conditionType,
conditionLabel: r.conditionLabel,
targetValue: r.targetValue,
timeframe: r.timeframe,
side: r.side,
plotKey: r.indicatorType,
satisfied: r.satisfied,
thresholdLabel: r.thresholdLabel,
currentValue: r.currentValue,
}));
return live.map(liveRowToVirtual);
}
return base.map(row => {
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
if (!liveRow) {
return { ...row, currentValue: null as number | null };
return { ...row, currentValue: null as number | null, satisfied: null };
}
return {
...row,
@@ -89,20 +96,20 @@ async function buildSnapshot(
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => ({ ...r, currentValue: null })),
rows: baseRows.map(r => ({ ...r, currentValue: null, satisfied: null })),
updatedAt: Date.now(),
matchRate: 0,
};
}
const status = await fetchLiveConditionStatus(market, strategyId);
if (!status || status.market !== market) {
if (!status || status.market !== market || status.strategyId !== strategyId) {
if (baseRows.length === 0) return null;
return {
market,
strategyId,
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
rows: baseRows.map(r => ({ ...r, currentValue: null })),
rows: baseRows.map(r => ({ ...r, currentValue: null, satisfied: null })),
updatedAt: Date.now(),
matchRate: 0,
};
@@ -133,6 +140,7 @@ export function useVirtualIndicatorSnapshots(
const strategiesRef = useRef(strategies);
strategiesRef.current = strategies;
const pinnedRef = useRef<Set<string>>(new Set());
const refreshGenRef = useRef<Record<string, number>>({});
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
const pinTargets = useCallback(async () => {
@@ -173,16 +181,17 @@ export function useVirtualIndicatorSnapshots(
const jobs = targets
.filter(t => t.strategyId != null)
.map(async t => {
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
refreshGenRef.current[t.market] = gen;
const strat = strats.find(s => s.id === t.strategyId);
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
if (snap) {
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
}
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
if (snap.strategyId !== t.strategyId) return snap;
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
return snap;
});
await Promise.all(jobs);
// 제거된 종목 스냅샷 정리
const activeMarkets = new Set(targets.map(t => t.market));
setSnapshots(prev => {
const next = { ...prev };
@@ -201,6 +210,7 @@ export function useVirtualIndicatorSnapshots(
if (targets.length === 0) {
setSnapshots({});
pinnedRef.current.clear();
refreshGenRef.current = {};
return;
}
void refresh();
+31 -2
View File
@@ -684,8 +684,37 @@
}
}
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
.ptd-order-card .top-submit--sell { background: var(--accent) !important; border-color: var(--accent) !important; }
/* 매수·매도 제출 버튼: 실시간 차트 우측 패널(.top-submit--buy/sell)과 동일 */
.ptd-order-card .top-submit--buy {
background: #ef5350;
border-color: #ef5350;
}
.ptd-order-card .top-submit--buy:hover {
background: #e53935;
}
.ptd-order-card .top-submit--sell {
background: #2962ff;
border-color: #2962ff;
}
.ptd-order-card .top-submit--sell:hover {
background: #1e53e5;
}
/* 매수·매도 카드 헤더: 실시간 차트 .rsp-trade-card--buy/sell 과 동일 */
.ptd-split-panel--right .ptd-split-card--top {
border-color: rgba(255, 107, 107, 0.35);
background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px);
}
.ptd-split-panel--right .ptd-split-card--bottom {
border-color: rgba(77, 171, 247, 0.35);
background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px);
}
.ptd-split-panel--right .ptd-split-card--top .ptd-split-card-head {
color: var(--up);
}
.ptd-split-panel--right .ptd-split-card--bottom .ptd-split-card-head {
color: var(--down);
}
.ptd-ob {
background: var(--bg2);
+457 -20
View File
@@ -536,6 +536,18 @@
min-width: 0;
}
.vtd-focus-meta {
display: flex;
align-items: center;
flex-shrink: 0;
}
.vtd-focus-meta .vtd-target-quote--compact {
margin-top: 0;
padding: 0;
gap: 6px;
}
.vtd-focus-title {
display: flex;
flex-direction: column;
@@ -612,7 +624,12 @@
}
.vtd-focus-pane--signal .vtd-focus-pane-body {
overflow-y: auto;
overflow: hidden;
}
.vtd-focus-pane--signal .vtd-focus-pane-body > .vtd-sig-panel-wrap {
flex: 1;
min-height: 0;
}
.vtd-sig-panel-wrap {
@@ -623,6 +640,73 @@
min-height: 0;
}
.vtd-sig-panel-wrap .vtd-card-foot {
margin-top: auto;
flex-shrink: 0;
}
/* 상세표시 — 상단 고정 + 중간 테이블 스크롤 + 푸터는 wrap 하단(margin-top:auto) */
.vtd-sig-panel-wrap--detail {
gap: 10px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.vtd-sig-panel-wrap--detail > .vtd-sig-panel--detail-top {
flex: 0 0 auto;
flex-grow: 0;
flex-shrink: 0;
min-height: 0;
overflow: hidden;
}
.vtd-sig-panel-wrap--detail .vtd-sig-panel--detail-top .vtd-sig-visual {
flex-shrink: 0;
}
.vtd-sig-panel-wrap--detail .vtd-sig-detail-table {
flex: 1 1 0;
min-height: 0;
overflow-x: auto;
overflow-y: auto;
}
.vtd-sig-panel-wrap--detail .vtd-sig-detail-table .vtd-sig-table-wrap {
margin: 0;
min-width: min(100%, 320px);
}
/* 전체보기(분할) 등 좁은 패널: 테이블만 내부 스크롤 */
.vtd-focus-pane--signal .vtd-sig-panel-wrap--detail .vtd-sig-detail-table {
flex: 1 1 0;
min-height: 100px;
overflow-x: hidden;
overflow-y: auto;
}
.vtd-card--detail .vtd-card-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px 10px;
}
.vtd-card--detail .vtd-card-meta .vtd-target-quote--compact {
grid-column: 1;
min-width: 0;
}
.vtd-card--detail .vtd-card-met-count {
grid-column: 2;
white-space: nowrap;
}
.vtd-card--detail .vtd-card-meta .vtd-live-badge {
grid-column: 3;
margin-left: 0;
}
.vtd-sig-panel-wrap--receiving .vtd-sig-panel {
box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent);
}
@@ -756,6 +840,7 @@
min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: minmax(520px, auto);
gap: 14px;
padding: 4px 4px 12px;
overflow-y: auto;
@@ -763,6 +848,7 @@
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
align-content: start;
align-items: stretch;
}
@media (max-width: 1280px) {
@@ -787,6 +873,46 @@
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
.vtd-card:not(.vtd-card--chart-mode):not(.vtd-card--detail) {
min-height: 360px;
}
.vtd-card > .vtd-sig-panel-wrap {
flex: 1;
min-height: 0;
}
/* 상세 카드 — 그리드 행 높이에 맞춰 동일 높이, 푸터는 카드 하단 고정 */
.vtd-card.vtd-card--detail {
height: 100%;
align-self: stretch;
overflow: hidden;
min-height: 520px;
}
.vtd-card.vtd-card--detail > .vtd-sig-panel-wrap--detail {
flex: 1;
min-height: 0;
overflow: hidden;
}
.vtd-card.vtd-card--detail .vtd-sig-panel.vtd-sig-panel--detail-top {
flex: 0 0 auto;
flex-grow: 0;
flex-shrink: 0;
}
.vtd-card.vtd-card--detail .vtd-sig-panel-wrap--detail .vtd-sig-panel--detail-top .vtd-heat-list {
max-height: 168px;
overflow-y: auto;
}
.vtd-card.vtd-card--detail .vtd-sig-panel-wrap .vtd-card-foot {
margin-top: auto;
flex-shrink: 0;
}
.vtd-card--signal {
@@ -898,6 +1024,14 @@
font-size: 11px;
}
.vtd-card-tf {
font-size: 10px;
font-weight: 600;
color: var(--se-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.vtd-card-head-actions {
display: flex;
align-items: center;
@@ -1064,6 +1198,29 @@
color: var(--se-text-muted);
}
.vtd-card-meta .vtd-target-quote--compact {
margin-top: 0;
padding: 0;
flex: 1;
min-width: 0;
gap: 6px;
}
.vtd-card-price-label {
font-size: 10px;
font-weight: 600;
color: var(--se-text-muted);
white-space: nowrap;
}
.vtd-card-meta .vtd-target-quote--compact .vtd-target-price {
font-size: 12px;
}
.vtd-card-meta .vtd-target-quote--compact .vtd-target-change {
font-size: 10px;
}
.vtd-card-empty {
min-height: 120px;
display: flex;
@@ -1155,36 +1312,53 @@
max-height: 160px;
}
.vtd-card--summary .vtd-card-foot {
margin-top: 2px;
}
.vtd-card-met-count {
color: color-mix(in srgb, #c9a227 80%, var(--se-text));
font-weight: 600;
}
.vtd-card--detail .vtd-card-met-count {
font-size: 13px;
font-weight: 800;
color: color-mix(in srgb, #f0c040 85%, #fff);
}
.vtd-card-foot {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
color: var(--se-text-muted);
padding-top: 4px;
padding-top: 8px;
margin-top: auto;
flex-shrink: 0;
border-top: 1px solid var(--se-border);
}
.vtd-card-match-summary {
font-weight: 700;
color: var(--accent, #3f7ef5);
}
/* 시그널 일치율 패널 — 카드 높이 고정·축소 방지, 전체 UI 항상 표시 */
/* 시그널 일치율 패널 — 요약: 스크롤 / 상세: 상단 고정 높이 */
.vtd-sig-panel {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
min-height: 0;
}
.vtd-sig-panel--summary {
overflow-y: auto;
}
.vtd-sig-panel--detail-top {
flex: 0 0 auto;
flex-grow: 0;
flex-shrink: 0;
overflow: hidden;
}
.vtd-card-match-summary {
font-weight: 700;
color: var(--accent, #3f7ef5);
}
.vtd-sig-panel-title {
@@ -1283,7 +1457,184 @@
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
}
/* 조건 목록 (이퀄라이저 ↔ 신호등 사이) */
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 (Cybernetic Command Desk) */
.vtd-heat-list {
list-style: none;
margin: 0;
padding: 2px 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 188px;
overflow-y: auto;
min-width: 0;
flex: 1;
}
.vtd-heat-list::-webkit-scrollbar {
width: 4px;
}
.vtd-heat-list::-webkit-scrollbar-thumb {
background: color-mix(in srgb, #c9a227 35%, var(--se-border));
border-radius: 2px;
}
.vtd-heat-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.vtd-heat-track {
position: relative;
flex: 1;
min-width: 0;
height: 26px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--se-border) 70%, #3a4a6b);
background: color-mix(in srgb, #060a14 85%, #000);
overflow: hidden;
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.45);
}
.vtd-heat-label {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 2;
max-width: calc(100% - 48px);
font-size: 9px;
font-weight: 600;
color: color-mix(in srgb, var(--se-text) 88%, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 1px 2px rgba(0, 0, 0, 0.8);
}
.vtd-heat-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
min-width: 0;
border-radius: 7px 0 0 7px;
transition: width 0.35s ease, box-shadow 0.35s ease, background 0.35s ease;
}
.vtd-heat-pct {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 2;
font-size: 10px;
font-weight: 800;
font-variant-numeric: tabular-nums;
pointer-events: none;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.85);
}
.vtd-heat-icon {
flex-shrink: 0;
width: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.vtd-heat-icon-svg {
display: block;
filter: drop-shadow(0 0 4px currentColor);
}
/* Mismatch — 낮음 (빨강) */
.vtd-heat-row--nomatch .vtd-heat-track {
border-color: color-mix(in srgb, #ef5350 40%, var(--se-border));
box-shadow:
inset 0 1px 4px rgba(0, 0, 0, 0.45),
0 0 8px color-mix(in srgb, #ef5350 15%, transparent);
}
.vtd-heat-row--nomatch .vtd-heat-fill {
background: linear-gradient(90deg, #b71c1c 0%, #ef5350 55%, #ff8a80 100%);
box-shadow:
0 0 12px color-mix(in srgb, #ef5350 50%, transparent),
inset 0 0 8px color-mix(in srgb, #ffcdd2 20%, transparent);
}
.vtd-heat-row--nomatch .vtd-heat-pct {
color: #ff8a80;
}
.vtd-heat-row--nomatch .vtd-heat-icon {
color: #ef5350;
}
/* Pending — 중간 (오렌지) */
.vtd-heat-row--pending .vtd-heat-track {
border-color: color-mix(in srgb, #ffb300 45%, var(--se-border));
box-shadow:
inset 0 1px 4px rgba(0, 0, 0, 0.45),
0 0 10px color-mix(in srgb, #ffb300 18%, transparent);
}
.vtd-heat-row--pending .vtd-heat-fill {
background: linear-gradient(90deg, #e65100 0%, #ffb300 45%, #ffe082 100%);
box-shadow:
0 0 14px color-mix(in srgb, #ffb300 45%, transparent),
inset 0 0 10px color-mix(in srgb, #fff59d 18%, transparent);
}
.vtd-heat-row--pending .vtd-heat-pct {
color: #ffe082;
}
.vtd-heat-row--pending .vtd-heat-icon {
color: #ffb300;
}
/* Match — 높음 (푸른색) */
.vtd-heat-row--match .vtd-heat-track {
border-color: color-mix(in srgb, #3f7ef5 45%, var(--se-border));
box-shadow:
inset 0 1px 4px rgba(0, 0, 0, 0.45),
0 0 12px color-mix(in srgb, #3f7ef5 22%, transparent);
}
.vtd-heat-row--match .vtd-heat-fill {
background: linear-gradient(90deg, #1565c0 0%, #3f7ef5 45%, #82b1ff 100%);
box-shadow:
0 0 16px color-mix(in srgb, #3f7ef5 55%, transparent),
inset 0 0 12px color-mix(in srgb, #bbdefb 25%, transparent);
}
.vtd-heat-row--match .vtd-heat-pct {
color: #82b1ff;
}
.vtd-heat-row--match .vtd-heat-icon {
color: #3f7ef5;
filter: drop-shadow(0 0 5px #3f7ef5);
}
.vtd-heat-list-empty {
margin: 0;
padding: 8px;
font-size: 10px;
text-align: center;
align-self: center;
}
.vtd-sig-panel--summary .vtd-heat-list {
max-height: 160px;
}
/* legacy 조건 목록 (상세 테이블 STATUS 열) */
.vtd-cond-list {
list-style: none;
margin: 0;
@@ -1357,7 +1708,7 @@
}
.vtd-cond-signal--match {
color: var(--down, #ef5350);
color: #3f7ef5;
}
.vtd-cond-signal--pending {
@@ -1365,7 +1716,7 @@
}
.vtd-cond-signal--nomatch {
color: var(--up, #26a69a);
color: #ef5350;
}
.vtd-cond-signal--unknown {
@@ -1471,8 +1822,10 @@
.vtd-sig-table-wrap {
border: 1px solid color-mix(in srgb, #c9a227 35%, var(--se-border));
border-radius: 8px;
overflow: hidden;
overflow-x: auto;
overflow-y: visible;
background: color-mix(in srgb, #0d111f 50%, transparent);
-webkit-overflow-scrolling: touch;
}
.vtd-sig-table-head {
@@ -1487,23 +1840,37 @@
.vtd-sig-table {
width: 100%;
min-width: 100%;
border-collapse: collapse;
font-size: 10px;
table-layout: fixed;
}
.vtd-sig-col--ind { width: 30%; }
.vtd-sig-col--val { width: 14%; }
.vtd-sig-col--threshold { width: 22%; }
.vtd-sig-col--progress { width: 16%; }
.vtd-sig-col--status { width: 18%; min-width: 4.75rem; }
.vtd-sig-table th {
text-align: left;
padding: 6px 8px;
padding: 6px 6px;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.02em;
color: color-mix(in srgb, #c9a227 80%, var(--se-text-muted));
border-bottom: 1px solid var(--se-border);
white-space: nowrap;
}
.vtd-sig-table th.vtd-sig-table-th--status,
.vtd-sig-table td.vtd-sig-table-status-cell {
padding-right: 10px;
text-align: right;
}
.vtd-sig-table td {
padding: 7px 8px;
padding: 7px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 60%, transparent);
vertical-align: middle;
}
@@ -1515,6 +1882,9 @@
.vtd-sig-table-ind {
font-weight: 600;
color: var(--se-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vtd-sig-table-val {
@@ -1528,7 +1898,24 @@
}
.vtd-sig-table-progress {
min-width: 88px;
min-width: 0;
}
.vtd-sig-table-status-cell {
min-width: 4.75rem;
overflow: visible;
}
.vtd-sig-table-status-cell .vtd-cond-signal {
justify-content: flex-end;
max-width: none;
}
.vtd-sig-table-status-cell .vtd-cond-signal--compact .vtd-cond-signal-label {
font-size: 9px;
max-width: none;
overflow: visible;
text-overflow: clip;
}
.vtd-sig-row-bar {
@@ -1561,6 +1948,56 @@
background: var(--se-border);
}
.vtd-sig-row-bar-fill--cold {
background: linear-gradient(90deg, #b71c1c, #ef5350);
box-shadow: 0 0 6px color-mix(in srgb, #ef5350 40%, transparent);
}
.vtd-sig-row-bar-fill--warming {
background: linear-gradient(90deg, #e65100, #ffb300, #ffe082);
box-shadow: 0 0 6px color-mix(in srgb, #ffb300 40%, transparent);
}
.vtd-sig-row-bar-fill--hot {
background: linear-gradient(90deg, #1565c0, #3f7ef5, #82b1ff);
box-shadow: 0 0 8px color-mix(in srgb, #3f7ef5 50%, transparent);
}
.vtd-sig-row-bar-fill--nomatch {
background: linear-gradient(90deg, #b71c1c, #ef5350);
box-shadow: 0 0 6px color-mix(in srgb, #ef5350 40%, transparent);
}
.vtd-sig-row-bar-fill--pending {
background: linear-gradient(90deg, #e65100, #ffb300, #ffe082);
box-shadow: 0 0 6px color-mix(in srgb, #ffb300 40%, transparent);
}
.vtd-sig-row-bar-fill--match {
background: linear-gradient(90deg, #1565c0, #3f7ef5, #82b1ff);
box-shadow: 0 0 8px color-mix(in srgb, #3f7ef5 50%, transparent);
}
.vtd-sig-table-pct {
font-size: 9px;
color: var(--se-text-muted);
font-variant-numeric: tabular-nums;
}
.vtd-sig-table-status {
font-size: 10px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.vtd-sig-table-status--cold { color: #ef5350; }
.vtd-sig-table-status--warming { color: #ffe082; }
.vtd-sig-table-status--hot { color: #82b1ff; }
.vtd-sig-table-status--nomatch { color: #ef5350; }
.vtd-sig-table-status--pending { color: #ffe082; }
.vtd-sig-table-status--match { color: #82b1ff; }
.vtd-sig-row-pct {
font-size: 9px;
color: var(--se-text-muted);
+1
View File
@@ -1083,6 +1083,7 @@ export interface LiveConditionRowDto {
satisfied: boolean | null;
timeframe: string;
side: 'buy' | 'sell';
plotKey?: string;
}
export interface LiveConditionStatusDto {
+10
View File
@@ -384,6 +384,16 @@ export function getIndicatorDef(type: string): IndicatorDef | undefined {
return INDICATOR_REGISTRY.find(d => d.type === type);
}
/** 가상투자·조건 목록 — 한글명 (영문약어) 예: 상품채널지수 (CCI) */
export function formatIndicatorDisplayLabel(indicatorType: string): string {
const def = getIndicatorDef(indicatorType);
const abbr = def?.shortName ?? indicatorType;
const ko = def?.koreanName ?? abbr;
if (!ko || ko === abbr) return abbr;
if (ko.includes(`(${abbr})`)) return ko;
return `${ko} (${abbr})`;
}
/** 실시간 차트 pane·범례 타이틀 (심리도·투자심리도·이격도 등 한글명 우선) */
export function getIndicatorChartTitle(type: string): string {
const resolved = type === 'NewPsychological' ? 'Psychological' : type;
+58 -14
View File
@@ -9,9 +9,23 @@ export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
export interface ConditionMetric {
row: VirtualConditionRow & { currentValue: number | null };
status: ConditionStatus;
/** 조건 매칭율 0~100 */
matchRate: number;
/** @deprecated matchRate 와 동일 — 하위 호환 */
progress: number | null;
}
export type HeatTier = 'nomatch' | 'pending' | 'match';
/** Heatmap 색상 — Mismatch(빨강) · Pending(오렌지) · Match(푸른색) */
export function resolveHeatTier(matchRate: number, status: ConditionStatus): HeatTier {
if (status === 'match' || matchRate >= 100) return 'match';
if (status === 'pending') return 'pending';
if (status === 'nomatch') return 'nomatch';
if (matchRate >= 45) return 'pending';
return 'nomatch';
}
export type TrafficLightState = 'red' | 'yellow' | 'blue';
const PENDING_PROGRESS = 72;
@@ -53,6 +67,35 @@ export function computeConditionProgress(
}
}
const CROSS_CONDITION_TYPES = new Set([
'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN',
]);
/** 조건별 매칭율 (0~100) — 백엔드 Ta4j satisfied 우선 */
export function computeConditionMatchRate(
row: VirtualConditionRow & { currentValue: number | null },
): number {
if (row.satisfied === true) return 100;
if (row.satisfied === false) {
if (CROSS_CONDITION_TYPES.has(row.conditionType)) return 0;
const progress = computeConditionProgress(
row.conditionType,
row.currentValue,
row.targetValue,
);
if (progress == null) return 0;
return Math.min(99, Math.max(0, Math.round(progress)));
}
if (row.currentValue == null) return 0;
const progress = computeConditionProgress(
row.conditionType,
row.currentValue,
row.targetValue,
);
if (progress == null) return 0;
return Math.min(99, Math.max(0, Math.round(progress)));
}
export function getConditionStatus(
conditionType: string,
current: number | null,
@@ -61,6 +104,7 @@ export function getConditionStatus(
): ConditionStatus {
if (satisfied === true) return 'match';
if (satisfied === false) {
if (CROSS_CONDITION_TYPES.has(conditionType)) return 'nomatch';
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
@@ -105,20 +149,20 @@ export function formatStrategyThreshold(
export function buildConditionMetrics(
rows: Array<VirtualConditionRow & { currentValue: number | null }>,
): ConditionMetric[] {
return rows.map(row => ({
row,
status: getConditionStatus(
row.conditionType,
row.currentValue,
row.targetValue,
row.satisfied,
),
progress: row.satisfied === true
? 100
: row.satisfied === false
? (computeConditionProgress(row.conditionType, row.currentValue, row.targetValue) ?? 0)
: computeConditionProgress(row.conditionType, row.currentValue, row.targetValue),
}));
return rows.map(row => {
const matchRate = computeConditionMatchRate(row);
return {
row,
status: getConditionStatus(
row.conditionType,
row.currentValue,
row.targetValue,
row.satisfied,
),
matchRate,
progress: matchRate,
};
});
}
/** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */
@@ -4,7 +4,7 @@
import type { LogicNode } from './strategyTypes';
import { CONDITION_LABEL } from './strategyTypes';
import type { StrategyDto } from './backendApi';
import { getIndicatorDef } from './indicatorRegistry';
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
export interface VirtualConditionRow {
id: string;
@@ -38,13 +38,18 @@ function walk(
return;
}
if (node.type === 'NOT') {
const notChild = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
if (notChild) walk(notChild, timeframe, side, out, seen);
return;
}
if (node.type === 'CONDITION' && node.condition) {
const c = node.condition;
const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`;
if (seen.has(key)) return;
seen.add(key);
const def = getIndicatorDef(c.indicatorType);
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
const target = c.targetValue ?? c.compareValue ?? null;
const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType;
@@ -52,7 +57,7 @@ function walk(
out.push({
id: `${node.id}-${side}`,
indicatorType: c.indicatorType,
displayName: def?.koreanName ?? def?.shortName ?? c.indicatorType,
displayName: formatIndicatorDisplayLabel(c.indicatorType),
conditionType: c.conditionType,
conditionLabel: condLabel,
targetValue: target,