추세검색기 기능 추가
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React 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,
|
||||
buildCategoryTogglePatch,
|
||||
countActiveTrendFilters,
|
||||
type TrendConditionDef,
|
||||
} from '../../utils/trendSearchConditions';
|
||||
|
||||
interface Props {
|
||||
@@ -24,36 +25,148 @@ function tfLabel(tf: string): string {
|
||||
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>;
|
||||
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 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);
|
||||
};
|
||||
const activeCount = countActiveTrendFilters(filters);
|
||||
|
||||
return (
|
||||
<div className="tsd-filter-panel">
|
||||
@@ -61,7 +174,10 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
||||
<span className="tsd-panel-icon">⚙</span>
|
||||
<div>
|
||||
<h3 className="tsd-panel-title">추세검색 조건</h3>
|
||||
<p className="tsd-panel-sub">20개 조건 · 필터 + 정렬</p>
|
||||
<p className="tsd-panel-sub">
|
||||
활성 조건 일치율 높은 순 랭킹
|
||||
{activeCount > 0 ? ` · 조건 ${activeCount}개` : ' · 조건 없음(추세 점수 순)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,12 +187,15 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
||||
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>
|
||||
<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({ [cat.key]: e.target.checked })}
|
||||
onChange={e => patch(buildCategoryTogglePatch(cat.key, e.target.checked))}
|
||||
/>
|
||||
<span className="tsd-toggle-track" />
|
||||
</label>
|
||||
@@ -85,39 +204,24 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
||||
<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 key={item.id} className="tsd-filter-item">
|
||||
<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)}
|
||||
onChange={e => patch({ [item.requestKey]: e.target.checked })}
|
||||
/>
|
||||
<span className="tsd-filter-item-label">{item.label}</span>
|
||||
{modeBadge(item.mode)}
|
||||
<span className="tsd-cond-badge tsd-cond-badge--filter">조건</span>
|
||||
</label>
|
||||
{!disabled && (
|
||||
<span className="tsd-filter-item-desc-inline" title={item.desc}>
|
||||
{item.desc}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
{checked && (
|
||||
<ConditionParams item={item} filters={filters} patch={patch} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -128,20 +232,6 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
||||
);
|
||||
})}
|
||||
|
||||
<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">
|
||||
|
||||
@@ -48,7 +48,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
return (
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">조건에 맞는 종목이 없습니다. 좌측 필터를 조정해 보세요.</p>
|
||||
<p className="vtd-muted">검색 결과가 없습니다. 조건·캔들 주기를 조정하거나 잠시 후 다시 시도해 보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user