추세검색기 기능 추가

This commit is contained in:
Macbook
2026-05-26 13:04:25 +09:00
parent ae8e96cef4
commit 7cf43609dc
9 changed files with 895 additions and 792 deletions
@@ -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>&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 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>
);