From 182b82e9901a910626d0f3dc5c3ac57f6a07bc50 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 16:02:52 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../goldenchart/dto/LiveConditionRowDto.java | 2 + .../service/LiveConditionStatusService.java | 12 + .../src/components/VirtualTradingPage.tsx | 1 + .../virtual/ConditionStatusSignal.tsx | 16 +- .../virtual/VirtualConditionList.tsx | 66 ++- .../virtual/VirtualIndicatorCompareTable.tsx | 40 +- .../components/virtual/VirtualTargetCard.tsx | 7 +- .../virtual/VirtualTargetFocusView.tsx | 8 + .../components/virtual/VirtualTargetGrid.tsx | 5 + .../components/virtual/VirtualTargetQuote.tsx | 18 +- .../virtual/VirtualTargetSignalPanel.tsx | 17 +- .../src/hooks/useVirtualIndicatorSnapshots.ts | 56 +- frontend/src/styles/paperDashboard.css | 33 +- .../src/styles/virtualTradingDashboard.css | 477 +++++++++++++++++- frontend/src/utils/backendApi.ts | 1 + frontend/src/utils/indicatorRegistry.ts | 10 + frontend/src/utils/virtualSignalMetrics.ts | 72 ++- .../src/utils/virtualStrategyConditions.ts | 11 +- 18 files changed, 747 insertions(+), 105 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java b/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java index 7f80a59..4bb0eb5 100644 --- a/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java +++ b/backend/src/main/java/com/goldenchart/dto/LiveConditionRowDto.java @@ -26,4 +26,6 @@ public class LiveConditionRowDto { private String timeframe; /** buy | sell */ private String side; + /** 지표 plot/leftField — 프론트 조건 병합용 */ + private String plotKey; } diff --git a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java index 325057f..f9c1a22 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveConditionStatusService.java @@ -197,6 +197,7 @@ public class LiveConditionStatusService { private LiveConditionRowDto toRowUnevaluated(PendingCond p) { JsonNode c = p.condition(); String indType = c.path("indicatorType").asText(""); + String plotKey = plotKeyFromCondition(c, indType); String condType = c.path("conditionType").asText("GT"); String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); Double target = readTargetNumeric(c); @@ -212,6 +213,7 @@ public class LiveConditionStatusService { .satisfied(null) .timeframe(p.timeframe()) .side(p.side()) + .plotKey(plotKey) .build(); } @@ -240,6 +242,7 @@ public class LiveConditionStatusService { Map> params, int index) { JsonNode c = p.condition(); String indType = c.path("indicatorType").asText(""); + String plotKey = plotKeyFromCondition(c, indType); String condType = c.path("conditionType").asText("GT"); String condLabel = CONDITION_LABEL.getOrDefault(condType, condType); @@ -255,6 +258,7 @@ public class LiveConditionStatusService { .satisfied(satisfied) .timeframe(p.timeframe()) .side(p.side()) + .plotKey(plotKey) .build(); } @@ -310,4 +314,12 @@ public class LiveConditionStatusService { return null; } + private static String plotKeyFromCondition(JsonNode cond, String indicatorType) { + String left = cond.path("leftField").asText(""); + if (left != null && !left.isBlank() && !"none".equalsIgnoreCase(left)) { + return left; + } + return indicatorType; + } + } diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 30f4dba..037d530 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -261,6 +261,7 @@ const VirtualTradingPage: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + tickers={tickers} /> )} rightTabs={rightTabs} diff --git a/frontend/src/components/virtual/ConditionStatusSignal.tsx b/frontend/src/components/virtual/ConditionStatusSignal.tsx index c0220d8..30b4f22 100644 --- a/frontend/src/components/virtual/ConditionStatusSignal.tsx +++ b/frontend/src/components/virtual/ConditionStatusSignal.tsx @@ -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 = { - match: 'Match', - pending: 'Pending', - nomatch: 'Mismatch', + match: '충족', + pending: '대기', + nomatch: '미충족', unknown: '—', }; -const ConditionStatusSignal: React.FC = ({ status, progress, compact = false }) => { +const ConditionStatusSignal: React.FC = ({ status, matchRate, progress, compact = false }) => { if (status === 'unknown') { return ; } - 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 ( diff --git a/frontend/src/components/virtual/VirtualConditionList.tsx b/frontend/src/components/virtual/VirtualConditionList.tsx index d39a1e3..07b0b85 100644 --- a/frontend/src/components/virtual/VirtualConditionList.tsx +++ b/frontend/src/components/virtual/VirtualConditionList.tsx @@ -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 = () => ( + + + + +); + +const IconPending = () => ( + + + + + +); + +const IconMatch = () => ( + + + +); + const VirtualConditionList: React.FC = ({ metrics }) => { if (metrics.length === 0) { - return

조건 없음

; + return

조건 없음

; } return ( -
    - {metrics.map(({ row, status, progress }) => ( -
  • - - {row.displayName} - - -
  • - ))} +
      + {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 ( +
    • +
      + + {row.displayName} + +
      + {pct}% +
      + + {tier === 'match' ? : tier === 'pending' ? : } + +
    • + ); + })}
    ); }; diff --git a/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx b/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx index 9038651..626ad89 100644 --- a/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx +++ b/frontend/src/components/virtual/VirtualIndicatorCompareTable.tsx @@ -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 = ({ metrics }) => (
    -
    REAL-TIME INDICATOR COMPARISON
    +
    실시간 지표 비교
    + + + + + + + - - - - - + + + + + - {metrics.map(({ row, status, progress }) => ( + {metrics.map(({ row, status, matchRate }) => { + const tier = resolveHeatTier(matchRate, status); + return ( @@ -31,17 +40,20 @@ const VirtualIndicatorCompareTable: React.FC = ({ metrics }) => ( ?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)} + - - ))} + ); + })}
    TECHNICAL INDICATORCURRENT VALUESTRATEGY THRESHOLDPROGRESSSTATUS기술적 지표현재값전략 조건진행률상태
    {row.displayName} {formatIndicatorValue(row.currentValue)} -
    +
    - {progress != null ? `${Math.round(progress)}%` : '—'} + {`${matchRate}%`} +
    +
    diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index 84454b1..428bbf0 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -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 = ({ @@ -56,6 +59,7 @@ const VirtualTargetCard: React.FC = ({ 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 = ({ ))} + 시간봉 {tf}
    {onEnterFocus && ( @@ -162,7 +167,7 @@ const VirtualTargetCard: React.FC = ({
    - 시간봉 {tf} + {!isChart && isDetail && evalCount > 0 && ( {metCount}/{evalCount} 조건 충족 )} diff --git a/frontend/src/components/virtual/VirtualTargetFocusView.tsx b/frontend/src/components/virtual/VirtualTargetFocusView.tsx index e8a3c30..592e6d2 100644 --- a/frontend/src/components/virtual/VirtualTargetFocusView.tsx +++ b/frontend/src/components/virtual/VirtualTargetFocusView.tsx @@ -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 = ({ 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 = ({
    +
    + +
    +
    차트
    diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index df0b386..b01e90f 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -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; } const VirtualTargetGrid: React.FC = ({ @@ -39,6 +41,7 @@ const VirtualTargetGrid: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + tickers, }) => { const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); const [globalDisplayMode, setGlobalDisplayMode] = useState('signal'); @@ -137,6 +140,7 @@ const VirtualTargetGrid: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + ticker={tickers?.get(focusTarget.market)} /> ) : (
    @@ -164,6 +168,7 @@ const VirtualTargetGrid: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + ticker={tickers?.get(t.market)} /> ); })} diff --git a/frontend/src/components/virtual/VirtualTargetQuote.tsx b/frontend/src/components/virtual/VirtualTargetQuote.tsx index b689df9..493d402 100644 --- a/frontend/src/components/virtual/VirtualTargetQuote.tsx +++ b/frontend/src/components/virtual/VirtualTargetQuote.tsx @@ -25,9 +25,11 @@ const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) interface Props { market: string; ticker?: TickerData; + /** 카드 메타 행 — 현재가만 간략 표시 */ + compact?: boolean; } -const VirtualTargetQuote: React.FC = ({ market, ticker }) => { +const VirtualTargetQuote: React.FC = ({ 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 = ({ market, ticker }) => { prevPriceRef.current = price; }, [ticker?.tradePrice]); + if (compact) { + return ( +
    + 현재가 + + {ticker ? fmtKrw(ticker.tradePrice) : '-'} + + + {ticker ? fmtPct(ticker.changeRate) : '-'} + +
    + ); + } + return (
    diff --git a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx index 39f857f..78e8717 100644 --- a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx +++ b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx @@ -34,8 +34,6 @@ const VirtualTargetSignalPanel: React.FC = ({ [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 = ({
    - {isDetail && evalCount > 0 && ( -
    - {metCount}/{evalCount} 조건 충족 -
    - )} -
    +
    SIGNAL INTELLIGENCE & MATCH RATES
    @@ -68,8 +61,12 @@ const VirtualTargetSignalPanel: React.FC = ({
    - {isDetail && }
    + {isDetail && ( +
    + +
    + )}
    갱신 {formatUpdatedTime(snapshot?.updatedAt)} 매매조건 일치율 {matchRate}% diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 69b2a6f..0c1c2ac 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -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 ({ - 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>(new Set()); + const refreshGenRef = useRef>({}); 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(); diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index f295053..9bedc89 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -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); diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 1934d3c..1aff441 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -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); diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 90493f7..92a5a25 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1083,6 +1083,7 @@ export interface LiveConditionRowDto { satisfied: boolean | null; timeframe: string; side: 'buy' | 'sell'; + plotKey?: string; } export interface LiveConditionStatusDto { diff --git a/frontend/src/utils/indicatorRegistry.ts b/frontend/src/utils/indicatorRegistry.ts index ce27250..7f0adb8 100644 --- a/frontend/src/utils/indicatorRegistry.ts +++ b/frontend/src/utils/indicatorRegistry.ts @@ -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; diff --git a/frontend/src/utils/virtualSignalMetrics.ts b/frontend/src/utils/virtualSignalMetrics.ts index 70099a1..848df1d 100644 --- a/frontend/src/utils/virtualSignalMetrics.ts +++ b/frontend/src/utils/virtualSignalMetrics.ts @@ -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, ): 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 우선 */ diff --git a/frontend/src/utils/virtualStrategyConditions.ts b/frontend/src/utils/virtualStrategyConditions.ts index 00d84b5..87b120f 100644 --- a/frontend/src/utils/virtualStrategyConditions.ts +++ b/frontend/src/utils/virtualStrategyConditions.ts @@ -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,