추세검색 화면 적용

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
+228 -7
View File
@@ -4110,24 +4110,27 @@ html.theme-blue {
.multi-chart-grid {
display: grid;
flex: 1;
/* 높이를 명시해야 fr 행이 계산된다 */
height: 100%;
min-height: 0;
min-width: 0;
gap: 2px;
background: var(--border, #2a2e3a);
gap: 12px;
padding: 8px 10px 12px;
background: var(--bg, #131722);
overflow: hidden;
align-content: stretch;
}
.multi-slot-wrap {
position: relative;
overflow: hidden;
background: var(--bg, #131722);
background: var(--panel, #1e222d);
min-width: 0;
min-height: 0;
/* grid stretch로 높이가 정해지므로 height 상속 허용 */
display: flex;
flex-direction: column;
border: 1px solid var(--se-border, var(--border, #2a2e3a));
border-radius: 12px;
transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out;
}
/* 개별 차트 슬롯 */
@@ -4135,16 +4138,234 @@ html.theme-blue {
display: flex;
flex-direction: column;
width: 100%;
/* 부모 multi-slot-wrap (flex col)이 flex:1을 주므로 이 쪽도 flex:1 */
flex: 1;
min-height: 0;
border: 2px solid transparent;
transition: border-color .15s;
transition: border-color .15s, box-shadow .15s;
}
.chart-slot.active {
border-color: var(--accent, #2962ff);
}
/* 멀티차트 컴팩트 — 가상투자 카드 차트 스타일 */
.chart-slot--compact {
border: none;
border-radius: 0;
min-height: 360px;
}
.chart-slot--compact.active {
border: none;
box-shadow: inset 0 0 0 1px var(--accent, #2962ff);
}
/* 멀티차트 추세색 — 가상투자 vtd-card--live / timing 과 동일하게 얇고 흐릿한 아웃라인 */
.multi-slot-wrap:has(.chart-slot--trend-up:not(.chart-slot--receiving)),
.chart-slot--compact.chart-slot--trend-up:not(.chart-slot--receiving) {
border-color: color-mix(in srgb, var(--up, #ff6b6b) 35%, var(--se-border, var(--border, #2a2e3a)));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--up, #ff6b6b) 12%, transparent);
}
.multi-slot-wrap:has(.chart-slot--trend-down:not(.chart-slot--receiving)),
.chart-slot--compact.chart-slot--trend-down:not(.chart-slot--receiving) {
border-color: color-mix(in srgb, var(--down, #4dabf7) 35%, var(--se-border, var(--border, #2a2e3a)));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--down, #4dabf7) 12%, transparent);
}
.chart-slot--compact.chart-slot--trend-up:not(.chart-slot--receiving) {
background: linear-gradient(
165deg,
color-mix(in srgb, var(--up, #ff6b6b) 10%, var(--panel, #1e222d)) 0%,
var(--panel, #1e222d) 55%
);
}
.chart-slot--compact.chart-slot--trend-down:not(.chart-slot--receiving) {
background: linear-gradient(
165deg,
color-mix(in srgb, var(--down, #4dabf7) 10%, var(--panel, #1e222d)) 0%,
var(--panel, #1e222d) 55%
);
}
.chart-slot--compact.chart-slot--trend-up.active:not(.chart-slot--receiving) {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--up, #ff6b6b) 45%, var(--accent, #2962ff));
}
.chart-slot--compact.chart-slot--trend-down.active:not(.chart-slot--receiving) {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--down, #4dabf7) 45%, var(--accent, #2962ff));
}
.chart-slot--compact.chart-slot--trend-up:not(.chart-slot--receiving) .slot-header--compact {
border-bottom-color: color-mix(in srgb, var(--up, #ff6b6b) 18%, var(--se-border, var(--border, #2a2e3a)));
}
.chart-slot--compact.chart-slot--trend-down:not(.chart-slot--receiving) .slot-header--compact {
border-bottom-color: color-mix(in srgb, var(--down, #4dabf7) 18%, var(--se-border, var(--border, #2a2e3a)));
}
/* 실시간 수신 — 가상투자 vtd-card--receiving 와 동일 형광 연두 */
.multi-slot-wrap:has(.chart-slot--receiving),
.chart-slot--compact.chart-slot--receiving {
border-color: #69f0ae !important;
box-shadow:
0 0 0 1px #69f0ae,
0 0 10px 4px #69f0ae66,
0 0 22px 8px #b9f6ca33,
inset 0 0 48px color-mix(in srgb, #b9f6ca 14%, transparent);
transition: box-shadow 0.15s ease-out, border-color 0.15s ease-out;
}
.chart-slot--compact.chart-slot--receiving {
background: linear-gradient(
165deg,
color-mix(in srgb, #b9f6ca 14%, #131722) 0%,
color-mix(in srgb, #69f0ae 5%, var(--panel, #1e222d)) 40%,
var(--panel, #1e222d) 65%
);
}
.chart-slot--compact.chart-slot--receiving.active {
box-shadow:
0 0 0 1px #69f0ae,
0 0 10px 4px #69f0ae66,
0 0 22px 8px #b9f6ca33,
inset 0 0 0 1px color-mix(in srgb, #69f0ae 55%, var(--accent, #2962ff));
}
.chart-slot--compact.chart-slot--receiving .slot-chart-wrap--compact {
box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent);
}
.slot-header--compact {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: 8px 10px;
min-height: unset;
border-bottom: 1px solid var(--border, #2a2e3a);
}
.slot-compact-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
width: 100%;
min-width: 0;
}
.slot-compact-name {
flex: 0 1 auto;
max-width: 38%;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text, #d1d4dc);
font-size: 13px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.slot-compact-name.open {
color: var(--accent, #2962ff);
}
.slot-compact-quote {
display: flex;
align-items: baseline;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
}
.slot-compact-price {
font-size: 13px;
font-weight: 700;
}
.slot-compact-change {
font-size: 11px;
font-weight: 600;
opacity: 0.92;
}
.slot-compact-tf {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.slot-compact-tf-label {
font-size: 10px;
color: var(--text-muted, #787b86);
flex-shrink: 0;
}
.slot-compact-tf-select {
appearance: none;
padding: 3px 22px 3px 8px;
border-radius: 6px;
border: 1px solid var(--border, #2a2e3a);
background: var(--bg, #131722)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23787b86' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E")
no-repeat right 6px center;
color: var(--text, #d1d4dc);
font-size: 11px;
font-weight: 600;
cursor: pointer;
min-width: 52px;
}
.slot-compact-tf-select:focus {
outline: none;
border-color: var(--accent, #2962ff);
}
.slot-compact-status {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
}
@media (max-width: 420px) {
.slot-compact-row {
row-gap: 6px;
}
.slot-compact-name {
max-width: 55%;
}
.slot-compact-status {
width: 100%;
justify-content: flex-end;
}
}
.chart-slot--compact .slot-chart-wrap--compact {
flex: 1;
min-height: 240px;
height: auto !important;
border: none !important;
border-radius: 0 !important;
border-top: none !important;
}
.chart-slot--compact .slot-chart-wrap--compact .chart-hover-toolbar {
display: none !important;
}
.chart-slot--compact .ws-badge {
font-size: 9px;
padding: 2px 6px;
}
/* 슬롯 미니 헤더 */
.slot-header {
display: flex;
+13
View File
@@ -70,6 +70,7 @@ import { BacktestHistoryPage } from './components/BacktestHistoryPage';
import SettingsPage from './components/SettingsPage';
import PaperTradingPage from './components/PaperTradingPage';
import VirtualTradingPage from './components/VirtualTradingPage';
import TrendSearchPage from './components/TrendSearchPage';
import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar';
@@ -85,6 +86,7 @@ import {
type BacktestSignal,
} from './utils/backendApi';
import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy';
import type { ChartRealtimeSource } from './hooks/useChartRealtimeData';
import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage';
import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
import { useIsMobile } from './hooks/useMediaQuery';
@@ -1628,6 +1630,15 @@ function App() {
/>
)}
{menuPage === 'trend-search' && (
<TrendSearchPage
theme={theme}
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
tickers={marketTickers}
/>
)}
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && (
<TradeNotificationListPage
@@ -1993,6 +2004,7 @@ function App() {
chartVolumeVisible={chartVolumeVisible}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
compactMode
/>
</div>
{/* 슬롯 1~7 */}
@@ -2028,6 +2040,7 @@ function App() {
chartVolumeVisible={chartVolumeVisible}
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
displayTimezone={displayTimezone}
compactMode
/>
</div>
))}
+159 -57
View File
@@ -20,6 +20,9 @@ import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/sma
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
import { isUpbitMarket } from '../utils/upbitApi';
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import { invalidateMarketCache } from '../utils/requestCache';
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
@@ -39,6 +42,24 @@ import type { ChartManager } from '../utils/ChartManager';
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
function compactKoName(market: string): string {
const ko = getKoreanName(market);
if (ko && ko !== market) return ko;
return market.replace(/^KRW-/, '');
}
function wsToLiveStatus(status: WsStatus): VirtualLiveStatus {
switch (status) {
case 'connected': return 'live';
case 'connecting': return 'connecting';
case 'disconnected':
case 'error':
return 'disconnected';
default:
return 'idle';
}
}
// ── Ws 배지 ───────────────────────────────────────────────────────────────
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
if (!isUpbit) return null;
@@ -139,6 +160,8 @@ export interface ChartSlotProps {
chartVolumeVisible?: boolean;
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
displayTimezone?: string;
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
compactMode?: boolean;
}
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
@@ -159,6 +182,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartVolumeVisible = true,
chartRealtimeSource = 'BACKEND_STOMP',
displayTimezone,
compactMode = false,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
@@ -489,6 +513,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
const receiveSignal = useMemo(() => {
if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null;
return `${latestBar.time}|${latestBar.close}|${latestBar.volume}`;
}, [useUpbit, wsStatus, latestBar]);
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
@@ -564,36 +595,127 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setShowMarket(false);
}, []);
const displayKoName = compactKoName(symbol);
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
return (
<div
className={`chart-slot${active ? ' active' : ''}`}
className={[
'chart-slot',
active ? 'active' : '',
compactMode ? 'chart-slot--compact' : '',
receiving ? 'chart-slot--receiving' : '',
trendUp ? 'chart-slot--trend-up' : '',
trendDown ? 'chart-slot--trend-down' : '',
].filter(Boolean).join(' ')}
onClick={onActivate}
>
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */}
<div className="slot-header" onClick={e => e.stopPropagation()}>
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */}
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
{getKoreanName(symbol) !== symbol
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
: <span className="slot-sym-kr">{symbol}</span>
}
</button>
{/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */}
<div
className={`slot-header${compactMode ? ' slot-header--compact' : ''}`}
onClick={e => e.stopPropagation()}
>
{compactMode ? (
<div className="slot-compact-row">
<button
type="button"
className={`slot-compact-name${showMarket ? ' open' : ''}`}
title={`${displayKoName} — 종목 변경`}
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
{displayKoName}
</button>
{currentBar && (
<div className="slot-compact-quote" aria-label="현재가">
<span
className="slot-compact-price"
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
>
{formatPrice(currentBar.close)}
</span>
<span
className="slot-compact-change"
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
>
{isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}%
</span>
</div>
)}
<label className="slot-compact-tf" onClick={e => e.stopPropagation()}>
<span className="slot-compact-tf-label"></span>
<select
className="slot-compact-tf-select"
value={timeframe}
aria-label="시간봉 선택"
onChange={e => setTimeframe(e.target.value as Timeframe)}
>
{TF_OPTIONS.map(tf => (
<option key={tf} value={tf}>{tf}</option>
))}
</select>
</label>
<div className="slot-compact-status">
{isLoading && <span className="slot-loading"></span>}
{useUpbit && (
<VirtualLiveBadge status={wsToLiveStatus(wsStatus)} receiving={receiving} />
)}
</div>
</div>
) : (
<>
<button
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
title="종목 변경"
onClick={() => {
if (!showMarket) {
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
}
setShowMarket(v => !v);
}}
>
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
</svg>
{getKoreanName(symbol) !== symbol
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
: <span className="slot-sym-kr">{symbol}</span>
}
</button>
<div className="slot-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)}
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
</span>
</span>
)}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>}
</>
)}
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
{showMarket && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={symbol}
@@ -603,44 +725,22 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
/>,
document.body,
)}
{/* 타임프레임 선택 */}
<div className="slot-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{/* 현재가 */}
{currentBar && (
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{formatPrice(currentBar.close)}
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
</span>
</span>
)}
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
{isLoading && <span className="slot-loading">...</span>}
</div>
{/* ── TradingChart ─────────────────────────────────────────────────── */}
<div
ref={slotContainerRef}
className="slot-chart-wrap"
className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'}
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
>
{useUpbit && isLoading && (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
compactMode ? (
<div className="vtd-card-chart-loading"> </div>
) : (
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
<div className="loading-spinner" />
</div>
)
)}
{isLoadingMore && (
<div className="chart-history-loading">
@@ -663,8 +763,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
drawingTool="cursor"
drawings={drawings}
logScale={false}
drawingsLocked={false}
drawingsVisible={true}
drawingsLocked={compactMode}
drawingsVisible={!compactMode}
showHoverToolbar={!compactMode}
volumeVisible={chartVolumeVisible}
onCrosshair={data => {
setLegend(data);
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
+10 -1
View File
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
interface TopMenuBarProps {
activePage: MenuPage;
@@ -137,11 +137,20 @@ const IcVirtual = () => (
</svg>
);
const IcTrendSearch = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="7" r="4.5"/>
<path d="M10.5 10.5L14 14"/>
<path d="M5 7h4M7 5v4"/>
</svg>
);
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
{ page: 'virtual', label: '가상투자', icon: <IcVirtual /> },
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
+151
View File
@@ -0,0 +1,151 @@
/**
* GoldenChart 암호화폐 추세검색 (Trend Search)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import BuilderPageShell from './layout/BuilderPageShell';
import TrendSearchFilterPanel from './trendSearch/TrendSearchFilterPanel';
import TrendSearchResultsCardGrid from './trendSearch/TrendSearchResultsCardGrid';
import TrendSearchViewHeaderControls from './trendSearch/TrendSearchViewHeaderControls';
import type { TrendSearchDisplayMode } from './trendSearch/TrendSearchResultCard';
import type { Theme } from '../types';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import type { TickerData } from '../hooks/useMarketTicker';
import {
DEFAULT_TREND_SEARCH_REQUEST,
scanTrendSearch,
fetchTrendSearchDetail,
type TrendSearchRequest,
type TrendSearchResultDto,
} from '../utils/trendSearchApi';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
const REFRESH_MS = 3000;
const DISPLAY_MODE_KEY = 'tsd-display-mode';
function loadDisplayMode(): TrendSearchDisplayMode {
try {
const v = localStorage.getItem(DISPLAY_MODE_KEY);
if (v === 'chart' || v === 'summary') return v;
} catch { /* ignore */ }
return 'summary';
}
interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
chartSeriesPriceLabels?: boolean;
tickers?: Map<string, TickerData>;
}
const TrendSearchPage: React.FC<Props> = ({
theme = 'dark',
chartRealtimeSource = 'BACKEND_STOMP',
chartSeriesPriceLabels = true,
tickers,
}) => {
const [filters, setFilters] = useState<TrendSearchRequest>(() => ({ ...DEFAULT_TREND_SEARCH_REQUEST }));
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters);
filtersRef.current = filters;
const runSearch = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setSearching(true);
try {
const list = await scanTrendSearch(filtersRef.current);
setResults(list);
setLastUpdatedAt(Date.now());
setSelectedMarket(prev => {
if (prev && list.some(r => r.market === prev)) return prev;
return list[0]?.market ?? null;
});
if (list.length > 0) {
setFlashMarkets(new Set(list.slice(0, 5).map(r => r.market)));
setTimeout(() => setFlashMarkets(new Set()), 600);
}
} catch (e) {
console.warn('[TrendSearch]', e);
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
} finally {
if (!opts?.silent) setSearching(false);
}
}, []);
useEffect(() => { void runSearch(); }, []);
useEffect(() => {
if (!autoRefresh) return;
const id = window.setInterval(() => void runSearch({ silent: true }), REFRESH_MS);
return () => clearInterval(id);
}, [autoRefresh, runSearch]);
useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
}, [displayMode]);
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
setSelectedMarket(row.market);
try {
const detail = await fetchTrendSearchDetail(row.market, filters.timeframe);
if (detail) {
setResults(prev => prev.map(r => (r.market === detail.market ? detail : r)));
}
} catch { /* keep row */ }
}, [filters.timeframe]);
return (
<BuilderPageShell
theme={theme}
title="추세검색"
subtitle="Golden Analysis · Trend Search"
pageClassName="bps-page--tsd bps-page--vtd"
headerActions={(
<TrendSearchViewHeaderControls
displayMode={displayMode}
onDisplayModeChange={setDisplayMode}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
searching={searching}
onRefresh={() => void runSearch()}
resultCount={results.length}
/>
)}
leftStorageKey="tsd-left-width"
leftDefaultWidth={320}
leftCollapsedStorageKey="tsd-left-open"
collapsiblePanels
left={(
<TrendSearchFilterPanel
filters={filters}
onChange={setFilters}
onSearch={() => void runSearch()}
searching={searching}
/>
)}
center={(
<TrendSearchResultsCardGrid
results={results}
timeframe={filters.timeframe}
displayMode={displayMode}
loading={searching}
selectedMarket={selectedMarket}
onSelect={row => void handleSelect(row)}
flashMarkets={flashMarkets}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
lastUpdatedAt={lastUpdatedAt}
/>
)}
/>
);
};
export default TrendSearchPage;
@@ -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;
@@ -0,0 +1,805 @@
/* GoldenChart 추세검색 — #131722 / #FFD700 테마 */
.bps-page--tsd {
--tsd-bg: #131722;
--tsd-gold: #ffd700;
--tsd-gold-dim: color-mix(in srgb, #ffd700 65%, #131722);
--tsd-match: #26a69a;
--tsd-mismatch: #ef5350;
--tsd-pending: #ffd700;
--tsd-card: color-mix(in srgb, #1e222d 92%, #131722);
--tsd-border: color-mix(in srgb, #ffd700 12%, #2a2e39);
--tsd-text: #d1d4dc;
--tsd-muted: #787b86;
background: var(--tsd-bg);
}
.bps-page--tsd .bps-header-title {
color: var(--tsd-gold);
letter-spacing: 0.02em;
}
.bps-page--tsd .bps-header-sub {
color: var(--tsd-muted);
font-size: 11px;
}
.bps-page--tsd .bps-left,
.bps-page--tsd .bps-center,
.bps-page--tsd .bps-right {
background: var(--tsd-card);
border-color: var(--tsd-border);
}
.bps-page--tsd .bps-right {
min-width: 380px;
}
/* Header actions */
.tsd-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.tsd-auto-refresh {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--tsd-muted);
cursor: pointer;
}
.tsd-auto-refresh input {
accent-color: var(--tsd-gold);
}
.tsd-refresh-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--tsd-gold) 35%, transparent);
background: color-mix(in srgb, var(--tsd-gold) 8%, transparent);
color: var(--tsd-gold);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.tsd-refresh-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--tsd-gold) 18%, transparent);
}
.tsd-refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Left filter panel */
.tsd-filter-panel {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
}
.tsd-panel-head {
display: flex;
gap: 10px;
align-items: flex-start;
padding-bottom: 10px;
border-bottom: 1px solid var(--tsd-border);
}
.tsd-panel-icon {
font-size: 18px;
color: var(--tsd-gold);
}
.tsd-panel-title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--tsd-gold);
}
.tsd-panel-sub {
margin: 4px 0 0;
font-size: 10px;
color: var(--tsd-muted);
}
.tsd-filter-cat {
border: 1px solid var(--tsd-border);
border-radius: 10px;
background: color-mix(in srgb, var(--tsd-bg) 40%, transparent);
overflow: hidden;
}
.tsd-filter-cat--on {
border-color: color-mix(in srgb, var(--tsd-gold) 35%, var(--tsd-border));
}
.tsd-filter-cat-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: color-mix(in srgb, var(--tsd-gold) 4%, transparent);
}
.tsd-filter-cat-en {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--tsd-gold);
}
.tsd-filter-cat-ko {
display: block;
font-size: 10px;
color: var(--tsd-muted);
}
.tsd-toggle {
position: relative;
width: 36px;
height: 20px;
cursor: pointer;
}
.tsd-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.tsd-toggle-track {
position: absolute;
inset: 0;
border-radius: 10px;
background: #3a3f4b;
transition: background 0.2s;
}
.tsd-toggle-track::before {
content: '';
position: absolute;
width: 14px;
height: 14px;
left: 3px;
top: 3px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.tsd-toggle input:checked + .tsd-toggle-track {
background: color-mix(in srgb, var(--tsd-gold) 75%, #3a3f4b);
}
.tsd-toggle input:checked + .tsd-toggle-track::before {
transform: translateX(16px);
}
.tsd-filter-items {
padding: 8px 12px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tsd-filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.tsd-filter-item-check {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
max-width: 48%;
font-size: 11px;
color: var(--tsd-text);
cursor: pointer;
}
.tsd-filter-item-check input {
accent-color: var(--tsd-gold);
flex-shrink: 0;
}
.tsd-filter-item-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.tsd-filter-item-desc-inline {
flex: 1;
min-width: 0;
font-size: 9px;
color: var(--tsd-muted);
line-height: 1.35;
text-align: right;
}
.tsd-filter-item-text {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tsd-filter-item-label {
line-height: 1.3;
}
.tsd-filter-item-desc {
margin: 0;
padding-left: 22px;
font-size: 9px;
color: var(--tsd-muted);
line-height: 1.35;
}
.tsd-filter-item--disabled {
opacity: 0.45;
}
.tsd-cond-badge {
font-size: 8px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.tsd-cond-badge--sort {
color: var(--tsd-gold);
border: 1px solid color-mix(in srgb, var(--tsd-gold) 40%, transparent);
}
.tsd-cond-badge--filter {
color: #6eb5ff;
border: 1px solid color-mix(in srgb, #6eb5ff 40%, transparent);
}
.tsd-cond-badge--na {
color: var(--tsd-muted);
border: 1px solid var(--tsd-border);
}
.tsd-sort-select {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--tsd-border);
background: var(--tsd-bg);
color: var(--tsd-text);
font-size: 11px;
}
.tsd-filter-params {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding-left: 22px;
}
.tsd-num {
width: 42px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid var(--tsd-border);
background: var(--tsd-bg);
color: var(--tsd-text);
font-size: 10px;
text-align: center;
}
.tsd-param-sep {
color: var(--tsd-muted);
align-self: center;
}
.tsd-filter-section-title {
margin: 0 0 8px;
font-size: 10px;
font-weight: 600;
color: var(--tsd-gold-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tsd-tf-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.tsd-tf-btn {
padding: 5px 4px;
border-radius: 6px;
border: 1px solid var(--tsd-border);
background: transparent;
color: var(--tsd-muted);
font-size: 9px;
cursor: pointer;
}
.tsd-tf-btn--on {
border-color: var(--tsd-gold);
background: color-mix(in srgb, var(--tsd-gold) 15%, transparent);
color: var(--tsd-gold);
font-weight: 700;
}
.tsd-limit-row {
display: flex;
align-items: center;
gap: 10px;
}
.tsd-slider {
flex: 1;
accent-color: var(--tsd-gold);
}
.tsd-limit-val {
min-width: 24px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: var(--tsd-gold);
}
.tsd-search-btn {
margin-top: auto;
padding: 10px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, color-mix(in srgb, var(--tsd-gold) 90%, #fff), var(--tsd-gold-dim));
color: #131722;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.tsd-search-btn:disabled {
opacity: 0.6;
cursor: wait;
}
/* Center stack */
.tsd-center-stack {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 0;
}
.tsd-results {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 10px 12px;
}
.tsd-results-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.tsd-results-meta {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.tsd-meta-chip {
padding: 3px 8px;
border-radius: 6px;
font-size: 9px;
font-weight: 600;
background: color-mix(in srgb, var(--tsd-gold) 10%, transparent);
color: var(--tsd-gold-dim);
border: 1px solid var(--tsd-border);
}
.tsd-meta-chip--live {
color: var(--tsd-match);
border-color: color-mix(in srgb, var(--tsd-match) 30%, transparent);
}
.tsd-results-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--tsd-border);
border-radius: 10px;
}
.tsd-results-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.tsd-results-table thead {
position: sticky;
top: 0;
z-index: 1;
background: color-mix(in srgb, var(--tsd-bg) 95%, var(--tsd-gold));
}
.tsd-results-table th {
padding: 8px 10px;
text-align: left;
font-size: 9px;
font-weight: 600;
color: var(--tsd-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: 1px solid var(--tsd-border);
}
.tsd-results-table td {
padding: 9px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 60%, transparent);
color: var(--tsd-text);
}
.tsd-results-table tbody tr {
cursor: pointer;
transition: background 0.15s;
}
.tsd-results-table tbody tr:hover {
background: color-mix(in srgb, var(--tsd-gold) 6%, transparent);
}
.tsd-row--sel td {
background: color-mix(in srgb, var(--tsd-gold) 12%, transparent) !important;
box-shadow: inset 3px 0 0 var(--tsd-gold);
}
.tsd-row--flash-up td {
animation: tsd-flash-up 0.55s ease;
}
.tsd-row--flash-down td {
animation: tsd-flash-down 0.55s ease;
}
@keyframes tsd-flash-up {
0%, 100% { background: transparent; }
40% { background: color-mix(in srgb, var(--tsd-match) 25%, transparent); }
}
@keyframes tsd-flash-down {
0%, 100% { background: transparent; }
40% { background: color-mix(in srgb, var(--tsd-mismatch) 20%, transparent); }
}
.tsd-col-symbol .tsd-symbol {
font-weight: 700;
color: var(--tsd-text);
}
.tsd-col-price {
font-weight: 700;
color: var(--tsd-gold);
font-variant-numeric: tabular-nums;
}
.tsd-col-change.up { color: var(--tsd-match); font-weight: 600; }
.tsd-col-change.down { color: var(--tsd-mismatch); font-weight: 600; }
.tsd-match-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.tsd-match-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--tsd-mismatch) 15%, #2a2e39);
overflow: hidden;
min-width: 60px;
}
.tsd-match-bar-fill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--tsd-match), color-mix(in srgb, var(--tsd-gold) 40%, var(--tsd-match)));
}
.tsd-match-pct {
font-size: 10px;
font-weight: 700;
color: var(--tsd-match);
min-width: 32px;
}
.tsd-badge-high {
padding: 2px 8px;
border-radius: 4px;
font-size: 8px;
font-weight: 800;
letter-spacing: 0.06em;
background: var(--tsd-gold);
color: #131722;
}
.tsd-badge-mid {
padding: 2px 6px;
border-radius: 4px;
font-size: 8px;
font-weight: 700;
color: var(--tsd-match);
border: 1px solid color-mix(in srgb, var(--tsd-match) 40%, transparent);
}
.tsd-empty {
text-align: center;
padding: 24px !important;
color: var(--tsd-muted);
font-size: 11px;
}
/* Signal panel */
.tsd-signal-panel {
flex-shrink: 0;
border-top: 1px solid var(--tsd-border);
padding: 10px 12px 12px;
max-height: 42%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tsd-signal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.tsd-signal-label {
display: block;
font-size: 10px;
font-weight: 700;
color: var(--tsd-gold);
letter-spacing: 0.05em;
}
.tsd-signal-count {
font-size: 10px;
color: var(--tsd-muted);
}
.tsd-compare-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.tsd-compare-title {
margin: 0 0 6px;
font-size: 9px;
font-weight: 600;
color: var(--tsd-muted);
letter-spacing: 0.06em;
}
.tsd-compare-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
.tsd-compare-table th {
padding: 5px 6px;
text-align: left;
font-size: 8px;
color: var(--tsd-muted);
border-bottom: 1px solid var(--tsd-border);
}
.tsd-compare-table td {
padding: 5px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 50%, transparent);
color: var(--tsd-text);
}
.tsd-progress-cell {
display: flex;
align-items: center;
gap: 6px;
}
.tsd-progress-bar {
flex: 1;
height: 4px;
border-radius: 2px;
background: #2a2e39;
min-width: 40px;
}
.tsd-progress-fill {
height: 100%;
border-radius: 2px;
}
.tsd-progress-fill--match { background: var(--tsd-match); }
.tsd-progress-fill--pending { background: var(--tsd-pending); }
.tsd-progress-fill--mismatch { background: var(--tsd-mismatch); }
.tsd-status {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
}
.tsd-status--match { color: var(--tsd-match); }
.tsd-status--pending { color: var(--tsd-pending); }
.tsd-status--mismatch { color: var(--tsd-mismatch); }
/* Chart panel */
.tsd-chart-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tsd-chart-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--tsd-border);
flex-shrink: 0;
}
.tsd-chart-toolbar-title {
font-size: 11px;
font-weight: 700;
color: var(--tsd-gold);
}
.tsd-chart-tools {
display: flex;
gap: 4px;
}
.tsd-chart-tool-btn {
width: 28px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--tsd-border);
background: color-mix(in srgb, var(--tsd-bg) 60%, transparent);
color: var(--tsd-muted);
font-size: 12px;
cursor: pointer;
}
.tsd-chart-tool-btn:hover {
border-color: var(--tsd-gold);
color: var(--tsd-gold);
}
.tsd-chart-body {
flex: 1;
min-height: 0;
position: relative;
}
.tsd-chart-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--tsd-muted);
font-size: 12px;
z-index: 2;
pointer-events: none;
}
.tsd-chart-placeholder {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--tsd-muted);
font-size: 12px;
text-align: center;
}
.tsd-chart-placeholder span {
font-size: 36px;
opacity: 0.5;
}
/* Card grid (가상투자 vtd 스타일 재사용) */
.bps-page--tsd.bps-page--vtd .bps-center {
padding: 0;
background: var(--tsd-bg);
}
.tsd-header-view {
flex-wrap: wrap;
gap: 10px;
}
.tsd-result-count {
font-size: 11px;
color: var(--tsd-muted);
font-weight: 600;
}
.bps-page--tsd .vtd-grid-wrap {
container-type: inline-size;
container-name: vtd-wrap;
height: 100%;
}
.bps-page--tsd .vtd-grid--chart-mode {
grid-auto-rows: minmax(560px, auto);
}
.tsd-card-high-badge {
font-size: 8px;
font-weight: 700;
color: var(--tsd-gold);
border: 1px solid color-mix(in srgb, var(--tsd-gold) 45%, transparent);
border-radius: 4px;
padding: 1px 5px;
margin-left: 6px;
}
.tsd-card--flash-up {
animation: tsd-flash-up 0.55s ease-out;
}
.tsd-card--flash-down {
animation: tsd-flash-down 0.55s ease-out;
}
@keyframes tsd-flash-up {
from { box-shadow: inset 0 0 0 1px color-mix(in srgb, #26a69a 70%, transparent); }
to { box-shadow: none; }
}
@keyframes tsd-flash-down {
from { box-shadow: inset 0 0 0 1px color-mix(in srgb, #ef5350 70%, transparent); }
to { box-shadow: none; }
}
@media (max-width: 1280px) {
.bps-page--tsd .bps-right {
min-width: 300px;
}
.tsd-tf-grid {
grid-template-columns: repeat(3, 1fr);
}
}
+14 -3
View File
@@ -24,10 +24,17 @@ import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
import { resolveBbBandBackground } from './bollingerConfig';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
import { formatChartAxisPrice } from './dataGenerator';
import { formatLwcTime, formatLwcTickMark, DEFAULT_DISPLAY_TIMEZONE } from './timezone';
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
const MAIN_PRICE_FORMAT = {
type: 'custom' as const,
minMove: 0.001,
formatter: formatChartAxisPrice,
};
import {
getIchimokuPlotTitle,
isIchimokuCloudVisible,
@@ -378,26 +385,30 @@ export class ChartManager {
upColor: t.upColor, downColor: t.downColor,
borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
});
case 'bar':
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor });
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT });
case 'line':
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true });
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true, priceFormat: MAIN_PRICE_FORMAT });
case 'area':
return this.chart.addSeries(AreaSeries, {
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
priceFormat: MAIN_PRICE_FORMAT,
});
case 'baseline':
return this.chart.addSeries(BaselineSeries, {
baseValue: { type: 'price', price: 0 },
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
priceFormat: MAIN_PRICE_FORMAT,
});
default:
return this.chart.addSeries(CandlestickSeries, {
upColor: t.upColor, downColor: t.downColor,
borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
});
}
}
@@ -419,7 +430,7 @@ export class ChartManager {
const timeFormatter = (time: Time) =>
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
this.chart.applyOptions({
localization: { timeFormatter },
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
});
}
+109
View File
@@ -1124,3 +1124,112 @@ export async function pinCandleWatch(market: string, candleType: string): Promis
/* pin 실패해도 평가 API는 시도 */
}
}
// ── 추세검색 ─────────────────────────────────────────────────────────────────
export interface TrendSearchConditionDto {
id: string;
category: string;
label: string;
currentValue: number | null;
thresholdLabel: string;
progress: number;
status: 'match' | 'pending' | 'mismatch';
}
export interface TrendSearchResultDto {
market: string;
koreanName: string;
currentPrice: number;
changeRate: number;
matchRate: number;
matchedCount: number;
totalConditions: number;
highMatch: boolean;
conditions: TrendSearchConditionDto[];
}
export interface TrendSearchRequest {
timeframe: string;
limit: number;
scanLimit: number;
sortBy: string;
newHighDays: number;
priceEnabled: boolean;
volumeEnabled: boolean;
flowEnabled: boolean;
fundamentalEnabled: boolean;
near52wHigh: boolean;
ret3m: boolean;
ret6m: boolean;
maDeviation: boolean;
maFullAlign: boolean;
stage2: boolean;
relativeStrength: boolean;
newHighCount: boolean;
tradeAmount: boolean;
volVs5dAvg: boolean;
turnover: boolean;
bidAskRatio: boolean;
smartMoney: boolean;
instConsecutive: boolean;
rsiHigh: boolean;
macdPeak: boolean;
lightCredit: boolean;
earningsGrowth: boolean;
roe: boolean;
opMargin: boolean;
pegBelow1: boolean;
}
export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
timeframe: '1d',
limit: 20,
scanLimit: 80,
sortBy: 'near52wHigh',
newHighDays: 20,
priceEnabled: true,
volumeEnabled: true,
flowEnabled: true,
fundamentalEnabled: false,
near52wHigh: true,
ret3m: false,
ret6m: false,
maDeviation: true,
maFullAlign: true,
stage2: false,
relativeStrength: true,
newHighCount: false,
tradeAmount: false,
volVs5dAvg: true,
turnover: false,
bidAskRatio: false,
smartMoney: false,
instConsecutive: false,
rsiHigh: true,
macdPeak: false,
lightCredit: false,
earningsGrowth: false,
roe: false,
opMargin: false,
pegBelow1: false,
};
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSearchResultDto[]> {
return (await request<TrendSearchResultDto[]>('/trend-search/scan', {
method: 'POST',
body: JSON.stringify(body),
})) ?? [];
}
export async function fetchTrendSearchDetail(
market: string,
timeframe?: string,
): Promise<TrendSearchResultDto | null> {
const params = new URLSearchParams({ market });
if (timeframe) params.set('timeframe', timeframe);
return request<TrendSearchResultDto>(`/trend-search/detail?${params}`);
}
+9
View File
@@ -88,6 +88,15 @@ export function formatPrice(price: number): string {
return price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
}
/** 실시간 차트 우측 가격축·크로스헤어 라벨 (10만원 미만: 소수 3자리, 이상: 정수) */
export function formatChartAxisPrice(price: number): string {
if (!Number.isFinite(price)) return String(price);
if (Math.abs(price) >= 100_000) {
return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
return price.toLocaleString('en-US', { minimumFractionDigits: 3, maximumFractionDigits: 3 });
}
export function formatTime(ts: number, timeframe: Timeframe): string {
return formatUnixForChart(ts, timeframe);
}
+3 -2
View File
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
| 'paper' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'paper', 'virtual', 'strategy-editor', 'backtest', 'settings',
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
];
export const ALL_MENU_IDS = [
'dashboard', 'chart', 'paper', 'virtual', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
] as const;
@@ -31,6 +31,7 @@ export const MENU_LABELS: Record<string, string> = {
chart: '실시간차트',
paper: '모의투자',
virtual: '가상투자',
'trend-search': '추세검색',
strategy: '투자전략',
'strategy-editor': '전략편집기',
backtest: '백테스팅',
+20
View File
@@ -0,0 +1,20 @@
/**
* 추세검색 API
*/
import { scanTrendSearch as apiScan, fetchTrendSearchDetail as apiDetail } from './backendApi';
export type {
TrendSearchConditionDto,
TrendSearchResultDto,
TrendSearchRequest,
} from './backendApi';
export { DEFAULT_TREND_SEARCH_REQUEST, TREND_TIMEFRAMES } from './backendApi';
export async function scanTrendSearch(body: import('./backendApi').TrendSearchRequest) {
return apiScan(body);
}
export async function fetchTrendSearchDetail(market: string, timeframe?: string) {
return apiDetail(market, timeframe);
}
+240
View File
@@ -0,0 +1,240 @@
/** 추세검색 20개 조건 정의 */
export type TrendConditionMode = 'sort' | 'filter' | 'unsupported';
export interface TrendConditionDef {
id: string;
category: 'price' | 'volume' | 'flow' | 'fundamental';
categoryLabel: string;
label: string;
desc: string;
mode: TrendConditionMode;
requestKey: keyof import('./backendApi').TrendSearchRequest;
categoryKey: keyof Pick<
import('./backendApi').TrendSearchRequest,
'priceEnabled' | 'volumeEnabled' | 'flowEnabled' | 'fundamentalEnabled'
>;
}
export const TREND_CONDITION_DEFS: TrendConditionDef[] = [
{
id: 'near52wHigh',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '52주 신고가 근접률',
desc: '1년 최고가 대비 현재가 근접도 (높을수록 상단)',
mode: 'sort',
requestKey: 'near52wHigh',
categoryKey: 'priceEnabled',
},
{
id: 'ret3m',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '3개월 수익률',
desc: '최근 12주 누적 상승률',
mode: 'sort',
requestKey: 'ret3m',
categoryKey: 'priceEnabled',
},
{
id: 'ret6m',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '6개월 수익률',
desc: '최근 24주 누적 상승률',
mode: 'sort',
requestKey: 'ret6m',
categoryKey: 'priceEnabled',
},
{
id: 'maDeviation',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '이동평균 이격도 (20·60일)',
desc: '20·60일선 대비 주가 이격률',
mode: 'sort',
requestKey: 'maDeviation',
categoryKey: 'priceEnabled',
},
{
id: 'maFullAlign',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '이동평균 완전 정배열',
desc: '주가 > 5 > 20 > 60 > 120 > 200일선',
mode: 'filter',
requestKey: 'maFullAlign',
categoryKey: 'priceEnabled',
},
{
id: 'stage2',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: "마크 미너비니 Stage 2",
desc: '150·200일선 위 + 200일선 1개월↑',
mode: 'filter',
requestKey: 'stage2',
categoryKey: 'priceEnabled',
},
{
id: 'relativeStrength',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '상대강도 (RS vs BTC)',
desc: 'BTC 대비 초과 수익률',
mode: 'sort',
requestKey: 'relativeStrength',
categoryKey: 'priceEnabled',
},
{
id: 'newHighCount',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '최근 N일 신고가 달성 횟수',
desc: '최근 구간 신고가 경신 일수',
mode: 'sort',
requestKey: 'newHighCount',
categoryKey: 'priceEnabled',
},
{
id: 'tradeAmount',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '당일 거래대금',
desc: '24h 누적 거래대금',
mode: 'sort',
requestKey: 'tradeAmount',
categoryKey: 'volumeEnabled',
},
{
id: 'volVs5dAvg',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '5일 평균 대비 거래대금 증가율',
desc: '5일 평균 대비 당일 거래대금',
mode: 'sort',
requestKey: 'volVs5dAvg',
categoryKey: 'volumeEnabled',
},
{
id: 'turnover',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '거래량 회전율',
desc: '20일 평균 대비 당일 거래량 비율',
mode: 'sort',
requestKey: 'turnover',
categoryKey: 'volumeEnabled',
},
{
id: 'bidAskRatio',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '매수 대기 잔량 비율',
desc: '호가 매수잔량 / 매도잔량',
mode: 'sort',
requestKey: 'bidAskRatio',
categoryKey: 'volumeEnabled',
},
{
id: 'smartMoney',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: '스마트머니 누적 (OBV)',
desc: 'OBV 20일 누적 상승폭 (기관·외국인 대용)',
mode: 'sort',
requestKey: 'smartMoney',
categoryKey: 'flowEnabled',
},
{
id: 'instConsecutive',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: '연속 순매수 일수',
desc: '양봉+거래량 증가 연속 일수 (연기금·투신 대용)',
mode: 'sort',
requestKey: 'instConsecutive',
categoryKey: 'flowEnabled',
},
{
id: 'rsiHigh',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: 'RSI (상대강도지수)',
desc: 'RSI 14 — 높을수록 강한 모멘텀',
mode: 'sort',
requestKey: 'rsiHigh',
categoryKey: 'flowEnabled',
},
{
id: 'macdPeak',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: 'MACD 오실레이터 최고치',
desc: '최근 20일 MACD 히스토그램 신고가',
mode: 'sort',
requestKey: 'macdPeak',
categoryKey: 'flowEnabled',
},
{
id: 'lightCredit',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: '거래량 감소 + 주가 상승',
desc: '매물 소화 후 가벼운 상승 (신용잔고↓ 대용)',
mode: 'filter',
requestKey: 'lightCredit',
categoryKey: 'flowEnabled',
},
{
id: 'earningsGrowth',
category: 'fundamental',
categoryLabel: 'Ⅳ. 펀더멘털',
label: '분기 영업이익 성장률 (YoY)',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'earningsGrowth',
categoryKey: 'fundamentalEnabled',
},
{
id: 'roe',
category: 'fundamental',
categoryLabel: 'Ⅳ. 펀더멘털',
label: 'ROE (자기자본이익률)',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'roe',
categoryKey: 'fundamentalEnabled',
},
{
id: 'opMargin',
category: 'fundamental',
categoryLabel: 'Ⅳ. 펀더멘털',
label: '매출액 영업이익률',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'opMargin',
categoryKey: 'fundamentalEnabled',
},
{
id: 'pegBelow1',
category: 'fundamental',
categoryLabel: 'Ⅳ. 펀더멘털',
label: 'PEG 1.0 이하',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'pegBelow1',
categoryKey: 'fundamentalEnabled',
},
];
export const TREND_SORT_CONDITION_IDS = TREND_CONDITION_DEFS
.filter(c => c.mode === 'sort')
.map(c => c.id);
export const TREND_CATEGORY_ORDER = [
{ key: 'priceEnabled' as const, label: '. 가격·탄력성', cat: 'price' as const },
{ key: 'volumeEnabled' as const, label: 'Ⅱ. 거래량·대금', cat: 'volume' as const },
{ key: 'flowEnabled' as const, label: 'Ⅲ. 수급·심리', cat: 'flow' as const },
{ key: 'fundamentalEnabled' as const, label: 'Ⅳ. 펀더멘털', cat: 'fundamental' as const },
];
+44
View File
@@ -0,0 +1,44 @@
import type { TrendSearchConditionDto } from './trendSearchApi';
import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics';
import type { VirtualConditionRow } from './virtualStrategyConditions';
export function buildTrendSearchMetrics(conditions: TrendSearchConditionDto[]): ConditionMetric[] {
return conditions.map(c => {
const status: ConditionStatus = c.status === 'match'
? 'match'
: c.status === 'pending'
? 'pending'
: 'nomatch';
const row: VirtualConditionRow & { currentValue: number | null } = {
id: c.id,
indicatorType: c.category,
displayName: c.label,
conditionType: 'GTE',
conditionLabel: c.label,
targetValue: null,
timeframe: '1d',
side: 'buy',
plotKey: c.id,
satisfied: c.status === 'match' ? true : c.status === 'mismatch' ? false : null,
thresholdLabel: c.thresholdLabel,
currentValue: c.currentValue ?? null,
};
return {
row,
status,
matchRate: c.progress,
progress: c.progress,
};
});
}
export function tfLabelShort(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;
}