추세검색 설정 추가

This commit is contained in:
Macbook
2026-05-27 01:36:06 +09:00
parent c1bcf88c6c
commit 2e08c6b16f
42 changed files with 1507 additions and 226 deletions
@@ -0,0 +1,93 @@
import React, { useMemo } from 'react';
import {
BULLISH_WEIGHT_ITEMS,
WEIGHT_STEP,
adjustBullishWeight,
snapToWeightStep,
sumWeights,
weightFromSliderPointer,
type BullishWeightKey,
} from '../../utils/trendSearchBullishWeights';
export type BullishWeightsRecord = Record<BullishWeightKey, number>;
interface Props {
weights: BullishWeightsRecord;
onChange: (next: BullishWeightsRecord) => void;
/** 설정 화면용 컴팩트 레이아웃 */
variant?: 'panel' | 'settings';
}
const TrendSearchBullishWeightsSection: React.FC<Props> = ({
weights,
onChange,
variant = 'panel',
}) => {
const weightTotal = useMemo(() => sumWeights(Object.values(weights)), [weights]);
const handleWeightChange = (key: BullishWeightKey, value: number) => {
onChange(adjustBullishWeight(weights, key, snapToWeightStep(value)));
};
const handleSliderPointer = (
e: React.PointerEvent<HTMLInputElement>,
key: BullishWeightKey,
) => {
const rect = e.currentTarget.getBoundingClientRect();
handleWeightChange(key, weightFromSliderPointer(e.clientX, rect));
};
const cardCls = variant === 'settings' ? 'tsd-bullish-card tsd-bullish-card--settings' : 'tsd-bullish-card';
return (
<div className={cardCls}>
<div className="tsd-bullish-card-head">
<div>
<span className="tsd-bullish-card-en">Bullish Trend Score</span>
<span className="tsd-bullish-card-ko"> </span>
</div>
<span className="tsd-bullish-card-total">{weightTotal}</span>
</div>
<div className="tsd-bullish-items">
{BULLISH_WEIGHT_ITEMS.map(item => {
const value = weights[item.key];
return (
<div key={item.key} className="tsd-bullish-item">
<div className="tsd-bullish-item-head">
<span className="tsd-bullish-item-label">{item.label}</span>
<span className="tsd-bullish-item-desc" title={item.desc}>{item.desc}</span>
</div>
<div className="tsd-bullish-item-controls">
<input
type="range"
className="tsd-slider tsd-bullish-slider"
min={0}
max={100}
step={WEIGHT_STEP}
value={value}
onPointerDown={e => handleSliderPointer(e, item.key)}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점`}
/>
<input
type="number"
className="tsd-num tsd-bullish-score"
min={0}
max={100}
step={WEIGHT_STEP}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
onBlur={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점 입력`}
/>
<span className="tsd-bullish-score-unit"></span>
</div>
</div>
);
})}
</div>
</div>
);
};
export default TrendSearchBullishWeightsSection;
@@ -55,7 +55,7 @@ const TrendSearchCardSignalPanel: React.FC<Props> = ({
</div>
</div>
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
<VirtualIndicatorCompareTable metrics={metrics} />
<VirtualIndicatorCompareTable metrics={metrics} showStrategyCondition={false} />
</div>
<div className="vtd-card-foot">
<span className="vtd-card-updated"> {updatedLabel}</span>
@@ -1,12 +1,8 @@
import React, { useMemo } from 'react';
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
import {
BULLISH_WEIGHT_ITEMS,
adjustBullishWeight,
sumWeights,
type BullishWeightKey,
} from '../../utils/trendSearchBullishWeights';
import { sumWeights } from '../../utils/trendSearchBullishWeights';
import TrendSearchBullishWeightsSection from './TrendSearchBullishWeightsSection';
interface Props {
filters: TrendSearchRequest;
@@ -43,11 +39,6 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
const weightTotal = sumWeights(Object.values(weights));
const handleWeightChange = (key: BullishWeightKey, value: number) => {
const next = adjustBullishWeight(weights, key, value);
patch(next);
};
return (
<div className="tsd-filter-panel">
<div className="tsd-panel-head">
@@ -60,51 +51,10 @@ const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch,
</div>
</div>
<div className="tsd-bullish-card">
<div className="tsd-bullish-card-head">
<div>
<span className="tsd-bullish-card-en">Bullish Trend Score</span>
<span className="tsd-bullish-card-ko"> </span>
</div>
<span className="tsd-bullish-card-total">{weightTotal}</span>
</div>
<div className="tsd-bullish-items">
{BULLISH_WEIGHT_ITEMS.map(item => {
const value = weights[item.key];
return (
<div key={item.key} className="tsd-bullish-item">
<div className="tsd-bullish-item-head">
<span className="tsd-bullish-item-label">{item.label}</span>
<span className="tsd-bullish-item-desc" title={item.desc}>{item.desc}</span>
</div>
<div className="tsd-bullish-item-controls">
<input
type="range"
className="tsd-slider tsd-bullish-slider"
min={0}
max={100}
step={1}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점`}
/>
<input
type="number"
className="tsd-num tsd-bullish-score"
min={0}
max={100}
value={value}
onChange={e => handleWeightChange(item.key, Number(e.target.value))}
aria-label={`${item.label} 배점 입력`}
/>
<span className="tsd-bullish-score-unit"></span>
</div>
</div>
);
})}
</div>
</div>
<TrendSearchBullishWeightsSection
weights={weights}
onChange={w => patch(w)}
/>
<div className="tsd-filter-section">
<h4 className="tsd-filter-section-title"> </h4>
@@ -9,6 +9,7 @@ import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
import TrendSearchCardChart from './TrendSearchCardChart';
import TrendSearchTargetActions from './TrendSearchTargetActions';
export type TrendSearchDisplayMode = 'summary' | 'chart';
@@ -24,6 +25,11 @@ interface Props {
ticker?: TickerData;
updatedAt?: number;
flash?: boolean;
inTargets?: boolean;
targetPinned?: boolean;
targetBusy?: boolean;
onAddTarget?: () => void;
onRemoveTarget?: () => void;
}
function resultToTicker(result: TrendSearchResultDto): TickerData {
@@ -55,6 +61,11 @@ const TrendSearchResultCard: React.FC<Props> = ({
ticker,
updatedAt,
flash = false,
inTargets = false,
targetPinned = false,
targetBusy = false,
onAddTarget,
onRemoveTarget,
}) => {
const en = result.market.replace(/^KRW-/, '');
const quoteTicker = ticker ?? resultToTicker(result);
@@ -97,6 +108,18 @@ const TrendSearchResultCard: React.FC<Props> = ({
<span className="tsd-card-score">{result.matchRate}</span>
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
</div>
{(onAddTarget || onRemoveTarget) && (
<div className="vtd-card-head-actions">
<TrendSearchTargetActions
market={result.market}
inTargets={inTargets}
targetPinned={targetPinned}
busy={targetBusy}
onAdd={() => onAddTarget?.()}
onRemove={() => onRemoveTarget?.()}
/>
</div>
)}
</div>
<div className="vtd-card-meta">
@@ -19,6 +19,11 @@ interface Props {
chartSeriesPriceLabels?: boolean;
tickers?: Map<string, TickerData>;
lastUpdatedAt?: number;
isInTargets?: (market: string) => boolean;
isPinned?: (market: string) => boolean;
onAddTarget?: (row: TrendSearchResultDto) => void;
onRemoveTarget?: (market: string) => void;
targetBusyMarket?: string | null;
}
const TrendSearchResultsCardGrid: React.FC<Props> = ({
@@ -34,6 +39,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
chartSeriesPriceLabels = true,
tickers,
lastUpdatedAt,
isInTargets,
isPinned,
onAddTarget,
onRemoveTarget,
targetBusyMarket = null,
}) => {
const sortedResults = useMemo(
() => sortTrendSearchByStrength(results),
@@ -77,6 +87,11 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
ticker={tickers?.get(row.market)}
updatedAt={lastUpdatedAt}
flash={flashMarkets?.has(row.market)}
inTargets={isInTargets?.(row.market) ?? false}
targetPinned={isPinned?.(row.market) ?? false}
targetBusy={targetBusyMarket === row.market}
onAddTarget={onAddTarget ? () => onAddTarget(row) : undefined}
onRemoveTarget={onRemoveTarget ? () => onRemoveTarget(row.market) : undefined}
/>
))}
</div>
@@ -0,0 +1,154 @@
import React, { useMemo } from 'react';
import type { TrendSearchAppSettings } from '../../utils/trendSearchAppSettings';
import {
TREND_SEARCH_REFRESH_OPTIONS,
formatTrendRefreshLabel,
} from '../../utils/trendSearchAppSettings';
import TrendSearchBullishWeightsSection from './TrendSearchBullishWeightsSection';
interface Props {
settings: TrendSearchAppSettings;
onChange: (next: TrendSearchAppSettings) => void;
}
const SettingRow: React.FC<{
label: string;
desc?: string;
children: React.ReactNode;
}> = ({ label, desc, children }) => (
<div className="stg-row">
<div className="stg-row-label">
<span className="stg-row-title">{label}</span>
{desc && <span className="stg-row-desc">{desc}</span>}
</div>
<div className="stg-row-ctrl">{children}</div>
</div>
);
const SettingSection: React.FC<{ title: string; children: React.ReactNode }> = ({
title, children,
}) => (
<div className="stg-section">
<div className="stg-section-title">{title}</div>
<div className="stg-section-body">{children}</div>
</div>
);
const TrendSearchSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
const patch = (p: Partial<TrendSearchAppSettings>) => onChange({ ...settings, ...p });
const weights = useMemo(() => ({
weightMaAlignment: settings.weightMaAlignment,
weightMaSlope: settings.weightMaSlope,
weightAdxTrend: settings.weightAdxTrend,
weightMacdMomentum: settings.weightMacdMomentum,
weightPricePosition: settings.weightPricePosition,
}), [settings]);
return (
<div className="tsd-settings-panel">
<SettingSection title="상승추세 검색그룹 배점">
<TrendSearchBullishWeightsSection
variant="settings"
weights={weights}
onChange={w => patch(w)}
/>
</SettingSection>
<SettingSection title="검색 조건">
<SettingRow label="최소 합격 점수" desc="이 점수 이상인 종목만 결과에 포함됩니다.">
<input
type="number"
className="stg-input stg-input--num"
min={0}
max={100}
step={5}
value={settings.minTrendScore}
onChange={e => patch({ minTrendScore: Number(e.target.value) })}
/>
</SettingRow>
<SettingRow label="결과 목록 최대 개수" desc="스캔 후 화면에 표시할 최대 종목 수입니다.">
<input
type="number"
className="stg-input stg-input--num"
min={5}
max={50}
value={settings.limit}
onChange={e => patch({ limit: Number(e.target.value) })}
/>
</SettingRow>
<SettingRow label="스캔 대상 종목 수" desc="거래대금 상위 N종목을 평가합니다.">
<input
type="number"
className="stg-input stg-input--num"
min={20}
max={120}
value={settings.scanLimit}
onChange={e => patch({ scanLimit: Number(e.target.value) })}
/>
</SettingRow>
</SettingSection>
<SettingSection title="자동 갱신">
<SettingRow label="기본 갱신 간격" desc="추세검색 화면 자동 갱신 드롭다운의 기본값입니다.">
<select
className="stg-select"
value={settings.autoRefreshSeconds}
onChange={e => patch({ autoRefreshSeconds: Number(e.target.value) })}
>
{TREND_SEARCH_REFRESH_OPTIONS.map(sec => (
<option key={sec} value={sec}>{formatTrendRefreshLabel(sec)}</option>
))}
</select>
</SettingRow>
</SettingSection>
<SettingSection title="투자대상 자동추가">
<SettingRow
label="자동추가 사용"
desc="추세검색 갱신 후 상위 순위 종목을 가상매매 투자대상에 자동 등록합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={settings.autoAddTargetsEnabled}
onChange={e => patch({ autoAddTargetsEnabled: e.target.checked })}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="자동추가 순위 (상위 N위)"
desc="추세점수 순위 1위부터 N위까지 자동추가 후보입니다. 투자대상 최대 개수를 초과하면 추가하지 않습니다."
>
<input
type="number"
className="stg-input stg-input--num"
min={1}
max={50}
disabled={!settings.autoAddTargetsEnabled}
value={settings.autoAddTopRank}
onChange={e => patch({ autoAddTopRank: Number(e.target.value) })}
/>
</SettingRow>
<SettingRow
label="자동추가 최소 점수"
desc="상위 순위 안에서도 추세점수가 이 값 이상인 종목만 투자대상에 자동추가합니다."
>
<input
type="number"
className="stg-input stg-input--num"
min={0}
max={100}
step={5}
disabled={!settings.autoAddTargetsEnabled}
value={settings.autoAddMinScore}
onChange={e => patch({ autoAddMinScore: Number(e.target.value) })}
/>
</SettingRow>
</SettingSection>
</div>
);
};
export default TrendSearchSettingsPanel;
@@ -0,0 +1,62 @@
import React from 'react';
interface Props {
market: string;
inTargets: boolean;
targetPinned?: boolean;
busy?: boolean;
onAdd: () => void;
onRemove: () => void;
}
const TrendSearchTargetActions: React.FC<Props> = ({
market,
inTargets,
targetPinned = false,
busy = false,
onAdd,
onRemove,
}) => (
<div
className="tsd-card-target-actions"
role="group"
aria-label={`${market} 투자대상`}
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
<button
type="button"
className="tsd-card-target-btn tsd-card-target-btn--add"
title={inTargets ? '이미 투자대상에 등록됨' : '가상매매 투자대상에 추가'}
aria-label="투자대상 추가"
disabled={inTargets || busy}
onClick={onAdd}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="7" y1="2" x2="7" y2="12" />
<line x1="2" y1="7" x2="12" y2="7" />
</svg>
</button>
<button
type="button"
className="tsd-card-target-btn tsd-card-target-btn--remove"
title={
!inTargets
? '투자대상에 없음'
: targetPinned
? '고정된 종목은 삭제할 수 없습니다'
: '투자대상에서 제거'
}
aria-label="투자대상 제거"
disabled={!inTargets || targetPinned || busy}
onClick={onRemove}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="3" y1="3" x2="11" y2="11" />
<line x1="11" y1="3" x2="3" y2="11" />
</svg>
</button>
</div>
);
export default TrendSearchTargetActions;
@@ -1,11 +1,19 @@
import React from 'react';
import type { TrendSearchDisplayMode } from './TrendSearchResultCard';
import {
TREND_SEARCH_REFRESH_OPTIONS,
formatTrendRefreshLabel,
} from '../../utils/trendSearchAppSettings';
interface Props {
displayMode: TrendSearchDisplayMode;
onDisplayModeChange: (mode: TrendSearchDisplayMode) => void;
autoRefresh: boolean;
onAutoRefreshChange: (v: boolean) => void;
refreshSeconds: number;
onRefreshSecondsChange: (sec: number) => void;
autoAddTargets: boolean;
onAutoAddTargetsChange: (v: boolean) => void;
searching?: boolean;
onRefresh: () => void;
resultCount?: number;
@@ -16,6 +24,10 @@ const TrendSearchViewHeaderControls: React.FC<Props> = ({
onDisplayModeChange,
autoRefresh,
onAutoRefreshChange,
refreshSeconds,
onRefreshSecondsChange,
autoAddTargets,
onAutoAddTargetsChange,
searching,
onRefresh,
resultCount = 0,
@@ -42,10 +54,34 @@ const TrendSearchViewHeaderControls: React.FC<Props> = ({
<span className="vtd-grid-head-divider" aria-hidden="true" />
<span className="tsd-result-count">{resultCount}</span>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<label className="tsd-auto-refresh">
<input type="checkbox" checked={autoRefresh} onChange={e => onAutoRefreshChange(e.target.checked)} />
<span>3 </span>
<label className="tsd-auto-add-toggle" title="검색 결과 상위 순위를 가상매매 투자대상에 자동 등록">
<input
type="checkbox"
checked={autoAddTargets}
onChange={e => onAutoAddTargetsChange(e.target.checked)}
/>
<span> </span>
</label>
<span className="vtd-grid-head-divider" aria-hidden="true" />
<label className="tsd-auto-refresh">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => onAutoRefreshChange(e.target.checked)}
/>
<span> </span>
</label>
<select
className="tsd-refresh-interval-select"
value={refreshSeconds}
disabled={!autoRefresh}
aria-label="자동 갱신 간격"
onChange={e => onRefreshSecondsChange(Number(e.target.value))}
>
{TREND_SEARCH_REFRESH_OPTIONS.map(sec => (
<option key={sec} value={sec}>{formatTrendRefreshLabel(sec)}</option>
))}
</select>
<button type="button" className="tsd-refresh-btn" disabled={searching} onClick={onRefresh}>
</button>