initial: stock trading dashboard
This commit is contained in:
commit
02356ba4a9
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
8
back/nest-cli.json
Normal file
8
back/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
4672
back/package-lock.json
generated
Normal file
4672
back/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
back/package.json
Normal file
25
back/package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "trading-bot-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"build": "nest build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^10.0.0",
|
||||||
|
"@nestjs/websockets": "^10.0.0",
|
||||||
|
"socket.io": "^4.6.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"reflect-metadata": "^0.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
back/src/app.module.ts
Normal file
10
back/src/app.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BotGateway } from './websocket/bot.gateway';
|
||||||
|
import { BotService } from './bot/bot.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
controllers: [],
|
||||||
|
providers: [BotGateway, BotService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
36
back/src/bot/bot.service.ts
Normal file
36
back/src/bot/bot.service.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BotService {
|
||||||
|
private logger = new Logger('BotService');
|
||||||
|
|
||||||
|
// 봇 상태 데이터 저장
|
||||||
|
private botState = {
|
||||||
|
isRunning: false,
|
||||||
|
positions: [],
|
||||||
|
balance: 0,
|
||||||
|
totalProfit: 0,
|
||||||
|
logs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
getBotState() {
|
||||||
|
return this.botState;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBotState(state: any) {
|
||||||
|
this.botState = { ...this.botState, ...state };
|
||||||
|
this.logger.log('Bot state updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(log: any) {
|
||||||
|
this.botState.logs.push({
|
||||||
|
...log,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 최근 100개만 유지
|
||||||
|
if (this.botState.logs.length > 100) {
|
||||||
|
this.botState.logs = this.botState.logs.slice(-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
back/src/main.ts
Normal file
15
back/src/main.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: ['http://localhost:5173', 'http://stock.wageulwageul.com', 'http://3.34.1.212'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen(3001);
|
||||||
|
console.log('🚀 Trading Bot Dashboard Server running on http://localhost:3001');
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
98
back/src/websocket/bot.gateway.ts
Normal file
98
back/src/websocket/bot.gateway.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: ['http://localhost:5173', 'http://stock.wageulwageul.com', 'http://3.34.1.212', 'https://stock.wageulwageul.com'],
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class BotGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private logger = new Logger('BotGateway');
|
||||||
|
private connectedClients = new Set<string>();
|
||||||
|
|
||||||
|
// 마지막 상태 캐시 - 새 클라이언트 접속 시 즉시 전송
|
||||||
|
private lastUpdate: any = null;
|
||||||
|
private recentLogs: any[] = [];
|
||||||
|
private readonly MAX_LOGS = 100;
|
||||||
|
|
||||||
|
handleConnection(client: Socket) {
|
||||||
|
this.connectedClients.add(client.id);
|
||||||
|
this.logger.log(`Client connected: ${client.id} (Total: ${this.connectedClients.size})`);
|
||||||
|
|
||||||
|
client.emit('connection:success', {
|
||||||
|
message: 'Connected to Trading Bot Dashboard',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 새 클라이언트에게 마지막 상태 즉시 전송
|
||||||
|
if (this.lastUpdate) {
|
||||||
|
client.emit('dashboard:update', this.lastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 로그 재전송 (최대 50개)
|
||||||
|
const logsToSend = this.recentLogs.slice(-50);
|
||||||
|
for (const log of logsToSend) {
|
||||||
|
client.emit('dashboard:log', log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
this.connectedClients.delete(client.id);
|
||||||
|
this.logger.log(`Client disconnected: ${client.id} (Total: ${this.connectedClients.size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('bot:update')
|
||||||
|
handleBotUpdate(client: Socket, payload: any) {
|
||||||
|
this.lastUpdate = payload;
|
||||||
|
this.server.emit('dashboard:update', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('bot:trade')
|
||||||
|
handleTrade(client: Socket, payload: any) {
|
||||||
|
this.logger.log(`Trade executed: ${payload.action} ${payload.stock_code}`);
|
||||||
|
this.server.emit('dashboard:trade', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('bot:log')
|
||||||
|
handleLog(client: Socket, payload: any) {
|
||||||
|
// 로그 캐시에 추가
|
||||||
|
this.recentLogs.push(payload);
|
||||||
|
if (this.recentLogs.length > this.MAX_LOGS) {
|
||||||
|
this.recentLogs.shift();
|
||||||
|
}
|
||||||
|
this.server.emit('dashboard:log', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastUpdate(data: any) {
|
||||||
|
this.lastUpdate = data;
|
||||||
|
this.server.emit('dashboard:update', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastTrade(data: any) {
|
||||||
|
this.server.emit('dashboard:trade', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastLog(message: string, level: string = 'info') {
|
||||||
|
const payload = {
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.recentLogs.push(payload);
|
||||||
|
if (this.recentLogs.length > this.MAX_LOGS) {
|
||||||
|
this.recentLogs.shift();
|
||||||
|
}
|
||||||
|
this.server.emit('dashboard:log', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
back/tsconfig.json
Normal file
21
back/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
1
front/.env.production
Normal file
1
front/.env.production
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_WS_URL=wss://stock.wageulwageul.com
|
||||||
12
front/index.html
Normal file
12
front/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trading Bot Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1346
front/package-lock.json
generated
Normal file
1346
front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
front/package.json
Normal file
18
front/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "trading-bot-dashboard-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.3.0",
|
||||||
|
"socket.io-client": "^4.6.0",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"vue-chartjs": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^4.4.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
475
front/src/App.vue
Normal file
475
front/src/App.vue
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="container">
|
||||||
|
<!-- 봇 상태 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>🤖 봇 상태</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">총자산</span>
|
||||||
|
<span class="value">{{ formatCurrency(balance) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">현금 잔고</span>
|
||||||
|
<span class="value">{{ formatCurrency(cashBalance) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">보유 종목</span>
|
||||||
|
<span class="value">{{ positions.length }}개</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">총 손익</span>
|
||||||
|
<span :class="['value', totalProfit >= 0 ? 'profit' : 'loss']">
|
||||||
|
{{ totalProfit >= 0 ? '+' : '' }}{{ formatCurrency(totalProfit) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 보유 종목 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>💼 보유 종목</h2>
|
||||||
|
<div v-if="positions.length === 0" class="empty">
|
||||||
|
보유 중인 종목이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="positions-list">
|
||||||
|
<div v-for="pos in positions" :key="pos.code" class="position-item">
|
||||||
|
<div class="position-info">
|
||||||
|
<div class="stock-name-section"><span class="stock-name">{{ pos.name }}</span><span class="code">({{ pos.code }})</span></div>
|
||||||
|
<span class="quantity">{{ pos.quantity }}주</span>
|
||||||
|
</div>
|
||||||
|
<div class="position-price">
|
||||||
|
<span class="buy-price">매수: {{ formatCurrency(pos.buyPrice) }}</span>
|
||||||
|
<span class="current-price">현재: {{ formatCurrency(pos.currentPrice) }}</span>
|
||||||
|
<span :class="['profit', pos.profitRate >= 0 ? 'positive' : 'negative']">
|
||||||
|
{{ pos.profitRate >= 0 ? '+' : '' }}{{ pos.profitRate.toFixed(2) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 실시간 거래 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>📊 실시간 거래</h2>
|
||||||
|
<div v-if="trades.length === 0" class="empty">
|
||||||
|
거래 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="trades-list">
|
||||||
|
<div v-for="trade in trades" :key="trade.id" :class="['trade-item', trade.action]">
|
||||||
|
<span class="time">{{ formatTime(trade.timestamp) }}</span>
|
||||||
|
<span :class="['action', trade.action]">{{ trade.action === 'buy' ? '매수' : '매도' }}</span>
|
||||||
|
<span class="stock-info">
|
||||||
|
<span class="stock-name">{{ trade.stock_name || trade.stock_code }}</span>
|
||||||
|
<span class="code">({{ trade.stock_code }})</span>
|
||||||
|
</span>
|
||||||
|
<span class="quantity">{{ trade.quantity }}주</span>
|
||||||
|
<span class="price">@ {{ formatCurrency(trade.price) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card logs">
|
||||||
|
<h2>📝 실시간 로그</h2>
|
||||||
|
<div class="log-container">
|
||||||
|
<div v-for="log in logs" :key="log.id" :class="['log-item', log.level]">
|
||||||
|
<span class="timestamp">{{ formatTime(log.timestamp) }}</span>
|
||||||
|
<span class="message">{{ log.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card trade-history">
|
||||||
|
<h2>📜 거래 내역 (최근 7일)</h2>
|
||||||
|
<div v-if="tradeHistory.length === 0" class="empty">
|
||||||
|
거래 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="trades-list">
|
||||||
|
<div v-for="(trade, idx) in tradeHistory" :key="idx" :class="['trade-item', trade.type]">
|
||||||
|
<span class="time">{{ trade.time.slice(0,2) }}:{{ trade.time.slice(2,4) }}</span>
|
||||||
|
<span :class="['action', trade.type]">{{ trade.type === 'buy' ? '매수' : '매도' }}</span>
|
||||||
|
<span class="code">{{ trade.name }}({{ trade.code }})</span>
|
||||||
|
<span class="quantity">{{ trade.quantity }}주</span>
|
||||||
|
<span class="price">{{ formatCurrency(trade.price) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
const connected = ref(false);
|
||||||
|
const balance = ref(0);
|
||||||
|
const totalProfit = ref(0);
|
||||||
|
const cashBalance = ref(0);
|
||||||
|
const positions = ref([]);
|
||||||
|
const trades = ref([]);
|
||||||
|
const tradeHistory = ref([]);
|
||||||
|
const logs = ref([]);
|
||||||
|
let logIdCounter = 0;
|
||||||
|
let isFirstConnect = true;
|
||||||
|
|
||||||
|
let socket;
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
socket = io(WS_URL);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
connected.value = true;
|
||||||
|
if (isFirstConnect) {
|
||||||
|
logs.value = [];
|
||||||
|
isFirstConnect = false;
|
||||||
|
}
|
||||||
|
addLog('대시보드 연결됨', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
connected.value = false;
|
||||||
|
addLog('대시보드 연결 끊김', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connection:success', (data) => {
|
||||||
|
addLog(data.message, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:update', (data) => {
|
||||||
|
console.log('[Dashboard] Update received:', data);
|
||||||
|
balance.value = data.totalAsset || data.balance || 0;
|
||||||
|
cashBalance.value = data.actualCash || data.balance || 0;
|
||||||
|
totalProfit.value = data.totalProfit || 0;
|
||||||
|
|
||||||
|
if (data.tradeHistory && Array.isArray(data.tradeHistory)) {
|
||||||
|
tradeHistory.value = data.tradeHistory.sort((a, b) => {
|
||||||
|
const dateCompare = b.date.localeCompare(a.date);
|
||||||
|
if (dateCompare !== 0) return dateCompare;
|
||||||
|
return b.time.localeCompare(a.time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.positions && Array.isArray(data.positions)) {
|
||||||
|
positions.value = data.positions.map(pos => ({
|
||||||
|
code: pos.code,
|
||||||
|
name: pos.name || pos.code,
|
||||||
|
quantity: pos.quantity,
|
||||||
|
buyPrice: pos.buyPrice,
|
||||||
|
currentPrice: pos.currentPrice,
|
||||||
|
profit: pos.profit,
|
||||||
|
profitRate: pos.profitRate
|
||||||
|
}));
|
||||||
|
console.log('[Dashboard] Positions updated:', positions.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:trade', (data) => {
|
||||||
|
const trade = {
|
||||||
|
id: Date.now(),
|
||||||
|
...data,
|
||||||
|
timestamp: data.timestamp || new Date().toISOString()
|
||||||
|
};
|
||||||
|
trades.value.unshift(trade);
|
||||||
|
if (trades.value.length > 50) {
|
||||||
|
trades.value = trades.value.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`${data.action === 'buy' ? '매수' : '매도'}: ${data.stock_code} ${data.quantity}주 @ ${formatCurrency(data.price)}`,
|
||||||
|
data.action === 'buy' ? 'success' : 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:log', (data) => {
|
||||||
|
addLog(data.message, data.level || 'info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLog = (message, level = 'info') => {
|
||||||
|
const log = {
|
||||||
|
id: ++logIdCounter,
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
logs.value.unshift(log);
|
||||||
|
if (logs.value.length > 100) {
|
||||||
|
logs.value = logs.value.slice(0, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
return '₩' + Math.round(value).toLocaleString('ko-KR');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
grid-row: 2;
|
||||||
|
min-height: 650px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-history {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value.profit {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value.loss {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-list, .trades-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-item {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info .quantity {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-price {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-price, .current-price {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit.positive {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item.buy {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item.sell {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .time {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action.buy {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action.sell {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .code {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item .timestamp {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.info {
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.success {
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.warning {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.error {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #000 !important;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
529
front/src/App.vue.backup3
Normal file
529
front/src/App.vue.backup3
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<header class="header">
|
||||||
|
<h1>📈 Trading Bot Dashboard</h1>
|
||||||
|
<div class="status">
|
||||||
|
<span :class="['status-indicator', connected ? 'connected' : 'disconnected']"></span>
|
||||||
|
<span>{{ connected ? '연결됨' : '연결 끊김' }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- 봇 상태 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>🤖 봇 상태</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">잔고</span>
|
||||||
|
<span class="value">{{ formatCurrency(balance) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">보유 종목</span>
|
||||||
|
<span class="value">{{ positions.length }}개</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">총 손익</span>
|
||||||
|
<span :class="['value', totalProfit >= 0 ? 'profit' : 'loss']">
|
||||||
|
{{ totalProfit >= 0 ? '+' : '' }}{{ formatCurrency(totalProfit) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 보유 종목 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>💼 보유 종목</h2>
|
||||||
|
<div v-if="positions.length === 0" class="empty">
|
||||||
|
보유 중인 종목이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="positions-list">
|
||||||
|
<div v-for="pos in positions" :key="pos.code" class="position-item">
|
||||||
|
<div class="position-info">
|
||||||
|
<div class="stock-name-section"><span class="stock-name">{{ pos.name }}</span><span class="code">({{ pos.code }})</span></div>
|
||||||
|
<span class="quantity">{{ pos.quantity }}주</span>
|
||||||
|
</div>
|
||||||
|
<div class="position-price">
|
||||||
|
<span class="buy-price">매수: {{ formatCurrency(pos.buyPrice) }}</span>
|
||||||
|
<span class="current-price">현재: {{ formatCurrency(pos.currentPrice) }}</span>
|
||||||
|
<span :class="['profit', pos.profitRate >= 0 ? 'positive' : 'negative']">
|
||||||
|
{{ pos.profitRate >= 0 ? '+' : '' }}{{ pos.profitRate.toFixed(2) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 거래 내역 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>📊 실시간 거래</h2>
|
||||||
|
<div v-if="trades.length === 0" class="empty">
|
||||||
|
거래 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="trades-list">
|
||||||
|
<div v-for="trade in trades" :key="trade.id" :class="['trade-item', trade.action]">
|
||||||
|
<span class="time">{{ formatTime(trade.timestamp) }}</span>
|
||||||
|
<span :class="['action', trade.action]">{{ trade.action === 'buy' ? '매수' : '매도' }}</span>
|
||||||
|
<span class="stock-info">
|
||||||
|
<span class="stock-name">{{ trade.stock_name || trade.stock_code }}</span>
|
||||||
|
<span class="code">({{ trade.stock_code }})</span>
|
||||||
|
</span>
|
||||||
|
<span class="quantity">{{ trade.quantity }}주</span>
|
||||||
|
<span class="price">@ {{ formatCurrency(trade.price) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="card logs">
|
||||||
|
<h2>📝 실시간 로그</h2>
|
||||||
|
<div class="log-container">
|
||||||
|
<div v-for="log in logs" :key="log.id" :class="['log-item', log.level]">
|
||||||
|
<span class="timestamp">{{ formatTime(log.timestamp) }}</span>
|
||||||
|
<span class="message">{{ log.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>📜 거래 내역 (최근 7일)</h2>
|
||||||
|
<div v-if="tradeHistory.length === 0" class="empty">
|
||||||
|
거래 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="trades-list">
|
||||||
|
<div v-for="(trade, idx) in tradeHistory" :key="idx" :class="['trade-item', trade.type]">
|
||||||
|
<span class="time">{{ trade.time.slice(0,2) }}:{{ trade.time.slice(2,4) }}</span>
|
||||||
|
<span :class="['action', trade.type]">{{ trade.type === 'buy' ? '매수' : '매도' }}</span>
|
||||||
|
<span class="code">{{ trade.name }}({{ trade.code }})</span>
|
||||||
|
<span class="quantity">{{ trade.quantity }}주</span>
|
||||||
|
<span class="price">{{ formatCurrency(trade.price) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
const connected = ref(false);
|
||||||
|
const balance = ref(0);
|
||||||
|
const totalProfit = ref(0);
|
||||||
|
const positions = ref([]);
|
||||||
|
const trades = ref([]);
|
||||||
|
const tradeHistory = ref([]);
|
||||||
|
const logs = ref([]);
|
||||||
|
|
||||||
|
let socket;
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Socket.io 연결
|
||||||
|
socket = io(WS_URL);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
connected.value = true;
|
||||||
|
addLog('대시보드 연결됨', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
connected.value = false;
|
||||||
|
addLog('대시보드 연결 끊김', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connection:success', (data) => {
|
||||||
|
addLog(data.message, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:update', (data) => {
|
||||||
|
console.log('[Dashboard] Update received:', data);
|
||||||
|
balance.value = data.balance || 0;
|
||||||
|
totalProfit.value = data.totalProfit || 0;
|
||||||
|
|
||||||
|
|
||||||
|
// 거래 내역 업데이트 (최신순 정렬)
|
||||||
|
if (data.tradeHistory && Array.isArray(data.tradeHistory)) {
|
||||||
|
tradeHistory.value = data.tradeHistory.sort((a, b) => {
|
||||||
|
const dateCompare = b.date.localeCompare(a.date);
|
||||||
|
if (dateCompare !== 0) return dateCompare;
|
||||||
|
return b.time.localeCompare(a.time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// positions는 배열로 받음
|
||||||
|
if (data.positions && Array.isArray(data.positions)) {
|
||||||
|
positions.value = data.positions.map(pos => ({
|
||||||
|
code: pos.code,
|
||||||
|
name: pos.name || pos.code,
|
||||||
|
quantity: pos.quantity,
|
||||||
|
buyPrice: pos.buyPrice,
|
||||||
|
currentPrice: pos.currentPrice,
|
||||||
|
profit: pos.profit,
|
||||||
|
profitRate: pos.profitRate
|
||||||
|
}));
|
||||||
|
console.log('[Dashboard] Positions updated:', positions.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:trade', (data) => {
|
||||||
|
const trade = {
|
||||||
|
id: Date.now(),
|
||||||
|
...data,
|
||||||
|
timestamp: data.timestamp || new Date().toISOString()
|
||||||
|
};
|
||||||
|
trades.value.unshift(trade);
|
||||||
|
if (trades.value.length > 50) {
|
||||||
|
trades.value = trades.value.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`${data.action === 'buy' ? '매수' : '매도'}: ${data.stock_code} ${data.quantity}주 @ ${formatCurrency(data.price)}`,
|
||||||
|
data.action === 'buy' ? 'success' : 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:log', (data) => {
|
||||||
|
addLog(data.message, data.level || 'info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addLog(message, level = 'info') {
|
||||||
|
logs.value.unshift({
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logs.value.length > 100) {
|
||||||
|
logs.value = logs.value.slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount) {
|
||||||
|
if (typeof amount !== 'number') return '₩0';
|
||||||
|
return new Intl.NumberFormat('ko-KR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'KRW',
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: white;
|
||||||
|
padding: 20px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connected {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.disconnected {
|
||||||
|
background: #ef4444;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 실시간 로그를 2칸 차지 */
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / 3; /* 1번 열부터 3번 열 전까지 (2칸) */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value.profit {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value.loss {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-list, .trades-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-item {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info .code {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info .quantity {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-price {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-price, .current-price {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit.positive {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item.buy {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item.sell {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .time {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action.buy {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action.sell {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .code {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 650px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
color: #1a202c;
|
||||||
|
color: #1a202c;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item .timestamp {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.info {
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.success {
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.warning {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.error {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #000 !important;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
535
front/src/App.vue.backup4
Normal file
535
front/src/App.vue.backup4
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<header class="header">
|
||||||
|
<h1>📈 Trading Bot Dashboard</h1>
|
||||||
|
<div class="status">
|
||||||
|
<span :class="['status-indicator', connected ? 'connected' : 'disconnected']"></span>
|
||||||
|
<span>{{ connected ? '연결됨' : '연결 끊김' }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- 봇 상태 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>🤖 봇 상태</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">잔고</span>
|
||||||
|
<span class="value">{{ formatCurrency(balance) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">보유 종목</span>
|
||||||
|
<span class="value">{{ positions.length }}개</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="label">총 손익</span>
|
||||||
|
<span :class="['value', totalProfit >= 0 ? 'profit' : 'loss']">
|
||||||
|
{{ totalProfit >= 0 ? '+' : '' }}{{ formatCurrency(totalProfit) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 보유 종목 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>💼 보유 종목</h2>
|
||||||
|
<div v-if="positions.length === 0" class="empty">
|
||||||
|
보유 중인 종목이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="positions-list">
|
||||||
|
<div v-for="pos in positions" :key="pos.code" class="position-item">
|
||||||
|
<div class="position-info">
|
||||||
|
<div class="stock-name-section"><span class="stock-name">{{ pos.name }}</span><span class="code">({{ pos.code }})</span></div>
|
||||||
|
<span class="quantity">{{ pos.quantity }}주</span>
|
||||||
|
</div>
|
||||||
|
<div class="position-price">
|
||||||
|
<span class="buy-price">매수: {{ formatCurrency(pos.buyPrice) }}</span>
|
||||||
|
<span class="current-price">현재: {{ formatCurrency(pos.currentPrice) }}</span>
|
||||||
|
<span :class="['profit', pos.profitRate >= 0 ? 'positive' : 'negative']">
|
||||||
|
{{ pos.profitRate >= 0 ? '+' : '' }}{{ pos.profitRate.toFixed(2) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 거래 내역 -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>📊 실시간 거래</h2>
|
||||||
|
<div v-if="trades.length === 0" class="empty">
|
||||||
|
거래 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="trades-list">
|
||||||
|
<div v-for="trade in trades" :key="trade.id" :class="['trade-item', trade.action]">
|
||||||
|
<span class="time">{{ formatTime(trade.timestamp) }}</span>
|
||||||
|
<span :class="['action', trade.action]">{{ trade.action === 'buy' ? '매수' : '매도' }}</span>
|
||||||
|
<span class="stock-info">
|
||||||
|
<span class="stock-name">{{ trade.stock_name || trade.stock_code }}</span>
|
||||||
|
<span class="code">({{ trade.stock_code }})</span>
|
||||||
|
</span>
|
||||||
|
<span class="quantity">{{ trade.quantity }}주</span>
|
||||||
|
<span class="price">@ {{ formatCurrency(trade.price) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="card logs">
|
||||||
|
<h2>📝 실시간 로그</h2>
|
||||||
|
<div class="log-container">
|
||||||
|
<div v-for="log in logs" :key="log.id" :class="['log-item', log.level]">
|
||||||
|
<span class="timestamp">{{ formatTime(log.timestamp) }}</span>
|
||||||
|
<span class="message">{{ log.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card trade-history">
|
||||||
|
<h2>📜 거래 내역 (최근 7일)</h2>
|
||||||
|
<div v-if="tradeHistory.length === 0" class="empty">
|
||||||
|
거래 내역이 없습니다
|
||||||
|
</div>
|
||||||
|
<div v-else class="trades-list">
|
||||||
|
<div v-for="(trade, idx) in tradeHistory" :key="idx" :class="['trade-item', trade.type]">
|
||||||
|
<span class="time">{{ trade.time.slice(0,2) }}:{{ trade.time.slice(2,4) }}</span>
|
||||||
|
<span :class="['action', trade.type]">{{ trade.type === 'buy' ? '매수' : '매도' }}</span>
|
||||||
|
<span class="code">{{ trade.name }}({{ trade.code }})</span>
|
||||||
|
<span class="quantity">{{ trade.quantity }}주</span>
|
||||||
|
<span class="price">{{ formatCurrency(trade.price) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
const connected = ref(false);
|
||||||
|
const balance = ref(0);
|
||||||
|
const totalProfit = ref(0);
|
||||||
|
const positions = ref([]);
|
||||||
|
const trades = ref([]);
|
||||||
|
const tradeHistory = ref([]);
|
||||||
|
const logs = ref([]);
|
||||||
|
|
||||||
|
let socket;
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Socket.io 연결
|
||||||
|
socket = io(WS_URL);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
connected.value = true;
|
||||||
|
addLog('대시보드 연결됨', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
connected.value = false;
|
||||||
|
addLog('대시보드 연결 끊김', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connection:success', (data) => {
|
||||||
|
addLog(data.message, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:update', (data) => {
|
||||||
|
console.log('[Dashboard] Update received:', data);
|
||||||
|
balance.value = data.balance || 0;
|
||||||
|
totalProfit.value = data.totalProfit || 0;
|
||||||
|
|
||||||
|
|
||||||
|
// 거래 내역 업데이트 (최신순 정렬)
|
||||||
|
if (data.tradeHistory && Array.isArray(data.tradeHistory)) {
|
||||||
|
tradeHistory.value = data.tradeHistory.sort((a, b) => {
|
||||||
|
const dateCompare = b.date.localeCompare(a.date);
|
||||||
|
if (dateCompare !== 0) return dateCompare;
|
||||||
|
return b.time.localeCompare(a.time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// positions는 배열로 받음
|
||||||
|
if (data.positions && Array.isArray(data.positions)) {
|
||||||
|
positions.value = data.positions.map(pos => ({
|
||||||
|
code: pos.code,
|
||||||
|
name: pos.name || pos.code,
|
||||||
|
quantity: pos.quantity,
|
||||||
|
buyPrice: pos.buyPrice,
|
||||||
|
currentPrice: pos.currentPrice,
|
||||||
|
profit: pos.profit,
|
||||||
|
profitRate: pos.profitRate
|
||||||
|
}));
|
||||||
|
console.log('[Dashboard] Positions updated:', positions.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:trade', (data) => {
|
||||||
|
const trade = {
|
||||||
|
id: Date.now(),
|
||||||
|
...data,
|
||||||
|
timestamp: data.timestamp || new Date().toISOString()
|
||||||
|
};
|
||||||
|
trades.value.unshift(trade);
|
||||||
|
if (trades.value.length > 50) {
|
||||||
|
trades.value = trades.value.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`${data.action === 'buy' ? '매수' : '매도'}: ${data.stock_code} ${data.quantity}주 @ ${formatCurrency(data.price)}`,
|
||||||
|
data.action === 'buy' ? 'success' : 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dashboard:log', (data) => {
|
||||||
|
addLog(data.message, data.level || 'info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addLog(message, level = 'info') {
|
||||||
|
logs.value.unshift({
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logs.value.length > 100) {
|
||||||
|
logs.value = logs.value.slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount) {
|
||||||
|
if (typeof amount !== 'number') return '₩0';
|
||||||
|
return new Intl.NumberFormat('ko-KR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'KRW',
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: white;
|
||||||
|
padding: 20px 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connected {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.disconnected {
|
||||||
|
background: #ef4444;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 실시간 로그를 2칸 차지 */
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / 3; /* 1-2번 열 */
|
||||||
|
grid-row: 2; /* 2번째 행 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-history {
|
||||||
|
grid-column: 3; /* 3번째 열 */
|
||||||
|
grid-row: 2; /* 2번째 행 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value.profit {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item .value.loss {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positions-list, .trades-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-item {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info .code {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-info .quantity {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-price {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-price, .current-price {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit.positive {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profit.negative {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item.buy {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item.sell {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .time {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action.buy {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .action.sell {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-item .code {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 650px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
color: #1a202c;
|
||||||
|
color: #1a202c;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item .timestamp {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.info {
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.success {
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.warning {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item.error {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-name {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #000 !important;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
front/src/main.js
Normal file
5
front/src/main.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
15
front/src/style.css
Normal file
15
front/src/style.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0a0e27;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
9
front/vite.config.js
Normal file
9
front/vite.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user