추세검색 수정
This commit is contained in:
@@ -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>< RSI <</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}
|
||||
|
||||
Reference in New Issue
Block a user