추세검색 수정

This commit is contained in:
Macbook
2026-05-27 00:28:09 +09:00
parent fe812389cc
commit c1bcf88c6c
10 changed files with 631 additions and 287 deletions
+62 -46
View File
@@ -17,6 +17,7 @@ import {
type TrendSearchRequest,
type TrendSearchResultDto,
} from '../utils/trendSearchApi';
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
@@ -59,7 +60,7 @@ const TrendSearchPage: React.FC<Props> = ({
if (!opts?.silent) setSearching(true);
try {
const list = await scanTrendSearch(filtersRef.current);
setResults(list);
setResults(sortTrendSearchByStrength(list));
setLastUpdatedAt(Date.now());
setSelectedMarket(prev => {
if (prev && list.some(r => r.market === prev)) return prev;
@@ -94,57 +95,72 @@ const TrendSearchPage: React.FC<Props> = ({
try {
const detail = await fetchTrendSearchDetail(row.market, filters.timeframe);
if (detail) {
setResults(prev => prev.map(r => (r.market === detail.market ? detail : r)));
setResults(prev => sortTrendSearchByStrength(
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}
/>
<>
<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}
selectedMarket={selectedMarket}
onSelect={row => void handleSelect(row)}
flashMarkets={flashMarkets}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
lastUpdatedAt={lastUpdatedAt}
/>
)}
/>
{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>
)}
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}
/>
)}
/>
</>
);
};
@@ -1,13 +1,12 @@
import React from 'react';
import React, { useMemo } from 'react';
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
import {
TREND_CATEGORY_ORDER,
TREND_CONDITION_DEFS,
buildCategoryTogglePatch,
countActiveTrendFilters,
type TrendConditionDef,
} from '../../utils/trendSearchConditions';
BULLISH_WEIGHT_ITEMS,
adjustBullishWeight,
sumWeights,
type BullishWeightKey,
} from '../../utils/trendSearchBullishWeights';
interface Props {
filters: TrendSearchRequest;
@@ -25,148 +24,29 @@ function tfLabel(tf: string): string {
return tf;
}
function ConditionParams({
item,
filters,
patch,
}: {
item: TrendConditionDef;
filters: TrendSearchRequest;
patch: (p: Partial<TrendSearchRequest>) => void;
}) {
if (!item.params) return null;
switch (item.params) {
case 'maConvergencePct':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num"
value={filters.maConvergencePct}
min={0.5}
max={10}
step={0.5}
onChange={e => patch({ maConvergencePct: Number(e.target.value) })}
/>
<span>% </span>
</div>
);
case 'ma20SlopeBars':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num"
value={filters.ma20SlopeBars}
min={2}
max={5}
onChange={e => patch({ ma20SlopeBars: Number(e.target.value) })}
/>
<span> </span>
</div>
);
case 'newHighBreakout':
return (
<div className="tsd-filter-params">
<input
type="number"
className="tsd-num"
value={filters.newHighBreakoutDays}
min={5}
max={60}
onChange={e => patch({ newHighBreakoutDays: Number(e.target.value) })}
/>
<span> · -</span>
<input
type="number"
className="tsd-num"
value={filters.newHighNearPct}
min={0}
max={10}
step={0.5}
onChange={e => patch({ newHighNearPct: Number(e.target.value) })}
/>
<span>% </span>
</div>
);
case 'volumeVsPrior':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num"
value={filters.volumeVsPriorPct}
min={100}
max={500}
step={10}
onChange={e => patch({ volumeVsPriorPct: Number(e.target.value) })}
/>
<span>%</span>
</div>
);
case 'minTurnover':
return (
<div className="tsd-filter-params">
<span></span>
<input
type="number"
className="tsd-num tsd-num--wide"
value={Math.round(filters.minTurnover5dAvgKrw / 1e8)}
min={1}
max={10000}
onChange={e => patch({ minTurnover5dAvgKrw: Number(e.target.value) * 1e8 })}
/>
<span> </span>
</div>
);
case 'rsiBand':
return (
<div className="tsd-filter-params">
<input
type="number"
className="tsd-num"
value={filters.rsiMin}
min={30}
max={60}
onChange={e => patch({ rsiMin: Number(e.target.value) })}
/>
<span>&lt; RSI &lt;</span>
<input
type="number"
className="tsd-num"
value={filters.rsiMax}
min={60}
max={85}
onChange={e => patch({ rsiMax: Number(e.target.value) })}
/>
</div>
);
case 'adxMin':
return (
<div className="tsd-filter-params">
<span>ADX </span>
<input
type="number"
className="tsd-num"
value={filters.adxMin}
min={10}
max={50}
onChange={e => patch({ adxMin: Number(e.target.value) })}
/>
</div>
);
default:
return null;
}
}
const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => {
const patch = (p: Partial<TrendSearchRequest>) => onChange({ ...filters, ...p });
const activeCount = countActiveTrendFilters(filters);
const weights = useMemo(() => ({
weightMaAlignment: filters.weightMaAlignment,
weightMaSlope: filters.weightMaSlope,
weightAdxTrend: filters.weightAdxTrend,
weightMacdMomentum: filters.weightMacdMomentum,
weightPricePosition: filters.weightPricePosition,
}), [
filters.weightMaAlignment,
filters.weightMaSlope,
filters.weightAdxTrend,
filters.weightMacdMomentum,
filters.weightPricePosition,
]);
const weightTotal = sumWeights(Object.values(weights));
const handleWeightChange = (key: BullishWeightKey, value: number) => {
const next = adjustBullishWeight(weights, key, value);
patch(next);
};
return (
<div className="tsd-filter-panel">
@@ -175,62 +55,56 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
<div>
<h3 className="tsd-panel-title"> </h3>
<p className="tsd-panel-sub">
{activeCount > 0 ? ` · 조건 ${activeCount}` : ' · 조건 없음(추세 점수 순)'}
{filters.minTrendScore} · {weightTotal}
</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">
<div>
<span className="tsd-filter-cat-en">{cat.en}</span>
<span className="tsd-filter-cat-ko">{cat.label}</span>
</div>
<label className="tsd-toggle">
<input
type="checkbox"
checked={catOn}
onChange={e => patch(buildCategoryTogglePatch(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]);
return (
<div key={item.id} className="tsd-filter-item">
<div className="tsd-filter-item-row">
<label className="tsd-filter-item-check">
<input
type="checkbox"
checked={checked}
onChange={e => patch({ [item.requestKey]: e.target.checked })}
/>
<span className="tsd-filter-item-label">{item.label}</span>
<span className="tsd-cond-badge tsd-cond-badge--filter"></span>
</label>
<span className="tsd-filter-item-desc-inline" title={item.desc}>
{item.desc}
</span>
</div>
{checked && (
<ConditionParams item={item} filters={filters} patch={patch} />
)}
</div>
);
})}
</div>
)}
<div className="tsd-bullish-card">
<div className="tsd-bullish-card-head">
<div>
<span className="tsd-bullish-card-en">Bullish Trend Score</span>
<span className="tsd-bullish-card-ko"> </span>
</div>
);
})}
<span className="tsd-bullish-card-total">{weightTotal}</span>
</div>
<div className="tsd-bullish-items">
{BULLISH_WEIGHT_ITEMS.map(item => {
const value = weights[item.key];
return (
<div key={item.key} className="tsd-bullish-item">
<div className="tsd-bullish-item-head">
<span className="tsd-bullish-item-label">{item.label}</span>
<span className="tsd-bullish-item-desc" title={item.desc}>{item.desc}</span>
</div>
<div className="tsd-bullish-item-controls">
<input
type="range"
className="tsd-slider tsd-bullish-slider"
min={0}
max={100}
step={1}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점`}
/>
<input
type="number"
className="tsd-num tsd-bullish-score"
min={0}
max={100}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점 입력`}
/>
<span className="tsd-bullish-score-unit"></span>
</div>
</div>
);
})}
</div>
</div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
@@ -248,6 +122,22 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
</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={0}
max={100}
step={5}
value={filters.minTrendScore}
onChange={e => patch({ minTrendScore: Number(e.target.value) })}
/>
<span className="tsd-limit-val">{filters.minTrendScore}</span>
</div>
</div>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
<div className="tsd-limit-row">
@@ -56,10 +56,10 @@ const TrendSearchResultCard: React.FC<Props> = ({
updatedAt,
flash = false,
}) => {
const ko = result.koreanName || getKoreanName(result.market);
const sym = result.market.replace(/^KRW-/, '');
const isChart = displayMode === 'chart';
const en = result.market.replace(/^KRW-/, '');
const quoteTicker = ticker ?? resultToTicker(result);
const ko = quoteTicker.koreanName || getKoreanName(result.market) || en;
const isChart = displayMode === 'chart';
const flashCls = useMemo(() => {
if (!flash) return '';
@@ -91,9 +91,10 @@ const TrendSearchResultCard: React.FC<Props> = ({
<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>
<span className="vtd-card-sym">{en}</span>
</div>
<span className="vtd-card-tf"> {tfLabelShort(timeframe)}</span>
<span className="tsd-card-score">{result.matchRate}</span>
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
</div>
</div>
@@ -102,7 +103,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
<VirtualTargetQuote market={result.market} ticker={quoteTicker} compact />
{!isChart && (
<span className="vtd-card-met-count">
{result.matchedCount}/{result.totalConditions}
{result.matchedCount}/{result.totalConditions}
</span>
)}
<VirtualLiveBadge status="live" receiving={flash} />
@@ -1,5 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
import { sortTrendSearchByStrength } from '../../utils/trendSearchMetrics';
import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { TickerData } from '../../hooks/useMarketTicker';
@@ -34,6 +35,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
tickers,
lastUpdatedAt,
}) => {
const sortedResults = useMemo(
() => sortTrendSearchByStrength(results),
[results],
);
if (loading && results.length === 0) {
return (
<div className="vtd-grid-wrap">
@@ -57,7 +63,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
return (
<div className="vtd-grid-wrap">
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}>
{results.map(row => (
{sortedResults.map(row => (
<TrendSearchResultCard
key={row.market}
result={row}
@@ -123,6 +123,88 @@
color: var(--tsd-muted);
}
/* 상승추세 검색그룹 카드 */
.tsd-bullish-card {
border: 1px solid color-mix(in srgb, var(--tsd-gold) 35%, var(--tsd-border));
border-radius: 10px;
background: var(--tsd-filter-cat-bg);
overflow: hidden;
}
.tsd-bullish-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: color-mix(in srgb, var(--tsd-gold) 8%, transparent);
border-bottom: 1px solid var(--tsd-border);
}
.tsd-bullish-card-en {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--tsd-gold);
}
.tsd-bullish-card-ko {
display: block;
font-size: 10px;
color: var(--tsd-muted);
margin-top: 2px;
}
.tsd-bullish-card-total {
font-size: 13px;
font-weight: 800;
color: var(--tsd-gold);
white-space: nowrap;
}
.tsd-bullish-items {
display: flex;
flex-direction: column;
gap: 0;
padding: 6px 0;
}
.tsd-bullish-item {
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 70%, transparent);
}
.tsd-bullish-item:last-child {
border-bottom: none;
}
.tsd-bullish-item-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 6px;
}
.tsd-bullish-item-label {
font-size: 12px;
font-weight: 700;
color: var(--se-text, var(--text));
}
.tsd-bullish-item-desc {
font-size: 9px;
color: var(--tsd-muted);
line-height: 1.35;
}
.tsd-bullish-item-controls {
display: flex;
align-items: center;
gap: 8px;
}
.tsd-bullish-slider {
flex: 1;
min-width: 0;
}
.tsd-bullish-score {
width: 44px;
text-align: center;
flex-shrink: 0;
}
.tsd-bullish-score-unit {
font-size: 10px;
color: var(--tsd-muted);
flex-shrink: 0;
}
.tsd-filter-cat {
border: 1px solid var(--tsd-border);
border-radius: 10px;
@@ -825,6 +907,13 @@
display: none !important;
}
.tsd-card-score {
font-size: 11px;
font-weight: 700;
color: var(--tsd-gold);
margin-left: auto;
white-space: nowrap;
}
.tsd-card-high-badge {
font-size: 8px;
font-weight: 700;
@@ -861,3 +950,56 @@
grid-template-columns: repeat(3, 1fr);
}
}
/* 검색 실행 — 화면 중앙 백드롭 프로그레스 */
.tsd-search-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
.tsd-search-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 28px 40px;
min-width: 240px;
border-radius: 12px;
background: var(--se-bg-elevated, var(--bg2));
border: 1px solid var(--tsd-border);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
}
.tsd-search-spinner {
width: 36px;
height: 36px;
}
.tsd-search-overlay-text {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--tsd-text);
}
.tsd-search-progress {
width: 200px;
height: 4px;
border-radius: 2px;
background: var(--tsd-track-bg);
overflow: hidden;
}
.tsd-search-progress-indeterminate {
width: 45%;
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--tsd-gold), var(--se-accent, var(--accent)));
animation: tsd-search-progress-slide 1.1s ease-in-out infinite;
}
@keyframes tsd-search-progress-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
+27 -6
View File
@@ -1158,6 +1158,17 @@ export interface TrendSearchRequest {
sortBy: string;
minMatchRate?: number;
/** 상승추세 검색그룹 (가중 점수화) */
bullishTrendEnabled: boolean;
weightMaAlignment: number;
weightMaSlope: number;
weightAdxTrend: number;
weightMacdMomentum: number;
weightPricePosition: number;
minTrendScore: number;
emaSlopeLookback: number;
adxTrendMin: number;
maAlignEnabled: boolean;
trendEnabled: boolean;
volumePowerEnabled: boolean;
@@ -1196,19 +1207,29 @@ export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
sortBy: 'matchRate',
minMatchRate: 0,
maAlignEnabled: true,
trendEnabled: true,
bullishTrendEnabled: true,
weightMaAlignment: 30,
weightMaSlope: 15,
weightAdxTrend: 25,
weightMacdMomentum: 15,
weightPricePosition: 15,
minTrendScore: 70,
emaSlopeLookback: 5,
adxTrendMin: 20,
maAlignEnabled: false,
trendEnabled: false,
volumePowerEnabled: false,
indicatorEnabled: false,
priceAboveMa: true,
maAlignment: true,
priceAboveMa: false,
maAlignment: false,
maConvergence: false,
maConvergencePct: 3,
ma20SlopeUp: true,
ma20SlopeUp: false,
ma20SlopeBars: 2,
newHighBreakout: true,
newHighBreakout: false,
newHighBreakoutDays: 20,
newHighNearPct: 5,
ichimokuAboveCloud: false,
@@ -0,0 +1,114 @@
/** 상승추세 검색그룹 — 5개 평가항목 배점 (합계 100 고정) */
export type BullishWeightKey =
| 'weightMaAlignment'
| 'weightMaSlope'
| 'weightAdxTrend'
| 'weightMacdMomentum'
| 'weightPricePosition';
export interface BullishWeightItem {
key: BullishWeightKey;
label: string;
desc: string;
}
export const BULLISH_WEIGHT_ITEMS: BullishWeightItem[] = [
{
key: 'weightMaAlignment',
label: '이평선 정배열',
desc: 'EMA(20) > EMA(60) > EMA(120)',
},
{
key: 'weightMaSlope',
label: '이평선 기울기',
desc: 'EMA(20) 현재 > 5봉 전',
},
{
key: 'weightAdxTrend',
label: '추세강도 (ADX)',
desc: '+DI > -DI · ADX ≥ 20',
},
{
key: 'weightMacdMomentum',
label: '모멘텀 (MACD)',
desc: 'MACD > Signal · MACD > 0',
},
{
key: 'weightPricePosition',
label: '가격위치',
desc: '종가 > EMA(20)',
},
];
export const DEFAULT_BULLISH_WEIGHTS: Record<BullishWeightKey, number> = {
weightMaAlignment: 30,
weightMaSlope: 15,
weightAdxTrend: 25,
weightMacdMomentum: 15,
weightPricePosition: 15,
};
export function bullishWeightsFromRequest(
req: Pick<import('./backendApi').TrendSearchRequest, BullishWeightKey>,
): number[] {
return BULLISH_WEIGHT_ITEMS.map(item => req[item.key] ?? DEFAULT_BULLISH_WEIGHTS[item.key]);
}
export function sumWeights(weights: number[]): number {
return weights.reduce((a, b) => a + b, 0);
}
/**
* 한 항목 배점 변경 시 나머지 항목에 차이를 균등 분배해 합계 100 유지.
*/
export function adjustBullishWeight(
current: Record<BullishWeightKey, number>,
changedKey: BullishWeightKey,
newValue: number,
): Record<BullishWeightKey, number> {
const keys = BULLISH_WEIGHT_ITEMS.map(i => i.key);
const idx = keys.indexOf(changedKey);
if (idx < 0) return current;
const clamped = Math.max(0, Math.min(100, Math.round(newValue)));
const oldValue = current[changedKey];
if (clamped === oldValue) return current;
const result: Record<BullishWeightKey, number> = { ...current };
result[changedKey] = clamped;
const delta = clamped - oldValue;
const otherKeys = keys.filter(k => k !== changedKey);
if (delta > 0) {
let remaining = delta;
for (let i = 0; i < otherKeys.length; i++) {
const k = otherKeys[i];
const take = i === otherKeys.length - 1
? remaining
: Math.floor(delta / otherKeys.length);
result[k] = Math.max(0, result[k] - take);
remaining -= take;
}
} else {
const add = Math.abs(delta);
let remaining = add;
for (let i = 0; i < otherKeys.length; i++) {
const k = otherKeys[i];
const give = i === otherKeys.length - 1
? remaining
: Math.floor(add / otherKeys.length);
result[k] += give;
remaining -= give;
}
}
const total = keys.reduce((s, k) => s + result[k], 0);
if (total !== 100) {
const fixKey = otherKeys[0] ?? changedKey;
result[fixKey] = Math.max(0, result[fixKey] + (100 - total));
}
return result;
}
+11 -1
View File
@@ -1,4 +1,4 @@
import type { TrendSearchConditionDto } from './trendSearchApi';
import type { TrendSearchConditionDto, TrendSearchResultDto } from './trendSearchApi';
import type { ConditionMetric, ConditionStatus } from './virtualSignalMetrics';
import type { VirtualConditionRow } from './virtualStrategyConditions';
@@ -42,3 +42,13 @@ export function tfLabelShort(tf: string): string {
if (tf.endsWith('w')) return `${tf.replace('w', '')}`;
return tf;
}
/** 추세강도(점수) 내림차순 정렬 */
export function sortTrendSearchByStrength(
results: TrendSearchResultDto[],
): TrendSearchResultDto[] {
return [...results].sort((a, b) => {
if (b.matchRate !== a.matchRate) return b.matchRate - a.matchRate;
return a.market.localeCompare(b.market);
});
}