프론트엔드 추가 및 자동매매 로직 개선:
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 1m42s

- Svelte 기반 프론트엔드 프로젝트 초기 설정 추가 (`vite`, `tailwindcss` 등 포함).
- "자동매매" 주요 상태 및 규칙 관리 페이지 구현.
- 1차/2차 손절 및 익절 조건 평가 로직 추가(`calcStopTargets`, `evalExitReason` 등).
- 포지션 상세 로그 및 WebSocket 기반 실시간 로그 스트림 추가.
- API 서비스 및 Frontend 간 Proxy 설정(Vite 서버).
- 세션 체크를 위한 `CheckSession` 핸들러 추가.
This commit is contained in:
hayato5246
2026-04-05 20:30:52 +09:00
parent f10a1ede3b
commit 00ffc6b54c
58 changed files with 6425 additions and 104 deletions

View File

@@ -0,0 +1,570 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { autotradeApi } from '$lib/api/autotrade'
import { stockApi } from '$lib/api/stock'
import { tradeLogStream, wsStore } from '$lib/stores/ws'
import Modal from '$lib/components/Modal.svelte'
import { formatPrice, formatRate, priceClass } from '$lib/utils'
import type {
AutoTradeRule, AutoTradePosition, AutoTradeLog, AutoTradeStatus,
AutoTradeWatchSource, ThemeGroup, ThemeRef
} from '$lib/api/types'
let status = $state<AutoTradeStatus | null>(null)
let rules = $state<AutoTradeRule[]>([])
let positions = $state<AutoTradePosition[]>([])
let logs = $state<AutoTradeLog[]>([])
let activeTab = $state<'rules' | 'positions' | 'logs'>('rules')
let showRuleModal = $state(false)
let editingRule = $state<Partial<AutoTradeRule> | null>(null)
let loading = $state(true)
let actionLoading = $state(false)
let emergencyConfirm = $state(false)
// 감시 소스 상태
let watchSource = $state<AutoTradeWatchSource>({ useScanner: true, selectedThemes: [] })
let allThemes = $state<ThemeGroup[]>([])
let themeSearch = $state('')
let showThemeDropdown = $state(false)
let filteredThemes = $derived(
allThemes
.filter(t =>
!watchSource.selectedThemes.some(s => s.code === t.code) &&
(!themeSearch || t.name.toLowerCase().includes(themeSearch.toLowerCase()))
)
.sort((a, b) => a.name.localeCompare(b.name, 'ko'))
)
// WebSocket으로 자동매매 로그 수신
const logUnsubscribe = tradeLogStream.subscribe((log) => {
if (log) {
logs = [log, ...logs].slice(0, 200)
}
})
onMount(async () => {
wsStore.connect()
await loadAll()
loading = false
})
onDestroy(() => {
logUnsubscribe()
})
async function loadAll() {
try {
const [s, r, p, l, ws, themes] = await Promise.all([
autotradeApi.getStatus(),
autotradeApi.getRules(),
autotradeApi.getPositions(),
autotradeApi.getLogs(),
autotradeApi.getWatchSource(),
stockApi.getThemes(),
])
status = s
rules = r
positions = p
logs = l
watchSource = ws
allThemes = themes
} catch (e) {
console.error(e)
}
}
async function toggleEngine() {
actionLoading = true
try {
if (status?.running) {
await autotradeApi.stop()
status = { ...status!, running: false }
} else {
await autotradeApi.start()
status = { ...status!, running: true }
}
} finally {
actionLoading = false
}
}
async function handleEmergency() {
if (!emergencyConfirm) {
emergencyConfirm = true
setTimeout(() => { emergencyConfirm = false }, 5000)
return
}
emergencyConfirm = false
actionLoading = true
try {
await autotradeApi.emergency()
positions = []
} finally {
actionLoading = false
}
}
// 감시 소스 토글
async function toggleScanner() {
const updated = { ...watchSource, useScanner: !watchSource.useScanner }
try {
await autotradeApi.setWatchSource(updated)
watchSource = updated
} catch (e) {
console.error(e)
}
}
// 테마 추가
async function addTheme(theme: ThemeGroup) {
const ref: ThemeRef = { code: theme.code, name: theme.name }
const updated = {
...watchSource,
selectedThemes: [...watchSource.selectedThemes, ref],
}
try {
await autotradeApi.setWatchSource(updated)
watchSource = updated
themeSearch = ''
showThemeDropdown = false
} catch (e) {
console.error(e)
}
}
// 테마 제거
async function removeTheme(code: string) {
const updated = {
...watchSource,
selectedThemes: watchSource.selectedThemes.filter(t => t.code !== code),
}
try {
await autotradeApi.setWatchSource(updated)
watchSource = updated
} catch (e) {
console.error(e)
}
}
// 개별 포지션 청산
async function closePosition(code: string, name: string) {
if (!confirm(`${name}(${code}) 포지션을 청산하시겠습니까?`)) return
try {
await autotradeApi.closePosition(code)
// 포지션 목록 새로고침
positions = await autotradeApi.getPositions()
} catch (e) {
alert(e instanceof Error ? e.message : '청산 실패')
}
}
function openAddRule() {
editingRule = {
name: '새 규칙',
enabled: true,
minRiseScore: 60,
minCntrStr: 110,
requireBullish: false,
orderAmount: 1000000,
maxPositions: 3,
stopLoss1Pct: -2.0,
stopLoss1Count: 3,
stopLossPct: -4.0,
takeProfitPct: 5.0,
maxHoldMinutes: 60,
exitBeforeClose: true,
}
showRuleModal = true
}
function openEditRule(rule: AutoTradeRule) {
editingRule = { ...rule }
showRuleModal = true
}
async function saveRule() {
if (!editingRule) return
actionLoading = true
try {
if (editingRule.id) {
const updated = await autotradeApi.updateRule(editingRule.id, editingRule)
rules = rules.map(r => r.id === updated.id ? updated : r)
} else {
const newRule = await autotradeApi.addRule(editingRule as Omit<AutoTradeRule, 'id'|'createdAt'>)
rules = [...rules, newRule]
}
showRuleModal = false
} catch (e: unknown) {
alert(e instanceof Error ? e.message : '저장 실패')
} finally {
actionLoading = false
}
}
async function deleteRule(id: string) {
if (!confirm('규칙을 삭제하시겠습니까?')) return
await autotradeApi.deleteRule(id)
rules = rules.filter(r => r.id !== id)
}
async function toggleRule(id: string) {
const updated = await autotradeApi.toggleRule(id)
rules = rules.map(r => r.id === id ? updated : r)
}
function logLevelClass(level: string): string {
if (level === 'error') return 'text-red-400'
if (level === 'warn') return 'text-yellow-400'
return 'text-gray-300'
}
function formatTime(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
}
</script>
<svelte:head>
<title>자동매매 - 주식 시세</title>
</svelte:head>
<!-- 상단 상태 바 -->
<div class="flex items-center justify-between mb-6 flex-wrap gap-4">
<div class="flex items-center gap-4">
<h1 class="text-xl font-bold text-white">자동매매</h1>
{#if status}
<span class="flex items-center gap-1.5 text-sm {status.running ? 'text-green-400' : 'text-gray-400'}">
<span class="w-2 h-2 rounded-full {status.running ? 'bg-green-400 animate-pulse' : 'bg-gray-600'}"></span>
{status.running ? '실행 중' : '중지됨'}
</span>
<span class="text-sm text-gray-400">포지션 {status.positions}</span>
<span class="text-sm text-gray-400">오늘 {status.todayTrades}</span>
{#if status.todayProfit !== 0}
<span class="text-sm {priceClass(status.todayProfit)}">
오늘 손익 {status.todayProfit >= 0 ? '+' : ''}{formatPrice(status.todayProfit)}
</span>
{/if}
{/if}
</div>
<div class="flex gap-2">
<button
onclick={toggleEngine}
disabled={actionLoading}
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50
{status?.running
? 'bg-gray-700 text-white hover:bg-gray-600'
: 'bg-green-600 text-white hover:bg-green-500'}"
>
{status?.running ? '⏹ 중지' : '▶ 시작'}
</button>
<button
onclick={handleEmergency}
disabled={actionLoading}
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50
{emergencyConfirm
? 'bg-red-500 text-white animate-pulse'
: 'bg-red-900/50 text-red-300 hover:bg-red-800/50'}"
>
{emergencyConfirm ? '⚠ 한번 더 클릭하면 전량 청산' : '🚨 긴급청산'}
</button>
</div>
</div>
<!-- 감시 소스 설정 -->
<div class="bg-gray-800 rounded-xl p-4 mb-6">
<h2 class="text-sm font-semibold text-white mb-3">감시 소스</h2>
<div class="flex flex-wrap items-start gap-6">
<!-- 체결강도 자동감지 토글 -->
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer shrink-0">
<button
onclick={toggleScanner}
class="w-10 h-5 rounded-full transition-colors relative {watchSource.useScanner ? 'bg-green-600' : 'bg-gray-600'}"
>
<span class="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform {watchSource.useScanner ? 'translate-x-5' : ''}"></span>
</button>
체결강도 자동감지
<span class="text-xs text-gray-500">(거래량 상위 20종목)</span>
</label>
<!-- 테마 선택 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-400 shrink-0">감시 테마</span>
<div class="relative flex-1">
<input
type="text"
bind:value={themeSearch}
onfocus={() => { showThemeDropdown = true }}
onblur={() => { setTimeout(() => { showThemeDropdown = false }, 200) }}
placeholder="테마 검색..."
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
{#if showThemeDropdown && filteredThemes.length > 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-gray-700 border border-gray-600 rounded-lg shadow-xl z-10 max-h-48 overflow-auto">
{#each filteredThemes as theme (theme.code)}
<button
onmousedown={() => addTheme(theme)}
class="w-full px-3 py-2 text-left text-sm text-gray-200 hover:bg-gray-600 transition-colors flex justify-between items-center"
>
<span>{theme.name}</span>
<span class="text-xs {priceClass(theme.fluRt)}">{formatRate(theme.fluRt)}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if watchSource.selectedThemes.length > 0}
<div class="flex flex-wrap gap-2">
{#each watchSource.selectedThemes as theme (theme.code)}
<span class="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-900/40 text-blue-300 text-xs rounded-full">
{theme.name}
<button
onclick={() => removeTheme(theme.code)}
class="text-blue-400 hover:text-blue-200 ml-0.5"
>×</button>
</span>
{/each}
</div>
{:else}
<div class="text-xs text-gray-500">선택된 테마 없음</div>
{/if}
</div>
</div>
</div>
<!-- 탭 -->
<div class="flex gap-1 mb-4 border-b border-gray-700 pb-1">
{#each [['rules', `규칙 (${rules.length})`], ['positions', `포지션 (${positions.length})`], ['logs', '로그']] as [tab, label]}
<button
onclick={() => { activeTab = tab as typeof activeTab }}
class="px-4 py-2 text-sm transition-colors {activeTab === tab ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-400 hover:text-white'}"
>{label}</button>
{/each}
</div>
<!-- 규칙 탭 -->
{#if activeTab === 'rules'}
<div class="mb-3">
<button
onclick={openAddRule}
class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>+ 규칙 추가</button>
</div>
{#if rules.length === 0}
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500">규칙을 추가해보세요</div>
{:else}
<div class="space-y-3">
{#each rules as rule (rule.id)}
<div class="bg-gray-800 rounded-xl p-4">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<span class="font-semibold text-white">{rule.name}</span>
<span class="text-xs px-2 py-0.5 rounded {rule.enabled ? 'bg-green-900/50 text-green-400' : 'bg-gray-700 text-gray-500'}">
{rule.enabled ? '활성' : '비활성'}
</span>
</div>
<div class="text-xs text-gray-400 mt-2 flex flex-wrap gap-3">
<span>진입점수 {rule.minRiseScore}</span>
<span>체결강도 {rule.minCntrStr}</span>
<span>주문금액 {formatPrice(rule.orderAmount)}</span>
<span>최대 {rule.maxPositions}종목</span>
<span>익절 +{rule.takeProfitPct}%</span>
<span>손절 {rule.stopLossPct}%</span>
{#if rule.maxHoldMinutes > 0}
<span>최대 {rule.maxHoldMinutes}</span>
{/if}
</div>
</div>
<div class="flex gap-2 shrink-0">
<button
onclick={() => toggleRule(rule.id)}
class="text-xs px-3 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
>{rule.enabled ? '비활성화' : '활성화'}</button>
<button
onclick={() => openEditRule(rule)}
class="text-xs px-3 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
>수정</button>
<button
onclick={() => deleteRule(rule.id)}
class="text-xs px-3 py-1 rounded bg-red-900/50 hover:bg-red-800/50 text-red-300 transition-colors"
>삭제</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- 포지션 탭 -->
{:else if activeTab === 'positions'}
{#if positions.length === 0}
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500">보유 포지션 없음</div>
{:else}
<div class="bg-gray-800 rounded-xl overflow-hidden">
<table>
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400">종목</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">수량</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">매수가</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">손절가</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">익절가</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">상태</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">진입시각</th>
<th class="px-3 py-3 text-center text-xs font-medium text-gray-400"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
{#each positions as pos (pos.code + pos.orderNo)}
<tr class="hover:bg-gray-700">
<td class="px-4 py-3">
<div class="text-sm font-medium text-white">{pos.name}</div>
<div class="text-xs text-gray-500">{pos.code}</div>
</td>
<td class="px-3 py-3 text-right text-sm text-gray-300">{pos.qty}</td>
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">{formatPrice(pos.buyPrice)}</td>
<td class="px-3 py-3 text-right text-sm text-blue-400">{formatPrice(pos.stopLoss)}</td>
<td class="px-3 py-3 text-right text-sm text-red-400">{formatPrice(pos.takeProfit)}</td>
<td class="px-3 py-3 text-right">
<span class="text-xs px-2 py-0.5 rounded {
pos.status === 'open' ? 'bg-green-900/50 text-green-400' :
pos.status === 'pending' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-gray-700 text-gray-400'
}">{pos.status}</span>
</td>
<td class="px-3 py-3 text-right text-xs text-gray-400">{formatTime(pos.entryTime)}</td>
<td class="px-3 py-3 text-center">
{#if pos.status === 'open' || pos.status === 'pending'}
<button
onclick={() => closePosition(pos.code, pos.name)}
class="text-xs px-2.5 py-1 rounded bg-orange-900/50 hover:bg-orange-800/50 text-orange-300 transition-colors"
>청산</button>
{:else if pos.exitReason}
<span class="text-xs text-gray-500">{pos.exitReason}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- 로그 탭 -->
{:else if activeTab === 'logs'}
<div class="bg-gray-800 rounded-xl p-4 font-mono text-xs space-y-1 max-h-[60vh] overflow-auto">
{#if logs.length === 0}
<div class="text-gray-500 text-center py-8">로그 없음</div>
{/if}
{#each logs as log}
<div class="flex gap-3">
<span class="text-gray-500 shrink-0">{formatTime(log.at)}</span>
<span class="shrink-0 {logLevelClass(log.level)}">[{log.level}]</span>
{#if log.code}
<span class="text-yellow-500 shrink-0">{log.code}</span>
{/if}
<span class="{logLevelClass(log.level)}">{log.message}</span>
</div>
{/each}
</div>
{/if}
<!-- 규칙 편집 모달 -->
<Modal title={editingRule?.id ? '규칙 수정' : '규칙 추가'} open={showRuleModal} on:close={() => { showRuleModal = false }}>
{#if editingRule}
<div class="space-y-4">
<div>
<label class="text-xs text-gray-400 mb-1 block">규칙명</label>
<input
type="text"
bind:value={editingRule.name}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="r-minRiseScore" class="text-xs text-gray-400 mb-1 block">최소 진입점수 (0~100)</label>
<input id="r-minRiseScore" type="number" bind:value={editingRule.minRiseScore}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-minCntrStr" class="text-xs text-gray-400 mb-1 block">최소 체결강도</label>
<input id="r-minCntrStr" type="number" bind:value={editingRule.minCntrStr}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-orderAmount" class="text-xs text-gray-400 mb-1 block">주문금액 (원)</label>
<input id="r-orderAmount" type="number" bind:value={editingRule.orderAmount}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-maxPositions" class="text-xs text-gray-400 mb-1 block">최대 보유종목 수</label>
<input id="r-maxPositions" type="number" bind:value={editingRule.maxPositions}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-stopLoss1Pct" class="text-xs text-gray-400 mb-1 block">1차 손절 % (예: -2)</label>
<input id="r-stopLoss1Pct" type="number" step="0.1" bind:value={editingRule.stopLoss1Pct}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-stopLoss1Count" class="text-xs text-gray-400 mb-1 block">1차 손절 터치 횟수</label>
<input id="r-stopLoss1Count" type="number" bind:value={editingRule.stopLoss1Count}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-stopLossPct" class="text-xs text-gray-400 mb-1 block">2차 손절 % (예: -4)</label>
<input id="r-stopLossPct" type="number" step="0.1" bind:value={editingRule.stopLossPct}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-takeProfitPct" class="text-xs text-gray-400 mb-1 block">익절 % (예: 5)</label>
<input id="r-takeProfitPct" type="number" step="0.1" bind:value={editingRule.takeProfitPct}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
<div>
<label for="r-maxHoldMinutes" class="text-xs text-gray-400 mb-1 block">최대 보유시간 (분, 0=무제한)</label>
<input id="r-maxHoldMinutes" type="number" bind:value={editingRule.maxHoldMinutes}
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white" />
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" bind:checked={editingRule.enabled} class="accent-blue-500" />
활성화
</label>
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" bind:checked={editingRule.requireBullish} class="accent-blue-500" />
AI 호재 신호 필요
</label>
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" bind:checked={editingRule.exitBeforeClose} class="accent-blue-500" />
장 마감 전 청산
</label>
</div>
<div class="flex gap-2 pt-2">
<button
onclick={saveRule}
disabled={actionLoading}
class="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium py-2 rounded-lg transition-colors"
>
{actionLoading ? '저장 중...' : '저장'}
</button>
<button
onclick={() => { showRuleModal = false }}
class="px-4 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
>취소</button>
</div>
</div>
{/if}
</Modal>