/** * 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, loadTrendSearchResults, fetchTrendSearchDetail, type TrendSearchRequest, type TrendSearchResultDto, } from '../utils/trendSearchApi'; import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics'; import { resolveVirtualTargetNames } from '../utils/virtualTargetNames'; import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry'; import { getAppSettingsCache, useAppSettings } from '../hooks/useAppSettings'; import { mergeFiltersFromTrendSearchSettings, mergeTrendSearchSettingsFromRequest, resolveTrendSearchAppSettings, trendSearchSettingsSignature, type TrendSearchAppSettings, } from '../utils/trendSearchAppSettings'; import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets'; import '../styles/trendSearchDashboard.css'; import '../styles/virtualTradingDashboard.css'; 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; } const TrendSearchPage: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, tickers, }) => { const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings(); const tsSettings = defaults.trendSearchSettings; const tsSettingsSig = trendSearchSettingsSignature(tsSettings); const [filters, setFilters] = useState(() => mergeFiltersFromTrendSearchSettings( { ...DEFAULT_TREND_SEARCH_REQUEST }, resolveTrendSearchAppSettings(), ), ); const [results, setResults] = useState([]); const [selectedMarket, setSelectedMarket] = useState(null); const [searching, setSearching] = useState(false); const [displayMode, setDisplayMode] = useState(() => loadDisplayMode()); const [flashMarkets, setFlashMarkets] = useState>(new Set()); const [lastUpdatedAt, setLastUpdatedAt] = useState(Date.now()); const filtersRef = useRef(filters); filtersRef.current = filters; const tsSettingsRef = useRef(tsSettings); tsSettingsRef.current = tsSettings; const lastSyncedSettingsSigRef = useRef(null); const schedulerSeededRef = useRef(false); const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry(); useEffect(() => { if (!appSettingsLoaded) return; if (lastSyncedSettingsSigRef.current === tsSettingsSig) return; lastSyncedSettingsSigRef.current = tsSettingsSig; setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings)); }, [appSettingsLoaded, tsSettingsSig, tsSettings]); useEffect(() => { if (!appSettingsLoaded || schedulerSeededRef.current) return; schedulerSeededRef.current = true; saveAppSettings({ trendSearchSettings: tsSettingsRef.current }); }, [appSettingsLoaded, saveAppSettings]); const persistTrendSettings = useCallback((patch: Partial) => { const base = resolveTrendSearchAppSettings( getAppSettingsCache().trendSearchSettings as Partial | undefined, ); const next = resolveTrendSearchAppSettings({ ...base, ...patch }); lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(next); saveAppSettings({ trendSearchSettings: next }); }, [saveAppSettings]); const handleFiltersChange = useCallback((next: TrendSearchRequest) => { setFilters(next); const base = resolveTrendSearchAppSettings( getAppSettingsCache().trendSearchSettings as Partial | undefined, ); const merged = mergeTrendSearchSettingsFromRequest(base, next); lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(merged); saveAppSettings({ trendSearchSettings: merged }); }, [saveAppSettings]); const applyResults = useCallback(async (sorted: TrendSearchResultDto[], scannedAtMs?: number) => { setResults(sorted); setLastUpdatedAt(scannedAtMs ?? Date.now()); setSelectedMarket(prev => { if (prev && sorted.some(r => r.market === prev)) return prev; return sorted[0]?.market ?? null; }); if (sorted.length > 0) { setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market))); setTimeout(() => setFlashMarkets(new Set()), 600); } await autoAddTrendSearchTargets( sorted, tsSettingsRef.current, filtersRef.current.timeframe, ); }, []); const loadFromDb = useCallback(async (opts?: { silent?: boolean }) => { try { const payload = await loadTrendSearchResults(); const sorted = sortTrendSearchByStrength(payload.results ?? []); const scannedAt = payload.scannedAt ? Date.parse(payload.scannedAt) : undefined; await applyResults(sorted, Number.isFinite(scannedAt) ? scannedAt : undefined); } catch (e) { console.warn('[TrendSearch] load results', e); if (!opts?.silent) window.alert('추세검색 결과를 불러오지 못했습니다. 백엔드 연결을 확인하세요.'); } }, [applyResults]); const runScan = useCallback(async () => { setSearching(true); try { const list = await scanTrendSearch(filtersRef.current); const sorted = sortTrendSearchByStrength(list); await applyResults(sorted); } catch (e) { console.warn('[TrendSearch] scan', e); window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.'); } finally { setSearching(false); } }, [applyResults]); useEffect(() => { if (!appSettingsLoaded) return; void loadFromDb({ silent: true }); }, [appSettingsLoaded, loadFromDb]); useEffect(() => { if (!tsSettings.autoRefreshEnabled) return; const ms = Math.max(1, tsSettings.autoRefreshSeconds) * 1000; const id = window.setInterval(() => void loadFromDb({ silent: true }), ms); return () => clearInterval(id); }, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]); 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 => sortTrendSearchByStrength( prev.map(r => (r.market === detail.market ? detail : r)), )); } } catch { /* keep row */ } }, [filters.timeframe]); const handleAddTarget = useCallback((row: TrendSearchResultDto) => { const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName); void add(row.market, { koreanName, englishName, candleType: filtersRef.current.timeframe, }); }, [add]); const handleRemoveTarget = useCallback((market: string) => { void remove(market); }, [remove]); const handleAutoAddToggle = useCallback((enabled: boolean) => { persistTrendSettings({ autoAddTargetsEnabled: enabled }); if (enabled && results.length > 0) { void autoAddTrendSearchTargets(results, { ...tsSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe); } }, [persistTrendSettings, results]); const handleAutoRefreshToggle = useCallback((enabled: boolean) => { persistTrendSettings({ autoRefreshEnabled: enabled }); }, [persistTrendSettings]); const handleRefreshSecondsChange = useCallback((sec: number) => { persistTrendSettings({ autoRefreshSeconds: sec }); }, [persistTrendSettings]); return (
추세검색 스캔 중…
) : undefined} headerActions={( void loadFromDb()} resultCount={results.length} /> )} leftStorageKey="tsd-left-width" leftDefaultWidth={320} leftCollapsedStorageKey="tsd-left-open" collapsiblePanels left={( void runScan()} searching={searching} /> )} center={( void handleSelect(row)} flashMarkets={flashMarkets} theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} tickers={tickers} lastUpdatedAt={lastUpdatedAt} isInTargets={isInTargets} isPinned={isPinned} onAddTarget={handleAddTarget} onRemoveTarget={handleRemoveTarget} targetBusyMarket={busyMarket} /> )} /> ); }; export default TrendSearchPage;