매매전략 수정중

This commit is contained in:
2026-03-06 20:46:08 +09:00
parent 2b9a90a03f
commit 12460b7c9e
15 changed files with 752 additions and 105 deletions

View File

@@ -1,17 +1,20 @@
import { Routes, Route } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import TradingRules from './pages/TradingRules'
import TradeHistory from './pages/TradeHistory'
import MCPStatus from './pages/MCPStatus'
import StrategySettings from './pages/StrategySettings'
import Layout from './components/Layout'
function App() {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/rules" element={<TradingRules />} />
<Route path="/trades" element={<TradeHistory />} />
<Route path="/mcp" element={<MCPStatus />} />
</Routes>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/trades" element={<TradeHistory />} />
<Route path="/mcp" element={<MCPStatus />} />
<Route path="/strategy" element={<StrategySettings />} />
</Routes>
</Layout>
)
}

View File

@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import clsx from 'clsx'
import HtsHeader from './HtsHeader'
import './HtsPage.css'
interface HtsPageProps {
@@ -12,7 +11,6 @@ interface HtsPageProps {
const HtsPage = ({ children, className, contentClassName }: HtsPageProps) => {
return (
<div className={clsx('hts-page', className)}>
<HtsHeader />
<div className={clsx('hts-content', contentClassName)}>
{children}
</div>

View File

@@ -1,5 +1,5 @@
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, FileText, History, Cpu, Activity } from 'lucide-react'
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
@@ -20,7 +20,7 @@ const Layout = ({ children }: LayoutProps) => {
const navItems = [
{ path: '/', icon: LayoutDashboard, label: '대시보드' },
{ path: '/rules', icon: FileText, label: '전략 룰' },
{ path: '/strategy', icon: SlidersHorizontal, label: '전략 설정' },
{ path: '/trades', icon: History, label: '매매 로그' },
{ path: '/mcp', icon: Cpu, label: 'MCP 상태' },
]

View File

@@ -8,7 +8,6 @@ import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel'
import { Activity } from 'lucide-react'
import './Dashboard.css'
// 마켓 운영 시간 체크 (한국장 + 미국장)
@@ -112,7 +111,7 @@ const Dashboard = () => {
const portfolioSummary = portfolioCombined?.summary
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
const { data: mcpStatus } = useQuery({
useQuery({
queryKey: ['mcp-status'],
queryFn: async () => (await api.get('/mcp/status')).data,
refetchInterval: isMarketOpen ? 30000 : false,
@@ -242,11 +241,11 @@ const Dashboard = () => {
if (!portfolio) return
setLivePrices(prev => {
const next = { ...prev }
;(portfolio as any[]).forEach((s: any) => {
if (s.current_price && !next[s.stock_code]) {
next[s.stock_code] = Number(s.current_price)
}
})
; (portfolio as any[]).forEach((s: any) => {
if (s.current_price && !next[s.stock_code]) {
next[s.stock_code] = Number(s.current_price)
}
})
return next
})
}, [portfolio])
@@ -502,31 +501,7 @@ const Dashboard = () => {
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return (
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col h-screen overflow-hidden">
<header className="border-b border-gray-800 bg-[#161b22] px-6 py-4 flex justify-between items-center z-50 backdrop-blur-md bg-opacity-90 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
<Activity className="text-white" size={20} />
</div>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
AutoStock Trader
</h1>
<p className="text-xs text-gray-500">Gen 3 AI Algorithm</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800 rounded-full border border-gray-700">
<div className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-red-500'}`} />
<span className="text-xs text-gray-400">{wsConnected ? '실시간 연결됨' : '연결 끊김'}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800 rounded-full border border-gray-700">
<div className={`w-2 h-2 rounded-full ${mcpStatus?.status === 'connected' ? 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'bg-yellow-500'}`} />
<span className="text-xs text-gray-400">MCP: {mcpStatus?.status || 'Unknown'}</span>
</div>
</div>
</header>
<div className="bg-[#0f1115] text-gray-100 font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-4 overflow-hidden">
<div className="flex-shrink-0">

View File

@@ -0,0 +1,398 @@
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 } from 'lucide-react'
// ── 타입 ─────────────────────────────────────────────────────────────────────
interface StrategyConfig {
id: number
key: string
group: string
label: string
description: string | null
value_type: string // 'float' | 'int' | 'bool' | 'str'
value: string
default_value: string
min_value: string | null
max_value: string | null
is_active: boolean
updated_by: string | null
}
// ── 그룹 한글 레이블 ──────────────────────────────────────────────────────────
const GROUP_LABELS: Record<string, string> = {
INDICATOR: '기술 지표',
RISK: '리스크 관리',
FILTER: '진입 필터',
DAY_TRADE: '데이트레이딩',
MEAN_REVERSION: '평균회귀 전략',
GENERAL: '일반 설정',
}
const GROUP_ORDER = ['GENERAL', 'RISK', 'FILTER', 'INDICATOR', 'MEAN_REVERSION', 'DAY_TRADE']
// ── 개별 파라미터 행 컴포넌트 ─────────────────────────────────────────────────
interface ConfigRowProps {
cfg: StrategyConfig
onSave: (key: string, value: string) => void
onToggle: (key: string) => void
onReset: (key: string) => void
isSaving: boolean
}
function ConfigRow({ cfg, onSave, onToggle, onReset, isSaving }: ConfigRowProps) {
const [editValue, setEditValue] = useState(cfg.value)
const [dirty, setDirty] = useState(false)
const isDefault = cfg.value === cfg.default_value
const isModified = !isDefault && !dirty
const handleChange = (val: string) => {
setEditValue(val)
setDirty(val !== cfg.value)
}
const handleSave = () => {
onSave(cfg.key, editValue)
setDirty(false)
}
const handleReset = () => {
setEditValue(cfg.default_value)
setDirty(cfg.default_value !== cfg.value)
onReset(cfg.key)
}
const isBool = cfg.value_type === 'bool'
const isTrue = cfg.value === 'true' || cfg.value === 'True' || cfg.value === '1'
return (
<div className={`relative bg-slate-900 border rounded-lg p-3 transition-all ${
!cfg.is_active
? 'border-slate-800 opacity-40'
: isModified
? 'border-yellow-500/40 bg-yellow-500/5'
: dirty
? 'border-blue-500/40 bg-blue-500/5'
: 'border-slate-800 hover:border-slate-700'
}`}>
{/* 상태 뱃지 */}
{(isModified || dirty) && (
<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 ? '미저장' : '수정됨'}
</span>
)}
{/* 레이블 + 설명 */}
<p className="text-xs font-medium text-slate-200 pr-8 leading-snug">{cfg.label}</p>
{cfg.description && (
<p className="text-[10px] text-slate-600 mt-0.5 mb-2 line-clamp-1">{cfg.description}</p>
)}
{/* 컨트롤 영역 */}
<div className="flex items-center justify-between mt-2">
{isBool ? (
<button
onClick={() => onSave(cfg.key, isTrue ? 'false' : 'true')}
disabled={!cfg.is_active}
className={`relative w-9 h-5 rounded-full transition-colors ${isTrue ? 'bg-blue-500' : 'bg-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${isTrue ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
) : (
<div className="flex items-center gap-1.5">
<input
type={cfg.value_type === 'int' || cfg.value_type === 'float' ? 'number' : 'text'}
value={editValue}
onChange={(e) => handleChange(e.target.value)}
disabled={!cfg.is_active}
step={cfg.value_type === 'float' ? '0.001' : '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"
/>
<span className="text-[10px] text-slate-600">/ {cfg.default_value}</span>
</div>
)}
{/* 액션 버튼 */}
<div className="flex items-center gap-0.5">
{!isBool && (
<button
onClick={handleSave}
disabled={!dirty || isSaving}
title="저장"
className="p-1 rounded text-blue-400 hover:bg-blue-500/10 disabled:opacity-20 disabled:cursor-not-allowed transition-colors"
>
<Save size={11} />
</button>
)}
<button
onClick={() => onToggle(cfg.key)}
title={cfg.is_active ? '비활성화' : '활성화'}
className={`p-1 rounded transition-colors ${cfg.is_active ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-700'}`}
>
{cfg.is_active ? <Power size={11} /> : <PowerOff size={11} />}
</button>
<button
onClick={handleReset}
title="기본값으로 초기화"
className="p-1 rounded text-slate-500 hover:text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
<RotateCcw size={10} />
</button>
</div>
</div>
</div>
)
}
// ── 그룹 섹션 컴포넌트 ────────────────────────────────────────────────────────
const GROUP_ICONS: Record<string, string> = {
GENERAL: '⚙️',
RISK: '🛡️',
FILTER: '🔍',
INDICATOR: '📊',
MEAN_REVERSION: '↩️',
DAY_TRADE: '⚡',
}
interface GroupSectionProps {
groupKey: string
configs: StrategyConfig[]
onSave: (key: string, value: string) => void
onToggle: (key: string) => void
onReset: (key: string) => void
isSaving: boolean
}
function GroupSection({ groupKey, configs, onSave, onToggle, onReset, isSaving }: GroupSectionProps) {
const [collapsed, setCollapsed] = useState(false)
const modifiedCount = configs.filter(c => c.value !== c.default_value).length
return (
<div>
{/* 그룹 헤더 */}
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-between py-2 mb-3 group"
>
<div className="flex items-center gap-2">
<span>{GROUP_ICONS[groupKey] || '📁'}</span>
<span className="font-semibold text-slate-200 text-sm">
{GROUP_LABELS[groupKey] || groupKey}
</span>
<span className="text-xs text-slate-600">{configs.length}개</span>
{modifiedCount > 0 && (
<span className="text-xs px-2 py-0.5 bg-yellow-500/15 text-yellow-400 rounded-full">
{modifiedCount}개 수정됨
</span>
)}
</div>
{collapsed
? <ChevronRight size={15} className="text-slate-500" />
: <ChevronDown size={15} className="text-slate-500" />
}
</button>
{/* 파라미터 카드 그리드 */}
{!collapsed && (
<div className="grid grid-cols-3 gap-2 mb-4">
{configs.map((cfg) => (
<ConfigRow
key={cfg.key}
cfg={cfg}
onSave={onSave}
onToggle={onToggle}
onReset={onReset}
isSaving={isSaving}
/>
))}
</div>
)}
</div>
)
}
// ── 메인 페이지 ───────────────────────────────────────────────────────────────
const StrategySettings = () => {
const queryClient = useQueryClient()
const [notification, setNotification] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
const showNotification = (type: 'success' | 'error', msg: string) => {
setNotification({ type, msg })
setTimeout(() => setNotification(null), 3000)
}
// 전략 파라미터 목록 조회
const { data: configs = [], isLoading, refetch } = useQuery<StrategyConfig[]>({
queryKey: ['strategy-config'],
queryFn: async () => {
const res = await api.get('/strategy-config')
return res.data
},
staleTime: 10_000,
})
// 단일 파라미터 수정
const updateMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: string }) => {
return api.put(`/strategy-config/${key}`, { value, updated_by: 'ui' })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['strategy-config'] })
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', '전체 파라미터가 기본값으로 초기화되었습니다.')
},
onError: (err: any) => {
showNotification('error', `초기화 실패: ${err.message}`)
},
})
// 그룹별 분류
const grouped = useMemo(() => {
const map: Record<string, StrategyConfig[]> = {}
for (const cfg of configs) {
if (!map[cfg.group]) map[cfg.group] = []
map[cfg.group].push(cfg)
}
return map
}, [configs])
// 수정된 파라미터 수
const totalModified = configs.filter(c => c.value !== c.default_value).length
if (isLoading) {
return (
<HtsPage>
<div className="flex items-center justify-center h-64 text-slate-400">
로딩 중...
</div>
</HtsPage>
)
}
return (
<HtsPage>
<div className="space-y-5">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white">전략 설정</h2>
<p className="text-sm text-slate-500 mt-1">
변경 즉시 캐시 무효화 → 30초 이내 전략 코드에 반영됩니다.
비활성화 시 config.py 기본값이 사용됩니다.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 hover:text-white border border-slate-700 rounded-lg hover:border-slate-500 transition-colors"
>
<RefreshCw size={14} />
새로고침
</button>
<button
onClick={() => {
if (confirm('모든 파라미터를 config.py 기본값으로 초기화하시겠습니까?')) {
resetAllMutation.mutate()
}
}}
disabled={resetAllMutation.isPending || totalModified === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-yellow-400 border border-yellow-500/40 rounded-lg hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} />
전체 초기화 {totalModified > 0 && `(${totalModified})`}
</button>
</div>
</div>
{/* 알림 배너 */}
{notification && (
<div className={`px-4 py-2.5 rounded-lg text-sm font-medium ${
notification.type === 'success'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-red-500/15 text-red-400 border border-red-500/30'
}`}>
{notification.msg}
</div>
)}
{/* 파라미터 없음 */}
{configs.length === 0 && (
<div className="text-center py-16 text-slate-500">
<p>전략 파라미터가 없습니다.</p>
<p className="text-xs mt-2">DB 마이그레이션 후 서버를 재시작하면 기본값이 자동 생성됩니다.</p>
</div>
)}
{/* 그룹별 섹션 — 2열 그리드 */}
<div className="grid grid-cols-3 gap-5 items-start">
{[
...GROUP_ORDER.filter(g => grouped[g]?.length),
...Object.keys(grouped).filter(g => !GROUP_ORDER.includes(g) && grouped[g]?.length),
].map(groupKey => (
<div key={groupKey} className="bg-slate-900/40 border border-slate-800 rounded-xl p-4">
<GroupSection
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}
/>
</div>
))}
</div>
</div>
</HtsPage>
)
}
export default StrategySettings