추세검색기 기능 추가
This commit is contained in:
@@ -3,7 +3,7 @@ package com.goldenchart.dto;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 추세검색 스캔 요청 — 좌측 필터 패널 (20개 조건).
|
* 추세검색 스캔 요청 — 정배열 / 상승세 / 거래량·유동성 / 보조지표 4개 카테고리.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class TrendSearchRequest {
|
public class TrendSearchRequest {
|
||||||
@@ -15,46 +15,59 @@ public class TrendSearchRequest {
|
|||||||
private int limit = 20;
|
private int limit = 20;
|
||||||
|
|
||||||
/** 스캔 대상 종목 수 (거래대금 상위) */
|
/** 스캔 대상 종목 수 (거래대금 상위) */
|
||||||
private int scanLimit = 80;
|
private int scanLimit = 60;
|
||||||
|
|
||||||
/** 주 정렬 기준 (조건 id) */
|
/** 주 정렬 (matchRate 권장) */
|
||||||
private String sortBy = "near52wHigh";
|
private String sortBy = "matchRate";
|
||||||
|
|
||||||
/** 신고가 달성 횟수 산출 기간 (거래일) */
|
/** 결과 목록에 포함할 최소 일치율(%) — 0=전체 랭킹, 50=50% 이상만 */
|
||||||
private int newHighDays = 20;
|
private int minMatchRate = 0;
|
||||||
|
|
||||||
// ── 카테고리 ON/OFF ──
|
// ── 카테고리 ON/OFF ──
|
||||||
private boolean priceEnabled = true;
|
private boolean maAlignEnabled = true;
|
||||||
private boolean volumeEnabled = true;
|
private boolean trendEnabled = true;
|
||||||
private boolean flowEnabled = true;
|
private boolean volumePowerEnabled = false;
|
||||||
private boolean fundamentalEnabled = false;
|
private boolean indicatorEnabled = false;
|
||||||
|
|
||||||
// Ⅰ. 가격 및 탄력성
|
// Ⅰ. 정배열 (Moving Average Alignment)
|
||||||
private boolean near52wHigh = true;
|
/** 주가(종가) > 5·20·60일 이평 */
|
||||||
private boolean ret3m = false;
|
private boolean priceAboveMa = true;
|
||||||
private boolean ret6m = false;
|
/** 5 > 20 > 60 > 120 정배열 */
|
||||||
private boolean maDeviation = true;
|
private boolean maAlignment = true;
|
||||||
private boolean maFullAlign = true;
|
/** 이평밀집도(5·20·60) 수렴 후 확산 (선택) */
|
||||||
private boolean stage2 = false;
|
private boolean maConvergence = false;
|
||||||
private boolean relativeStrength = true;
|
/** 이평밀집도 허용 % (기본 3) */
|
||||||
private boolean newHighCount = false;
|
private double maConvergencePct = 3.0;
|
||||||
|
|
||||||
// Ⅱ. 거래량 및 대금
|
// Ⅱ. 상승세 (Trend & Momentum)
|
||||||
private boolean tradeAmount = false;
|
/** 20일 이평 연속 상승 봉 수 */
|
||||||
private boolean volVs5dAvg = true;
|
private boolean ma20SlopeUp = true;
|
||||||
private boolean turnover = false;
|
private int ma20SlopeBars = 2;
|
||||||
private boolean bidAskRatio = false;
|
/** N일 신고가 돌파 또는 고가 대비 -near% 이내 */
|
||||||
|
private boolean newHighBreakout = true;
|
||||||
|
private int newHighBreakoutDays = 20;
|
||||||
|
private double newHighNearPct = 5.0;
|
||||||
|
/** 일목 구름대(선행스팬) 상단 */
|
||||||
|
private boolean ichimokuAboveCloud = false;
|
||||||
|
|
||||||
// Ⅲ. 메이저 수급 및 심리
|
// Ⅲ. 돈과 힘 (Volume & Liquidity)
|
||||||
private boolean smartMoney = false;
|
/** 직전 봉 대비 거래량 % (일봉=전일, 분봉=직전 봉) */
|
||||||
private boolean instConsecutive = false;
|
private boolean volumeVsPrior200 = false;
|
||||||
private boolean rsiHigh = true;
|
private double volumeVsPriorPct = 150.0;
|
||||||
private boolean macdPeak = false;
|
/** 최근 5일 평균 거래대금 하한 (KRW) */
|
||||||
private boolean lightCredit = false;
|
private boolean minTurnover5dAvg = false;
|
||||||
|
private long minTurnover5dAvgKrw = 1_000_000_000L;
|
||||||
|
/** 5일 거래량 이평 > 20일 거래량 이평 */
|
||||||
|
private boolean volMa5Over20 = false;
|
||||||
|
|
||||||
// Ⅳ. 펀더멘털 (주식 전용 — 코인 미지원)
|
// Ⅳ. 보조지표
|
||||||
private boolean earningsGrowth = false;
|
/** MACD Line > Signal 또는 MACD > 0 */
|
||||||
private boolean roe = false;
|
private boolean macdGoldenCross = false;
|
||||||
private boolean opMargin = false;
|
/** RSI 구간 (기본 50~70) */
|
||||||
private boolean pegBelow1 = false;
|
private boolean rsiBand = false;
|
||||||
|
private double rsiMin = 50.0;
|
||||||
|
private double rsiMax = 75.0;
|
||||||
|
/** +DI > -DI AND ADX >= adxMin */
|
||||||
|
private boolean dmiBullish = false;
|
||||||
|
private double adxMin = 18.0;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
|
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
|
||||||
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
|
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
|
||||||
import {
|
import {
|
||||||
TREND_CATEGORY_ORDER,
|
TREND_CATEGORY_ORDER,
|
||||||
TREND_CONDITION_DEFS,
|
TREND_CONDITION_DEFS,
|
||||||
TREND_SORT_CONDITION_IDS,
|
buildCategoryTogglePatch,
|
||||||
type TrendConditionMode,
|
countActiveTrendFilters,
|
||||||
|
type TrendConditionDef,
|
||||||
} from '../../utils/trendSearchConditions';
|
} from '../../utils/trendSearchConditions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,36 +25,148 @@ function tfLabel(tf: string): string {
|
|||||||
return tf;
|
return tf;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeBadge(mode: TrendConditionMode): React.ReactNode {
|
function ConditionParams({
|
||||||
if (mode === 'filter') return <span className="tsd-cond-badge tsd-cond-badge--filter">필터</span>;
|
item,
|
||||||
if (mode === 'unsupported') return <span className="tsd-cond-badge tsd-cond-badge--na">미지원</span>;
|
filters,
|
||||||
return <span className="tsd-cond-badge tsd-cond-badge--sort">정렬</span>;
|
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 TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => {
|
||||||
const patch = (p: Partial<TrendSearchRequest>) => onChange({ ...filters, ...p });
|
const patch = (p: Partial<TrendSearchRequest>) => onChange({ ...filters, ...p });
|
||||||
|
const activeCount = countActiveTrendFilters(filters);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tsd-filter-panel">
|
<div className="tsd-filter-panel">
|
||||||
@@ -61,7 +174,10 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
|||||||
<span className="tsd-panel-icon">⚙</span>
|
<span className="tsd-panel-icon">⚙</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="tsd-panel-title">추세검색 조건</h3>
|
<h3 className="tsd-panel-title">추세검색 조건</h3>
|
||||||
<p className="tsd-panel-sub">20개 조건 · 필터 + 정렬</p>
|
<p className="tsd-panel-sub">
|
||||||
|
활성 조건 일치율 높은 순 랭킹
|
||||||
|
{activeCount > 0 ? ` · 조건 ${activeCount}개` : ' · 조건 없음(추세 점수 순)'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,12 +187,15 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
|||||||
return (
|
return (
|
||||||
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
|
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
|
||||||
<div className="tsd-filter-cat-head">
|
<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">
|
<label className="tsd-toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={catOn}
|
checked={catOn}
|
||||||
onChange={e => patch({ [cat.key]: e.target.checked })}
|
onChange={e => patch(buildCategoryTogglePatch(cat.key, e.target.checked))}
|
||||||
/>
|
/>
|
||||||
<span className="tsd-toggle-track" />
|
<span className="tsd-toggle-track" />
|
||||||
</label>
|
</label>
|
||||||
@@ -85,39 +204,24 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
|
|||||||
<div className="tsd-filter-items">
|
<div className="tsd-filter-items">
|
||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const checked = Boolean(filters[item.requestKey]);
|
const checked = Boolean(filters[item.requestKey]);
|
||||||
const disabled = item.mode === 'unsupported';
|
|
||||||
return (
|
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">
|
<div className="tsd-filter-item-row">
|
||||||
<label className="tsd-filter-item-check">
|
<label className="tsd-filter-item-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
onChange={e => patch({ [item.requestKey]: e.target.checked })}
|
||||||
onChange={e => handleToggleCondition(item.requestKey, e.target.checked, item.id, item.mode)}
|
|
||||||
/>
|
/>
|
||||||
<span className="tsd-filter-item-label">{item.label}</span>
|
<span className="tsd-filter-item-label">{item.label}</span>
|
||||||
{modeBadge(item.mode)}
|
<span className="tsd-cond-badge tsd-cond-badge--filter">조건</span>
|
||||||
</label>
|
</label>
|
||||||
{!disabled && (
|
<span className="tsd-filter-item-desc-inline" title={item.desc}>
|
||||||
<span className="tsd-filter-item-desc-inline" title={item.desc}>
|
{item.desc}
|
||||||
{item.desc}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{item.id === 'newHighCount' && checked && (
|
{checked && (
|
||||||
<div className="tsd-filter-params">
|
<ConditionParams item={item} filters={filters} patch={patch} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="tsd-filter-section">
|
||||||
<h4 className="tsd-filter-section-title">캔들 주기</h4>
|
<h4 className="tsd-filter-section-title">캔들 주기</h4>
|
||||||
<div className="tsd-tf-grid">
|
<div className="tsd-tf-grid">
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="vtd-grid-wrap">
|
<div className="vtd-grid-wrap">
|
||||||
<div className="vtd-grid-empty">
|
<div className="vtd-grid-empty">
|
||||||
<p className="vtd-muted">조건에 맞는 종목이 없습니다. 좌측 필터를 조정해 보세요.</p>
|
<p className="vtd-muted">검색 결과가 없습니다. 조건·캔들 주기를 조정하거나 잠시 후 다시 시도해 보세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,18 +42,20 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
|
|||||||
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
|
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="vtd-sig-eq-bar-wrap">
|
<div className="vtd-sig-eq-column">
|
||||||
<div className="vtd-sig-eq-bar">
|
<div className="vtd-sig-eq-bar-wrap">
|
||||||
{segments.map((seg, i) => (
|
<div className="vtd-sig-eq-bar">
|
||||||
<div
|
{segments.map((seg, i) => (
|
||||||
key={i}
|
<div
|
||||||
className={[
|
key={i}
|
||||||
'vtd-sig-eq-seg',
|
className={[
|
||||||
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
'vtd-sig-eq-seg',
|
||||||
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
||||||
].filter(Boolean).join(' ')}
|
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
||||||
/>
|
].filter(Boolean).join(' ')}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{rate >= 100 && (
|
{rate >= 100 && (
|
||||||
<div className="vtd-sig-eq-badge">100% MATCH</div>
|
<div className="vtd-sig-eq-badge">100% MATCH</div>
|
||||||
|
|||||||
@@ -316,6 +316,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tsd-num--wide {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
.tsd-param-sep {
|
.tsd-param-sep {
|
||||||
color: var(--tsd-muted);
|
color: var(--tsd-muted);
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|||||||
@@ -1443,8 +1443,8 @@
|
|||||||
.vtd-sig-visual {
|
.vtd-sig-visual {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: stretch;
|
align-items: start;
|
||||||
gap: 10px 12px;
|
gap: 12px 14px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border));
|
border: 1px solid color-mix(in srgb, var(--se-gold) 30%, var(--se-border));
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -1465,6 +1465,15 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.vtd-sig-eq-scale {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1477,8 +1486,7 @@
|
|||||||
|
|
||||||
.vtd-sig-eq-bar-wrap {
|
.vtd-sig-eq-bar-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 36px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-bar {
|
.vtd-sig-eq-bar {
|
||||||
@@ -1516,18 +1524,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vtd-sig-eq-badge {
|
.vtd-sig-eq-badge {
|
||||||
position: absolute;
|
margin-top: 6px;
|
||||||
right: -8px;
|
font-size: 8px;
|
||||||
top: -4px;
|
|
||||||
transform: translateX(100%);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
color: #82b1ff;
|
color: #82b1ff;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 (Cybernetic Command Desk) */
|
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 */
|
||||||
.vtd-heat-list {
|
.vtd-heat-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1575,7 +1583,7 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
max-width: calc(100% - 48px);
|
max-width: calc(100% - 52px);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--vtd-heat-label-color);
|
color: var(--vtd-heat-label-color);
|
||||||
@@ -1594,6 +1602,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 7px 0 0 7px;
|
border-radius: 7px 0 0 7px;
|
||||||
transition: width 0.35s ease, box-shadow 0.35s ease, background 0.35s ease;
|
transition: width 0.35s ease, box-shadow 0.35s ease, background 0.35s ease;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vtd-heat-pct {
|
.vtd-heat-pct {
|
||||||
@@ -1601,7 +1610,7 @@
|
|||||||
right: 8px;
|
right: 8px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
z-index: 2;
|
z-index: 3;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -1156,65 +1156,75 @@ export interface TrendSearchRequest {
|
|||||||
limit: number;
|
limit: number;
|
||||||
scanLimit: number;
|
scanLimit: number;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
newHighDays: number;
|
minMatchRate?: number;
|
||||||
priceEnabled: boolean;
|
|
||||||
volumeEnabled: boolean;
|
maAlignEnabled: boolean;
|
||||||
flowEnabled: boolean;
|
trendEnabled: boolean;
|
||||||
fundamentalEnabled: boolean;
|
volumePowerEnabled: boolean;
|
||||||
near52wHigh: boolean;
|
indicatorEnabled: boolean;
|
||||||
ret3m: boolean;
|
|
||||||
ret6m: boolean;
|
priceAboveMa: boolean;
|
||||||
maDeviation: boolean;
|
maAlignment: boolean;
|
||||||
maFullAlign: boolean;
|
maConvergence: boolean;
|
||||||
stage2: boolean;
|
maConvergencePct: number;
|
||||||
relativeStrength: boolean;
|
|
||||||
newHighCount: boolean;
|
ma20SlopeUp: boolean;
|
||||||
tradeAmount: boolean;
|
ma20SlopeBars: number;
|
||||||
volVs5dAvg: boolean;
|
newHighBreakout: boolean;
|
||||||
turnover: boolean;
|
newHighBreakoutDays: number;
|
||||||
bidAskRatio: boolean;
|
newHighNearPct: number;
|
||||||
smartMoney: boolean;
|
ichimokuAboveCloud: boolean;
|
||||||
instConsecutive: boolean;
|
|
||||||
rsiHigh: boolean;
|
volumeVsPrior200: boolean;
|
||||||
macdPeak: boolean;
|
volumeVsPriorPct: number;
|
||||||
lightCredit: boolean;
|
minTurnover5dAvg: boolean;
|
||||||
earningsGrowth: boolean;
|
minTurnover5dAvgKrw: number;
|
||||||
roe: boolean;
|
volMa5Over20: boolean;
|
||||||
opMargin: boolean;
|
|
||||||
pegBelow1: boolean;
|
macdGoldenCross: boolean;
|
||||||
|
rsiBand: boolean;
|
||||||
|
rsiMin: number;
|
||||||
|
rsiMax: number;
|
||||||
|
dmiBullish: boolean;
|
||||||
|
adxMin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
|
export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
|
||||||
timeframe: '1d',
|
timeframe: '1d',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
scanLimit: 80,
|
scanLimit: 60,
|
||||||
sortBy: 'near52wHigh',
|
sortBy: 'matchRate',
|
||||||
newHighDays: 20,
|
minMatchRate: 0,
|
||||||
priceEnabled: true,
|
|
||||||
volumeEnabled: true,
|
maAlignEnabled: true,
|
||||||
flowEnabled: true,
|
trendEnabled: true,
|
||||||
fundamentalEnabled: false,
|
volumePowerEnabled: false,
|
||||||
near52wHigh: true,
|
indicatorEnabled: false,
|
||||||
ret3m: false,
|
|
||||||
ret6m: false,
|
priceAboveMa: true,
|
||||||
maDeviation: true,
|
maAlignment: true,
|
||||||
maFullAlign: true,
|
maConvergence: false,
|
||||||
stage2: false,
|
maConvergencePct: 3,
|
||||||
relativeStrength: true,
|
|
||||||
newHighCount: false,
|
ma20SlopeUp: true,
|
||||||
tradeAmount: false,
|
ma20SlopeBars: 2,
|
||||||
volVs5dAvg: true,
|
newHighBreakout: true,
|
||||||
turnover: false,
|
newHighBreakoutDays: 20,
|
||||||
bidAskRatio: false,
|
newHighNearPct: 5,
|
||||||
smartMoney: false,
|
ichimokuAboveCloud: false,
|
||||||
instConsecutive: false,
|
|
||||||
rsiHigh: true,
|
volumeVsPrior200: false,
|
||||||
macdPeak: false,
|
volumeVsPriorPct: 150,
|
||||||
lightCredit: false,
|
minTurnover5dAvg: false,
|
||||||
earningsGrowth: false,
|
minTurnover5dAvgKrw: 1_000_000_000,
|
||||||
roe: false,
|
volMa5Over20: false,
|
||||||
opMargin: false,
|
|
||||||
pegBelow1: 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;
|
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
|
||||||
|
|||||||
@@ -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 {
|
export interface TrendConditionDef {
|
||||||
id: string;
|
id: string;
|
||||||
category: 'price' | 'volume' | 'flow' | 'fundamental';
|
category: 'ma' | 'trend' | 'volume' | 'indicator';
|
||||||
categoryLabel: string;
|
categoryLabel: string;
|
||||||
label: string;
|
label: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
mode: TrendConditionMode;
|
mode: TrendConditionMode;
|
||||||
requestKey: keyof import('./backendApi').TrendSearchRequest;
|
requestKey: keyof import('./backendApi').TrendSearchRequest;
|
||||||
categoryKey: keyof Pick<
|
categoryKey: TrendCategoryKey;
|
||||||
import('./backendApi').TrendSearchRequest,
|
/** 조건 파라미터 UI */
|
||||||
'priceEnabled' | 'volumeEnabled' | 'flowEnabled' | 'fundamentalEnabled'
|
params?: 'maConvergencePct' | 'ma20SlopeBars' | 'newHighBreakout' | 'volumeVsPrior' | 'minTurnover' | 'rsiBand' | 'adxMin';
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TREND_CONDITION_DEFS: TrendConditionDef[] = [
|
export const TREND_CONDITION_DEFS: TrendConditionDef[] = [
|
||||||
|
// Ⅰ. 정배열
|
||||||
{
|
{
|
||||||
id: 'near52wHigh',
|
id: 'priceAboveMa',
|
||||||
category: 'price',
|
category: 'ma',
|
||||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
categoryLabel: 'Ⅰ. 정배열',
|
||||||
label: '52주 신고가 근접률',
|
label: '주가 > 5·20·60 이평',
|
||||||
desc: '1년 최고가 대비 현재가 근접도 (높을수록 상단)',
|
desc: '종가가 5·20·60일 이동평균선 위',
|
||||||
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일선',
|
|
||||||
mode: 'filter',
|
mode: 'filter',
|
||||||
requestKey: 'maFullAlign',
|
requestKey: 'priceAboveMa',
|
||||||
categoryKey: 'priceEnabled',
|
categoryKey: 'maAlignEnabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stage2',
|
id: 'maAlignment',
|
||||||
category: 'price',
|
category: 'ma',
|
||||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
categoryLabel: 'Ⅰ. 정배열',
|
||||||
label: "마크 미너비니 Stage 2",
|
label: '5 > 20 > 60 > 120 정배열',
|
||||||
desc: '150·200일선 위 + 200일선 1개월↑',
|
desc: '단기 이평이 장기 이평 위에 배열',
|
||||||
mode: 'filter',
|
mode: 'filter',
|
||||||
requestKey: 'stage2',
|
requestKey: 'maAlignment',
|
||||||
categoryKey: 'priceEnabled',
|
categoryKey: 'maAlignEnabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'relativeStrength',
|
id: 'maConvergence',
|
||||||
category: 'price',
|
category: 'ma',
|
||||||
categoryLabel: 'Ⅰ. 가격·탄력성',
|
categoryLabel: 'Ⅰ. 정배열',
|
||||||
label: '상대강도 (RS vs BTC)',
|
label: '이평밀집도 수렴 (선택)',
|
||||||
desc: 'BTC 대비 초과 수익률',
|
desc: '5·20·60 이평 간격이 N% 이내 (확산 전 포착)',
|
||||||
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: '매물 소화 후 가벼운 상승 (신용잔고↓ 대용)',
|
|
||||||
mode: 'filter',
|
mode: 'filter',
|
||||||
requestKey: 'lightCredit',
|
requestKey: 'maConvergence',
|
||||||
categoryKey: 'flowEnabled',
|
categoryKey: 'maAlignEnabled',
|
||||||
|
params: 'maConvergencePct',
|
||||||
|
},
|
||||||
|
// Ⅱ. 상승세
|
||||||
|
{
|
||||||
|
id: 'ma20SlopeUp',
|
||||||
|
category: 'trend',
|
||||||
|
categoryLabel: 'Ⅱ. 상승세',
|
||||||
|
label: '20일 이평 연속 상승',
|
||||||
|
desc: '20일선(생명선) N봉 연속 우상향',
|
||||||
|
mode: 'filter',
|
||||||
|
requestKey: 'ma20SlopeUp',
|
||||||
|
categoryKey: 'trendEnabled',
|
||||||
|
params: 'ma20SlopeBars',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'earningsGrowth',
|
id: 'newHighBreakout',
|
||||||
category: 'fundamental',
|
category: 'trend',
|
||||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
categoryLabel: 'Ⅱ. 상승세',
|
||||||
label: '분기 영업이익 성장률 (YoY)',
|
label: '신고가 돌파·근접',
|
||||||
desc: '주식 전용 — 코인 미지원',
|
desc: 'N일 고가 돌파 또는 고가 대비 -M% 이내',
|
||||||
mode: 'unsupported',
|
mode: 'filter',
|
||||||
requestKey: 'earningsGrowth',
|
requestKey: 'newHighBreakout',
|
||||||
categoryKey: 'fundamentalEnabled',
|
categoryKey: 'trendEnabled',
|
||||||
|
params: 'newHighBreakout',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'roe',
|
id: 'ichimokuAboveCloud',
|
||||||
category: 'fundamental',
|
category: 'trend',
|
||||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
categoryLabel: 'Ⅱ. 상승세',
|
||||||
label: 'ROE (자기자본이익률)',
|
label: '일목 구름대 상단',
|
||||||
desc: '주식 전용 — 코인 미지원',
|
desc: '주가 > 선행스팬1 · 선행스팬2',
|
||||||
mode: 'unsupported',
|
mode: 'filter',
|
||||||
requestKey: 'roe',
|
requestKey: 'ichimokuAboveCloud',
|
||||||
categoryKey: 'fundamentalEnabled',
|
categoryKey: 'trendEnabled',
|
||||||
|
},
|
||||||
|
// Ⅲ. 돈과 힘
|
||||||
|
{
|
||||||
|
id: 'volumeVsPrior200',
|
||||||
|
category: 'volume',
|
||||||
|
categoryLabel: 'Ⅲ. 돈과 힘',
|
||||||
|
label: '직전 봉 대비 거래량',
|
||||||
|
desc: '일봉=전일, 분봉=직전 봉 대비 거래량 %',
|
||||||
|
mode: 'filter',
|
||||||
|
requestKey: 'volumeVsPrior200',
|
||||||
|
categoryKey: 'volumePowerEnabled',
|
||||||
|
params: 'volumeVsPrior',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'opMargin',
|
id: 'minTurnover5dAvg',
|
||||||
category: 'fundamental',
|
category: 'volume',
|
||||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
categoryLabel: 'Ⅲ. 돈과 힘',
|
||||||
label: '매출액 영업이익률',
|
label: '5일 평균 거래대금',
|
||||||
desc: '주식 전용 — 코인 미지원',
|
desc: '최근 5일 평균 거래대금 하한 (억 원)',
|
||||||
mode: 'unsupported',
|
mode: 'filter',
|
||||||
requestKey: 'opMargin',
|
requestKey: 'minTurnover5dAvg',
|
||||||
categoryKey: 'fundamentalEnabled',
|
categoryKey: 'volumePowerEnabled',
|
||||||
|
params: 'minTurnover',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pegBelow1',
|
id: 'volMa5Over20',
|
||||||
category: 'fundamental',
|
category: 'volume',
|
||||||
categoryLabel: 'Ⅳ. 펀더멘털',
|
categoryLabel: 'Ⅲ. 돈과 힘',
|
||||||
label: 'PEG 1.0 이하',
|
label: '거래량 이평 정배열',
|
||||||
desc: '주식 전용 — 코인 미지원',
|
desc: '5일 거래량 이평 > 20일 거래량 이평',
|
||||||
mode: 'unsupported',
|
mode: 'filter',
|
||||||
requestKey: 'pegBelow1',
|
requestKey: 'volMa5Over20',
|
||||||
categoryKey: 'fundamentalEnabled',
|
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
|
export const TREND_CATEGORY_ORDER: {
|
||||||
.filter(c => c.mode === 'sort')
|
key: TrendCategoryKey;
|
||||||
.map(c => c.id);
|
label: string;
|
||||||
|
cat: TrendConditionDef['category'];
|
||||||
export const TREND_CATEGORY_ORDER = [
|
en: string;
|
||||||
{ key: 'priceEnabled' as const, label: 'Ⅰ. 가격·탄력성', cat: 'price' as const },
|
}[] = [
|
||||||
{ key: 'volumeEnabled' as const, label: 'Ⅱ. 거래량·대금', cat: 'volume' as const },
|
{ key: 'maAlignEnabled', label: 'Ⅰ. 정배열', cat: 'ma', en: 'Moving Average Alignment' },
|
||||||
{ key: 'flowEnabled' as const, label: 'Ⅲ. 수급·심리', cat: 'flow' as const },
|
{ key: 'trendEnabled', label: 'Ⅱ. 상승세', cat: 'trend', en: 'Trend & Momentum' },
|
||||||
{ key: 'fundamentalEnabled' as const, label: 'Ⅳ. 펀더멘털', cat: 'fundamental' as const },
|
{ 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user