추세검색 설정 추가

This commit is contained in:
Macbook
2026-05-27 01:36:06 +09:00
parent c1bcf88c6c
commit 2e08c6b16f
42 changed files with 1507 additions and 226 deletions
+109 -31
View File
@@ -18,10 +18,19 @@ import {
type TrendSearchResultDto,
} from '../utils/trendSearchApi';
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry';
import { useAppSettings } from '../hooks/useAppSettings';
import {
extractTrendSearchAppSettings,
mergeTrendSearchRequest,
resolveTrendSearchAppSettings,
type TrendSearchAppSettings,
} from '../utils/trendSearchAppSettings';
import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
const REFRESH_MS = 3000;
const DISPLAY_MODE_KEY = 'tsd-display-mode';
function loadDisplayMode(): TrendSearchDisplayMode {
@@ -45,7 +54,16 @@ const TrendSearchPage: React.FC<Props> = ({
chartSeriesPriceLabels = true,
tickers,
}) => {
const [filters, setFilters] = useState<TrendSearchRequest>(() => ({ ...DEFAULT_TREND_SEARCH_REQUEST }));
const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings();
const [pageSettings, setPageSettings] = useState<TrendSearchAppSettings>(() =>
resolveTrendSearchAppSettings(),
);
const [filters, setFilters] = useState<TrendSearchRequest>(() =>
mergeTrendSearchRequest(
{ ...DEFAULT_TREND_SEARCH_REQUEST },
resolveTrendSearchAppSettings(),
),
);
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
@@ -55,21 +73,50 @@ const TrendSearchPage: React.FC<Props> = ({
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters);
filtersRef.current = filters;
const pageSettingsRef = useRef(pageSettings);
pageSettingsRef.current = pageSettings;
const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry();
useEffect(() => {
if (!appSettingsLoaded) return;
const resolved = defaults.trendSearchSettings;
setPageSettings(resolved);
setFilters(prev => mergeTrendSearchRequest(prev, resolved));
}, [appSettingsLoaded, defaults.trendSearchSettings]);
const persistPageSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => {
const next = resolveTrendSearchAppSettings({ ...pageSettingsRef.current, ...patch });
setPageSettings(next);
pageSettingsRef.current = next;
saveAppSettings({ trendSearchSettings: next });
}, [saveAppSettings]);
const handleFiltersChange = useCallback((next: TrendSearchRequest) => {
setFilters(next);
const extracted = extractTrendSearchAppSettings(next);
persistPageSettings(extracted);
}, [persistPageSettings]);
const runSearch = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setSearching(true);
try {
const list = await scanTrendSearch(filtersRef.current);
setResults(sortTrendSearchByStrength(list));
const sorted = sortTrendSearchByStrength(list);
setResults(sorted);
setLastUpdatedAt(Date.now());
setSelectedMarket(prev => {
if (prev && list.some(r => r.market === prev)) return prev;
return list[0]?.market ?? null;
if (prev && sorted.some(r => r.market === prev)) return prev;
return sorted[0]?.market ?? null;
});
if (list.length > 0) {
setFlashMarkets(new Set(list.slice(0, 5).map(r => r.market)));
if (sorted.length > 0) {
setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market)));
setTimeout(() => setFlashMarkets(new Set()), 600);
}
await autoAddTrendSearchTargets(
sorted,
pageSettingsRef.current,
filtersRef.current.timeframe,
);
} catch (e) {
console.warn('[TrendSearch]', e);
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
@@ -78,13 +125,17 @@ const TrendSearchPage: React.FC<Props> = ({
}
}, []);
useEffect(() => { void runSearch(); }, []);
useEffect(() => {
if (!appSettingsLoaded) return;
void runSearch();
}, [appSettingsLoaded, runSearch]);
useEffect(() => {
if (!autoRefresh) return;
const id = window.setInterval(() => void runSearch({ silent: true }), REFRESH_MS);
const ms = Math.max(1, pageSettings.autoRefreshSeconds) * 1000;
const id = window.setInterval(() => void runSearch({ silent: true }), ms);
return () => clearInterval(id);
}, [autoRefresh, runSearch]);
}, [autoRefresh, pageSettings.autoRefreshSeconds, runSearch]);
useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
@@ -102,19 +153,54 @@ const TrendSearchPage: React.FC<Props> = ({
} 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) => {
persistPageSettings({ autoAddTargetsEnabled: enabled });
if (enabled && results.length > 0) {
void autoAddTrendSearchTargets(results, { ...pageSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe);
}
}, [persistPageSettings, results]);
const handleRefreshSecondsChange = useCallback((sec: number) => {
persistPageSettings({ autoRefreshSeconds: sec });
}, [persistPageSettings]);
return (
<>
<BuilderPageShell
theme={theme}
title="추세검색"
subtitle="Golden Analysis · Trend Search"
pageClassName="bps-page--tsd bps-page--vtd"
headerActions={(
<BuilderPageShell
theme={theme}
title="추세검색"
subtitle="Golden Analysis · Trend Search"
pageClassName="bps-page--tsd bps-page--vtd"
banner={searching ? (
<div className="tsd-search-loading" role="status" aria-live="polite" aria-busy="true">
<div className="tsd-search-loading-track" role="progressbar" aria-label="추세검색 스캔 중">
<div className="tsd-search-loading-fill" />
</div>
<span className="tsd-search-loading-label"> </span>
</div>
) : undefined}
headerActions={(
<TrendSearchViewHeaderControls
displayMode={displayMode}
onDisplayModeChange={setDisplayMode}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
refreshSeconds={pageSettings.autoRefreshSeconds}
onRefreshSecondsChange={handleRefreshSecondsChange}
autoAddTargets={pageSettings.autoAddTargetsEnabled}
onAutoAddTargetsChange={handleAutoAddToggle}
searching={searching}
onRefresh={() => void runSearch()}
resultCount={results.length}
@@ -127,7 +213,7 @@ const TrendSearchPage: React.FC<Props> = ({
left={(
<TrendSearchFilterPanel
filters={filters}
onChange={setFilters}
onChange={handleFiltersChange}
onSearch={() => void runSearch()}
searching={searching}
/>
@@ -145,22 +231,14 @@ const TrendSearchPage: React.FC<Props> = ({
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
lastUpdatedAt={lastUpdatedAt}
isInTargets={isInTargets}
isPinned={isPinned}
onAddTarget={handleAddTarget}
onRemoveTarget={handleRemoveTarget}
targetBusyMarket={busyMarket}
/>
)}
/>
{searching && (
<div className="tsd-search-overlay" role="status" aria-live="polite" aria-busy="true">
<div className="tsd-search-overlay-card">
<div className="loading-spinner tsd-search-spinner" />
<p className="tsd-search-overlay-text"> </p>
<div className="tsd-search-progress" aria-hidden="true">
<div className="tsd-search-progress-indeterminate" />
</div>
</div>
</div>
)}
</>
);
};