Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 11m20s
- `/templates/pages/asset.html`, `/templates/pages/autotrade.html` HTML 템플릿 삭제. - `/static/js/asset.js`, `/static/js/autotrade.js` 클라이언트 스크립트 제거. - 관련 함수 및 초기화 로직 삭제 (자산 조회 및 자동매매 기능 비활성화).
627 lines
25 KiB
Svelte
627 lines
25 KiB
Svelte
<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' | 'trades' | 'logs'>('rules')
|
||
let trades = $state<AutoTradePosition[]>([])
|
||
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, t, l, ws, themes] = await Promise.all([
|
||
autotradeApi.getStatus(),
|
||
autotradeApi.getRules(),
|
||
autotradeApi.getPositions(),
|
||
autotradeApi.getTrades(),
|
||
autotradeApi.getLogs(),
|
||
autotradeApi.getWatchSource(),
|
||
stockApi.getThemes(),
|
||
])
|
||
status = s
|
||
rules = r
|
||
positions = p
|
||
trades = t ?? []
|
||
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.activePositions}개</span>
|
||
<span class="text-sm text-gray-400">오늘 {status.tradeCount}건</span>
|
||
<span class="text-sm {status.totalPL !== 0 ? priceClass(status.totalPL) : 'text-gray-400'}">
|
||
오늘 손익 {status.totalPL >= 0 ? '+' : ''}{formatPrice(status.totalPL)}
|
||
</span>
|
||
{/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})`], ['trades', `거래 (${trades.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 === 'trades'}
|
||
{#if trades.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-center 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>
|
||
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">청산시각</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-700">
|
||
{#each trades as trade (trade.orderNo + (trade.exitTime ?? ''))}
|
||
{@const pl = ((trade.exitPrice ?? 0) - trade.buyPrice) * trade.qty}
|
||
{@const plRate = trade.buyPrice > 0 ? ((trade.exitPrice ?? 0) - trade.buyPrice) / trade.buyPrice * 100 : 0}
|
||
<tr class="hover:bg-gray-700">
|
||
<td class="px-4 py-3">
|
||
<div class="text-sm font-medium text-white">{trade.name}</div>
|
||
<div class="text-xs text-gray-500">{trade.code}</div>
|
||
</td>
|
||
<td class="px-3 py-3 text-center">
|
||
<span class="text-xs px-2 py-0.5 rounded bg-blue-900/50 text-blue-300">매도</span>
|
||
</td>
|
||
<td class="px-3 py-3 text-right text-sm text-gray-300">{trade.qty}</td>
|
||
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">{formatPrice(trade.buyPrice)}</td>
|
||
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(pl)}">{formatPrice(trade.exitPrice ?? 0)}</td>
|
||
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(pl)}">
|
||
{pl >= 0 ? '+' : ''}{formatPrice(pl)}
|
||
</td>
|
||
<td class="px-3 py-3 text-right text-sm {priceClass(plRate)}">
|
||
{plRate >= 0 ? '+' : ''}{plRate.toFixed(2)}%
|
||
</td>
|
||
<td class="px-3 py-3 text-center">
|
||
<span class="text-xs px-2 py-0.5 rounded {
|
||
trade.exitReason === '익절' ? 'bg-red-900/50 text-red-300' :
|
||
trade.exitReason === '손절' ? 'bg-blue-900/50 text-blue-300' :
|
||
'bg-gray-700 text-gray-400'
|
||
}">{trade.exitReason ?? '-'}</span>
|
||
</td>
|
||
<td class="px-3 py-3 text-right text-xs text-gray-400">{formatTime(trade.exitTime ?? '')}</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> |