추세검색기 기능 추가

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>
);
@@ -42,18 +42,20 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
))}
</div>
<div className="vtd-sig-eq-bar-wrap">
<div className="vtd-sig-eq-bar">
{segments.map((seg, i) => (
<div
key={i}
className={[
'vtd-sig-eq-seg',
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
].filter(Boolean).join(' ')}
/>
))}
<div className="vtd-sig-eq-column">
<div className="vtd-sig-eq-bar-wrap">
<div className="vtd-sig-eq-bar">
{segments.map((seg, i) => (
<div
key={i}
className={[
'vtd-sig-eq-seg',
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
].filter(Boolean).join(' ')}
/>
))}
</div>
</div>
{rate >= 100 && (
<div className="vtd-sig-eq-badge">100% MATCH</div>
@@ -316,6 +316,10 @@
text-align: center;
}
.tsd-num--wide {
width: 56px;
}
.tsd-param-sep {
color: var(--tsd-muted);
align-self: center;
+21 -12
View File
@@ -1443,8 +1443,8 @@
.vtd-sig-visual {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
gap: 10px 12px;
align-items: start;
gap: 12px 14px;
padding: 8px 10px;
border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border));
border-radius: 10px;
@@ -1465,6 +1465,15 @@
flex-shrink: 0;
}
.vtd-sig-eq-column {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 40px;
max-width: 52px;
}
.vtd-sig-eq-scale {
display: flex;
flex-direction: column;
@@ -1477,8 +1486,7 @@
.vtd-sig-eq-bar-wrap {
position: relative;
flex: 1;
min-width: 36px;
width: 100%;
}
.vtd-sig-eq-bar {
@@ -1516,18 +1524,18 @@
}
.vtd-sig-eq-badge {
position: absolute;
right: -8px;
top: -4px;
transform: translateX(100%);
font-size: 9px;
margin-top: 6px;
font-size: 8px;
font-weight: 800;
letter-spacing: 0.04em;
color: #82b1ff;
white-space: nowrap;
text-align: center;
line-height: 1.2;
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
}
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 (Cybernetic Command Desk) */
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */
.vtd-heat-list {
list-style: none;
margin: 0;
@@ -1575,7 +1583,7 @@
top: 50%;
transform: translateY(-50%);
z-index: 2;
max-width: calc(100% - 48px);
max-width: calc(100% - 52px);
font-size: 9px;
font-weight: 600;
color: var(--vtd-heat-label-color);
@@ -1594,6 +1602,7 @@
min-width: 0;
border-radius: 7px 0 0 7px;
transition: width 0.35s ease, box-shadow 0.35s ease, background 0.35s ease;
z-index: 0;
}
.vtd-heat-pct {
@@ -1601,7 +1610,7 @@
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 2;
z-index: 3;
font-size: 10px;
font-weight: 800;
font-variant-numeric: tabular-nums;
+64 -54
View File
@@ -1156,65 +1156,75 @@ export interface TrendSearchRequest {
limit: number;
scanLimit: number;
sortBy: string;
newHighDays: number;
priceEnabled: boolean;
volumeEnabled: boolean;
flowEnabled: boolean;
fundamentalEnabled: boolean;
near52wHigh: boolean;
ret3m: boolean;
ret6m: boolean;
maDeviation: boolean;
maFullAlign: boolean;
stage2: boolean;
relativeStrength: boolean;
newHighCount: boolean;
tradeAmount: boolean;
volVs5dAvg: boolean;
turnover: boolean;
bidAskRatio: boolean;
smartMoney: boolean;
instConsecutive: boolean;
rsiHigh: boolean;
macdPeak: boolean;
lightCredit: boolean;
earningsGrowth: boolean;
roe: boolean;
opMargin: boolean;
pegBelow1: boolean;
minMatchRate?: number;
maAlignEnabled: boolean;
trendEnabled: boolean;
volumePowerEnabled: boolean;
indicatorEnabled: boolean;
priceAboveMa: boolean;
maAlignment: boolean;
maConvergence: boolean;
maConvergencePct: number;
ma20SlopeUp: boolean;
ma20SlopeBars: number;
newHighBreakout: boolean;
newHighBreakoutDays: number;
newHighNearPct: number;
ichimokuAboveCloud: boolean;
volumeVsPrior200: boolean;
volumeVsPriorPct: number;
minTurnover5dAvg: boolean;
minTurnover5dAvgKrw: number;
volMa5Over20: boolean;
macdGoldenCross: boolean;
rsiBand: boolean;
rsiMin: number;
rsiMax: number;
dmiBullish: boolean;
adxMin: number;
}
export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
timeframe: '1d',
limit: 20,
scanLimit: 80,
sortBy: 'near52wHigh',
newHighDays: 20,
priceEnabled: true,
volumeEnabled: true,
flowEnabled: true,
fundamentalEnabled: false,
near52wHigh: true,
ret3m: false,
ret6m: false,
maDeviation: true,
maFullAlign: true,
stage2: false,
relativeStrength: true,
newHighCount: false,
tradeAmount: false,
volVs5dAvg: true,
turnover: false,
bidAskRatio: false,
smartMoney: false,
instConsecutive: false,
rsiHigh: true,
macdPeak: false,
lightCredit: false,
earningsGrowth: false,
roe: false,
opMargin: false,
pegBelow1: false,
scanLimit: 60,
sortBy: 'matchRate',
minMatchRate: 0,
maAlignEnabled: true,
trendEnabled: true,
volumePowerEnabled: false,
indicatorEnabled: false,
priceAboveMa: true,
maAlignment: true,
maConvergence: false,
maConvergencePct: 3,
ma20SlopeUp: true,
ma20SlopeBars: 2,
newHighBreakout: true,
newHighBreakoutDays: 20,
newHighNearPct: 5,
ichimokuAboveCloud: false,
volumeVsPrior200: false,
volumeVsPriorPct: 150,
minTurnover5dAvg: false,
minTurnover5dAvgKrw: 1_000_000_000,
volMa5Over20: false,
macdGoldenCross: false,
rsiBand: false,
rsiMin: 50,
rsiMax: 75,
dmiBullish: false,
adxMin: 18,
};
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
+160 -209
View File
@@ -1,240 +1,191 @@
/** 추세검색 20개 조건 정의 */
export type TrendConditionMode = 'sort' | 'filter' | 'unsupported';
/** 추세검색 조건 정의 — 정배열 / 상승세 / 거래량 / 보조지표 */
export type TrendConditionMode = 'filter';
export type TrendCategoryKey =
| 'maAlignEnabled'
| 'trendEnabled'
| 'volumePowerEnabled'
| 'indicatorEnabled';
export interface TrendConditionDef {
id: string;
category: 'price' | 'volume' | 'flow' | 'fundamental';
category: 'ma' | 'trend' | 'volume' | 'indicator';
categoryLabel: string;
label: string;
desc: string;
mode: TrendConditionMode;
requestKey: keyof import('./backendApi').TrendSearchRequest;
categoryKey: keyof Pick<
import('./backendApi').TrendSearchRequest,
'priceEnabled' | 'volumeEnabled' | 'flowEnabled' | 'fundamentalEnabled'
>;
categoryKey: TrendCategoryKey;
/** 조건 파라미터 UI */
params?: 'maConvergencePct' | 'ma20SlopeBars' | 'newHighBreakout' | 'volumeVsPrior' | 'minTurnover' | 'rsiBand' | 'adxMin';
}
export const TREND_CONDITION_DEFS: TrendConditionDef[] = [
// . 정배열
{
id: 'near52wHigh',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '52주 신고가 근접률',
desc: '1년 최고가 대비 현재가 근접도 (높을수록 상단)',
mode: 'sort',
requestKey: 'near52wHigh',
categoryKey: 'priceEnabled',
},
{
id: 'ret3m',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '3개월 수익률',
desc: '최근 12주 누적 상승률',
mode: 'sort',
requestKey: 'ret3m',
categoryKey: 'priceEnabled',
},
{
id: 'ret6m',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '6개월 수익률',
desc: '최근 24주 누적 상승률',
mode: 'sort',
requestKey: 'ret6m',
categoryKey: 'priceEnabled',
},
{
id: 'maDeviation',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '이동평균 이격도 (20·60일)',
desc: '20·60일선 대비 주가 이격률',
mode: 'sort',
requestKey: 'maDeviation',
categoryKey: 'priceEnabled',
},
{
id: 'maFullAlign',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '이동평균 완전 정배열',
desc: '주가 > 5 > 20 > 60 > 120 > 200일선',
id: 'priceAboveMa',
category: 'ma',
categoryLabel: '. 정배열',
label: '주가 > 5·20·60 이평',
desc: '종가가 5·20·60일 이동평균선 위',
mode: 'filter',
requestKey: 'maFullAlign',
categoryKey: 'priceEnabled',
requestKey: 'priceAboveMa',
categoryKey: 'maAlignEnabled',
},
{
id: 'stage2',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: "마크 미너비니 Stage 2",
desc: '150·200일선 위 + 200일선 1개월↑',
id: 'maAlignment',
category: 'ma',
categoryLabel: '. 정배열',
label: '5 > 20 > 60 > 120 정배열',
desc: '단기 이평이 장기 이평 위에 배열',
mode: 'filter',
requestKey: 'stage2',
categoryKey: 'priceEnabled',
requestKey: 'maAlignment',
categoryKey: 'maAlignEnabled',
},
{
id: 'relativeStrength',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '상대강도 (RS vs BTC)',
desc: 'BTC 대비 초과 수익률',
mode: 'sort',
requestKey: 'relativeStrength',
categoryKey: 'priceEnabled',
},
{
id: 'newHighCount',
category: 'price',
categoryLabel: '. 가격·탄력성',
label: '최근 N일 신고가 달성 횟수',
desc: '최근 구간 신고가 경신 일수',
mode: 'sort',
requestKey: 'newHighCount',
categoryKey: 'priceEnabled',
},
{
id: 'tradeAmount',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '당일 거래대금',
desc: '24h 누적 거래대금',
mode: 'sort',
requestKey: 'tradeAmount',
categoryKey: 'volumeEnabled',
},
{
id: 'volVs5dAvg',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '5일 평균 대비 거래대금 증가율',
desc: '5일 평균 대비 당일 거래대금',
mode: 'sort',
requestKey: 'volVs5dAvg',
categoryKey: 'volumeEnabled',
},
{
id: 'turnover',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '거래량 회전율',
desc: '20일 평균 대비 당일 거래량 비율',
mode: 'sort',
requestKey: 'turnover',
categoryKey: 'volumeEnabled',
},
{
id: 'bidAskRatio',
category: 'volume',
categoryLabel: 'Ⅱ. 거래량·대금',
label: '매수 대기 잔량 비율',
desc: '호가 매수잔량 / 매도잔량',
mode: 'sort',
requestKey: 'bidAskRatio',
categoryKey: 'volumeEnabled',
},
{
id: 'smartMoney',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: '스마트머니 누적 (OBV)',
desc: 'OBV 20일 누적 상승폭 (기관·외국인 대용)',
mode: 'sort',
requestKey: 'smartMoney',
categoryKey: 'flowEnabled',
},
{
id: 'instConsecutive',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: '연속 순매수 일수',
desc: '양봉+거래량 증가 연속 일수 (연기금·투신 대용)',
mode: 'sort',
requestKey: 'instConsecutive',
categoryKey: 'flowEnabled',
},
{
id: 'rsiHigh',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: 'RSI (상대강도지수)',
desc: 'RSI 14 — 높을수록 강한 모멘텀',
mode: 'sort',
requestKey: 'rsiHigh',
categoryKey: 'flowEnabled',
},
{
id: 'macdPeak',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: 'MACD 오실레이터 최고치',
desc: '최근 20일 MACD 히스토그램 신고가',
mode: 'sort',
requestKey: 'macdPeak',
categoryKey: 'flowEnabled',
},
{
id: 'lightCredit',
category: 'flow',
categoryLabel: 'Ⅲ. 수급·심리',
label: '거래량 감소 + 주가 상승',
desc: '매물 소화 후 가벼운 상승 (신용잔고↓ 대용)',
id: 'maConvergence',
category: 'ma',
categoryLabel: '. 정배열',
label: '이평밀집도 수렴 (선택)',
desc: '5·20·60 이평 간격이 N% 이내 (확산 전 포착)',
mode: 'filter',
requestKey: 'lightCredit',
categoryKey: 'flowEnabled',
requestKey: 'maConvergence',
categoryKey: 'maAlignEnabled',
params: 'maConvergencePct',
},
// Ⅱ. 상승세
{
id: 'ma20SlopeUp',
category: 'trend',
categoryLabel: 'Ⅱ. 상승세',
label: '20일 이평 연속 상승',
desc: '20일선(생명선) N봉 연속 우상향',
mode: 'filter',
requestKey: 'ma20SlopeUp',
categoryKey: 'trendEnabled',
params: 'ma20SlopeBars',
},
{
id: 'earningsGrowth',
category: 'fundamental',
categoryLabel: '. 펀더멘털',
label: '분기 영업이익 성장률 (YoY)',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'earningsGrowth',
categoryKey: 'fundamentalEnabled',
id: 'newHighBreakout',
category: 'trend',
categoryLabel: '. 상승세',
label: '신고가 돌파·근접',
desc: 'N일 고가 돌파 또는 고가 대비 -M% 이내',
mode: 'filter',
requestKey: 'newHighBreakout',
categoryKey: 'trendEnabled',
params: 'newHighBreakout',
},
{
id: 'roe',
category: 'fundamental',
categoryLabel: '. 펀더멘털',
label: 'ROE (자기자본이익률)',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'roe',
categoryKey: 'fundamentalEnabled',
id: 'ichimokuAboveCloud',
category: 'trend',
categoryLabel: '. 상승세',
label: '일목 구름대 상단',
desc: '주가 > 선행스팬1 · 선행스팬2',
mode: 'filter',
requestKey: 'ichimokuAboveCloud',
categoryKey: 'trendEnabled',
},
// Ⅲ. 돈과 힘
{
id: 'volumeVsPrior200',
category: 'volume',
categoryLabel: 'Ⅲ. 돈과 힘',
label: '직전 봉 대비 거래량',
desc: '일봉=전일, 분봉=직전 봉 대비 거래량 %',
mode: 'filter',
requestKey: 'volumeVsPrior200',
categoryKey: 'volumePowerEnabled',
params: 'volumeVsPrior',
},
{
id: 'opMargin',
category: 'fundamental',
categoryLabel: '. 펀더멘털',
label: '매출액 영업이익률',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'opMargin',
categoryKey: 'fundamentalEnabled',
id: 'minTurnover5dAvg',
category: 'volume',
categoryLabel: '. 돈과 힘',
label: '5일 평균 거래대금',
desc: '최근 5일 평균 거래대금 하한 (억 원)',
mode: 'filter',
requestKey: 'minTurnover5dAvg',
categoryKey: 'volumePowerEnabled',
params: 'minTurnover',
},
{
id: 'pegBelow1',
category: 'fundamental',
categoryLabel: '. 펀더멘털',
label: 'PEG 1.0 이하',
desc: '주식 전용 — 코인 미지원',
mode: 'unsupported',
requestKey: 'pegBelow1',
categoryKey: 'fundamentalEnabled',
id: 'volMa5Over20',
category: 'volume',
categoryLabel: '. 돈과 힘',
label: '거래량 이평 정배열',
desc: '5일 거래량 이평 > 20일 거래량 이평',
mode: 'filter',
requestKey: 'volMa5Over20',
categoryKey: 'volumePowerEnabled',
},
// Ⅳ. 보조지표
{
id: 'macdGoldenCross',
category: 'indicator',
categoryLabel: 'Ⅳ. 보조지표',
label: 'MACD 골든크로스',
desc: 'MACD Line > Signal 또는 MACD > 0',
mode: 'filter',
requestKey: 'macdGoldenCross',
categoryKey: 'indicatorEnabled',
},
{
id: 'rsiBand',
category: 'indicator',
categoryLabel: 'Ⅳ. 보조지표',
label: 'RSI 구간',
desc: '과열·침체 구간 제외 (기본 50~70)',
mode: 'filter',
requestKey: 'rsiBand',
categoryKey: 'indicatorEnabled',
params: 'rsiBand',
},
{
id: 'dmiBullish',
category: 'indicator',
categoryLabel: 'Ⅳ. 보조지표',
label: '+DI > -DI · ADX',
desc: '추세 강도 ADX 하한 이상',
mode: 'filter',
requestKey: 'dmiBullish',
categoryKey: 'indicatorEnabled',
params: 'adxMin',
},
];
export const TREND_SORT_CONDITION_IDS = TREND_CONDITION_DEFS
.filter(c => c.mode === 'sort')
.map(c => c.id);
export const TREND_CATEGORY_ORDER = [
{ key: 'priceEnabled' as const, label: '. 가격·탄력성', cat: 'price' as const },
{ key: 'volumeEnabled' as const, label: '. 거래량·대금', cat: 'volume' as const },
{ key: 'flowEnabled' as const, label: '. 수급·심리', cat: 'flow' as const },
{ key: 'fundamentalEnabled' as const, label: '. 펀더멘털', cat: 'fundamental' as const },
export const TREND_CATEGORY_ORDER: {
key: TrendCategoryKey;
label: string;
cat: TrendConditionDef['category'];
en: string;
}[] = [
{ key: 'maAlignEnabled', label: '. 정배열', cat: 'ma', en: 'Moving Average Alignment' },
{ key: 'trendEnabled', label: '. 상승세', cat: 'trend', en: 'Trend & Momentum' },
{ key: 'volumePowerEnabled', label: '. 돈과 힘', cat: 'volume', en: 'Volume & Liquidity' },
{ key: 'indicatorEnabled', label: 'Ⅳ. 보조지표', cat: 'indicator', en: 'Indicators' },
];
/** 카테고리 OFF 시 하위 조건까지 함께 끄기 위한 패치 */
export function buildCategoryTogglePatch(
catKey: TrendCategoryKey,
on: boolean,
): Partial<import('./backendApi').TrendSearchRequest> {
const patch: Partial<import('./backendApi').TrendSearchRequest> = { [catKey]: on };
if (!on) {
for (const def of TREND_CONDITION_DEFS.filter(d => d.categoryKey === catKey)) {
patch[def.requestKey] = false as never;
}
}
return patch;
}
export function countActiveTrendFilters(
filters: import('./backendApi').TrendSearchRequest,
): number {
let n = 0;
for (const def of TREND_CONDITION_DEFS) {
if (filters[def.categoryKey] && filters[def.requestKey]) n++;
}
return n;
}