추세검색기 기능 추가
This commit is contained in:
@@ -3,7 +3,7 @@ package com.goldenchart.dto;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 추세검색 스캔 요청 — 좌측 필터 패널 (20개 조건).
|
||||
* 추세검색 스캔 요청 — 정배열 / 상승세 / 거래량·유동성 / 보조지표 4개 카테고리.
|
||||
*/
|
||||
@Data
|
||||
public class TrendSearchRequest {
|
||||
@@ -15,46 +15,59 @@ public class TrendSearchRequest {
|
||||
private int limit = 20;
|
||||
|
||||
/** 스캔 대상 종목 수 (거래대금 상위) */
|
||||
private int scanLimit = 80;
|
||||
private int scanLimit = 60;
|
||||
|
||||
/** 주 정렬 기준 (조건 id) */
|
||||
private String sortBy = "near52wHigh";
|
||||
/** 주 정렬 (matchRate 권장) */
|
||||
private String sortBy = "matchRate";
|
||||
|
||||
/** 신고가 달성 횟수 산출 기간 (거래일) */
|
||||
private int newHighDays = 20;
|
||||
/** 결과 목록에 포함할 최소 일치율(%) — 0=전체 랭킹, 50=50% 이상만 */
|
||||
private int minMatchRate = 0;
|
||||
|
||||
// ── 카테고리 ON/OFF ──
|
||||
private boolean priceEnabled = true;
|
||||
private boolean volumeEnabled = true;
|
||||
private boolean flowEnabled = true;
|
||||
private boolean fundamentalEnabled = false;
|
||||
private boolean maAlignEnabled = true;
|
||||
private boolean trendEnabled = true;
|
||||
private boolean volumePowerEnabled = false;
|
||||
private boolean indicatorEnabled = false;
|
||||
|
||||
// Ⅰ. 가격 및 탄력성
|
||||
private boolean near52wHigh = true;
|
||||
private boolean ret3m = false;
|
||||
private boolean ret6m = false;
|
||||
private boolean maDeviation = true;
|
||||
private boolean maFullAlign = true;
|
||||
private boolean stage2 = false;
|
||||
private boolean relativeStrength = true;
|
||||
private boolean newHighCount = false;
|
||||
// Ⅰ. 정배열 (Moving Average Alignment)
|
||||
/** 주가(종가) > 5·20·60일 이평 */
|
||||
private boolean priceAboveMa = true;
|
||||
/** 5 > 20 > 60 > 120 정배열 */
|
||||
private boolean maAlignment = true;
|
||||
/** 이평밀집도(5·20·60) 수렴 후 확산 (선택) */
|
||||
private boolean maConvergence = false;
|
||||
/** 이평밀집도 허용 % (기본 3) */
|
||||
private double maConvergencePct = 3.0;
|
||||
|
||||
// Ⅱ. 거래량 및 대금
|
||||
private boolean tradeAmount = false;
|
||||
private boolean volVs5dAvg = true;
|
||||
private boolean turnover = false;
|
||||
private boolean bidAskRatio = false;
|
||||
// Ⅱ. 상승세 (Trend & Momentum)
|
||||
/** 20일 이평 연속 상승 봉 수 */
|
||||
private boolean ma20SlopeUp = true;
|
||||
private int ma20SlopeBars = 2;
|
||||
/** N일 신고가 돌파 또는 고가 대비 -near% 이내 */
|
||||
private boolean newHighBreakout = true;
|
||||
private int newHighBreakoutDays = 20;
|
||||
private double newHighNearPct = 5.0;
|
||||
/** 일목 구름대(선행스팬) 상단 */
|
||||
private boolean ichimokuAboveCloud = false;
|
||||
|
||||
// Ⅲ. 메이저 수급 및 심리
|
||||
private boolean smartMoney = false;
|
||||
private boolean instConsecutive = false;
|
||||
private boolean rsiHigh = true;
|
||||
private boolean macdPeak = false;
|
||||
private boolean lightCredit = false;
|
||||
// Ⅲ. 돈과 힘 (Volume & Liquidity)
|
||||
/** 직전 봉 대비 거래량 % (일봉=전일, 분봉=직전 봉) */
|
||||
private boolean volumeVsPrior200 = false;
|
||||
private double volumeVsPriorPct = 150.0;
|
||||
/** 최근 5일 평균 거래대금 하한 (KRW) */
|
||||
private boolean minTurnover5dAvg = false;
|
||||
private long minTurnover5dAvgKrw = 1_000_000_000L;
|
||||
/** 5일 거래량 이평 > 20일 거래량 이평 */
|
||||
private boolean volMa5Over20 = false;
|
||||
|
||||
// Ⅳ. 펀더멘털 (주식 전용 — 코인 미지원)
|
||||
private boolean earningsGrowth = false;
|
||||
private boolean roe = false;
|
||||
private boolean opMargin = false;
|
||||
private boolean pegBelow1 = false;
|
||||
// Ⅳ. 보조지표
|
||||
/** MACD Line > Signal 또는 MACD > 0 */
|
||||
private boolean macdGoldenCross = false;
|
||||
/** RSI 구간 (기본 50~70) */
|
||||
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 { 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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user