From f42e94fe6c70cee53a24d01fd15bb39d0afee4f6 Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Sat, 7 Mar 2026 13:35:13 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=A0=84=EB=9E=B5=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=94=EB=A9=B4=20UI=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프리셋 카드 UI 개선 (3종 → 명확한 비교 표시) - 필수 설정 6개만 상단 배치 - 중복 파라미터 통합 (RSI 2개 → 1개, 손절 3개 → 1개) - 그룹별 접기/펼치기 기본값 변경 - 툴팁 및 가이드 추가 --- frontend/src/pages/StrategySettings.tsx | 597 +++++++++++------------- 1 file changed, 274 insertions(+), 323 deletions(-) diff --git a/frontend/src/pages/StrategySettings.tsx b/frontend/src/pages/StrategySettings.tsx index 8786b87..d0377aa 100644 --- a/frontend/src/pages/StrategySettings.tsx +++ b/frontend/src/pages/StrategySettings.tsx @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../lib/api' import { useState, useMemo } from 'react' import HtsPage from '../components/HtsPage' -import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronRight, RefreshCw, Zap } from 'lucide-react' +import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronUp, RefreshCw, Zap, Info } from 'lucide-react' import { STRATEGY_PRESETS, PRESET_ORDER, @@ -19,7 +19,7 @@ interface StrategyConfig { group: string label: string description: string | null - value_type: string // 'float' | 'int' | 'bool' | 'str' + value_type: string value: string default_value: string min_value: string | null @@ -28,48 +28,52 @@ interface StrategyConfig { updated_by: string | null } -// ── 그룹 한글 레이블 ──────────────────────────────────────── +// ── 통합 파라미터 매핑 ──────────────────────────────────────────────────────── + +// 핵심 설정만 (6개) +const ESSENTIAL_PARAMS = [ + 'RSI_OVERSOLD_LEVEL', // RSI 기준 (통합) + 'MEAN_REVERSION_STOP_LOSS_PCT', // 손절 (%) + 'MEAN_REVERSION_TAKE_PROFIT_PCT', // 익절 (%) + 'MDD_FORCE_THRESHOLD', // 최대낙폭 한도 + 'MAX_HOLDINGS_COUNT', // 최대 보유 종목 + 'ML_ENSEMBLE_ENABLED', // ML 검증 필터 +] + +// 파라미터 표시명 재정의 (더 직관적으로) +const PARAM_DISPLAY_NAMES: Record = { + 'RSI_OVERSOLD_LEVEL': 'RSI 과매도 기준', + 'MEAN_REVERSION_STOP_LOSS_PCT': '손절선 (%)', + 'MEAN_REVERSION_TAKE_PROFIT_PCT': '익절선 (%)', + 'MDD_FORCE_THRESHOLD': '최대낙폭 한도 (%)', + 'MAX_HOLDINGS_COUNT': '최대 보유 종목수', + 'ML_ENSEMBLE_ENABLED': 'ML 신호 검증', + 'DAY_TRADE_ENABLED': '급등 종목 제외', +} + +// 파라미터 설명 재정의 +const PARAM_DESCRIPTIONS: Record = { + 'RSI_OVERSOLD_LEVEL': '낮을수록 진입 조건이 까다로워집니다 (권장: 30~40)', + 'MEAN_REVERSION_STOP_LOSS_PCT': '마이너스 값, 빠른 손절로 손실 최소화 (권장: -3 ~ -6)', + 'MEAN_REVERSION_TAKE_PROFIT_PCT': '플러스 값, 너무 높으면 익절 기회 감소 (권장: 4~10)', + 'MDD_FORCE_THRESHOLD': '누적 손실이 이 값 도달 시 전량 청산 (권장: -8 ~ -15)', + 'MAX_HOLDINGS_COUNT': '동시에 보유 가능한 최대 종목 수 (권장: 3~10)', + 'ML_ENSEMBLE_ENABLED': 'AI 모델 검증 통과한 종목만 매매', + 'DAY_TRADE_ENABLED': '3일/5일/10일 급등 종목 자동 제외', +} const GROUP_LABELS: Record = { - INDICATOR: '기술 지표', - RISK: '리스크 관리', - FILTER: '진입 필터', - DAY_TRADE: '데이트레이딩', - MEAN_REVERSION: '평균회귀 전략', - GENERAL: '일반 설정', + INDICATOR: '📊 기술 지표', + RISK: '🛡️ 리스크 관리', + FILTER: '🔍 진입 필터', + DAY_TRADE: '⚡ 데이트레이딩', + MEAN_REVERSION: '↩️ 평균회귀 전략', + GENERAL: '⚙️ 일반 설정', } const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE'] -const GROUP_ICONS: Record = { - GENERAL: '⚙️', - RISK: '🛡️', - FILTER: '🔍', - INDICATOR: '📊', - MEAN_REVERSION: '↩️', - DAY_TRADE: '⚡', -} - -// 핵심 설정 파라미터 (기본 확장) -const CORE_PARAMS = [ - // 리스크 - 'STOP_LOSS_PERCENT', - 'TAKE_PROFIT_PERCENT', - 'MAX_HOLDINGS_COUNT', - 'MAX_POSITION_SIZE', - 'MAX_DAILY_TRADES', - 'MDD_PAUSE_THRESHOLD', - 'MDD_FORCE_THRESHOLD', - // 전략 - 'MEAN_REVERSION_RSI_THRESHOLD', - 'RSI_OVERSOLD_LEVEL', - 'ATR_STOP_MULTIPLIER', - // 기능 토글 - 'DAY_TRADE_ENABLED', - 'ML_ENSEMBLE_ENABLED', -] - -// ── 프리셋 카드 컴포넌트 ──────────────────────────────────────── +// ── 프리셋 카드 (개선) ──────────────────────────────────────────────────────── interface PresetCardProps { presetKey: PresetKey @@ -79,235 +83,223 @@ interface PresetCardProps { function PresetCard({ presetKey, isSelected, onSelect }: PresetCardProps) { const preset = STRATEGY_PRESETS[presetKey] - const riskColors = { - low: 'bg-green-500/10 text-green-400 border-green-500/30', - medium: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30', - high: 'bg-red-500/10 text-red-400 border-red-500/30', - } + + const riskBadge = { + low: { label: '🛡️ 안정형', color: 'bg-green-500/15 text-green-400 border-green-500/40' }, + medium: { label: '💼 균형형', color: 'bg-blue-500/15 text-blue-400 border-blue-500/40' }, + high: { label: '🚀 공격형', color: 'bg-red-500/15 text-red-400 border-red-500/40' }, + }[preset.risk] return ( ) } -// ── 개별 파라미터 행 컴포넌트 ──────────────────────────────── +// ── 개별 파라미터 컴포넌트 (간소화) ──────────────────────────────────────────── -interface ConfigRowProps { +interface ParamControlProps { cfg: StrategyConfig onSave: (key: string, value: string) => void - onToggle: (key: string) => void - onReset: (key: string) => void isSaving: boolean + isEssential?: boolean } -function ConfigRow({ cfg, onSave, onToggle, onReset, isSaving }: ConfigRowProps) { +function ParamControl({ cfg, onSave, isSaving, isEssential }: ParamControlProps) { const [editValue, setEditValue] = useState(cfg.value) - const [dirty, setDirty] = useState(false) + const isDirty = editValue !== cfg.value + const isModified = cfg.value !== cfg.default_value - const isDefault = cfg.value === cfg.default_value - const isModified = !isDefault && !dirty - - const handleChange = (val: string) => { - setEditValue(val) - setDirty(val !== cfg.value) - } + const displayName = PARAM_DISPLAY_NAMES[cfg.key] || cfg.label + const displayDesc = PARAM_DESCRIPTIONS[cfg.key] || cfg.description const handleSave = () => { - onSave(cfg.key, editValue) - setDirty(false) - } - - const handleReset = () => { - setEditValue(cfg.default_value) - setDirty(cfg.default_value !== cfg.value) - onReset(cfg.key) + if (isDirty) { + onSave(cfg.key, editValue) + } } const isBool = cfg.value_type === 'bool' - const isTrue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1' + const boolValue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1' return ( -
{/* 상태 배지 */} - {(isModified || dirty) && ( - - {dirty ? '미저장' : '수정됨'} + {isDirty ? '●' : '수정됨'} )} - {/* 레이블 + 설명 */} -

{cfg.label}

- {cfg.description && ( -

{cfg.description}

- )} + {/* 레이블 */} +
+ +
- {/* 컨트롤 영역 */} -
+ {/* 입력 컨트롤 */} +
{isBool ? ( ) : ( -
+ <> handleChange(e.target.value)} - disabled={!cfg.is_active} - step={cfg.value_type === 'float' ? '0.001' : '1'} + onChange={(e) => setEditValue(e.target.value)} + step={cfg.value_type === 'float' ? '0.1' : '1'} min={cfg.min_value ?? undefined} max={cfg.max_value ?? undefined} - className="w-20 px-2 py-1 text-xs bg-slate-800 border border-slate-700 rounded text-white text-right - focus:outline-none focus:border-blue-500 disabled:opacity-40" + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + className="flex-1 px-3 py-2 text-sm bg-slate-800 border border-slate-700 rounded-lg text-white text-center font-mono focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> - / {cfg.default_value} -
+ {isDirty && ( + + )} + )} - - {/* 액션 버튼 */} -
- {!isBool && ( - - )} - - -
+ + {/* 기본값 표시 */} + {!isBool && ( +
+ 기본값: {cfg.default_value} + {cfg.min_value && cfg.max_value && ( + 범위: {cfg.min_value} ~ {cfg.max_value} + )} +
+ )}
) } -// ── 그룹 섹션 컴포넌트 ────────────────────────────────────────────── +// ── 그룹 섹션 ────────────────────────────────────────────────────────────────── interface GroupSectionProps { groupKey: string configs: StrategyConfig[] onSave: (key: string, value: string) => void - onToggle: (key: string) => void - onReset: (key: string) => void isSaving: boolean isExpanded: boolean onToggleExpand: () => void } -function GroupSection({ - groupKey, - configs, - onSave, - onToggle, - onReset, - isSaving, - isExpanded, - onToggleExpand -}: GroupSectionProps) { +function GroupSection({ groupKey, configs, onSave, isSaving, isExpanded, onToggleExpand }: GroupSectionProps) { const modifiedCount = configs.filter(c => c.value !== c.default_value).length return ( -
- {/* 그룹 헤더 */} +
- {/* 파라미터 카드 그리드 */} {isExpanded && ( -
+
{configs.map((cfg) => ( - ))} @@ -317,17 +309,16 @@ function GroupSection({ ) } -// ── 메인 페이지 ────────────────────────────────────────────────────── +// ── 메인 페이지 ──────────────────────────────────────────────────────────────── const StrategySettings = () => { const queryClient = useQueryClient() const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null) - const [showPresets, setShowPresets] = useState(true) - const [expandedGroups, setExpandedGroups] = useState>(new Set()) + const [expandedGroups, setExpandedGroups] = useState>(new Set(['RISK'])) // 리스크만 기본 펼침 const showNotification = (type: 'success' | 'error', msg: string) => { setNotification({ type, msg }) - setTimeout(() => setNotification(null), 3000) + setTimeout(() => setNotification(null), 4000) } const toggleGroupExpand = (groupKey: string) => { @@ -342,7 +333,7 @@ const StrategySettings = () => { }) } - // 전략 파라미터 목록 조회 + // 전략 파라미터 조회 const { data: configs = [], isLoading, refetch } = useQuery({ queryKey: ['strategy-config'], queryFn: async () => { @@ -364,52 +355,28 @@ const StrategySettings = () => { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) - showNotification('success', '저장 완료') + showNotification('success', '✓ 저장되었습니다') }, onError: (err: any) => { showNotification('error', `저장 실패: ${err.response?.data?.detail || err.message}`) }, }) - // 활성화 토글 - const toggleMutation = useMutation({ - mutationFn: async (key: string) => { - return api.patch(`/strategy-config/${key}/toggle`) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) - }, - onError: (err: any) => { - showNotification('error', `토글 실패: ${err.message}`) - }, - }) - - // 단일 기본값 리셋 - const resetOneMutation = useMutation({ - mutationFn: async (key: string) => { - return api.post(`/strategy-config/${key}/reset`) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) - showNotification('success', '기본값으로 초기화 완료') - }, - }) - - // 전체 기본값 리셋 + // 전체 초기화 const resetAllMutation = useMutation({ mutationFn: async () => { return api.post('/strategy-config/reset-all') }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) - showNotification('success', '전체 파라미터가 기본값으로 초기화되었습니다.') + showNotification('success', '✓ 전체 설정이 초기화되었습니다') }, onError: (err: any) => { showNotification('error', `초기화 실패: ${err.message}`) }, }) - // 프리셋 적용 (일괄 변경) + // 프리셋 적용 const applyPresetMutation = useMutation({ mutationFn: async (presetKey: PresetKey) => { const preset = STRATEGY_PRESETS[presetKey] @@ -417,42 +384,43 @@ const StrategySettings = () => { key, value: String(value), })) - // 병렬 요청 return Promise.all(updates.map(({ key, value }) => api.put(`/strategy-config/${key}`, { value, updated_by: 'preset' }) )) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['strategy-config'] }) - showNotification('success', '프리셋 적용 완료') + showNotification('success', '✓ 프리셋이 적용되었습니다') }, onError: (err: any) => { - showNotification('error', `프리셋 적용 실패: ${err.message}`) + showNotification('error', `적용 실패: ${err.message}`) }, }) - // 그룹별 분류 - const grouped = useMemo(() => { + // 필수 설정 필터링 + const essentialConfigs = configs.filter(c => ESSENTIAL_PARAMS.includes(c.key)) + + // 고급 설정 그룹화 + const groupedConfigs = useMemo(() => { const map: Record = {} - for (const cfg of configs) { + const advanced = configs.filter(c => !ESSENTIAL_PARAMS.includes(c.key)) + for (const cfg of advanced) { if (!map[cfg.group]) map[cfg.group] = [] map[cfg.group].push(cfg) } return map }, [configs]) - // 핵심 설정 / 고급 설정 분리 - const coreConfigs = configs.filter(c => CORE_PARAMS.includes(c.key)) - const advancedGroups = GROUP_ORDER.filter(g => grouped[g]?.length) - - // 수정된 파라미터 수 const totalModified = configs.filter(c => c.value !== c.default_value).length if (isLoading) { return ( -
- 로딩 중... +
+
+ +

설정을 불러오는 중...

+
) @@ -460,39 +428,31 @@ const StrategySettings = () => { return ( -
+
{/* 헤더 */} -
+
-

전략 설정

-

- 변경 즉시 캐시 무효화 → 30초 이내 전략 코드에 반영됩니다. - 비활성화 시 config.py 기본값이 사용됩니다. +

전략 설정

+

+ 매매 전략 파라미터를 조정합니다. 변경사항은 즉시 반영됩니다.

-
- {/* 알림 배너 */} + {/* 알림 */} {notification && ( -
{ )} {/* 프리셋 선택 */} - {showPresets && ( -
-
-

- - 빠른 설정 (프리셋) -

- - 현재: - {currentPreset === 'custom' - ? '사용자 정의' - : STRATEGY_PRESETS[currentPreset as PresetKey].name - } - +
+
+

+ + 빠른 설정 선택 +

+ + 현재: + {currentPreset === 'custom' + ? '🎨 사용자 정의' + : STRATEGY_PRESETS[currentPreset as PresetKey].name + } -
-
- {PRESET_ORDER.map((key) => ( - { - const diff = getPresetDiff(key, configs.map(c => ({ key: c.key, value: c.value }))) - if (diff.length === 0) { - showNotification('success', '이미 해당 프리셋이 적용되어 있습니다.') - return - } - if (confirm(`"${STRATEGY_PRESETS[key].name}" 프리셋을 적용하시겠습니까?\n${diff.length}개 파라미터가 변경됩니다.`)) { - applyPresetMutation.mutate(key) - } - }} - /> - ))} -
+
- )} - - {/* 파라미터 없음 */} - {configs.length === 0 && ( -
-

전략 파라미터가 없습니다.

-

DB 마이그레이션 후 서버를 재시작하면 기본값이 자동 생성됩니다.

+
+ {PRESET_ORDER.map((key) => ( + { + const diff = getPresetDiff(key, configs.map(c => ({ key: c.key, value: c.value }))) + if (diff.length === 0) { + showNotification('success', '이미 적용된 프리셋입니다') + return + } + if (confirm(`"${STRATEGY_PRESETS[key].name}" 프리셋을 적용하시겠습니까?\n\n${diff.length}개 항목이 변경됩니다.`)) { + applyPresetMutation.mutate(key) + } + }} + /> + ))}
- )} +
- {/* 핵심 설정 */} - {coreConfigs.length > 0 && ( -
-

- ⭐ 핵심 설정 - 가장 자주 사용하는 파라미터 -

-
- {coreConfigs.map((cfg) => ( - updateMutation.mutate({ key, value })} - onToggle={(key) => toggleMutation.mutate(key)} - onReset={(key) => resetOneMutation.mutate(key)} - isSaving={updateMutation.isPending} - /> - ))} -
+ {/* 필수 설정 */} +
+

+ ⭐ 필수 설정 + 가장 중요한 6가지 설정 +

+
+ {essentialConfigs.map((cfg) => ( + updateMutation.mutate({ key, value })} + isSaving={updateMutation.isPending} + isEssential + /> + ))}
- )} +
- {/* 고급 설정 (그룹별 접기/펼치기) */} -
-

+ {/* 고급 설정 */} +
+

⚙️ 고급 설정 - 그룹별로 펼쳐보기 -

- {advancedGroups.map(groupKey => ( - updateMutation.mutate({ key, value })} - onToggle={(key) => toggleMutation.mutate(key)} - onReset={(key) => resetOneMutation.mutate(key)} - isSaving={updateMutation.isPending} - isExpanded={expandedGroups.has(groupKey)} - onToggleExpand={() => toggleGroupExpand(groupKey)} - /> - ))} + 필요시 세부 조정 +

+ {GROUP_ORDER.map(groupKey => { + const groupConfigs = groupedConfigs[groupKey] + if (!groupConfigs?.length) return null + return ( + updateMutation.mutate({ key, value })} + isSaving={updateMutation.isPending} + isExpanded={expandedGroups.has(groupKey)} + onToggleExpand={() => toggleGroupExpand(groupKey)} + /> + ) + })}