추세검색 화면 적용

This commit is contained in:
Macbook
2026-05-26 00:51:07 +09:00
parent 1e950c7db4
commit 1e11884fef
29 changed files with 3753 additions and 71 deletions
@@ -0,0 +1,187 @@
/**
* 추세검색 카드 — 인라인 캔들 + RSI/MACD/BB
*/
import React, {
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from 'react';
import TradingChart from '../TradingChart';
import { pinCandleWatch } from '../../utils/backendApi';
import type {
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
} from '../../types';
import { isUpbitMarket } from '../../utils/upbitApi';
import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import type { ChartManager } from '../../utils/ChartManager';
import { timeframeToCandleType } from '../../utils/chartCandleType';
import { enrichIndicatorConfig } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
timeframe: string;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
}
const noop = () => {};
const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const;
function toChartTimeframe(tf: string): Timeframe {
if (tf === '1d') return '1D';
if (tf === '1w') return '1W';
if (tf === '1M') return '1M';
return tf as Timeframe;
}
const TrendSearchCardChart: React.FC<Props> = ({
market,
timeframe,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
}) => {
const chartTf = toChartTimeframe(timeframe);
const { getParams, getVisualConfig } = useIndicatorSettings();
const indicators = useMemo(
() => CHART_INDICATOR_TYPES.map(type => {
const base = enrichIndicatorConfig({
id: `${type}-tsd-card`,
type,
params: { ...getParams(type) },
});
return {
...base,
plots: getVisualConfig(type)?.plots ?? base.plots,
};
}),
[getParams, getVisualConfig],
);
const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const chartLiveReadyRef = useRef(false);
const marketRef = useRef(market);
marketRef.current = market;
const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true;
const pending = pendingBarRef.current;
const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
pendingBarRef.current = null;
mgr.updateBar(pending);
}, []);
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
if (barsMarketRef.current !== marketRef.current) return;
if (!chartLiveReadyRef.current || !managerRef.current) {
pendingBarRef.current = bar;
return;
}
if (append) managerRef.current.appendBar(bar);
else managerRef.current.updateBar(bar);
}, []);
const handleTickUpdate = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, false), [applyRealtimeBar]);
const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
chartTf,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe: chartTf,
isUpbit: useUpbit,
managerRef,
});
barsMarketRef.current = barsMarket;
useLayoutEffect(() => {
chartLiveReadyRef.current = false;
pendingBarRef.current = null;
chartReloadTriggeredRef.current = false;
}, [market, chartTf]);
useEffect(() => {
const timers = [100, 300, 800, 1500].map(delay =>
setTimeout(() => {
const wrap = canvasWrapRef.current;
const mgr = managerRef.current;
if (!wrap) return;
const h = wrap.clientHeight;
if (h <= 0) return;
if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h);
else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) {
chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1);
}
}, delay),
);
return () => timers.forEach(clearTimeout);
}, [market, chartTf, bars.length]);
useEffect(() => {
if (!useUpbit) return;
void pinCandleWatch(market, timeframeToCandleType(chartTf));
}, [market, chartTf, useUpbit]);
return (
<div className="vtd-card-chart-wrap">
<div ref={canvasWrapRef} className="vtd-card-chart slot-chart-wrap">
{isLoading && <div className="vtd-card-chart-loading"> </div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> </span>
</div>
)}
<TradingChart
key={`${market}-${chartTf}-${chartReloadTick}`}
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={chartTf}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible
showHoverToolbar={false}
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
/>
</div>
</div>
);
};
export default TrendSearchCardChart;
@@ -0,0 +1,70 @@
import React, { useMemo } from 'react';
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics';
import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics';
import VirtualSignalEqualizer from '../virtual/VirtualSignalEqualizer';
import VirtualConditionList from '../virtual/VirtualConditionList';
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
import VirtualIndicatorCompareTable from '../virtual/VirtualIndicatorCompareTable';
interface Props {
conditions: TrendSearchConditionDto[];
matchRate: number;
matchedCount: number;
totalConditions: number;
updatedAt?: number;
}
const TrendSearchCardSignalPanel: React.FC<Props> = ({
conditions,
matchRate,
matchedCount,
totalConditions,
updatedAt,
}) => {
const metrics = useMemo(() => buildTrendSearchMetrics(conditions), [conditions]);
const resolvedMatch = useMemo(
() => computeMatchRate(metrics, matchRate),
[metrics, matchRate],
);
const trafficState = useMemo(
() => getTrafficLightState(resolvedMatch, metrics),
[resolvedMatch, metrics],
);
if (conditions.length === 0) {
return <p className="vtd-muted vtd-card-empty"> </p>;
}
const updatedLabel = updatedAt
? new Date(updatedAt).toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
})
: '—';
return (
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE &amp; MATCH RATES</div>
<div className="vtd-sig-visual">
<div className="vtd-sig-visual-eq">
<VirtualSignalEqualizer matchRate={resolvedMatch} />
</div>
<VirtualConditionList metrics={metrics} />
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
</div>
</div>
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
<VirtualIndicatorCompareTable metrics={metrics} />
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {updatedLabel}</span>
<span className="vtd-card-match-summary">
{matchedCount}/{totalConditions} · {resolvedMatch}%
</span>
</div>
</div>
);
};
export default TrendSearchCardSignalPanel;
@@ -0,0 +1,192 @@
/**
* 추세검색 — 우측 심층 차트
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import TradingChart from '../TradingChart';
import type { ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, IndicatorConfig } from '../../types';
import { pinCandleWatch } from '../../utils/backendApi';
import { isUpbitMarket } from '../../utils/upbitApi';
import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import type { ChartManager } from '../../utils/ChartManager';
import { timeframeToCandleType } from '../../utils/chartCandleType';
import { enrichIndicatorConfig } from '../../utils/indicatorRegistry';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
timeframe: string;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
}
const noop = () => {};
function toChartTimeframe(tf: string): Timeframe {
if (tf === '1d') return '1D';
if (tf === '1w') return '1W';
if (tf === '1M') return '1M';
return tf as Timeframe;
}
const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const;
const TrendSearchChartPanel: React.FC<Props> = ({
market,
timeframe,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
}) => {
const chartTf = toChartTimeframe(timeframe);
const { getParams, getVisualConfig } = useIndicatorSettings();
const indicators = useMemo((): IndicatorConfig[] =>
CHART_INDICATOR_TYPES.map(type => {
const base = enrichIndicatorConfig({
id: `${type}-tsd`,
type,
params: { ...getParams(type) },
});
return {
...base,
plots: getVisualConfig(type)?.plots ?? base.plots,
};
}),
[getParams, getVisualConfig]);
const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const chartLiveReadyRef = useRef(false);
const marketRef = useRef(market);
marketRef.current = market;
const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true;
const pending = pendingBarRef.current;
const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
pendingBarRef.current = null;
mgr.updateBar(pending);
}, []);
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
if (barsMarketRef.current !== marketRef.current) return;
if (!chartLiveReadyRef.current || !managerRef.current) {
pendingBarRef.current = bar;
return;
}
if (append) managerRef.current.appendBar(bar);
else managerRef.current.updateBar(bar);
}, []);
const handleTickUpdate = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, false), [applyRealtimeBar]);
const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
chartTf,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe: chartTf,
isUpbit: useUpbit,
managerRef,
});
barsMarketRef.current = barsMarket;
useLayoutEffect(() => {
chartLiveReadyRef.current = false;
pendingBarRef.current = null;
chartReloadTriggeredRef.current = false;
}, [market, chartTf]);
useEffect(() => {
const timers = [100, 300, 800, 1500].map(delay =>
setTimeout(() => {
const wrap = canvasWrapRef.current;
const mgr = managerRef.current;
if (!wrap) return;
const h = wrap.clientHeight;
if (h <= 0) return;
if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h);
else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) {
chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1);
}
}, delay),
);
return () => timers.forEach(clearTimeout);
}, [market, chartTf, bars.length]);
useEffect(() => {
if (!useUpbit) return;
void pinCandleWatch(market, timeframeToCandleType(chartTf));
if (timeframeToCandleType(chartTf) !== '1m') void pinCandleWatch(market, '1m');
}, [market, chartTf, useUpbit]);
return (
<div className="tsd-chart-panel">
<div className="tsd-chart-toolbar">
<span className="tsd-chart-toolbar-title"> </span>
<div className="tsd-chart-tools" aria-label="차트 도구">
{['↖', '', '⌇', '□', 'T', '📷'].map((icon, i) => (
<button key={i} type="button" className="tsd-chart-tool-btn" title="차트 도구">{icon}</button>
))}
</div>
</div>
<div ref={canvasWrapRef} className="tsd-chart-body slot-chart-wrap">
{isLoading && <div className="tsd-chart-loading"> </div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
<span> </span>
</div>
)}
<TradingChart
key={`${market}-${chartTf}-${chartReloadTick}`}
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={chartTf}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible
showHoverToolbar={false}
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
/>
</div>
</div>
);
};
export default TrendSearchChartPanel;
@@ -0,0 +1,183 @@
import React, { useMemo } from 'react';
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
import {
TREND_CATEGORY_ORDER,
TREND_CONDITION_DEFS,
TREND_SORT_CONDITION_IDS,
type TrendConditionMode,
} from '../../utils/trendSearchConditions';
interface Props {
filters: TrendSearchRequest;
onChange: (next: TrendSearchRequest) => void;
onSearch: () => void;
searching?: boolean;
}
function tfLabel(tf: string): string {
if (tf === '1M') return '1M';
if (tf.endsWith('m')) return tf.replace('m', '분');
if (tf.endsWith('h')) return tf.replace('h', '시간');
if (tf.endsWith('d')) return tf.replace('d', '일');
if (tf.endsWith('w')) return tf.replace('w', '주');
return tf;
}
function modeBadge(mode: TrendConditionMode): React.ReactNode {
if (mode === 'filter') return <span className="tsd-cond-badge tsd-cond-badge--filter"></span>;
if (mode === 'unsupported') return <span className="tsd-cond-badge tsd-cond-badge--na"></span>;
return <span className="tsd-cond-badge tsd-cond-badge--sort"></span>;
}
const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => {
const patch = (p: Partial<TrendSearchRequest>) => onChange({ ...filters, ...p });
const sortOptions = useMemo(() => {
return TREND_CONDITION_DEFS.filter(c => {
if (c.mode !== 'sort') return false;
if (!filters[c.categoryKey]) return false;
return Boolean(filters[c.requestKey]);
});
}, [filters]);
const handleToggleCondition = (key: keyof TrendSearchRequest, checked: boolean, id: string, mode: TrendConditionMode) => {
const next = { ...filters, [key]: checked };
if (checked && mode === 'sort' && !TREND_SORT_CONDITION_IDS.includes(filters.sortBy)) {
next.sortBy = id;
}
if (!checked && filters.sortBy === id) {
const fallback = TREND_CONDITION_DEFS.find(
c => c.mode === 'sort' && c.id !== id && Boolean(next[c.requestKey]),
);
if (fallback) next.sortBy = fallback.id;
}
onChange(next);
};
return (
<div className="tsd-filter-panel">
<div className="tsd-panel-head">
<span className="tsd-panel-icon"></span>
<div>
<h3 className="tsd-panel-title"> </h3>
<p className="tsd-panel-sub">20 · + </p>
</div>
</div>
{TREND_CATEGORY_ORDER.map(cat => {
const items = TREND_CONDITION_DEFS.filter(c => c.category === cat.cat);
const catOn = filters[cat.key];
return (
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
<div className="tsd-filter-cat-head">
<span className="tsd-filter-cat-ko">{cat.label}</span>
<label className="tsd-toggle">
<input
type="checkbox"
checked={catOn}
onChange={e => patch({ [cat.key]: e.target.checked })}
/>
<span className="tsd-toggle-track" />
</label>
</div>
{catOn && (
<div className="tsd-filter-items">
{items.map(item => {
const checked = Boolean(filters[item.requestKey]);
const disabled = item.mode === 'unsupported';
return (
<div key={item.id} className={`tsd-filter-item${disabled ? ' tsd-filter-item--disabled' : ''}`}>
<div className="tsd-filter-item-row">
<label className="tsd-filter-item-check">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={e => handleToggleCondition(item.requestKey, e.target.checked, item.id, item.mode)}
/>
<span className="tsd-filter-item-label">{item.label}</span>
{modeBadge(item.mode)}
</label>
{!disabled && (
<span className="tsd-filter-item-desc-inline" title={item.desc}>
{item.desc}
</span>
)}
</div>
{item.id === 'newHighCount' && checked && (
<div className="tsd-filter-params">
<span>N=</span>
<input
type="number"
className="tsd-num"
value={filters.newHighDays}
min={5}
max={60}
onChange={e => patch({ newHighDays: Number(e.target.value) })}
/>
<span></span>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<select
className="tsd-sort-select"
value={sortOptions.some(o => o.id === filters.sortBy) ? filters.sortBy : sortOptions[0]?.id ?? 'near52wHigh'}
onChange={e => patch({ sortBy: e.target.value })}
>
{sortOptions.length === 0 && <option value="near52wHigh">52 </option>}
{sortOptions.map(o => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
</div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-tf-grid">
{TREND_TIMEFRAMES.map(tf => (
<button
key={tf}
type="button"
className={`tsd-tf-btn${filters.timeframe === tf ? ' tsd-tf-btn--on' : ''}`}
onClick={() => patch({ timeframe: tf })}
>
{tfLabel(tf)}
</button>
))}
</div>
</div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-limit-row">
<input
type="range"
className="tsd-slider"
min={5}
max={50}
value={filters.limit}
onChange={e => patch({ limit: Number(e.target.value) })}
/>
<span className="tsd-limit-val">{filters.limit}</span>
</div>
</div>
<button type="button" className="tsd-search-btn" disabled={searching} onClick={onSearch}>
{searching ? '스캔 중…' : '조건 검색 실행'}
</button>
</div>
);
};
export default TrendSearchFilterPanel;
@@ -0,0 +1,132 @@
import React, { useMemo } from 'react';
import { getKoreanName } from '../../utils/marketNameCache';
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
import { tfLabelShort } from '../../utils/trendSearchMetrics';
import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { TickerData } from '../../hooks/useMarketTicker';
import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
import TrendSearchCardChart from './TrendSearchCardChart';
export type TrendSearchDisplayMode = 'summary' | 'chart';
interface Props {
result: TrendSearchResultDto;
timeframe: string;
displayMode: TrendSearchDisplayMode;
selected?: boolean;
onSelect?: () => void;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
ticker?: TickerData;
updatedAt?: number;
flash?: boolean;
}
function resultToTicker(result: TrendSearchResultDto): TickerData {
const rate = result.changeRate / 100;
return {
market: result.market,
koreanName: result.koreanName,
tradePrice: result.currentPrice,
changeRate: rate,
changePrice: null,
accTradePrice24: 0,
accTradeVolume24: null,
openingPrice: null,
highPrice: null,
lowPrice: null,
change: result.changeRate > 0 ? 'RISE' : result.changeRate < 0 ? 'FALL' : 'EVEN',
};
}
const TrendSearchResultCard: React.FC<Props> = ({
result,
timeframe,
displayMode,
selected = false,
onSelect,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
ticker,
updatedAt,
flash = false,
}) => {
const ko = result.koreanName || getKoreanName(result.market);
const sym = result.market.replace(/^KRW-/, '');
const isChart = displayMode === 'chart';
const quoteTicker = ticker ?? resultToTicker(result);
const flashCls = useMemo(() => {
if (!flash) return '';
return result.changeRate >= 0 ? 'tsd-card--flash-up' : 'tsd-card--flash-down';
}, [flash, result.changeRate]);
return (
<div
className={[
'vtd-card',
'vtd-card--signal',
'vtd-card--detail',
isChart ? 'vtd-card--chart-mode' : '',
selected ? 'vtd-card--selected' : '',
flashCls,
].filter(Boolean).join(' ')}
onClick={onSelect}
onKeyDown={e => {
if (onSelect && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onSelect();
}
}}
role={onSelect ? 'button' : undefined}
tabIndex={onSelect ? 0 : undefined}
aria-pressed={onSelect ? selected : undefined}
>
<div className="vtd-card-head">
<div className="vtd-card-head-main">
<div className="vtd-card-title">
<span className="vtd-card-ko">{ko}</span>
<span className="vtd-card-sym">{sym}</span>
</div>
<span className="vtd-card-tf"> {tfLabelShort(timeframe)}</span>
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
</div>
</div>
<div className="vtd-card-meta">
<VirtualTargetQuote market={result.market} ticker={quoteTicker} compact />
{!isChart && (
<span className="vtd-card-met-count">
{result.matchedCount}/{result.totalConditions}
</span>
)}
<VirtualLiveBadge status="live" receiving={flash} />
</div>
{isChart ? (
<TrendSearchCardChart
market={result.market}
timeframe={timeframe}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
/>
) : (
<TrendSearchCardSignalPanel
conditions={result.conditions}
matchRate={result.matchRate}
matchedCount={result.matchedCount}
totalConditions={result.totalConditions}
updatedAt={updatedAt}
/>
)}
</div>
);
};
export default TrendSearchResultCard;
@@ -0,0 +1,81 @@
import React from 'react';
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { TickerData } from '../../hooks/useMarketTicker';
import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard';
interface Props {
results: TrendSearchResultDto[];
timeframe: string;
displayMode: TrendSearchDisplayMode;
loading?: boolean;
selectedMarket?: string | null;
onSelect?: (row: TrendSearchResultDto) => void;
flashMarkets?: Set<string>;
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
tickers?: Map<string, TickerData>;
lastUpdatedAt?: number;
}
const TrendSearchResultsCardGrid: React.FC<Props> = ({
results,
timeframe,
displayMode,
loading,
selectedMarket,
onSelect,
flashMarkets,
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
tickers,
lastUpdatedAt,
}) => {
if (loading && results.length === 0) {
return (
<div className="vtd-grid-wrap">
<div className="vtd-grid-empty">
<p className="vtd-muted"> </p>
</div>
</div>
);
}
if (!loading && results.length === 0) {
return (
<div className="vtd-grid-wrap">
<div className="vtd-grid-empty">
<p className="vtd-muted"> . .</p>
</div>
</div>
);
}
return (
<div className="vtd-grid-wrap">
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}>
{results.map(row => (
<TrendSearchResultCard
key={row.market}
result={row}
timeframe={timeframe}
displayMode={displayMode}
selected={selectedMarket === row.market}
onSelect={onSelect ? () => onSelect(row) : undefined}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
ticker={tickers?.get(row.market)}
updatedAt={lastUpdatedAt}
flash={flashMarkets?.has(row.market)}
/>
))}
</div>
</div>
);
};
export default TrendSearchResultsCardGrid;
@@ -0,0 +1,111 @@
import React, { useMemo } from 'react';
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
interface Props {
results: TrendSearchResultDto[];
selectedMarket: string | null;
onSelect: (row: TrendSearchResultDto) => void;
loading?: boolean;
flashMarkets?: Set<string>;
}
function fmtPrice(n: number): string {
if (n >= 1_000_000) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (n >= 100) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
return n.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
}
function fmtPct(n: number): string {
const sign = n >= 0 ? '+' : '';
return `${sign}${n.toFixed(1)}%`;
}
function coinLabel(market: string, name?: string): string {
const code = market.replace(/^KRW-/, '');
return name ? `${code}/KRW` : `${code}/KRW`;
}
const TrendSearchResultsGrid: React.FC<Props> = ({
results,
selectedMarket,
onSelect,
loading,
flashMarkets,
}) => {
const avgMatch = useMemo(() => {
if (!results.length) return 0;
return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length);
}, [results]);
return (
<div className="tsd-results">
<div className="tsd-results-head">
<div>
<h3 className="tsd-panel-title"> </h3>
<p className="tsd-panel-sub">Search Condition Match Rate % Sorting · 100ms </p>
</div>
<div className="tsd-results-meta">
<span className="tsd-meta-chip"> {avgMatch}%</span>
<span className="tsd-meta-chip tsd-meta-chip--live"> LIVE</span>
</div>
</div>
<div className="tsd-results-table-wrap">
<table className="tsd-results-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading && results.length === 0 && (
<tr><td colSpan={5} className="tsd-empty"> </td></tr>
)}
{!loading && results.length === 0 && (
<tr><td colSpan={5} className="tsd-empty"> . .</td></tr>
)}
{results.map(row => {
const up = row.changeRate >= 0;
const active = selectedMarket === row.market;
const flash = flashMarkets?.has(row.market);
return (
<tr
key={row.market}
className={[
active ? 'tsd-row--sel' : '',
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
].filter(Boolean).join(' ')}
onClick={() => onSelect(row)}
>
<td className="tsd-col-symbol">
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
</td>
<td className="tsd-col-price">{fmtPrice(row.currentPrice)}</td>
<td className={`tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</td>
<td className="tsd-col-match">
<div className="tsd-match-bar-wrap">
<div className="tsd-match-bar">
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
</div>
<span className="tsd-match-pct">{row.matchRate}%</span>
</div>
</td>
<td className="tsd-col-badge">
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
export default TrendSearchResultsGrid;
@@ -0,0 +1,80 @@
import React from 'react';
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
import type { TrafficLightState } from '../../utils/virtualSignalMetrics';
interface Props {
conditions: TrendSearchConditionDto[];
matchRate: number;
matchedCount: number;
totalConditions: number;
}
function resolveTraffic(matchRate: number, conditions: TrendSearchConditionDto[]): TrafficLightState {
if (matchRate >= 100) return 'blue';
const hasPending = conditions.some(c => c.status === 'pending');
const hasPartial = conditions.some(c => c.status === 'match');
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
return 'red';
}
const TrendSearchSignalPanel: React.FC<Props> = ({
conditions,
matchRate,
matchedCount,
totalConditions,
}) => (
<div className="tsd-signal-panel">
<div className="tsd-signal-head">
<div className="tsd-signal-intel">
<span className="tsd-signal-label">Signal Intelligence</span>
<span className="tsd-signal-count">{matchedCount}/{totalConditions} </span>
</div>
<VirtualSignalTrafficLight state={resolveTraffic(matchRate, conditions)} matchRate={matchRate} />
</div>
<div className="tsd-compare-wrap">
<h4 className="tsd-compare-title">REAL-TIME INDICATOR COMPARISON</h4>
<table className="tsd-compare-table">
<thead>
<tr>
<th>TECHNICAL INDICATOR</th>
<th>CURRENT</th>
<th>THRESHOLD</th>
<th>PROGRESS</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{conditions.length === 0 ? (
<tr><td colSpan={5} className="tsd-empty"> </td></tr>
) : conditions.map(c => (
<tr key={c.id} className={`tsd-compare-row--${c.status}`}>
<td>{c.label}</td>
<td>{c.currentValue != null ? c.currentValue.toLocaleString() : '—'}</td>
<td>{c.thresholdLabel}</td>
<td>
<div className="tsd-progress-cell">
<div className="tsd-progress-bar">
<div
className={`tsd-progress-fill tsd-progress-fill--${c.status}`}
style={{ width: `${c.progress}%` }}
/>
</div>
<span>{c.progress}%</span>
</div>
</td>
<td>
<span className={`tsd-status tsd-status--${c.status}`}>
{c.status === 'match' ? 'Match' : c.status === 'pending' ? 'Pending' : 'Mismatch'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
export default TrendSearchSignalPanel;
@@ -0,0 +1,55 @@
import React from 'react';
import type { TrendSearchDisplayMode } from './TrendSearchResultCard';
interface Props {
displayMode: TrendSearchDisplayMode;
onDisplayModeChange: (mode: TrendSearchDisplayMode) => void;
autoRefresh: boolean;
onAutoRefreshChange: (v: boolean) => void;
searching?: boolean;
onRefresh: () => void;
resultCount?: number;
}
const TrendSearchViewHeaderControls: React.FC<Props> = ({
displayMode,
onDisplayModeChange,
autoRefresh,
onAutoRefreshChange,
searching,
onRefresh,
resultCount = 0,
}) => (
<div className="vtd-header-view tsd-header-view">
<div className="vtd-grid-head-group" role="group" aria-label="검색 결과 표시">
<div className="vtd-view-toggle">
<button
type="button"
className={`vtd-view-toggle-btn${displayMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onDisplayModeChange('summary')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${displayMode === 'chart' ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => onDisplayModeChange('chart')}
>
</button>
</div>
</div>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<span className="tsd-result-count">{resultCount}</span>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<label className="tsd-auto-refresh">
<input type="checkbox" checked={autoRefresh} onChange={e => onAutoRefreshChange(e.target.checked)} />
<span>3 </span>
</label>
<button type="button" className="tsd-refresh-btn" disabled={searching} onClick={onRefresh}>
</button>
</div>
);
export default TrendSearchViewHeaderControls;