feat: Add preset selector and improved UI with collapsible groups
This commit is contained in:
@@ -2,9 +2,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import HtsPage from '../components/HtsPage'
|
import HtsPage from '../components/HtsPage'
|
||||||
import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
|
import { RotateCcw, Save, Power, PowerOff, ChevronDown, ChevronRight, RefreshCw, Zap } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
STRATEGY_PRESETS,
|
||||||
|
PRESET_ORDER,
|
||||||
|
PresetKey,
|
||||||
|
detectCurrentPreset,
|
||||||
|
getPresetDiff,
|
||||||
|
} from '../utils/strategyPresets'
|
||||||
|
|
||||||
// ── 타입 ─────────────────────────────────────────────────────────────────────
|
// ── 타입 ──────────────────────────────────────
|
||||||
|
|
||||||
interface StrategyConfig {
|
interface StrategyConfig {
|
||||||
id: number
|
id: number
|
||||||
@@ -21,7 +28,7 @@ interface StrategyConfig {
|
|||||||
updated_by: string | null
|
updated_by: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 그룹 한글 레이블 ──────────────────────────────────────────────────────────
|
// ── 그룹 한글 레이블 ────────────────────────────
|
||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
INDICATOR: '기술 지표',
|
INDICATOR: '기술 지표',
|
||||||
@@ -34,7 +41,79 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE']
|
const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE']
|
||||||
|
|
||||||
// ── 개별 파라미터 행 컴포넌트 ─────────────────────────────────────────────────
|
const GROUP_ICONS: Record<string, string> = {
|
||||||
|
GENERAL: '⚙️',
|
||||||
|
RISK: '🛡️',
|
||||||
|
FILTER: '🔍',
|
||||||
|
INDICATOR: '📊',
|
||||||
|
MEAN_REVERSION: '↩️',
|
||||||
|
DAY_TRADE: '⚡',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 핵심 설정 파라미터 (기본 확장)
|
||||||
|
const CORE_PARAMS = [
|
||||||
|
'MEAN_REVERSION_STOP_LOSS_PCT',
|
||||||
|
'MEAN_REVERSION_TAKE_PROFIT_PCT',
|
||||||
|
'MAX_TOTAL_POSITIONS',
|
||||||
|
'MEAN_REVERSION_RSI_THRESHOLD',
|
||||||
|
'DAY_TRADE_ENABLED',
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── 프리셋 카드 컴포넌트 ────────────────────────────
|
||||||
|
|
||||||
|
interface PresetCardProps {
|
||||||
|
presetKey: PresetKey
|
||||||
|
isSelected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`relative w-full p-4 rounded-lg border-2 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 bg-blue-500/10 shadow-lg shadow-blue-500/20'
|
||||||
|
: 'border-slate-700 bg-slate-900/60 hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 선택 배지 */}
|
||||||
|
{isSelected && (
|
||||||
|
<span className="absolute top-2 right-2 text-xs px-2 py-0.5 bg-blue-500 text-white rounded-full font-medium">
|
||||||
|
선택됨
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 아이콘 + 이름 */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-2xl">{preset.emoji}</span>
|
||||||
|
<h3 className="text-base font-bold text-white">{preset.name}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{preset.description}</p>
|
||||||
|
|
||||||
|
{/* 태그 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${riskColors[preset.risk]}`}>
|
||||||
|
{preset.risk === 'low' ? '낮은 리스크' : preset.risk === 'medium' ? '중간 리스크' : '높은 리스크'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
기대 수익: {preset.expectedReturn}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 개별 파라미터 행 컴포넌트 ────────────────────────
|
||||||
|
|
||||||
interface ConfigRowProps {
|
interface ConfigRowProps {
|
||||||
cfg: StrategyConfig
|
cfg: StrategyConfig
|
||||||
@@ -80,7 +159,7 @@ function ConfigRow({ cfg, onSave, onToggle, onReset, isSaving }: ConfigRowProps)
|
|||||||
? 'border-blue-500/40 bg-blue-500/5'
|
? 'border-blue-500/40 bg-blue-500/5'
|
||||||
: 'border-slate-800 hover:border-slate-700'
|
: 'border-slate-800 hover:border-slate-700'
|
||||||
}`}>
|
}`}>
|
||||||
{/* 상태 뱃지 */}
|
{/* 상태 배지 */}
|
||||||
{(isModified || dirty) && (
|
{(isModified || dirty) && (
|
||||||
<span className={`absolute top-1.5 right-1.5 text-[9px] px-1 py-0.5 rounded font-medium ${
|
<span className={`absolute top-1.5 right-1.5 text-[9px] px-1 py-0.5 rounded font-medium ${
|
||||||
dirty ? 'bg-blue-500/20 text-blue-400' : 'bg-yellow-500/20 text-yellow-400'
|
dirty ? 'bg-blue-500/20 text-blue-400' : 'bg-yellow-500/20 text-yellow-400'
|
||||||
@@ -154,16 +233,7 @@ function ConfigRow({ cfg, onSave, onToggle, onReset, isSaving }: ConfigRowProps)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 그룹 섹션 컴포넌트 ────────────────────────────────────────────────────────
|
// ── 그룹 섹션 컴포넌트 ──────────────────────────────
|
||||||
|
|
||||||
const GROUP_ICONS: Record<string, string> = {
|
|
||||||
GENERAL: '⚙️',
|
|
||||||
RISK: '🛡️',
|
|
||||||
FILTER: '🔍',
|
|
||||||
INDICATOR: '📊',
|
|
||||||
MEAN_REVERSION: '↩️',
|
|
||||||
DAY_TRADE: '⚡',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupSectionProps {
|
interface GroupSectionProps {
|
||||||
groupKey: string
|
groupKey: string
|
||||||
@@ -172,10 +242,11 @@ interface GroupSectionProps {
|
|||||||
onToggle: (key: string) => void
|
onToggle: (key: string) => void
|
||||||
onReset: (key: string) => void
|
onReset: (key: string) => void
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
|
defaultExpanded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupSection({ groupKey, configs, onSave, onToggle, onReset, isSaving }: GroupSectionProps) {
|
function GroupSection({ groupKey, configs, onSave, onToggle, onReset, isSaving, defaultExpanded = false }: GroupSectionProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(!defaultExpanded)
|
||||||
const modifiedCount = configs.filter(c => c.value !== c.default_value).length
|
const modifiedCount = configs.filter(c => c.value !== c.default_value).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -222,11 +293,12 @@ function GroupSection({ groupKey, configs, onSave, onToggle, onReset, isSaving }
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 메인 페이지 ───────────────────────────────────────────────────────────────
|
// ── 메인 페이지 ──────────────────────────────────────
|
||||||
|
|
||||||
const StrategySettings = () => {
|
const StrategySettings = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
||||||
|
const [showPresets, setShowPresets] = useState(true)
|
||||||
|
|
||||||
const showNotification = (type: 'success' | 'error', msg: string) => {
|
const showNotification = (type: 'success' | 'error', msg: string) => {
|
||||||
setNotification({ type, msg })
|
setNotification({ type, msg })
|
||||||
@@ -243,6 +315,11 @@ const StrategySettings = () => {
|
|||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 현재 프리셋 감지
|
||||||
|
const currentPreset = useMemo(() => {
|
||||||
|
return detectCurrentPreset(configs.map(c => ({ key: c.key, value: c.value })))
|
||||||
|
}, [configs])
|
||||||
|
|
||||||
// 단일 파라미터 수정
|
// 단일 파라미터 수정
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async ({ key, value }: { key: string; value: string }) => {
|
mutationFn: async ({ key, value }: { key: string; value: string }) => {
|
||||||
@@ -295,6 +372,28 @@ const StrategySettings = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 프리셋 적용 (일괄 변경)
|
||||||
|
const applyPresetMutation = useMutation({
|
||||||
|
mutationFn: async (presetKey: PresetKey) => {
|
||||||
|
const preset = STRATEGY_PRESETS[presetKey]
|
||||||
|
const updates = Object.entries(preset.params).map(([key, value]) => ({
|
||||||
|
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', '프리셋 적용 완료')
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
showNotification('error', `프리셋 적용 실패: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 그룹별 분류
|
// 그룹별 분류
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map: Record<string, StrategyConfig[]> = {}
|
const map: Record<string, StrategyConfig[]> = {}
|
||||||
@@ -305,6 +404,10 @@ const StrategySettings = () => {
|
|||||||
return map
|
return map
|
||||||
}, [configs])
|
}, [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
|
const totalModified = configs.filter(c => c.value !== c.default_value).length
|
||||||
|
|
||||||
@@ -338,6 +441,13 @@ const StrategySettings = () => {
|
|||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPresets(!showPresets)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-blue-400 border border-blue-500/40 rounded-lg hover:bg-blue-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Zap size={14} />
|
||||||
|
{showPresets ? '프리셋 숨기기' : '프리셋 보기'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('모든 파라미터를 config.py 기본값으로 초기화하시겠습니까?')) {
|
if (confirm('모든 파라미터를 config.py 기본값으로 초기화하시겠습니까?')) {
|
||||||
@@ -364,6 +474,45 @@ const StrategySettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 프리셋 선택 */}
|
||||||
|
{showPresets && (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<Zap size={18} className="text-yellow-400" />
|
||||||
|
빠른 설정 (프리셋)
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
현재: <span className="text-blue-400 font-medium">
|
||||||
|
{currentPreset === 'custom'
|
||||||
|
? '사용자 정의'
|
||||||
|
: STRATEGY_PRESETS[currentPreset as PresetKey].name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{PRESET_ORDER.map((key) => (
|
||||||
|
<PresetCard
|
||||||
|
key={key}
|
||||||
|
presetKey={key}
|
||||||
|
isSelected={currentPreset === key}
|
||||||
|
onSelect={() => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 파라미터 없음 */}
|
{/* 파라미터 없음 */}
|
||||||
{configs.length === 0 && (
|
{configs.length === 0 && (
|
||||||
<div className="text-center py-16 text-slate-500">
|
<div className="text-center py-16 text-slate-500">
|
||||||
@@ -372,22 +521,45 @@ const StrategySettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 그룹별 섹션 — 2열 그리드 */}
|
{/* 핵심 설정 */}
|
||||||
<div className="grid grid-cols-3 gap-5 items-start">
|
{coreConfigs.length > 0 && (
|
||||||
{[
|
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
...GROUP_ORDER.filter(g => grouped[g]?.length),
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
...Object.keys(grouped).filter(g => !GROUP_ORDER.includes(g) && grouped[g]?.length),
|
⭐ 핵심 설정
|
||||||
].map(groupKey => (
|
<span className="text-xs text-slate-500 font-normal">가장 자주 사용하는 파라미터</span>
|
||||||
<div key={groupKey} className="bg-slate-900/40 border border-slate-800 rounded-xl p-4">
|
</h3>
|
||||||
<GroupSection
|
<div className="grid grid-cols-3 gap-2">
|
||||||
groupKey={groupKey}
|
{coreConfigs.map((cfg) => (
|
||||||
configs={grouped[groupKey]}
|
<ConfigRow
|
||||||
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
key={cfg.key}
|
||||||
onToggle={(key) => toggleMutation.mutate(key)}
|
cfg={cfg}
|
||||||
onReset={(key) => resetOneMutation.mutate(key)}
|
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
||||||
isSaving={updateMutation.isPending}
|
onToggle={(key) => toggleMutation.mutate(key)}
|
||||||
/>
|
onReset={(key) => resetOneMutation.mutate(key)}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 고급 설정 (그룹별 접기/펼치기) */}
|
||||||
|
<div className="bg-slate-900/40 border border-slate-800 rounded-xl p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
⚙️ 고급 설정
|
||||||
|
<span className="text-xs text-slate-500 font-normal">그룹별로 펼쳐보기</span>
|
||||||
|
</h3>
|
||||||
|
{advancedGroups.map(groupKey => (
|
||||||
|
<GroupSection
|
||||||
|
key={groupKey}
|
||||||
|
groupKey={groupKey}
|
||||||
|
configs={grouped[groupKey]}
|
||||||
|
onSave={(key, value) => updateMutation.mutate({ key, value })}
|
||||||
|
onToggle={(key) => toggleMutation.mutate(key)}
|
||||||
|
onReset={(key) => resetOneMutation.mutate(key)}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
defaultExpanded={false}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user