프론트엔드 추가 및 자동매매 로직 개선:
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 1m42s
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:
@@ -40,7 +40,8 @@
|
|||||||
"Bash(docker compose:*)",
|
"Bash(docker compose:*)",
|
||||||
"Bash(tree:*)",
|
"Bash(tree:*)",
|
||||||
"Bash(go vet:*)",
|
"Bash(go vet:*)",
|
||||||
"Bash(python3:*)"
|
"Bash(python3:*)",
|
||||||
|
"Bash(head:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,8 @@ tasks.md
|
|||||||
# Docker compose
|
# Docker compose
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
Caddyfile
|
Caddyfile
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/build/
|
||||||
|
frontend/.svelte-kit/
|
||||||
|
|||||||
@@ -12,3 +12,10 @@ CACHE_TTL_SECONDS=1
|
|||||||
# WebSocket 설정
|
# WebSocket 설정
|
||||||
WS_MAX_CLIENTS=100
|
WS_MAX_CLIENTS=100
|
||||||
WS_POLL_INTERVAL_MS=1000
|
WS_POLL_INTERVAL_MS=1000
|
||||||
|
|
||||||
|
# CORS 설정 (SvelteKit 개발 서버용, 프로덕션에서는 비워둠)
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# 관리자 계정
|
||||||
|
ADMIN_ID=admin
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,4 +1,15 @@
|
|||||||
# ── 빌드 스테이지 ──────────────────────────────────────────
|
# ── 프론트엔드 빌드 스테이지 ─────────────────────────────────
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Go 빌드 스테이지 ─────────────────────────────────────────
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -32,6 +43,9 @@ COPY --from=builder /app/templates/ templates/
|
|||||||
COPY --from=builder /app/static/ static/
|
COPY --from=builder /app/static/ static/
|
||||||
COPY --from=builder /app/CORPCODE.xml .
|
COPY --from=builder /app/CORPCODE.xml .
|
||||||
|
|
||||||
|
# 프론트엔드 빌드 결과물 복사
|
||||||
|
COPY --from=frontend /app/build frontend/build/
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# .env 파일은 컨테이너 실행 시 마운트하거나 환경변수로 주입
|
# .env 파일은 컨테이너 실행 시 마운트하거나 환경변수로 주입
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Config struct {
|
|||||||
WSPollIntervalMS int
|
WSPollIntervalMS int
|
||||||
AdminID string // 관리자 ID
|
AdminID string // 관리자 ID
|
||||||
AdminPassword string // 관리자 비밀번호
|
AdminPassword string // 관리자 비밀번호
|
||||||
|
CORSOrigin string // CORS 허용 오리진 (예: http://localhost:5173)
|
||||||
}
|
}
|
||||||
|
|
||||||
var App *Config
|
var App *Config
|
||||||
@@ -52,6 +53,7 @@ func Load() {
|
|||||||
WSPollIntervalMS: getEnvInt("WS_POLL_INTERVAL_MS", 1000),
|
WSPollIntervalMS: getEnvInt("WS_POLL_INTERVAL_MS", 1000),
|
||||||
AdminID: getEnv("ADMIN_ID", "admin"),
|
AdminID: getEnv("ADMIN_ID", "admin"),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||||
|
CORSOrigin: getEnv("CORS_ORIGIN", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
data/watchlist.json
Normal file
10
data/watchlist.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "000660",
|
||||||
|
"name": "SK하이닉스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "005930",
|
||||||
|
"name": "삼성전자"
|
||||||
|
}
|
||||||
|
]
|
||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.vscode/
|
||||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
23
frontend/Dockerfile
Normal file
23
frontend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ── 빌드 스테이지 ──────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 설치 (캐시 활용)
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 소스 복사 및 빌드
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── 실행 스테이지 ──────────────────────────────────────────
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# nginx 설정 복사
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 빌드 결과물 복사
|
||||||
|
COPY --from=builder /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
npx sv@0.13.2 create --template minimal --types ts --install npm frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
42
frontend/nginx.conf
Normal file
42
frontend/nginx.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback: 파일이 없으면 index.html로
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 에셋 캐싱 (해시된 파일명)
|
||||||
|
location /_app/immutable/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API/WS는 백엔드로 프록시 (docker-compose에서 설정)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /login {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /logout {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
2435
frontend/package-lock.json
generated
Normal file
2435
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"svelte": "^5.54.0",
|
||||||
|
"svelte-check": "^4.4.2",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lightweight-charts": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/app.css
Normal file
33
frontend/src/app.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* 기본 다크 테마 */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gray-900 text-gray-100 antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤바 스타일 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-600 rounded-full;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 테이블 공통 */
|
||||||
|
table {
|
||||||
|
@apply w-full border-collapse;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
@apply sticky top-0 bg-gray-800 z-10;
|
||||||
|
}
|
||||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.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" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
frontend/src/lib/api/account.ts
Normal file
52
frontend/src/lib/api/account.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type { AccountBalance, OrderRequest, OrderResult, PendingOrder, OrderHistory } from './types'
|
||||||
|
|
||||||
|
export const accountApi = {
|
||||||
|
// 계좌 잔고
|
||||||
|
getBalance: () =>
|
||||||
|
apiFetch<AccountBalance>('/api/account/balance'),
|
||||||
|
|
||||||
|
// 미체결 주문
|
||||||
|
getPending: () =>
|
||||||
|
apiFetch<PendingOrder[]>('/api/account/pending'),
|
||||||
|
|
||||||
|
// 주문 내역
|
||||||
|
getHistory: () =>
|
||||||
|
apiFetch<OrderHistory[]>('/api/account/history'),
|
||||||
|
|
||||||
|
// 예수금
|
||||||
|
getDeposit: () =>
|
||||||
|
apiFetch<unknown>('/api/account/deposit'),
|
||||||
|
|
||||||
|
// 주문 가능 금액/수량
|
||||||
|
getOrderable: (code: string, price: number) =>
|
||||||
|
apiFetch<unknown>(`/api/account/orderable?code=${code}&price=${price}`),
|
||||||
|
|
||||||
|
// 매수 주문
|
||||||
|
buy: (req: OrderRequest) =>
|
||||||
|
apiFetch<OrderResult>('/api/order/buy', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 매도 주문
|
||||||
|
sell: (req: OrderRequest) =>
|
||||||
|
apiFetch<OrderResult>('/api/order/sell', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 주문 정정
|
||||||
|
modify: (req: OrderRequest & { orderNo: string }) =>
|
||||||
|
apiFetch<OrderResult>('/api/order/modify', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 주문 취소
|
||||||
|
cancel: (orderNo: string, code: string, qty: number) =>
|
||||||
|
apiFetch<{ ok: boolean }>('/api/order', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ orderNo, code, qty }),
|
||||||
|
}),
|
||||||
|
}
|
||||||
69
frontend/src/lib/api/autotrade.ts
Normal file
69
frontend/src/lib/api/autotrade.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type { AutoTradeRule, AutoTradePosition, AutoTradeLog, AutoTradeStatus, AutoTradeWatchSource } from './types'
|
||||||
|
|
||||||
|
export const autotradeApi = {
|
||||||
|
// 엔진 상태
|
||||||
|
getStatus: () =>
|
||||||
|
apiFetch<AutoTradeStatus>('/api/autotrade/status'),
|
||||||
|
|
||||||
|
// 규칙 목록
|
||||||
|
getRules: () =>
|
||||||
|
apiFetch<AutoTradeRule[]>('/api/autotrade/rules'),
|
||||||
|
|
||||||
|
// 규칙 추가
|
||||||
|
addRule: (rule: Omit<AutoTradeRule, 'id' | 'createdAt'>) =>
|
||||||
|
apiFetch<AutoTradeRule>('/api/autotrade/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(rule),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
updateRule: (id: string, rule: Partial<AutoTradeRule>) =>
|
||||||
|
apiFetch<AutoTradeRule>(`/api/autotrade/rules/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(rule),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
deleteRule: (id: string) =>
|
||||||
|
apiFetch<{ ok: boolean }>(`/api/autotrade/rules/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// 규칙 활성화/비활성화
|
||||||
|
toggleRule: (id: string) =>
|
||||||
|
apiFetch<AutoTradeRule>(`/api/autotrade/rules/${id}/toggle`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// 포지션 목록
|
||||||
|
getPositions: () =>
|
||||||
|
apiFetch<AutoTradePosition[]>('/api/autotrade/positions'),
|
||||||
|
|
||||||
|
// 이벤트 로그
|
||||||
|
getLogs: () =>
|
||||||
|
apiFetch<AutoTradeLog[]>('/api/autotrade/logs'),
|
||||||
|
|
||||||
|
// 감시 소스 조회
|
||||||
|
getWatchSource: () =>
|
||||||
|
apiFetch<AutoTradeWatchSource>('/api/autotrade/watch-source'),
|
||||||
|
|
||||||
|
// 감시 소스 설정
|
||||||
|
setWatchSource: (src: AutoTradeWatchSource) =>
|
||||||
|
apiFetch<{ ok: boolean }>('/api/autotrade/watch-source', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(src),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 엔진 시작
|
||||||
|
start: () =>
|
||||||
|
apiFetch<{ running: boolean }>('/api/autotrade/start', { method: 'POST' }),
|
||||||
|
|
||||||
|
// 엔진 중지
|
||||||
|
stop: () =>
|
||||||
|
apiFetch<{ running: boolean }>('/api/autotrade/stop', { method: 'POST' }),
|
||||||
|
|
||||||
|
// 긴급 전량 청산
|
||||||
|
emergency: () =>
|
||||||
|
apiFetch<{ ok: boolean }>('/api/autotrade/emergency', { method: 'POST' }),
|
||||||
|
|
||||||
|
// 개별 포지션 청산
|
||||||
|
closePosition: (code: string) =>
|
||||||
|
apiFetch<{ ok: boolean }>(`/api/autotrade/positions/${code}/close`, { method: 'POST' }),
|
||||||
|
}
|
||||||
30
frontend/src/lib/api/client.ts
Normal file
30
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
// API 공통 fetch 래퍼 — 세션 쿠키 자동 전송, 401 시 /login 리다이렉트
|
||||||
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
goto('/login')
|
||||||
|
throw new Error('unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
let msg = text
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text)
|
||||||
|
msg = json.error ?? text
|
||||||
|
} catch {}
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content 처리
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
81
frontend/src/lib/api/stock.ts
Normal file
81
frontend/src/lib/api/stock.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type {
|
||||||
|
StockPrice, CandleData, StockInfo, ThemeGroup, ThemeDetail,
|
||||||
|
Kospi200Stock, IndexQuote, Signal, NewsItem, Disclosure, WatchlistItem
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export const stockApi = {
|
||||||
|
// 현재가 조회
|
||||||
|
getPrice: (code: string) =>
|
||||||
|
apiFetch<StockPrice>(`/api/stock/${code}`),
|
||||||
|
|
||||||
|
// 일봉 차트 데이터
|
||||||
|
getChart: (code: string, type: 'D' | 'W' | 'M' | 'm' = 'D', count?: number) => {
|
||||||
|
const params = new URLSearchParams({ type })
|
||||||
|
if (count) params.set('count', String(count))
|
||||||
|
return apiFetch<CandleData[]>(`/api/stock/${code}/chart?${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 종목 검색
|
||||||
|
search: (q: string) =>
|
||||||
|
apiFetch<StockInfo[]>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||||
|
|
||||||
|
// 지수 (코스피/코스닥/다우/나스닥)
|
||||||
|
getIndices: () =>
|
||||||
|
apiFetch<IndexQuote[]>('/api/indices'),
|
||||||
|
|
||||||
|
// 스캐너 신호
|
||||||
|
getSignals: () =>
|
||||||
|
apiFetch<Signal[]>('/api/signal'),
|
||||||
|
|
||||||
|
// 관심종목 신호
|
||||||
|
getWatchlistSignals: () =>
|
||||||
|
apiFetch<Signal[]>('/api/watchlist-signal'),
|
||||||
|
|
||||||
|
// 스캐너 상태
|
||||||
|
getScannerStatus: () =>
|
||||||
|
apiFetch<{ running: boolean }>('/api/scanner/status'),
|
||||||
|
|
||||||
|
// 스캐너 토글
|
||||||
|
toggleScanner: () =>
|
||||||
|
apiFetch<{ running: boolean }>('/api/scanner/toggle', { method: 'POST' }),
|
||||||
|
|
||||||
|
// 뉴스
|
||||||
|
getNews: (code?: string) => {
|
||||||
|
const params = code ? `?code=${code}` : ''
|
||||||
|
return apiFetch<NewsItem[]>(`/api/news${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 공시
|
||||||
|
getDisclosures: (code?: string) => {
|
||||||
|
const params = code ? `?code=${code}` : ''
|
||||||
|
return apiFetch<Disclosure[]>(`/api/disclosure${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 코스피200
|
||||||
|
getKospi200: () =>
|
||||||
|
apiFetch<Kospi200Stock[]>('/api/kospi200'),
|
||||||
|
|
||||||
|
// 테마 목록
|
||||||
|
getThemes: () =>
|
||||||
|
apiFetch<ThemeGroup[]>('/api/themes'),
|
||||||
|
|
||||||
|
// 테마 구성종목
|
||||||
|
getThemeStocks: (code: string) =>
|
||||||
|
apiFetch<ThemeDetail>(`/api/themes/${code}`),
|
||||||
|
|
||||||
|
// 관심종목 목록
|
||||||
|
getWatchlist: () =>
|
||||||
|
apiFetch<WatchlistItem[]>('/api/watchlist'),
|
||||||
|
|
||||||
|
// 관심종목 추가
|
||||||
|
addWatchlist: (code: string, name: string) =>
|
||||||
|
apiFetch<{ ok: boolean }>('/api/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code, name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 관심종목 삭제
|
||||||
|
removeWatchlist: (code: string) =>
|
||||||
|
apiFetch<{ ok: boolean }>(`/api/watchlist/${code}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
288
frontend/src/lib/api/types.ts
Normal file
288
frontend/src/lib/api/types.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// Go models/ 구조체와 1:1 대응하는 TypeScript 타입 정의
|
||||||
|
|
||||||
|
export interface StockPrice {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
currentPrice: number
|
||||||
|
changePrice: number
|
||||||
|
changeRate: number
|
||||||
|
volume: number
|
||||||
|
tradeMoney: number
|
||||||
|
high: number
|
||||||
|
low: number
|
||||||
|
open: number
|
||||||
|
tradeTime: string
|
||||||
|
tradeVolume: number
|
||||||
|
askPrice1: number
|
||||||
|
bidPrice1: number
|
||||||
|
marketStatus: string
|
||||||
|
cntrStr: number
|
||||||
|
market: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandleData {
|
||||||
|
time: number
|
||||||
|
open: number
|
||||||
|
high: number
|
||||||
|
low: number
|
||||||
|
close: number
|
||||||
|
volume: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockInfo {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
market: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBookEntry {
|
||||||
|
price: number
|
||||||
|
volume: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBook {
|
||||||
|
code: string
|
||||||
|
askTime: string
|
||||||
|
asks: OrderBookEntry[]
|
||||||
|
bids: OrderBookEntry[]
|
||||||
|
totalAskVol: number
|
||||||
|
totalBidVol: number
|
||||||
|
expectedPrc: number
|
||||||
|
expectedVol: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeGroup {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
stockCount: number
|
||||||
|
fluSig: string
|
||||||
|
fluRt: number
|
||||||
|
risingCount: number
|
||||||
|
fallCount: number
|
||||||
|
periodRt: number
|
||||||
|
mainStock: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeStock {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
curPrc: number
|
||||||
|
fluSig: string
|
||||||
|
predPre: number
|
||||||
|
fluRt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeDetail {
|
||||||
|
fluRt: number
|
||||||
|
periodRt: number
|
||||||
|
stocks: ThemeStock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Kospi200Stock {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
curPrc: number
|
||||||
|
predPreSig: string
|
||||||
|
predPre: number
|
||||||
|
fluRt: number
|
||||||
|
volume: number
|
||||||
|
open: number
|
||||||
|
high: number
|
||||||
|
low: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoTradeRule {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
minRiseScore: number
|
||||||
|
minCntrStr: number
|
||||||
|
requireBullish: boolean
|
||||||
|
orderAmount: number
|
||||||
|
maxPositions: number
|
||||||
|
stopLoss1Pct: number
|
||||||
|
stopLoss1Count: number
|
||||||
|
stopLossPct: number
|
||||||
|
takeProfitPct: number
|
||||||
|
maxHoldMinutes: number
|
||||||
|
exitBeforeClose: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoTradePosition {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
buyPrice: number
|
||||||
|
qty: number
|
||||||
|
orderNo: string
|
||||||
|
entryTime: string
|
||||||
|
ruleId: string
|
||||||
|
stopLoss1: number
|
||||||
|
stopLoss1Touches: number
|
||||||
|
stopLoss: number
|
||||||
|
takeProfit: number
|
||||||
|
status: string
|
||||||
|
exitTime?: string
|
||||||
|
exitPrice?: number
|
||||||
|
exitReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoTradeLog {
|
||||||
|
at: string
|
||||||
|
level: string
|
||||||
|
message: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoTradeStatus {
|
||||||
|
running: boolean
|
||||||
|
positions: number
|
||||||
|
todayTrades: number
|
||||||
|
todayProfit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistItem {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexQuote {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
change: number
|
||||||
|
changeRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalStock — scanner_service.go의 SignalStock과 1:1 대응
|
||||||
|
export interface Signal {
|
||||||
|
// StockPrice 임베드 필드
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
currentPrice: number
|
||||||
|
changePrice: number
|
||||||
|
changeRate: number
|
||||||
|
volume: number
|
||||||
|
high: number
|
||||||
|
low: number
|
||||||
|
open: number
|
||||||
|
cntrStr: number
|
||||||
|
market: string
|
||||||
|
// 시그널 전용 필드
|
||||||
|
prevCntrStr: number
|
||||||
|
risingCount: number
|
||||||
|
detectedAt: string
|
||||||
|
sentiment: string // 호재/악재/중립/정보없음
|
||||||
|
sentimentReason: string
|
||||||
|
targetPrice: number
|
||||||
|
targetReason: string
|
||||||
|
riseScore: number
|
||||||
|
riseLabel: string // "매우 높음" | "높음" | ""
|
||||||
|
nextDayTrend: string // 상승 | 하락 | 횡보
|
||||||
|
nextDayConf: string // 높음 | 보통 | 낮음
|
||||||
|
nextDayReason: string
|
||||||
|
// 복합 지표
|
||||||
|
totalAskVol: number
|
||||||
|
totalBidVol: number
|
||||||
|
askBidRatio: number
|
||||||
|
volDelta: number
|
||||||
|
volRatio: number
|
||||||
|
upperWick: number
|
||||||
|
pricePos: number
|
||||||
|
signalType: string // 강한매수 | 매수우세 | 물량소화 | 추격위험 | 약한상승
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsItem {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
publishedAt: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Disclosure {
|
||||||
|
rceptNo: string
|
||||||
|
corpName: string
|
||||||
|
reportNm: string
|
||||||
|
rceptDt: string
|
||||||
|
flrNm: string
|
||||||
|
url: string
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountBalance {
|
||||||
|
totalAsset: number
|
||||||
|
deposit: number
|
||||||
|
stockValue: number
|
||||||
|
profitLoss: number
|
||||||
|
profitRate: number
|
||||||
|
stocks: BalanceStock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceStock {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
qty: number
|
||||||
|
buyPrice: number
|
||||||
|
curPrice: number
|
||||||
|
profitLoss: number
|
||||||
|
profitRate: number
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미체결 주문 항목
|
||||||
|
export interface PendingOrder {
|
||||||
|
ordNo: string // 주문번호
|
||||||
|
stkCd: string // 종목코드
|
||||||
|
stkNm: string // 종목명
|
||||||
|
ordQty: string // 주문수량
|
||||||
|
ordPric: string // 주문가격
|
||||||
|
osoQty: string // 미체결수량
|
||||||
|
ioTpNm: string // 주문구분
|
||||||
|
trdeTp: string // 매매구분 (1:매도, 2:매수)
|
||||||
|
tm: string // 시간
|
||||||
|
cntrPric: string // 체결가
|
||||||
|
cntrQty: string // 체결량
|
||||||
|
}
|
||||||
|
|
||||||
|
// 체결내역 항목
|
||||||
|
export interface OrderHistory {
|
||||||
|
ordNo: string
|
||||||
|
stkNm: string
|
||||||
|
ioTpNm: string
|
||||||
|
ordPric: string
|
||||||
|
ordQty: string
|
||||||
|
cntrPric: string
|
||||||
|
cntrQty: string
|
||||||
|
osoQty: string
|
||||||
|
trdeCmsn: string
|
||||||
|
trdeTax: string
|
||||||
|
ordStt: string
|
||||||
|
trdeTp: string
|
||||||
|
ordTm: string
|
||||||
|
stkCd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderRequest {
|
||||||
|
code: string
|
||||||
|
qty: number
|
||||||
|
price: number
|
||||||
|
tradeType?: string
|
||||||
|
exchange?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderResult {
|
||||||
|
orderNo: string
|
||||||
|
code: string
|
||||||
|
qty: number
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeRef {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoTradeWatchSource {
|
||||||
|
useScanner: boolean
|
||||||
|
selectedThemes: ThemeRef[]
|
||||||
|
}
|
||||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
134
frontend/src/lib/components/CandleChart.svelte
Normal file
134
frontend/src/lib/components/CandleChart.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { createChart, ColorType, CrosshairMode, CandlestickSeries } from 'lightweight-charts'
|
||||||
|
import type { IChartApi, ISeriesApi, CandlestickSeriesOptions, Time } from 'lightweight-charts'
|
||||||
|
import type { CandleData, StockPrice } from '$lib/api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: CandleData[]
|
||||||
|
height?: number
|
||||||
|
// 실시간 현재가 (WebSocket에서 수신) — 일봉 모드에서만 의미 있음
|
||||||
|
livePrice?: StockPrice | null
|
||||||
|
chartType?: 'D' | 'W' | 'M'
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, height = 320, livePrice = null, chartType = 'D' }: Props = $props()
|
||||||
|
|
||||||
|
let container: HTMLElement
|
||||||
|
let chart: IChartApi | null = null
|
||||||
|
let series: ISeriesApi<'Candlestick'> | null = null
|
||||||
|
|
||||||
|
// 다크 테마 색상
|
||||||
|
const chartColors = {
|
||||||
|
bg: '#111827',
|
||||||
|
grid: '#1f2937',
|
||||||
|
text: '#9ca3af',
|
||||||
|
up: '#ef4444',
|
||||||
|
down: '#3b82f6',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChartData(d: CandleData) {
|
||||||
|
return {
|
||||||
|
time: d.time as unknown as Time,
|
||||||
|
open: d.open,
|
||||||
|
high: d.high,
|
||||||
|
low: d.low,
|
||||||
|
close: d.close,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오늘 날짜를 YYYY-MM-DD 형식의 Time으로 변환
|
||||||
|
function todayTime(): Time {
|
||||||
|
const now = new Date()
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(now.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}` as unknown as Time
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
chart = createChart(container, {
|
||||||
|
width: container.clientWidth,
|
||||||
|
height,
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: chartColors.bg },
|
||||||
|
textColor: chartColors.text,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: chartColors.grid },
|
||||||
|
horzLines: { color: chartColors.grid },
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
mode: CrosshairMode.Normal,
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderColor: chartColors.grid,
|
||||||
|
scaleMargins: { top: 0.1, bottom: 0.15 },
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderColor: chartColors.grid,
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
series = chart.addSeries(CandlestickSeries, {
|
||||||
|
upColor: chartColors.up,
|
||||||
|
downColor: chartColors.down,
|
||||||
|
borderUpColor: chartColors.up,
|
||||||
|
borderDownColor: chartColors.down,
|
||||||
|
wickUpColor: chartColors.up,
|
||||||
|
wickDownColor: chartColors.down,
|
||||||
|
} as CandlestickSeriesOptions)
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
series.setData(data.map(toChartData))
|
||||||
|
chart.timeScale().fitContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리사이즈 대응
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
chart?.applyOptions({ width: container.clientWidth })
|
||||||
|
})
|
||||||
|
ro.observe(container)
|
||||||
|
|
||||||
|
return () => ro.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 과거 데이터 변경 시 전체 재설정
|
||||||
|
$effect(() => {
|
||||||
|
if (series && data.length > 0) {
|
||||||
|
series.setData(data.map(toChartData))
|
||||||
|
chart?.timeScale().fitContent()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 실시간 현재가로 현재 봉 업데이트 (일봉 모드에서만)
|
||||||
|
$effect(() => {
|
||||||
|
if (!series || !livePrice || chartType !== 'D') return
|
||||||
|
|
||||||
|
const price = livePrice.currentPrice
|
||||||
|
if (price <= 0) return
|
||||||
|
|
||||||
|
// 마지막 봉 데이터 가져오기
|
||||||
|
const lastBar = data.at(-1)
|
||||||
|
if (!lastBar) return
|
||||||
|
|
||||||
|
// 현재 봉의 고가/저가 반영
|
||||||
|
series.update({
|
||||||
|
time: todayTime(),
|
||||||
|
open: lastBar.open,
|
||||||
|
high: Math.max(lastBar.high, price),
|
||||||
|
low: Math.min(lastBar.low, price),
|
||||||
|
close: price,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
chart?.remove()
|
||||||
|
chart = null
|
||||||
|
series = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container} style="width:100%; height:{height}px;"></div>
|
||||||
83
frontend/src/lib/components/CntrChart.svelte
Normal file
83
frontend/src/lib/components/CntrChart.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
history: number[] // 체결강도 히스토리 (오래된→최신)
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let { history, height = 48 }: Props = $props()
|
||||||
|
let canvas: HTMLCanvasElement
|
||||||
|
let ro: ResizeObserver
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!canvas || history.length < 2) return
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const w = Math.floor(rect.width * dpr)
|
||||||
|
const h = Math.floor(rect.height * dpr)
|
||||||
|
if (w === 0 || h === 0) return
|
||||||
|
if (canvas.width !== w || canvas.height !== h) {
|
||||||
|
canvas.width = w
|
||||||
|
canvas.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const pad = 3 * dpr
|
||||||
|
const drawH = h - pad * 2
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
const min = Math.min(...history)
|
||||||
|
const max = Math.max(...history)
|
||||||
|
const range = max - min || 1
|
||||||
|
const step = w / (history.length - 1)
|
||||||
|
const getY = (val: number) => h - pad - ((val - min) / range) * drawH
|
||||||
|
|
||||||
|
// 그라디언트 채우기
|
||||||
|
const lastX = (history.length - 1) * step
|
||||||
|
const grad = ctx.createLinearGradient(0, pad, 0, h)
|
||||||
|
grad.addColorStop(0, 'rgba(249,115,22,0.35)')
|
||||||
|
grad.addColorStop(1, 'rgba(249,115,22,0)')
|
||||||
|
ctx.beginPath()
|
||||||
|
history.forEach((val, i) => {
|
||||||
|
i === 0 ? ctx.moveTo(i * step, getY(val)) : ctx.lineTo(i * step, getY(val))
|
||||||
|
})
|
||||||
|
ctx.lineTo(lastX, h)
|
||||||
|
ctx.lineTo(0, h)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fillStyle = grad
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// 라인
|
||||||
|
ctx.beginPath()
|
||||||
|
history.forEach((val, i) => {
|
||||||
|
i === 0 ? ctx.moveTo(i * step, getY(val)) : ctx.lineTo(i * step, getY(val))
|
||||||
|
})
|
||||||
|
ctx.strokeStyle = '#f97316'
|
||||||
|
ctx.lineWidth = 1.5 * dpr
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// 마지막 점 강조
|
||||||
|
const lastVal = history[history.length - 1]
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = '#f97316'
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ro = new ResizeObserver(() => draw())
|
||||||
|
ro.observe(canvas)
|
||||||
|
draw()
|
||||||
|
return () => ro.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
// history 변경 시 재그리기
|
||||||
|
$effect(() => {
|
||||||
|
history
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas bind:this={canvas} style="width:100%;height:{height}px;display:block;" class="rounded-sm"></canvas>
|
||||||
37
frontend/src/lib/components/Modal.svelte
Normal file
37
frontend/src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
open: boolean
|
||||||
|
children?: import('svelte').Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, open, children }: Props = $props()
|
||||||
|
const dispatch = createEventDispatcher<{ close: void }>()
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) dispatch('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onclick={onBackdrop}
|
||||||
|
>
|
||||||
|
<div class="bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-white transition-colors"
|
||||||
|
onclick={() => dispatch('close')}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
25
frontend/src/lib/components/SortableHeader.svelte
Normal file
25
frontend/src/lib/components/SortableHeader.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
col: string
|
||||||
|
label: string
|
||||||
|
sortCol: string
|
||||||
|
sortDesc: boolean
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { col, label, sortCol, sortDesc, align = 'right', class: cls = '' }: Props = $props()
|
||||||
|
const dispatch = createEventDispatcher<{ sort: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<th
|
||||||
|
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 {align === 'left' ? 'text-left' : 'text-right'} text-xs font-medium text-gray-400 uppercase tracking-wider hover:text-gray-200 {cls}"
|
||||||
|
onclick={() => dispatch('sort', col)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{#if sortCol === col}
|
||||||
|
<span class="ml-1 text-yellow-400">{sortDesc ? '▼' : '▲'}</span>
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
44
frontend/src/lib/stores/watchlist.ts
Normal file
44
frontend/src/lib/stores/watchlist.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { writable } from 'svelte/store'
|
||||||
|
import { stockApi } from '$lib/api/stock'
|
||||||
|
import type { WatchlistItem } from '$lib/api/types'
|
||||||
|
|
||||||
|
// 관심종목 스토어 (서버 동기화)
|
||||||
|
function createWatchlistStore() {
|
||||||
|
const { subscribe, set, update } = writable<WatchlistItem[]>([])
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
// 서버에서 목록 로딩
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const list = await stockApi.getWatchlist()
|
||||||
|
set(list ?? [])
|
||||||
|
} catch {
|
||||||
|
set([])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관심종목 추가
|
||||||
|
async add(code: string, name: string) {
|
||||||
|
await stockApi.addWatchlist(code, name)
|
||||||
|
update((list) => {
|
||||||
|
if (list.some((i) => i.code === code)) return list
|
||||||
|
return [...list, { code, name }]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관심종목 삭제
|
||||||
|
async remove(code: string) {
|
||||||
|
await stockApi.removeWatchlist(code)
|
||||||
|
update((list) => list.filter((i) => i.code !== code))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 포함 여부 확인 (동기)
|
||||||
|
has(list: WatchlistItem[], code: string) {
|
||||||
|
return list.some((i) => i.code === code)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const watchlist = createWatchlistStore()
|
||||||
121
frontend/src/lib/stores/ws.ts
Normal file
121
frontend/src/lib/stores/ws.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { writable, derived } from 'svelte/store'
|
||||||
|
import type { StockPrice, OrderBook, AutoTradeLog } from '$lib/api/types'
|
||||||
|
|
||||||
|
export type WsMsg =
|
||||||
|
| { type: 'price'; code: string; data: StockPrice }
|
||||||
|
| { type: 'orderbook'; code: string; data: OrderBook }
|
||||||
|
| { type: 'tradelog'; data: AutoTradeLog }
|
||||||
|
| { type: 'market'; data: unknown }
|
||||||
|
| { type: string; code?: string; data: unknown }
|
||||||
|
|
||||||
|
// 마지막으로 수신한 WebSocket 메시지
|
||||||
|
const { subscribe: msgSubscribe, set: setMsg } = writable<WsMsg | null>(null)
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let subscribedCodes = new Set<string>()
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (socket && (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
socket = new WebSocket(`${protocol}//${location.host}/ws`)
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
// 재연결 시 기존 구독 복원
|
||||||
|
for (const code of subscribedCodes) {
|
||||||
|
socket!.send(JSON.stringify({ type: 'subscribe', code }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
setMsg(JSON.parse(e.data))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
socket = null
|
||||||
|
// 3초 후 자동 재연결
|
||||||
|
reconnectTimer = setTimeout(connect, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종목 구독 추가
|
||||||
|
function subscribeCode(code: string) {
|
||||||
|
subscribedCodes.add(code)
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'subscribe', code }))
|
||||||
|
} else {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종목 구독 해제
|
||||||
|
function unsubscribeCode(code: string) {
|
||||||
|
subscribedCodes.delete(code)
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'unsubscribe', code }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 구독 목록으로 일괄 구독
|
||||||
|
function subscribeCodes(codes: string[]) {
|
||||||
|
for (const code of codes) {
|
||||||
|
subscribeCode(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 끊기
|
||||||
|
function disconnect() {
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
|
subscribedCodes.clear()
|
||||||
|
socket?.close()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wsStore = {
|
||||||
|
subscribe: msgSubscribe,
|
||||||
|
connect,
|
||||||
|
subscribeCode,
|
||||||
|
unsubscribeCode,
|
||||||
|
subscribeCodes,
|
||||||
|
disconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종목별 최신 시세 맵 (code → StockPrice)
|
||||||
|
export const priceMap = derived(
|
||||||
|
{ subscribe: msgSubscribe },
|
||||||
|
($msg, set, update) => {
|
||||||
|
if ($msg?.type === 'price' && $msg.code) {
|
||||||
|
update((m) => ({ ...m, [$msg.code!]: $msg.data as StockPrice }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{} as Record<string, StockPrice>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 종목별 최신 호가창 맵 (code → OrderBook)
|
||||||
|
export const orderBookMap = derived(
|
||||||
|
{ subscribe: msgSubscribe },
|
||||||
|
($msg, set, update) => {
|
||||||
|
if ($msg?.type === 'orderbook' && $msg.code) {
|
||||||
|
update((m) => ({ ...m, [$msg.code!]: $msg.data as OrderBook }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{} as Record<string, OrderBook>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 자동매매 로그 스트림
|
||||||
|
export const tradeLogStream = derived(
|
||||||
|
{ subscribe: msgSubscribe },
|
||||||
|
($msg) => {
|
||||||
|
if ($msg?.type === 'tradelog') return $msg.data as AutoTradeLog
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
)
|
||||||
54
frontend/src/lib/utils.ts
Normal file
54
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// 가격 포맷 (천 단위 콤마)
|
||||||
|
export function formatPrice(n: number): string {
|
||||||
|
if (n === 0) return '0'
|
||||||
|
return Math.abs(n).toLocaleString('ko-KR')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등락률 포맷 (+/-% 포함)
|
||||||
|
export function formatRate(rate: number): string {
|
||||||
|
const sign = rate >= 0 ? '+' : ''
|
||||||
|
return `${sign}${rate.toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등락에 따른 CSS 색상 클래스 (한국 관행: 상승=빨강, 하락=파랑)
|
||||||
|
export function priceClass(value: number): string {
|
||||||
|
if (value > 0) return 'text-red-400'
|
||||||
|
if (value < 0) return 'text-blue-400'
|
||||||
|
return 'text-gray-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 체결강도 CSS 클래스 (100 기준)
|
||||||
|
export function cntrClass(cntr: number): string {
|
||||||
|
if (cntr > 100) return 'text-red-400'
|
||||||
|
if (cntr < 100 && cntr > 0) return 'text-blue-400'
|
||||||
|
return 'text-gray-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 거래량 포맷 (만 단위 축약)
|
||||||
|
export function formatVolume(n: number): string {
|
||||||
|
if (n >= 100_000_000) return (n / 100_000_000).toFixed(1) + '억'
|
||||||
|
if (n >= 10_000) return (n / 10_000).toFixed(0) + '만'
|
||||||
|
return n.toLocaleString('ko-KR')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 포맷 (ISO → YYYY-MM-DD HH:mm)
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전일대비 부호 문자 (2=상승, 3=보합, 5=하락)
|
||||||
|
export function sigToArrow(sig: string): string {
|
||||||
|
if (sig === '2') return '▲'
|
||||||
|
if (sig === '5') return '▼'
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전일대비 부호에 따른 클래스
|
||||||
|
export function sigClass(sig: string): string {
|
||||||
|
if (sig === '2') return 'text-red-400'
|
||||||
|
if (sig === '5') return 'text-blue-400'
|
||||||
|
return 'text-gray-400'
|
||||||
|
}
|
||||||
64
frontend/src/routes/(app)/+layout.svelte
Normal file
64
frontend/src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
let { children } = $props()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: '시세' },
|
||||||
|
{ href: '/theme', label: '테마' },
|
||||||
|
{ href: '/kospi200', label: '코스피200' },
|
||||||
|
{ href: '/asset', label: '자산' },
|
||||||
|
{ href: '/autotrade', label: '자동매매' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/logout', { method: 'POST', credentials: 'include' })
|
||||||
|
goto('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === '/') return page.url.pathname === '/'
|
||||||
|
return page.url.pathname.startsWith(href)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col bg-gray-900">
|
||||||
|
<!-- 헤더 네비게이션 -->
|
||||||
|
<header class="bg-gray-800 border-b border-gray-700 sticky top-0 z-30">
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4">
|
||||||
|
<div class="flex items-center h-14 gap-6">
|
||||||
|
<!-- 로고 -->
|
||||||
|
<a href="/" class="text-white font-bold text-lg shrink-0">📈 StockSearch</a>
|
||||||
|
|
||||||
|
<!-- 네비 링크 -->
|
||||||
|
<nav class="flex items-center gap-1 flex-1">
|
||||||
|
{#each navItems as item}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||||
|
{isActive(item.href)
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-gray-700'}"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 로그아웃 -->
|
||||||
|
<button
|
||||||
|
onclick={logout}
|
||||||
|
class="text-xs text-gray-400 hover:text-white transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 메인 콘텐츠 -->
|
||||||
|
<main class="flex-1 max-w-screen-2xl mx-auto w-full px-4 py-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
13
frontend/src/routes/(app)/+layout.ts
Normal file
13
frontend/src/routes/(app)/+layout.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
// 인증 가드: 세션 유효하지 않으면 /login으로 리다이렉트
|
||||||
|
export async function load({ fetch }) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/check', { credentials: 'include' })
|
||||||
|
if (!res.ok) throw redirect(302, '/login')
|
||||||
|
} catch (e) {
|
||||||
|
// redirect 재throw
|
||||||
|
if (e && typeof e === 'object' && 'status' in e) throw e
|
||||||
|
throw redirect(302, '/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
670
frontend/src/routes/(app)/+page.svelte
Normal file
670
frontend/src/routes/(app)/+page.svelte
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { stockApi } from '$lib/api/stock'
|
||||||
|
import { watchlist } from '$lib/stores/watchlist'
|
||||||
|
import { priceMap, wsStore } from '$lib/stores/ws'
|
||||||
|
import { formatPrice, formatRate, priceClass, formatVolume } from '$lib/utils'
|
||||||
|
import CntrChart from '$lib/components/CntrChart.svelte'
|
||||||
|
import SortableHeader from '$lib/components/SortableHeader.svelte'
|
||||||
|
import Modal from '$lib/components/Modal.svelte'
|
||||||
|
import type { IndexQuote, Signal, WatchlistItem, StockInfo } from '$lib/api/types'
|
||||||
|
|
||||||
|
let indices = $state<IndexQuote[]>([])
|
||||||
|
let signals = $state<Signal[]>([])
|
||||||
|
let watchlistSignals = $state<Map<string, Signal>>(new Map())
|
||||||
|
let scannerOn = $state(true)
|
||||||
|
let updatedAt = $state('')
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let searchResults = $state<StockInfo[]>([])
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let addingCode = $state('')
|
||||||
|
let showGuide = $state(false)
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let watchlistPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// 관심종목 테이블 정렬
|
||||||
|
let wlSortCol = $state('')
|
||||||
|
let wlSortDesc = $state(true)
|
||||||
|
let wlSorted = $state<WatchlistItem[]>([])
|
||||||
|
|
||||||
|
function handleWlSort(e: CustomEvent<string>) {
|
||||||
|
const col = e.detail
|
||||||
|
if (wlSortCol === col) wlSortDesc = !wlSortDesc
|
||||||
|
else { wlSortCol = col; wlSortDesc = true }
|
||||||
|
applyWlSort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWlSort() {
|
||||||
|
const items = [...$watchlist]
|
||||||
|
if (!wlSortCol) { wlSorted = []; return }
|
||||||
|
const pm = $priceMap
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (wlSortCol === 'name') {
|
||||||
|
const cmp = a.name.localeCompare(b.name, 'ko')
|
||||||
|
return wlSortDesc ? -cmp : cmp
|
||||||
|
}
|
||||||
|
const pa = pm[a.code]
|
||||||
|
const pb = pm[b.code]
|
||||||
|
let av = 0, bv = 0
|
||||||
|
if (wlSortCol === 'currentPrice') { av = pa?.currentPrice ?? 0; bv = pb?.currentPrice ?? 0 }
|
||||||
|
else if (wlSortCol === 'changeRate') { av = pa?.changeRate ?? 0; bv = pb?.changeRate ?? 0 }
|
||||||
|
else if (wlSortCol === 'cntrStr') { av = pa?.cntrStr ?? 0; bv = pb?.cntrStr ?? 0 }
|
||||||
|
return wlSortDesc ? bv - av : av - bv
|
||||||
|
})
|
||||||
|
wlSorted = items
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종목별 체결강도 히스토리 (canvas 미니차트용)
|
||||||
|
const cntrHistoryMap = new Map<string, number[]>()
|
||||||
|
// 히스토리 트리거용 (Svelte 반응성)
|
||||||
|
let cntrTick = $state(0)
|
||||||
|
|
||||||
|
const MAX_HISTORY = 60
|
||||||
|
|
||||||
|
function recordCntr(code: string, val: number) {
|
||||||
|
if (!val) return
|
||||||
|
if (!cntrHistoryMap.has(code)) cntrHistoryMap.set(code, [])
|
||||||
|
const arr = cntrHistoryMap.get(code)!
|
||||||
|
arr.push(val)
|
||||||
|
if (arr.length > MAX_HISTORY) arr.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCntrHistory(code: string): number[] {
|
||||||
|
return cntrHistoryMap.get(code) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await watchlist.load()
|
||||||
|
wsStore.connect()
|
||||||
|
loadIndices()
|
||||||
|
loadScanner()
|
||||||
|
fetchWatchlistSignals()
|
||||||
|
watchlistPollTimer = setInterval(fetchWatchlistSignals, 10_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 관심종목 변경 시 WebSocket 구독
|
||||||
|
$effect(() => {
|
||||||
|
const codes = $watchlist.map(i => i.code)
|
||||||
|
if (codes.length > 0) wsStore.subscribeCodes(codes)
|
||||||
|
})
|
||||||
|
|
||||||
|
// WebSocket 가격 수신 → 체결강도 히스토리 업데이트 (시그널 + 관심종목)
|
||||||
|
$effect(() => {
|
||||||
|
const map = $priceMap // 반응성 트리거
|
||||||
|
signals.forEach(s => {
|
||||||
|
const live = map[s.code]
|
||||||
|
if (live?.cntrStr) {
|
||||||
|
recordCntr(s.code, live.cntrStr)
|
||||||
|
cntrTick++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$watchlist.forEach(item => {
|
||||||
|
const live = map[item.code]
|
||||||
|
if (live?.cntrStr) {
|
||||||
|
recordCntr(item.code, live.cntrStr)
|
||||||
|
cntrTick++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchWatchlistSignals() {
|
||||||
|
try {
|
||||||
|
const data = await stockApi.getWatchlistSignals()
|
||||||
|
const m = new Map<string, Signal>()
|
||||||
|
data?.forEach(s => m.set(s.code, s))
|
||||||
|
watchlistSignals = m
|
||||||
|
// 관심종목 WS 구독 + 히스토리 초기값
|
||||||
|
data?.forEach(s => recordCntr(s.code, s.cntrStr))
|
||||||
|
cntrTick++
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIndices() {
|
||||||
|
try { indices = await stockApi.getIndices() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScanner() {
|
||||||
|
try {
|
||||||
|
const status = await stockApi.getScannerStatus()
|
||||||
|
scannerOn = (status as { enabled?: boolean; running?: boolean }).enabled
|
||||||
|
?? (status as { running?: boolean }).running
|
||||||
|
?? true
|
||||||
|
} catch {}
|
||||||
|
if (scannerOn) startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSignals() {
|
||||||
|
try {
|
||||||
|
const data = await stockApi.getSignals()
|
||||||
|
signals = data ?? []
|
||||||
|
const now = new Date()
|
||||||
|
updatedAt = now.toTimeString().slice(0, 8) + ' 기준'
|
||||||
|
// 새 신호 종목 WS 구독 + 히스토리 초기 기록
|
||||||
|
const codes = signals.map(s => s.code)
|
||||||
|
wsStore.subscribeCodes(codes)
|
||||||
|
signals.forEach(s => recordCntr(s.code, s.cntrStr))
|
||||||
|
cntrTick++
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) return
|
||||||
|
fetchSignals()
|
||||||
|
pollTimer = setInterval(fetchSignals, 10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
signals = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleScanner() {
|
||||||
|
try {
|
||||||
|
await stockApi.toggleScanner()
|
||||||
|
scannerOn = !scannerOn
|
||||||
|
scannerOn ? startPolling() : stopPolling()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
if (!searchQuery.trim()) { searchResults = []; return }
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
try { searchResults = await stockApi.search(searchQuery) } catch {}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToWatchlist(item: StockInfo) {
|
||||||
|
addingCode = item.code
|
||||||
|
try {
|
||||||
|
await watchlist.add(item.code, item.name)
|
||||||
|
wsStore.subscribeCode(item.code)
|
||||||
|
searchQuery = ''
|
||||||
|
searchResults = []
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : '추가 실패')
|
||||||
|
} finally {
|
||||||
|
addingCode = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromWatchlist(code: string) {
|
||||||
|
await watchlist.remove(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 신호 카드 헬퍼 함수 ──
|
||||||
|
|
||||||
|
function signalTypeCls(type: string): string {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
'강한매수': 'bg-red-600 text-white border-red-700',
|
||||||
|
'매수우세': 'bg-orange-400 text-white border-orange-500',
|
||||||
|
'물량소화': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||||
|
'추격위험': 'bg-gray-800 text-white border-gray-900',
|
||||||
|
'약한상승': 'bg-gray-100 text-gray-500 border-gray-300',
|
||||||
|
}
|
||||||
|
return m[type] ?? 'bg-gray-100 text-gray-500 border-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
function riseProbCls(label: string): string {
|
||||||
|
return label === '매우 높음'
|
||||||
|
? 'bg-emerald-500 text-white border border-emerald-600'
|
||||||
|
: 'bg-teal-100 text-teal-700 border border-teal-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
function risingBadgeCls(n: number): string {
|
||||||
|
if (n >= 4) return 'bg-red-500 text-white'
|
||||||
|
if (n >= 2) return 'bg-orange-400 text-white'
|
||||||
|
return 'bg-yellow-100 text-yellow-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
function risingLabel(n: number): string {
|
||||||
|
if (n >= 4) return `🔥${n}연속`
|
||||||
|
if (n >= 2) return `▲${n}연속`
|
||||||
|
return '↑상승'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentimentCls(s: string): string {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
'호재': 'bg-green-100 text-green-700 border border-green-200',
|
||||||
|
'악재': 'bg-red-100 text-red-600 border border-red-200',
|
||||||
|
'중립': 'bg-gray-100 text-gray-500',
|
||||||
|
}
|
||||||
|
return m[s] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateBadgeCls(r: number): string {
|
||||||
|
if (r > 0) return 'bg-red-50 text-red-500'
|
||||||
|
if (r < 0) return 'bg-blue-50 text-blue-500'
|
||||||
|
return 'bg-gray-100 text-gray-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function volRatioCls(r: number): string {
|
||||||
|
if (r >= 10) return 'text-gray-400 line-through'
|
||||||
|
if (r >= 5) return 'text-orange-400'
|
||||||
|
if (r >= 2) return 'text-green-500 font-semibold'
|
||||||
|
if (r >= 1) return 'text-green-400'
|
||||||
|
return 'text-gray-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function askBidCls(r: number): string {
|
||||||
|
if (r <= 0.7) return 'text-green-500 font-semibold'
|
||||||
|
if (r <= 1.0) return 'text-green-400'
|
||||||
|
if (r <= 1.5) return 'text-gray-400'
|
||||||
|
return 'text-blue-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function askBidLabel(r: number): string {
|
||||||
|
if (r <= 0.7) return '매수 강세'
|
||||||
|
if (r <= 1.0) return '매수 우세'
|
||||||
|
if (r <= 1.5) return '균형'
|
||||||
|
return '매도 우세'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pricePosClass(p: number): string {
|
||||||
|
if (p >= 80) return 'text-red-400 font-semibold'
|
||||||
|
if (p >= 60) return 'text-orange-400'
|
||||||
|
if (p <= 30) return 'text-blue-400'
|
||||||
|
return 'text-gray-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDayCls(trend: string): string {
|
||||||
|
if (trend === '상승') return 'bg-red-50 text-red-500 border border-red-200'
|
||||||
|
if (trend === '하락') return 'bg-blue-50 text-blue-500 border border-blue-200'
|
||||||
|
return 'bg-gray-800 text-gray-400 border border-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDayIcon(trend: string): string {
|
||||||
|
if (trend === '상승') return '▲'
|
||||||
|
if (trend === '하락') return '▼'
|
||||||
|
return '─'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>주식 시세</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- 지수 현황 -->
|
||||||
|
{#if indices.length > 0}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||||
|
{#each indices as idx}
|
||||||
|
<div class="bg-gray-800 rounded-lg px-4 py-3">
|
||||||
|
<div class="text-xs text-gray-400 mb-1">{idx.name}</div>
|
||||||
|
<div class="text-lg font-bold text-white">{idx.value.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}</div>
|
||||||
|
<div class="text-sm {priceClass(idx.changeRate)}">{formatRate(idx.changeRate)}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- 왼쪽: 관심종목 -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span class="text-yellow-400">★</span> 관심종목
|
||||||
|
</h2>
|
||||||
|
<!-- 종목 검색 -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
oninput={onSearchInput}
|
||||||
|
placeholder="종목 검색..."
|
||||||
|
class="bg-gray-700 border border-gray-600 text-white text-sm rounded-lg px-3 py-1.5 w-44 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
{#if searchResults.length > 0}
|
||||||
|
<div class="absolute right-0 top-full mt-1 w-64 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 overflow-hidden">
|
||||||
|
{#each searchResults.slice(0, 8) as result}
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-2 hover:bg-gray-700 flex items-center justify-between text-sm"
|
||||||
|
onclick={() => addToWatchlist(result)}
|
||||||
|
disabled={addingCode === result.code}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="text-white font-medium">{result.name}</span>
|
||||||
|
<span class="text-gray-400 ml-2 text-xs">{result.code}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">{result.market}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $watchlist.length === 0}
|
||||||
|
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500 text-sm border border-dashed border-gray-700">
|
||||||
|
종목을 검색해 관심종목을 추가하세요
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader col="name" label="종목" sortCol={wlSortCol} sortDesc={wlSortDesc} align="left" on:sort={handleWlSort} />
|
||||||
|
<SortableHeader col="currentPrice" label="현재가" sortCol={wlSortCol} sortDesc={wlSortDesc} on:sort={handleWlSort} />
|
||||||
|
<SortableHeader col="changeRate" label="등락" sortCol={wlSortCol} sortDesc={wlSortDesc} on:sort={handleWlSort} />
|
||||||
|
<SortableHeader col="cntrStr" label="강도" sortCol={wlSortCol} sortDesc={wlSortDesc} on:sort={handleWlSort} />
|
||||||
|
<th class="w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700/50">
|
||||||
|
{#each (wlSorted.length > 0 ? wlSorted : $watchlist) as item (item.code)}
|
||||||
|
{@const price = $priceMap[item.code]}
|
||||||
|
<tr
|
||||||
|
class="hover:bg-gray-750 cursor-pointer transition-colors"
|
||||||
|
onclick={() => goto(`/stock/${item.code}`)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<div class="font-medium text-white text-sm">{item.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">{item.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2.5 text-right font-mono text-sm font-medium {price ? priceClass(price.changeRate) : 'text-gray-400'}">
|
||||||
|
{price ? formatPrice(price.currentPrice) : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2.5 text-right text-xs {price ? priceClass(price.changeRate) : 'text-gray-400'}">
|
||||||
|
{price ? formatRate(price.changeRate) : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2.5 text-right text-xs {price ? (price.cntrStr > 100 ? 'text-red-400' : price.cntrStr > 0 ? 'text-blue-400' : 'text-gray-400') : 'text-gray-400'}">
|
||||||
|
{price ? price.cntrStr.toFixed(1) : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="pr-3 py-2.5 text-center">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<span
|
||||||
|
class="text-gray-600 hover:text-red-400 cursor-pointer text-base transition-colors"
|
||||||
|
onclick={(e) => { e.stopPropagation(); removeFromWatchlist(item.code) }}
|
||||||
|
>×</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 오른쪽: 체결강도 상승 감지 -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||||
|
<h2 class="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<span class="text-orange-400">⚡</span> 체결강도 상승 감지
|
||||||
|
<span class="text-xs font-normal text-gray-500">(거래량 상위 20 · 10초 갱신)</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 기준 안내 -->
|
||||||
|
<button
|
||||||
|
onclick={() => { showGuide = true }}
|
||||||
|
class="w-5 h-5 rounded-full bg-gray-700 text-gray-400 text-xs font-bold hover:bg-orange-900/50 hover:text-orange-400 transition-colors flex items-center justify-center shrink-0"
|
||||||
|
>?</button>
|
||||||
|
|
||||||
|
<!-- 스캐너 ON/OFF -->
|
||||||
|
<button
|
||||||
|
onclick={toggleScanner}
|
||||||
|
class="text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors shrink-0
|
||||||
|
{scannerOn ? 'bg-green-900/50 text-green-400 border-green-700' : 'bg-gray-700 text-gray-400 border-gray-600'}"
|
||||||
|
>
|
||||||
|
{scannerOn ? '● ON' : '○ OFF'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if updatedAt}
|
||||||
|
<span class="text-xs text-gray-500 ml-auto">{updatedAt}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !scannerOn}
|
||||||
|
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||||
|
스캐너가 꺼져 있습니다. 버튼을 눌러 켜주세요.
|
||||||
|
</div>
|
||||||
|
{:else if signals.length === 0}
|
||||||
|
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||||
|
08:00 이후 거래량 상위 종목에서 체결강도 100 이상 + 상승 종목을 표시합니다.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{#each signals as sig (sig.code)}
|
||||||
|
{@const live = $priceMap[sig.code]}
|
||||||
|
{@const displayPrice = live ?? sig}
|
||||||
|
{@const history = getCntrHistory(sig.code)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 rounded-xl p-4 border border-orange-900/30 hover:border-orange-700/50 cursor-pointer transition-all hover:shadow-lg hover:shadow-orange-900/10"
|
||||||
|
onclick={() => goto(`/stock/${sig.code}`)}
|
||||||
|
>
|
||||||
|
<!-- 상단: 종목코드 + 뱃지들 -->
|
||||||
|
<div class="flex items-start justify-between mb-2 gap-1 flex-wrap">
|
||||||
|
<span class="text-xs text-gray-500 font-mono">{sig.code}</span>
|
||||||
|
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||||
|
{#if sig.signalType}
|
||||||
|
<span class="border {signalTypeCls(sig.signalType)} text-xs px-1.5 py-0.5 rounded font-bold">{sig.signalType}</span>
|
||||||
|
{/if}
|
||||||
|
{#if sig.riseLabel}
|
||||||
|
<span class="{riseProbCls(sig.riseLabel)} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: {sig.riseScore}점">
|
||||||
|
{sig.riseLabel === '매우 높음' ? '🚀' : '📈'} {sig.riseLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if sig.risingCount >= 1}
|
||||||
|
<span class="{risingBadgeCls(sig.risingCount)} text-xs px-1.5 py-0.5 rounded font-bold">{risingLabel(sig.risingCount)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if sig.sentiment && sig.sentiment !== '정보없음'}
|
||||||
|
<span class="{sentimentCls(sig.sentiment)} text-xs px-1.5 py-0.5 rounded font-semibold" title={sig.sentimentReason}>{sig.sentiment}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 종목명 + 현재가 -->
|
||||||
|
<p class="font-bold text-white text-sm truncate mb-0.5">{sig.name}</p>
|
||||||
|
<p class="text-2xl font-bold {priceClass(displayPrice.changeRate)} mb-2">
|
||||||
|
{formatPrice(displayPrice.currentPrice)}원
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 지표 -->
|
||||||
|
<div class="border-t border-gray-700 pt-2 space-y-1.5 text-xs">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">체결강도</span>
|
||||||
|
<span class="font-bold text-orange-400">{(live?.cntrStr ?? sig.cntrStr).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">직전 대비</span>
|
||||||
|
<span class="text-gray-400">
|
||||||
|
{sig.prevCntrStr.toFixed(2)} →
|
||||||
|
<span class="text-green-400 font-semibold">+{(sig.cntrStr - sig.prevCntrStr).toFixed(2)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">등락률</span>
|
||||||
|
<span class="{rateBadgeCls(displayPrice.changeRate)} px-1.5 py-0.5 rounded-full font-semibold">
|
||||||
|
{formatRate(displayPrice.changeRate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">거래량</span>
|
||||||
|
<span class="text-gray-300">{formatVolume(live?.volume ?? sig.volume)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 복합 지표 -->
|
||||||
|
{#if sig.volRatio > 0}
|
||||||
|
<div class="flex justify-between pt-1.5 border-t border-gray-700">
|
||||||
|
<span class="text-gray-400">거래량 증가</span>
|
||||||
|
<span class="{volRatioCls(sig.volRatio)}">
|
||||||
|
{sig.volRatio.toFixed(1)}배{sig.volRatio >= 10 ? ' ⚠과열' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if sig.totalAskVol > 0 && sig.totalBidVol > 0}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">매도/매수 잔량</span>
|
||||||
|
<span class="{askBidCls(sig.askBidRatio)}">
|
||||||
|
{sig.askBidRatio.toFixed(2)} ({askBidLabel(sig.askBidRatio)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if sig.pricePos !== undefined}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-400">가격 위치</span>
|
||||||
|
<span class="{pricePosClass(sig.pricePos)}">{sig.pricePos.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- AI 목표가 -->
|
||||||
|
{#if sig.targetPrice && sig.targetPrice > 0}
|
||||||
|
{@const diff = sig.targetPrice - displayPrice.currentPrice}
|
||||||
|
{@const pct = ((diff / displayPrice.currentPrice) * 100).toFixed(1)}
|
||||||
|
<div class="flex justify-between pt-1.5 border-t border-purple-900/30">
|
||||||
|
<span class="text-gray-400">AI 목표가</span>
|
||||||
|
<span
|
||||||
|
class="bg-purple-900/40 text-purple-300 border border-purple-700/50 px-1.5 py-0.5 rounded font-semibold"
|
||||||
|
title={sig.targetReason}
|
||||||
|
>
|
||||||
|
{formatPrice(sig.targetPrice)}원
|
||||||
|
<span class="opacity-70 text-xs">({diff >= 0 ? '+' : ''}{pct}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 익일 추세 -->
|
||||||
|
{#if sig.nextDayTrend}
|
||||||
|
<div class="flex items-start justify-between pt-1.5 border-t border-gray-700">
|
||||||
|
<span class="text-gray-400 shrink-0">익일 추세</span>
|
||||||
|
<div class="text-right">
|
||||||
|
<span
|
||||||
|
class="{nextDayCls(sig.nextDayTrend)} text-xs px-2 py-0.5 rounded font-bold"
|
||||||
|
title={sig.nextDayReason}
|
||||||
|
>
|
||||||
|
{nextDayIcon(sig.nextDayTrend)} {sig.nextDayTrend}
|
||||||
|
{#if sig.nextDayConf}
|
||||||
|
<span class="opacity-60 font-normal ml-0.5">({sig.nextDayConf})</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if sig.nextDayReason}
|
||||||
|
<p class="text-gray-500 text-xs mt-0.5 truncate max-w-[160px]" title={sig.nextDayReason}>{sig.nextDayReason}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex justify-between pt-1.5 border-t border-gray-700">
|
||||||
|
<span class="text-gray-400">익일 추세</span>
|
||||||
|
<span class="text-gray-500 animate-pulse">분석 중...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 체결강도 미니 라인차트 -->
|
||||||
|
{#key cntrTick}
|
||||||
|
{#if history.length >= 2}
|
||||||
|
<div class="mt-3">
|
||||||
|
<CntrChart {history} height={44} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 시그널 판단 기준 모달 -->
|
||||||
|
<Modal title="⚡ 시그널 판단 기준" open={showGuide} on:close={() => { showGuide = false }}>
|
||||||
|
<div class="space-y-5 text-sm text-gray-300 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
|
||||||
|
<!-- 스코어 -->
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white mb-2">📊 상승 확률 스코어 (0~100점)</p>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">4가지 복합 요소를 동시에 만족해야 진짜 상승 확률이 높습니다.</p>
|
||||||
|
<div class="overflow-auto rounded-lg border border-gray-700">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-700/50">
|
||||||
|
<th class="text-left px-3 py-2 font-semibold text-gray-300">요소</th>
|
||||||
|
<th class="text-center px-3 py-2 font-semibold text-gray-300 w-16">배점</th>
|
||||||
|
<th class="text-left px-3 py-2 font-semibold text-gray-300">핵심 로직</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{#each [
|
||||||
|
['A. 체결강도 레벨', '0~30', '150+: 30점 · 130+: 22점 · 110+: 14점'],
|
||||||
|
['B. 연속 상승 횟수', '0~25', '5회+: 25점 · 3회+: 14점 · 1회: 3점'],
|
||||||
|
['C. 가격 위치/캔들', '0~20', '윗꼬리 10% 미만: +10점 · 60% 이상: −8점'],
|
||||||
|
['D. 거래량 건전성', '0~15', '2~5배 증가: +15점 · 10배 이상: −5점'],
|
||||||
|
['E. 매도잔량 소화', '0~10', '매수잔량 압도적: +10점 · 매도 우세: −3점'],
|
||||||
|
] as [name, score, desc]}
|
||||||
|
<tr class="hover:bg-gray-700/30">
|
||||||
|
<td class="px-3 py-2 font-medium text-gray-200">{name}</td>
|
||||||
|
<td class="px-3 py-2 text-center text-gray-400">{score}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-400">{desc}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 신호 유형 -->
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white mb-2">🏷️ 신호 유형 분류</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each [
|
||||||
|
['강한매수', 'bg-red-600 text-white border-red-700', '체결강도 130+ · 3회 이상 연속 상승 · 윗꼬리 없음 · 매도잔량 소화 중'],
|
||||||
|
['매수우세', 'bg-orange-400 text-white border-orange-500', '기본 상승 패턴. 강한매수 조건 미충족이나 전반적으로 매수세가 우위'],
|
||||||
|
['물량소화', 'bg-yellow-100 text-yellow-700 border-yellow-300', '체결강도는 높은데 가격이 제자리 + 긴 윗꼬리 → 위에서 파는 물량을 받아내는 중'],
|
||||||
|
['추격위험', 'bg-gray-800 text-white border-gray-700', '체결강도 170+ · 거래량 7배 이상 · 긴 윗꼬리 → 고점 물량털기 가능성 경계'],
|
||||||
|
['약한상승', 'bg-gray-700 text-gray-300 border-gray-600', '거래량 증가 없음 · 체결강도 120 미만 → 얇은 호가에서 뜬 상승'],
|
||||||
|
] as [label, cls, desc]}
|
||||||
|
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-gray-700/30">
|
||||||
|
<span class="border {cls} text-xs px-1.5 py-0.5 rounded font-bold shrink-0 mt-0.5">{label}</span>
|
||||||
|
<span class="text-gray-400 text-xs">{desc}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 지표 상세 -->
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white mb-2">🔍 지표 상세</p>
|
||||||
|
<div class="space-y-2 text-xs text-gray-400">
|
||||||
|
<div class="rounded-lg border border-gray-700 p-3">
|
||||||
|
<p class="font-semibold text-gray-200 mb-1">체결강도</p>
|
||||||
|
<p>100 = 균형 / 100 초과 = 매수 우위 / 100 미만 = 매도 우위. 10초마다 연속 상승 종목만 표시.</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
<span class="bg-red-900/30 text-red-400 px-2 py-0.5 rounded">150+ 강한 매수세</span>
|
||||||
|
<span class="bg-orange-900/30 text-orange-400 px-2 py-0.5 rounded">130~150 매수 우세</span>
|
||||||
|
<span class="bg-blue-900/30 text-blue-400 px-2 py-0.5 rounded">100 미만 매도 경향</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-700 p-3">
|
||||||
|
<p class="font-semibold text-gray-200 mb-1">거래량 증가</p>
|
||||||
|
<p>직전 6회(1분) 평균 대비 현재 10초 구간 배수. 2~5배가 최적, 10배 이상은 고점 물량털기 가능성.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-700 p-3">
|
||||||
|
<p class="font-semibold text-gray-200 mb-1">매도/매수 잔량</p>
|
||||||
|
<p>10단계 호가 총잔량 비율. 1 미만 = 매수 우위, 클수록 위에 팔 물량이 많음.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-700 p-3">
|
||||||
|
<p class="font-semibold text-gray-200 mb-1">가격 위치</p>
|
||||||
|
<p>장중 저가~고가 내 현재가 위치(%). 80%+ = 고가권 강한 매수, 30% 미만 = 저가권.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-700 p-3">
|
||||||
|
<p class="font-semibold text-gray-200 mb-1">AI 목표가 / 익일 추세</p>
|
||||||
|
<p>현재가·기술 지표·AI 감성을 종합한 추론. 참고용이며 투자 권유가 아닙니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-orange-950/40 rounded-xl p-3 border border-orange-800/40 text-xs text-orange-300">
|
||||||
|
💡 <strong>진짜 상승 한 줄 기준</strong><br/>
|
||||||
|
가격이 오르면서 · 거래량이 받쳐주고 · 체결강도가 유지되고 · 위 매도물량이 실제로 소화되는 흐름
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-700/40 rounded-xl p-3 border border-gray-700 text-xs text-gray-500">
|
||||||
|
⚠️ 모든 지표와 AI 분석은 참고용이며 투자 권유가 아닙니다. 모든 투자 손실의 책임은 투자자 본인에게 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
282
frontend/src/routes/(app)/asset/+page.svelte
Normal file
282
frontend/src/routes/(app)/asset/+page.svelte
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { accountApi } from '$lib/api/account'
|
||||||
|
import { formatPrice, formatRate, priceClass } from '$lib/utils'
|
||||||
|
import type { AccountBalance, PendingOrder, OrderHistory } from '$lib/api/types'
|
||||||
|
|
||||||
|
let balance = $state<AccountBalance | null>(null)
|
||||||
|
let pending = $state<PendingOrder[]>([])
|
||||||
|
let history = $state<OrderHistory[]>([])
|
||||||
|
let loading = $state(true)
|
||||||
|
let activeTab = $state<'balance' | 'pending' | 'history'>('balance')
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading = true
|
||||||
|
try {
|
||||||
|
balance = await accountApi.getBalance()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPending() {
|
||||||
|
try {
|
||||||
|
pending = (await accountApi.getPending()) ?? []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
history = (await accountApi.getHistory()) ?? []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab: typeof activeTab) {
|
||||||
|
activeTab = tab
|
||||||
|
if (tab === 'pending') loadPending()
|
||||||
|
if (tab === 'history') loadHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매매구분 표시 (1:매도, 2:매수)
|
||||||
|
function tradeTypeLabel(tp: string): string {
|
||||||
|
if (tp === '1') return '매도'
|
||||||
|
if (tp === '2') return '매수'
|
||||||
|
return tp
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeTypeClass(tp: string): string {
|
||||||
|
if (tp === '1') return 'text-blue-400'
|
||||||
|
if (tp === '2') return 'text-red-400'
|
||||||
|
return 'text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시간 포맷 (HHmmss → HH:mm:ss)
|
||||||
|
function formatTm(tm: string): string {
|
||||||
|
if (!tm || tm.length < 6) return tm || ''
|
||||||
|
return `${tm.slice(0, 2)}:${tm.slice(2, 4)}:${tm.slice(4, 6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미체결 주문 취소
|
||||||
|
async function cancelOrder(order: PendingOrder) {
|
||||||
|
if (!confirm(`${order.stkNm} 주문을 취소하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
await accountApi.cancel(order.ordNo, order.stkCd, parseInt(order.osoQty) || 0)
|
||||||
|
await loadPending()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : '주문 취소 실패')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>자산 현황 - 주식 시세</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-xl font-bold text-white mb-4">자산 현황</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center h-64 text-gray-400">로딩 중...</div>
|
||||||
|
{:else if balance}
|
||||||
|
<!-- 요약 카드 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
{#each [
|
||||||
|
{ label: '총 자산', value: balance.totalAsset, fmt: 'price' },
|
||||||
|
{ label: '예수금', value: balance.deposit, fmt: 'price' },
|
||||||
|
{ label: '주식 평가액', value: balance.stockValue, fmt: 'price' },
|
||||||
|
{ label: '평가 손익', value: balance.profitLoss, fmt: 'profit' },
|
||||||
|
] as card}
|
||||||
|
<div class="bg-gray-800 rounded-xl px-4 py-4">
|
||||||
|
<div class="text-xs text-gray-400 mb-1">{card.label}</div>
|
||||||
|
<div class="text-xl font-bold {card.fmt === 'profit' ? priceClass(card.value) : 'text-white'}">
|
||||||
|
{card.value >= 0 && card.fmt === 'profit' ? '+' : ''}{formatPrice(card.value)}
|
||||||
|
</div>
|
||||||
|
{#if card.fmt === 'profit'}
|
||||||
|
<div class="text-sm {priceClass(balance.profitRate)}">
|
||||||
|
{formatRate(balance.profitRate)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 탭 -->
|
||||||
|
<div class="flex gap-1 mb-4 border-b border-gray-700 pb-1">
|
||||||
|
{#each [['balance', '보유종목'], ['pending', '미체결'], ['history', '주문내역']] as [tab, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => setTab(tab as typeof activeTab)}
|
||||||
|
class="px-4 py-2 text-sm rounded-t 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 === 'balance'}
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{#each balance.stocks as stock (stock.code)}
|
||||||
|
<tr
|
||||||
|
class="cursor-pointer hover:bg-gray-700 transition-colors"
|
||||||
|
onclick={() => goto(`/stock/${stock.code}`)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium text-white">{stock.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">{stock.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">{stock.qty.toLocaleString()}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">{formatPrice(stock.buyPrice)}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(stock.profitRate)}">{formatPrice(stock.curPrice)}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(stock.profitLoss)}">
|
||||||
|
{stock.profitLoss >= 0 ? '+' : ''}{formatPrice(stock.profitLoss)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(stock.profitRate)}">{formatRate(stock.profitRate)}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">{formatPrice(stock.value)}</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-8 text-center text-gray-500">보유 종목 없음</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if activeTab === 'pending'}
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
{#if pending.length === 0}
|
||||||
|
<div class="p-8 text-center text-gray-500">미체결 주문 없음</div>
|
||||||
|
{:else}
|
||||||
|
<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-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 pending as order (order.ordNo)}
|
||||||
|
<tr class="hover:bg-gray-700 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium text-white">{order.stkNm}</div>
|
||||||
|
<div class="text-xs text-gray-500">{order.stkCd}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm font-medium {tradeTypeClass(order.trdeTp)}">
|
||||||
|
{order.ioTpNm || tradeTypeLabel(order.trdeTp)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">
|
||||||
|
{parseInt(order.ordPric).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">{order.ordQty}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-yellow-400">{order.osoQty}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">
|
||||||
|
{parseInt(order.cntrPric) > 0 ? parseInt(order.cntrPric).toLocaleString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">
|
||||||
|
{parseInt(order.cntrQty) > 0 ? order.cntrQty : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-xs text-gray-400">{formatTm(order.tm)}</td>
|
||||||
|
<td class="px-3 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onclick={() => cancelOrder(order)}
|
||||||
|
class="text-xs px-2 py-1 rounded bg-red-900/50 hover:bg-red-800/50 text-red-300 transition-colors"
|
||||||
|
>취소</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
{#if history.length === 0}
|
||||||
|
<div class="p-8 text-center text-gray-500">오늘 주문 내역 없음</div>
|
||||||
|
{:else}
|
||||||
|
<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>
|
||||||
|
<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 history as order (order.ordNo + order.ordTm)}
|
||||||
|
<tr class="hover:bg-gray-700 transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium text-white">{order.stkNm}</div>
|
||||||
|
<div class="text-xs text-gray-500">{order.stkCd}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center text-sm font-medium {tradeTypeClass(order.trdeTp)}">
|
||||||
|
{order.ioTpNm || tradeTypeLabel(order.trdeTp)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">
|
||||||
|
{parseInt(order.ordPric).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">{order.ordQty}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">
|
||||||
|
{parseInt(order.cntrPric) > 0 ? parseInt(order.cntrPric).toLocaleString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">
|
||||||
|
{parseInt(order.cntrQty) > 0 ? order.cntrQty : '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {parseInt(order.osoQty) > 0 ? 'text-yellow-400' : 'text-gray-500'}">
|
||||||
|
{parseInt(order.osoQty) > 0 ? order.osoQty : '0'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded {
|
||||||
|
order.ordStt === '체결' ? 'bg-green-900/50 text-green-400' :
|
||||||
|
order.ordStt === '확인' ? 'bg-blue-900/50 text-blue-400' :
|
||||||
|
'bg-gray-700 text-gray-400'
|
||||||
|
}">{order.ordStt || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-xs text-gray-400">
|
||||||
|
{parseInt(order.trdeCmsn) + parseInt(order.trdeTax) > 0
|
||||||
|
? (parseInt(order.trdeCmsn) + parseInt(order.trdeTax)).toLocaleString()
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-xs text-gray-400">{formatTm(order.ordTm)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500">
|
||||||
|
계좌 정보를 불러올 수 없습니다.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
570
frontend/src/routes/(app)/autotrade/+page.svelte
Normal file
570
frontend/src/routes/(app)/autotrade/+page.svelte
Normal 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>
|
||||||
117
frontend/src/routes/(app)/kospi200/+page.svelte
Normal file
117
frontend/src/routes/(app)/kospi200/+page.svelte
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { stockApi } from '$lib/api/stock'
|
||||||
|
import { formatRate, priceClass, sigClass, sigToArrow, formatVolume } from '$lib/utils'
|
||||||
|
import SortableHeader from '$lib/components/SortableHeader.svelte'
|
||||||
|
import type { Kospi200Stock } from '$lib/api/types'
|
||||||
|
|
||||||
|
let stocks = $state<Kospi200Stock[]>([])
|
||||||
|
let loading = $state(true)
|
||||||
|
let sortCol = $state('fluRt')
|
||||||
|
let sortDesc = $state(true)
|
||||||
|
let filterQuery = $state('')
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
stocks = await stockApi.getKospi200()
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSort(e: CustomEvent<string>) {
|
||||||
|
const col = e.detail
|
||||||
|
if (sortCol === col) {
|
||||||
|
sortDesc = !sortDesc
|
||||||
|
} else {
|
||||||
|
sortCol = col
|
||||||
|
sortDesc = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = $derived(
|
||||||
|
filterQuery
|
||||||
|
? stocks.filter(s =>
|
||||||
|
s.name.includes(filterQuery) || s.code.includes(filterQuery)
|
||||||
|
)
|
||||||
|
: stocks
|
||||||
|
)
|
||||||
|
|
||||||
|
let sorted = $derived(
|
||||||
|
[...filtered].sort((a, b) => {
|
||||||
|
if (sortCol === 'name') {
|
||||||
|
const cmp = a.name.localeCompare(b.name, 'ko')
|
||||||
|
return sortDesc ? -cmp : cmp
|
||||||
|
}
|
||||||
|
const av = (a as unknown as Record<string, number>)[sortCol] ?? 0
|
||||||
|
const bv = (b as unknown as Record<string, number>)[sortCol] ?? 0
|
||||||
|
return sortDesc ? bv - av : av - bv
|
||||||
|
})
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>코스피200 - 주식 시세</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-xl font-bold text-white">코스피200</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={filterQuery}
|
||||||
|
placeholder="종목 검색..."
|
||||||
|
class="bg-gray-700 border border-gray-600 text-white text-sm rounded-lg px-3 py-1.5 w-48 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center h-64 text-gray-400">로딩 중...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader col="name" label="종목" {sortCol} {sortDesc} align="left" class="sticky left-0 bg-gray-800" on:sort={handleSort} />
|
||||||
|
<SortableHeader col="curPrc" label="현재가" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="predPre" label="전일대비" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="fluRt" label="등락률" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="volume" label="거래량" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="open" label="시가" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="high" label="고가" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="low" label="저가" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{#each sorted as stock (stock.code)}
|
||||||
|
<tr
|
||||||
|
class="cursor-pointer hover:bg-gray-700 transition-colors"
|
||||||
|
onclick={() => goto(`/stock/${stock.code}`)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 sticky left-0 bg-gray-800">
|
||||||
|
<div class="text-sm font-medium text-white">{stock.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">{stock.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right font-mono text-sm {priceClass(stock.fluRt)}">
|
||||||
|
{stock.curPrc.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {sigClass(stock.predPreSig)}">
|
||||||
|
{sigToArrow(stock.predPreSig)} {stock.predPre.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(stock.fluRt)}">
|
||||||
|
{formatRate(stock.fluRt)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">
|
||||||
|
{formatVolume(stock.volume)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">{stock.open.toLocaleString()}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-red-400">{stock.high.toLocaleString()}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-blue-400">{stock.low.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
256
frontend/src/routes/(app)/stock/[code]/+page.svelte
Normal file
256
frontend/src/routes/(app)/stock/[code]/+page.svelte
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { page } from '$app/state'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { stockApi } from '$lib/api/stock'
|
||||||
|
import { accountApi } from '$lib/api/account'
|
||||||
|
import { watchlist } from '$lib/stores/watchlist'
|
||||||
|
import { priceMap, orderBookMap, wsStore } from '$lib/stores/ws'
|
||||||
|
import { formatPrice, formatRate, priceClass } from '$lib/utils'
|
||||||
|
import CandleChart from '$lib/components/CandleChart.svelte'
|
||||||
|
import type { CandleData, StockPrice } from '$lib/api/types'
|
||||||
|
|
||||||
|
const code = page.params.code!
|
||||||
|
let stockInfo = $state<StockPrice | null>(null)
|
||||||
|
let candles = $state<CandleData[]>([])
|
||||||
|
let chartType = $state<'D' | 'W' | 'M'>('D')
|
||||||
|
let buyQty = $state(1)
|
||||||
|
let buyPrice = $state(0)
|
||||||
|
let sellQty = $state(1)
|
||||||
|
let orderMsg = $state('')
|
||||||
|
let loading = $state(true)
|
||||||
|
|
||||||
|
let livePrice = $derived($priceMap[code])
|
||||||
|
let liveOrderBook = $derived($orderBookMap[code])
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await watchlist.load()
|
||||||
|
wsStore.connect()
|
||||||
|
wsStore.subscribeCode(code)
|
||||||
|
await loadStock()
|
||||||
|
loading = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
wsStore.unsubscribeCode(code)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStock() {
|
||||||
|
try {
|
||||||
|
const [price, chart] = await Promise.all([
|
||||||
|
stockApi.getPrice(code),
|
||||||
|
stockApi.getChart(code, chartType),
|
||||||
|
])
|
||||||
|
stockInfo = price
|
||||||
|
candles = chart
|
||||||
|
buyPrice = price.currentPrice
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChart(type: 'D' | 'W' | 'M') {
|
||||||
|
chartType = type
|
||||||
|
try {
|
||||||
|
candles = await stockApi.getChart(code, type)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBuy() {
|
||||||
|
try {
|
||||||
|
await accountApi.buy({ code, qty: buyQty, price: buyPrice })
|
||||||
|
orderMsg = '매수 주문 완료'
|
||||||
|
} catch (e: unknown) {
|
||||||
|
orderMsg = e instanceof Error ? e.message : '주문 실패'
|
||||||
|
}
|
||||||
|
setTimeout(() => { orderMsg = '' }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSell() {
|
||||||
|
const curPrice = livePrice?.currentPrice ?? stockInfo?.currentPrice ?? 0
|
||||||
|
try {
|
||||||
|
await accountApi.sell({ code, qty: sellQty, price: curPrice })
|
||||||
|
orderMsg = '매도 주문 완료'
|
||||||
|
} catch (e: unknown) {
|
||||||
|
orderMsg = e instanceof Error ? e.message : '주문 실패'
|
||||||
|
}
|
||||||
|
setTimeout(() => { orderMsg = '' }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isWatched = $derived($watchlist.some(i => i.code === code))
|
||||||
|
|
||||||
|
async function toggleWatchlist() {
|
||||||
|
if (isWatched) {
|
||||||
|
await watchlist.remove(code)
|
||||||
|
} else {
|
||||||
|
const name = livePrice?.name ?? stockInfo?.name ?? code
|
||||||
|
await watchlist.add(code, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayPrice = $derived(livePrice ?? stockInfo)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{displayPrice?.name ?? code} - 주식 시세</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center h-64 text-gray-400">로딩 중...</div>
|
||||||
|
{:else if displayPrice}
|
||||||
|
<!-- 종목 헤더 -->
|
||||||
|
<div class="flex items-start justify-between mb-6 flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick={() => goto('/')} class="text-gray-400 hover:text-white">←</button>
|
||||||
|
<h1 class="text-2xl font-bold text-white">{displayPrice.name}</h1>
|
||||||
|
<span class="text-gray-400 text-sm">{code}</span>
|
||||||
|
<span class="text-xs bg-gray-700 text-gray-300 px-2 py-0.5 rounded">{displayPrice.market}</span>
|
||||||
|
<button
|
||||||
|
onclick={toggleWatchlist}
|
||||||
|
class="text-xl transition-colors {isWatched ? 'text-yellow-400' : 'text-gray-600 hover:text-yellow-400'}"
|
||||||
|
title={isWatched ? '관심종목 해제' : '관심종목 추가'}
|
||||||
|
>★</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-4 mt-3">
|
||||||
|
<span class="text-4xl font-bold {priceClass(displayPrice.changeRate)}">
|
||||||
|
{formatPrice(displayPrice.currentPrice)}
|
||||||
|
</span>
|
||||||
|
<div class="{priceClass(displayPrice.changeRate)} text-lg">
|
||||||
|
{displayPrice.changePrice >= 0 ? '+' : ''}{formatPrice(displayPrice.changePrice)}
|
||||||
|
({formatRate(displayPrice.changeRate)})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4 mt-2 text-sm text-gray-400">
|
||||||
|
<span>시가 {formatPrice(displayPrice.open)}</span>
|
||||||
|
<span>고가 <span class="text-red-400">{formatPrice(displayPrice.high)}</span></span>
|
||||||
|
<span>저가 <span class="text-blue-400">{formatPrice(displayPrice.low)}</span></span>
|
||||||
|
<span>거래량 {displayPrice.volume.toLocaleString()}</span>
|
||||||
|
<span>체결강도 <span class="{displayPrice.cntrStr > 100 ? 'text-red-400' : 'text-blue-400'}">{displayPrice.cntrStr.toFixed(1)}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<!-- 차트 -->
|
||||||
|
<div class="xl:col-span-2">
|
||||||
|
<div class="bg-gray-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-sm font-medium text-gray-300">캔들 차트</h2>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#each [['D','일봉'], ['W','주봉'], ['M','월봉']] as [type, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => loadChart(type as 'D'|'W'|'M')}
|
||||||
|
class="px-3 py-1 text-xs rounded {chartType === type ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||||
|
>{label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CandleChart data={candles} height={350} livePrice={livePrice} {chartType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사이드: 호가창 + 주문 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 호가창 -->
|
||||||
|
{#if liveOrderBook}
|
||||||
|
<div class="bg-gray-800 rounded-xl p-4">
|
||||||
|
<h2 class="text-sm font-medium text-gray-300 mb-3">호가창</h2>
|
||||||
|
<div class="text-xs space-y-0.5">
|
||||||
|
<!-- 매도 호가 (역순: 10→1) -->
|
||||||
|
{#each [...liveOrderBook.asks].reverse() as ask}
|
||||||
|
<div class="flex justify-between py-0.5 hover:bg-gray-700 rounded px-1">
|
||||||
|
<span class="text-blue-400 font-mono">{ask.volume.toLocaleString()}</span>
|
||||||
|
<span class="text-blue-300 font-mono">{formatPrice(ask.price)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<!-- 현재가 구분선 -->
|
||||||
|
<div class="border-t border-gray-600 my-1 text-center text-yellow-400 font-bold text-sm py-1">
|
||||||
|
{formatPrice(displayPrice.currentPrice)}
|
||||||
|
</div>
|
||||||
|
<!-- 매수 호가 -->
|
||||||
|
{#each liveOrderBook.bids as bid}
|
||||||
|
<div class="flex justify-between py-0.5 hover:bg-gray-700 rounded px-1">
|
||||||
|
<span class="text-red-300 font-mono">{formatPrice(bid.price)}</span>
|
||||||
|
<span class="text-red-400 font-mono">{bid.volume.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 주문 패널 -->
|
||||||
|
<div class="bg-gray-800 rounded-xl p-4">
|
||||||
|
<h2 class="text-sm font-medium text-gray-300 mb-3">주문</h2>
|
||||||
|
{#if orderMsg}
|
||||||
|
<div class="text-sm text-center py-2 mb-3 rounded bg-gray-700 text-green-400">
|
||||||
|
{orderMsg}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 매수 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-gray-400 mb-1 block" for="buy-price">가격</label>
|
||||||
|
<input
|
||||||
|
id="buy-price"
|
||||||
|
type="number"
|
||||||
|
bind:value={buyPrice}
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-2 py-1.5 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-20">
|
||||||
|
<label class="text-xs text-gray-400 mb-1 block" for="buy-qty">수량</label>
|
||||||
|
<input
|
||||||
|
id="buy-qty"
|
||||||
|
type="number"
|
||||||
|
bind:value={buyQty}
|
||||||
|
min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-2 py-1.5 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleBuy}
|
||||||
|
class="w-full bg-red-600 hover:bg-red-500 text-white text-sm font-medium rounded py-2 transition-colors"
|
||||||
|
>
|
||||||
|
매수 ({(buyPrice * buyQty).toLocaleString()}원)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-700" />
|
||||||
|
|
||||||
|
<!-- 매도 -->
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="text-xs text-gray-400 mb-1 block" for="sell-qty">수량</label>
|
||||||
|
<input
|
||||||
|
id="sell-qty"
|
||||||
|
type="number"
|
||||||
|
bind:value={sellQty}
|
||||||
|
min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-2 py-1.5 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleSell}
|
||||||
|
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded py-2 transition-colors"
|
||||||
|
>
|
||||||
|
시장가 매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center h-64">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-gray-400 mb-3">종목 정보를 불러올 수 없습니다</div>
|
||||||
|
<button onclick={() => goto('/')} class="text-blue-400 hover:underline">← 돌아가기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
206
frontend/src/routes/(app)/theme/+page.svelte
Normal file
206
frontend/src/routes/(app)/theme/+page.svelte
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { stockApi } from '$lib/api/stock'
|
||||||
|
import { formatRate, priceClass, sigToArrow } from '$lib/utils'
|
||||||
|
import SortableHeader from '$lib/components/SortableHeader.svelte'
|
||||||
|
import type { ThemeGroup, ThemeDetail } from '$lib/api/types'
|
||||||
|
|
||||||
|
let themes = $state<ThemeGroup[]>([])
|
||||||
|
let selectedTheme = $state<ThemeGroup | null>(null)
|
||||||
|
let detail = $state<ThemeDetail | null>(null)
|
||||||
|
let loading = $state(true)
|
||||||
|
let detailLoading = $state(false)
|
||||||
|
|
||||||
|
// 테마 목록 정렬
|
||||||
|
let sortCol = $state('fluRt')
|
||||||
|
let sortDesc = $state(true)
|
||||||
|
|
||||||
|
// 테마 검색 필터
|
||||||
|
let filterQuery = $state('')
|
||||||
|
|
||||||
|
// 구성종목 정렬
|
||||||
|
let detailSortCol = $state('fluRt')
|
||||||
|
let detailSortDesc = $state(true)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
themes = await stockApi.getThemes()
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSort(e: CustomEvent<string>) {
|
||||||
|
const col = e.detail
|
||||||
|
if (sortCol === col) sortDesc = !sortDesc
|
||||||
|
else { sortCol = col; sortDesc = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetailSort(e: CustomEvent<string>) {
|
||||||
|
const col = e.detail
|
||||||
|
if (detailSortCol === col) detailSortDesc = !detailSortDesc
|
||||||
|
else { detailSortCol = col; detailSortDesc = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = $derived(
|
||||||
|
filterQuery
|
||||||
|
? themes.filter(t => t.name.toLowerCase().includes(filterQuery.toLowerCase()) || t.mainStock.toLowerCase().includes(filterQuery.toLowerCase()))
|
||||||
|
: themes
|
||||||
|
)
|
||||||
|
|
||||||
|
let sorted = $derived(
|
||||||
|
[...filtered].sort((a, b) => {
|
||||||
|
if (sortCol === 'name') {
|
||||||
|
const cmp = a.name.localeCompare(b.name, 'ko')
|
||||||
|
return sortDesc ? -cmp : cmp
|
||||||
|
}
|
||||||
|
const av = (a as unknown as Record<string, number>)[sortCol] ?? 0
|
||||||
|
const bv = (b as unknown as Record<string, number>)[sortCol] ?? 0
|
||||||
|
return sortDesc ? bv - av : av - bv
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
let sortedStocks = $derived(
|
||||||
|
detail
|
||||||
|
? [...detail.stocks].sort((a, b) => {
|
||||||
|
if (detailSortCol === 'name') {
|
||||||
|
const cmp = a.name.localeCompare(b.name, 'ko')
|
||||||
|
return detailSortDesc ? -cmp : cmp
|
||||||
|
}
|
||||||
|
const av = (a as unknown as Record<string, number>)[detailSortCol] ?? 0
|
||||||
|
const bv = (b as unknown as Record<string, number>)[detailSortCol] ?? 0
|
||||||
|
return detailSortDesc ? bv - av : av - bv
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
async function selectTheme(theme: ThemeGroup) {
|
||||||
|
selectedTheme = theme
|
||||||
|
detailLoading = true
|
||||||
|
detail = null
|
||||||
|
detailSortCol = 'fluRt'
|
||||||
|
detailSortDesc = true
|
||||||
|
try {
|
||||||
|
detail = await stockApi.getThemeStocks(theme.code)
|
||||||
|
} finally {
|
||||||
|
detailLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>테마 분석 - 주식 시세</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-xl font-bold text-white mb-4">테마 분석</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
<!-- 테마 목록 -->
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
{#if loading}
|
||||||
|
<div class="p-8 text-center text-gray-400">로딩 중...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="px-4 py-3 border-b border-gray-700">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={filterQuery}
|
||||||
|
placeholder="테마명 또는 대장주 검색..."
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-[75vh]">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader col="name" label="테마명" {sortCol} {sortDesc} align="left" class="w-full" on:sort={handleSort} />
|
||||||
|
<SortableHeader col="fluRt" label="등락률" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="periodRt" label="기간수익" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="risingCount" label="상승" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
<SortableHeader col="stockCount" label="종목수" {sortCol} {sortDesc} on:sort={handleSort} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{#each sorted as theme (theme.code)}
|
||||||
|
<tr
|
||||||
|
class="cursor-pointer hover:bg-gray-700 transition-colors {selectedTheme?.code === theme.code ? 'bg-gray-700' : ''}"
|
||||||
|
onclick={() => selectTheme(theme)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium text-white">{theme.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">{theme.mainStock}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(theme.fluRt)}">
|
||||||
|
{formatRate(theme.fluRt)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(theme.periodRt)}">
|
||||||
|
{formatRate(theme.periodRt)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-red-400">
|
||||||
|
{theme.risingCount}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">
|
||||||
|
{theme.stockCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 테마 구성종목 -->
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
{#if !selectedTheme}
|
||||||
|
<div class="p-8 text-center text-gray-500">테마를 선택하면 구성종목이 표시됩니다</div>
|
||||||
|
{:else if detailLoading}
|
||||||
|
<div class="p-8 text-center text-gray-400">로딩 중...</div>
|
||||||
|
{:else if detail}
|
||||||
|
<div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-white">{selectedTheme.name}</h2>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span>등락률 <span class="{priceClass(detail.fluRt)}">{formatRate(detail.fluRt)}</span></span>
|
||||||
|
<span>기간수익 <span class="{priceClass(detail.periodRt)}">{formatRate(detail.periodRt)}</span></span>
|
||||||
|
<span class="text-gray-500">{detail.stocks.length}종목</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-[68vh]">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader col="name" label="종목" sortCol={detailSortCol} sortDesc={detailSortDesc} align="left" class="w-full" on:sort={handleDetailSort} />
|
||||||
|
<SortableHeader col="curPrc" label="현재가" sortCol={detailSortCol} sortDesc={detailSortDesc} on:sort={handleDetailSort} />
|
||||||
|
<SortableHeader col="predPre" label="전일대비" sortCol={detailSortCol} sortDesc={detailSortDesc} on:sort={handleDetailSort} />
|
||||||
|
<SortableHeader col="fluRt" label="등락률" sortCol={detailSortCol} sortDesc={detailSortDesc} on:sort={handleDetailSort} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{#each sortedStocks as stock (stock.code)}
|
||||||
|
<tr
|
||||||
|
class="cursor-pointer hover:bg-gray-700 transition-colors"
|
||||||
|
onclick={() => goto(`/stock/${stock.code}`)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium text-white">{stock.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">{stock.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(stock.fluRt)}">
|
||||||
|
{stock.curPrc.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(stock.fluRt)}">
|
||||||
|
{sigToArrow(stock.fluSig)}{stock.predPre.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(stock.fluRt)}">
|
||||||
|
{formatRate(stock.fluRt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
7
frontend/src/routes/+layout.svelte
Normal file
7
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css'
|
||||||
|
|
||||||
|
let { children } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
3
frontend/src/routes/+layout.ts
Normal file
3
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// SPA 모드: prerendering 비활성화
|
||||||
|
export const prerender = false
|
||||||
|
export const ssr = false
|
||||||
94
frontend/src/routes/login/+page.svelte
Normal file
94
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
let id = $state('')
|
||||||
|
let password = $state('')
|
||||||
|
let error = $state('')
|
||||||
|
let loading = $state(false)
|
||||||
|
|
||||||
|
async function handleLogin(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
error = ''
|
||||||
|
loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const form = new URLSearchParams()
|
||||||
|
form.set('id', id)
|
||||||
|
form.set('password', password)
|
||||||
|
form.set('next', '/')
|
||||||
|
|
||||||
|
const res = await fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: form.toString(),
|
||||||
|
redirect: 'manual',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok || res.status === 302 || res.type === 'opaqueredirect') {
|
||||||
|
await goto('/')
|
||||||
|
} else {
|
||||||
|
error = '아이디 또는 비밀번호가 올바르지 않습니다.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = '서버 연결에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>로그인 - 주식 시세</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-white">주식 시세</h1>
|
||||||
|
<p class="text-gray-400 mt-2">로그인이 필요합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={handleLogin} class="bg-gray-800 rounded-xl p-8 shadow-2xl space-y-5">
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-900/40 border border-red-500/50 text-red-300 text-sm rounded-lg px-4 py-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-1.5" for="id">아이디</label>
|
||||||
|
<input
|
||||||
|
id="id"
|
||||||
|
type="text"
|
||||||
|
bind:value={id}
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
placeholder="아이디 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-1.5" for="password">비밀번호</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
placeholder="비밀번호 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-gray-600 text-white font-semibold rounded-lg px-4 py-2.5 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
25
frontend/svelte.config.js
Normal file
25
frontend/svelte.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { relative, sep } from 'node:path';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
compilerOptions: {
|
||||||
|
runes: ({ filename }) => {
|
||||||
|
const relativePath = relative(import.meta.dirname, filename);
|
||||||
|
const pathSegments = relativePath.toLowerCase().split(sep);
|
||||||
|
const isExternalLibrary = pathSegments.includes('node_modules');
|
||||||
|
return isExternalLibrary ? undefined : true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html', // SPA fallback
|
||||||
|
precompress: false,
|
||||||
|
strict: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/ws': { target: 'ws://localhost:8080', ws: true },
|
||||||
|
'/login': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/logout': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -87,6 +87,18 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, next, http.StatusFound)
|
http.Redirect(w, r, next, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckSession GET /api/auth/check — 세션 유효성 확인 (200 OK / 401 Unauthorized)
|
||||||
|
func (h *AuthHandler) CheckSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(middleware.SessionCookieName)
|
||||||
|
if err != nil || !h.sessionSvc.Validate(cookie.Value) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
// Logout POST /logout — 세션 삭제 후 /login 리다이렉트
|
// Logout POST /logout — 세션 삭제 후 /login 리다이렉트
|
||||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ func (h *AutoTradeHandler) Emergency(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, map[string]bool{"ok": true})
|
jsonResponse(w, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClosePosition POST /api/autotrade/positions/{code}/close — 개별 포지션 청산
|
||||||
|
func (h *AutoTradeHandler) ClosePosition(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.PathValue("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "종목코드 필요", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.ClosePosition(code); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// jsonResponse JSON 응답 헬퍼
|
// jsonResponse JSON 응답 헬퍼
|
||||||
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
21
main.go
21
main.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"stocksearch/config"
|
"stocksearch/config"
|
||||||
"stocksearch/handlers"
|
"stocksearch/handlers"
|
||||||
"stocksearch/middleware"
|
"stocksearch/middleware"
|
||||||
@@ -66,6 +67,7 @@ func main() {
|
|||||||
mux.HandleFunc("GET /login", authHandler.LoginPage)
|
mux.HandleFunc("GET /login", authHandler.LoginPage)
|
||||||
mux.HandleFunc("POST /login", authHandler.Login)
|
mux.HandleFunc("POST /login", authHandler.Login)
|
||||||
mux.HandleFunc("POST /logout", authHandler.Logout)
|
mux.HandleFunc("POST /logout", authHandler.Logout)
|
||||||
|
mux.HandleFunc("GET /api/auth/check", authHandler.CheckSession)
|
||||||
|
|
||||||
// --- 페이지 라우트 ---
|
// --- 페이지 라우트 ---
|
||||||
mux.HandleFunc("GET /", pageHandler.IndexPage)
|
mux.HandleFunc("GET /", pageHandler.IndexPage)
|
||||||
@@ -118,6 +120,7 @@ func main() {
|
|||||||
mux.HandleFunc("POST /api/autotrade/start", autoTradeHandler.Start)
|
mux.HandleFunc("POST /api/autotrade/start", autoTradeHandler.Start)
|
||||||
mux.HandleFunc("POST /api/autotrade/stop", autoTradeHandler.Stop)
|
mux.HandleFunc("POST /api/autotrade/stop", autoTradeHandler.Stop)
|
||||||
mux.HandleFunc("POST /api/autotrade/emergency", autoTradeHandler.Emergency)
|
mux.HandleFunc("POST /api/autotrade/emergency", autoTradeHandler.Emergency)
|
||||||
|
mux.HandleFunc("POST /api/autotrade/positions/{code}/close", autoTradeHandler.ClosePosition)
|
||||||
|
|
||||||
// --- WebSocket 라우트 ---
|
// --- WebSocket 라우트 ---
|
||||||
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
|
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
|
||||||
@@ -125,8 +128,22 @@ func main() {
|
|||||||
// --- 정적 파일 ---
|
// --- 정적 파일 ---
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
// 미들웨어 체인 적용 (Auth → Logger → Recovery 순)
|
// --- SvelteKit 빌드 정적 서빙 (SPA fallback 포함) ---
|
||||||
handler := middleware.Chain(mux, middleware.Recovery, middleware.Logger, middleware.Auth(sessionSvc))
|
if _, err := os.Stat("frontend/build"); err == nil {
|
||||||
|
spa := http.FileServer(http.Dir("frontend/build"))
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := "frontend/build" + r.URL.Path
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
// SPA fallback: 파일 없으면 index.html 서빙
|
||||||
|
http.ServeFile(w, r, "frontend/build/index.html")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spa.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미들웨어 체인 적용 (CORS → Auth → Logger → Recovery 순)
|
||||||
|
handler := middleware.Chain(mux, middleware.Recovery, middleware.Logger, middleware.Auth(sessionSvc), middleware.CORS)
|
||||||
|
|
||||||
addr := "0.0.0.0:" + config.App.ServerPort
|
addr := "0.0.0.0:" + config.App.ServerPort
|
||||||
log.Printf("서버 시작: http://%s", addr)
|
log.Printf("서버 시작: http://%s", addr)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ var publicPaths = []string{
|
|||||||
"/api/kospi200",
|
"/api/kospi200",
|
||||||
"/api/news",
|
"/api/news",
|
||||||
"/api/disclosure",
|
"/api/disclosure",
|
||||||
|
"/api/auth/check",
|
||||||
"/ws",
|
"/ws",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
middleware/cors.go
Normal file
29
middleware/cors.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"stocksearch/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS SvelteKit 개발 서버(localhost:5173)에서의 크로스오리진 요청 허용 미들웨어
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
allowed := config.App.CORSOrigin
|
||||||
|
|
||||||
|
if allowed != "" && origin == allowed {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight 요청 즉시 응답
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,7 +20,12 @@ type AutoTradeRule struct {
|
|||||||
MaxPositions int `json:"maxPositions"` // 동시 최대 보유 종목 수 (기본 3)
|
MaxPositions int `json:"maxPositions"` // 동시 최대 보유 종목 수 (기본 3)
|
||||||
|
|
||||||
// 청산 조건
|
// 청산 조건
|
||||||
StopLossPct float64 `json:"stopLossPct"` // 손절 % (예: -3.0)
|
// 2중 손절 구조:
|
||||||
|
// 1차 - StopLoss1Pct 에 StopLoss1Count 회 터치 시 매도
|
||||||
|
// 2차 - StopLossPct 에 닿으면 즉시 매도 (StopLoss1Count==0 이면 단일 손절)
|
||||||
|
StopLoss1Pct float64 `json:"stopLoss1Pct"` // 1차 손절 % (예: -2.0, 0=비활성)
|
||||||
|
StopLoss1Count int `json:"stopLoss1Count"` // 1차 손절 터치 횟수 (예: 3)
|
||||||
|
StopLossPct float64 `json:"stopLossPct"` // 2차 손절 % (예: -4.0, 즉시 매도)
|
||||||
TakeProfitPct float64 `json:"takeProfitPct"` // 익절 % (예: 5.0)
|
TakeProfitPct float64 `json:"takeProfitPct"` // 익절 % (예: 5.0)
|
||||||
MaxHoldMinutes int `json:"maxHoldMinutes"` // 최대 보유 시간(분, 0=무제한)
|
MaxHoldMinutes int `json:"maxHoldMinutes"` // 최대 보유 시간(분, 0=무제한)
|
||||||
ExitBeforeClose bool `json:"exitBeforeClose"` // 장 마감 전 청산(15:20 기준)
|
ExitBeforeClose bool `json:"exitBeforeClose"` // 장 마감 전 청산(15:20 기준)
|
||||||
@@ -37,7 +42,9 @@ type AutoTradePosition struct {
|
|||||||
OrderNo string `json:"orderNo"` // 매수 주문번호
|
OrderNo string `json:"orderNo"` // 매수 주문번호
|
||||||
EntryTime time.Time `json:"entryTime"` // 진입 시각
|
EntryTime time.Time `json:"entryTime"` // 진입 시각
|
||||||
RuleID string `json:"ruleId"` // 규칙 ID
|
RuleID string `json:"ruleId"` // 규칙 ID
|
||||||
StopLoss int64 `json:"stopLoss"` // 절대 손절가
|
StopLoss1 int64 `json:"stopLoss1"` // 절대 1차 손절가 (0=비활성)
|
||||||
|
StopLoss1Touches int `json:"stopLoss1Touches"` // 1차 손절가 터치 누적 횟수
|
||||||
|
StopLoss int64 `json:"stopLoss"` // 절대 2차 손절가 (즉시 매도)
|
||||||
TakeProfit int64 `json:"takeProfit"` // 절대 익절가
|
TakeProfit int64 `json:"takeProfit"` // 절대 익절가
|
||||||
|
|
||||||
// 상태: "pending"=체결 대기 | "open"=보유중 | "closed"=청산완료
|
// 상태: "pending"=체결 대기 | "open"=보유중 | "closed"=청산완료
|
||||||
|
|||||||
@@ -380,11 +380,11 @@ func (s *AutoTradeService) exitLoop() {
|
|||||||
|
|
||||||
// checkEntries 진입 조건 체크 및 매수 주문
|
// checkEntries 진입 조건 체크 및 매수 주문
|
||||||
func (s *AutoTradeService) checkEntries() {
|
func (s *AutoTradeService) checkEntries() {
|
||||||
signals := s.getWatchSignals()
|
// 규칙 및 현재 포지션 수 선조회
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
rules := make([]models.AutoTradeRule, len(s.rules))
|
rules := make([]models.AutoTradeRule, len(s.rules))
|
||||||
copy(rules, s.rules)
|
copy(rules, s.rules)
|
||||||
|
posCount := s.countActivePositions()
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
// 활성 규칙 수 계산
|
// 활성 규칙 수 계산
|
||||||
@@ -395,6 +395,21 @@ func (s *AutoTradeService) checkEntries() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모든 활성 규칙이 최대 포지션에 도달했으면 신호 조회 생략 → 보유 종목 감시만 진행
|
||||||
|
hasRoom := false
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Enabled && posCount < r.MaxPositions {
|
||||||
|
hasRoom = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRoom && activeRules > 0 {
|
||||||
|
s.addLog("debug", "", fmt.Sprintf("진입 스캔 생략: 최대 포지션 도달 (%d개 보유 중, 청산 감시만 진행)", posCount))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
signals := s.getWatchSignals()
|
||||||
|
|
||||||
s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules))
|
s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules))
|
||||||
|
|
||||||
if len(signals) == 0 {
|
if len(signals) == 0 {
|
||||||
@@ -497,13 +512,13 @@ func (s *AutoTradeService) checkEntries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 포지션 등록 (현재가 기준 예상 손절/익절가 미리 계산 → UI에 0원 방지)
|
// 포지션 등록 (현재가 기준 예상 손절/익절가 미리 계산 → UI에 0원 방지)
|
||||||
estStop := int64(float64(sig.CurrentPrice) * (1 + rule.StopLossPct/100))
|
estStop1, estStop, estProfit := calcStopTargets(sig.CurrentPrice, &rule)
|
||||||
estProfit := int64(float64(sig.CurrentPrice) * (1 + rule.TakeProfitPct/100))
|
|
||||||
pos := &models.AutoTradePosition{
|
pos := &models.AutoTradePosition{
|
||||||
Code: code,
|
Code: code,
|
||||||
Name: sig.Name,
|
Name: sig.Name,
|
||||||
Qty: qty,
|
Qty: qty,
|
||||||
BuyPrice: sig.CurrentPrice,
|
BuyPrice: sig.CurrentPrice,
|
||||||
|
StopLoss1: estStop1,
|
||||||
StopLoss: estStop,
|
StopLoss: estStop,
|
||||||
TakeProfit: estProfit,
|
TakeProfit: estProfit,
|
||||||
OrderNo: result.OrderNo,
|
OrderNo: result.OrderNo,
|
||||||
@@ -561,21 +576,13 @@ func (s *AutoTradeService) checkExits() {
|
|||||||
// 포지션 모니터링 debug 로그
|
// 포지션 모니터링 debug 로그
|
||||||
if posCopy.BuyPrice > 0 {
|
if posCopy.BuyPrice > 0 {
|
||||||
pl := (float64(curPrice) - float64(posCopy.BuyPrice)) / float64(posCopy.BuyPrice) * 100
|
pl := (float64(curPrice) - float64(posCopy.BuyPrice)) / float64(posCopy.BuyPrice) * 100
|
||||||
s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (손절=%s 익절=%s)",
|
s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (1차손절=%s[%d/%d회] 2차손절=%s 익절=%s)",
|
||||||
posCopy.Name, formatComma(curPrice), pl, formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit)))
|
posCopy.Name, formatComma(curPrice), pl,
|
||||||
|
formatComma(posCopy.StopLoss1), posCopy.StopLoss1Touches, s.stopLoss1Limit(posCopy.RuleID),
|
||||||
|
formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit)))
|
||||||
}
|
}
|
||||||
|
|
||||||
var reason string
|
reason := s.evalExitReason(code, &posCopy, curPrice, kstNow, closeTime, now)
|
||||||
switch {
|
|
||||||
case curPrice <= posCopy.StopLoss:
|
|
||||||
reason = "손절"
|
|
||||||
case curPrice >= posCopy.TakeProfit:
|
|
||||||
reason = "익절"
|
|
||||||
case exitBeforeCloseRule(s.rules, &posCopy) && kstNow.After(closeTime):
|
|
||||||
reason = "장마감"
|
|
||||||
case maxHoldExpired(s.rules, &posCopy, now):
|
|
||||||
reason = "시간초과"
|
|
||||||
}
|
|
||||||
|
|
||||||
if reason != "" {
|
if reason != "" {
|
||||||
if err := s.executeSell(&posCopy, reason); err != nil {
|
if err := s.executeSell(&posCopy, reason); err != nil {
|
||||||
@@ -629,23 +636,27 @@ func (s *AutoTradeService) checkPending() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rule := s.findRule(pos.RuleID)
|
rule := s.findRule(pos.RuleID)
|
||||||
var stopLoss, takeProfit int64
|
var stopLoss1, stopLoss, takeProfit int64
|
||||||
if rule != nil && buyPrice > 0 {
|
if rule != nil && buyPrice > 0 {
|
||||||
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
|
stopLoss1, stopLoss, takeProfit = calcStopTargets(buyPrice, rule)
|
||||||
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" {
|
if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" {
|
||||||
p.BuyPrice = buyPrice
|
p.BuyPrice = buyPrice
|
||||||
|
p.StopLoss1 = stopLoss1
|
||||||
p.StopLoss = stopLoss
|
p.StopLoss = stopLoss
|
||||||
p.TakeProfit = takeProfit
|
p.TakeProfit = takeProfit
|
||||||
p.Status = "open"
|
p.Status = "open"
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)",
|
sl1Count := 0
|
||||||
pos.Name, pos.Qty, buyPrice, stopLoss, takeProfit))
|
if rule != nil {
|
||||||
|
sl1Count = rule.StopLoss1Count
|
||||||
|
}
|
||||||
|
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (1차손절: %d[%d회], 2차손절: %d, 익절: %d)",
|
||||||
|
pos.Name, pos.Qty, buyPrice, stopLoss1, sl1Count, stopLoss, takeProfit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,6 +717,20 @@ func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClosePosition 개별 포지션 수동 청산
|
||||||
|
func (s *AutoTradeService) ClosePosition(code string) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
pos, ok := s.positions[code]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("포지션을 찾을 수 없습니다: %s", code)
|
||||||
|
}
|
||||||
|
if pos.Status != "open" && pos.Status != "pending" {
|
||||||
|
return fmt.Errorf("이미 청산된 포지션입니다: %s", code)
|
||||||
|
}
|
||||||
|
return s.executeSell(pos, "수동청산")
|
||||||
|
}
|
||||||
|
|
||||||
// countActivePositions pending+open 포지션 수
|
// countActivePositions pending+open 포지션 수
|
||||||
func (s *AutoTradeService) countActivePositions() int {
|
func (s *AutoTradeService) countActivePositions() int {
|
||||||
count := 0
|
count := 0
|
||||||
@@ -799,3 +824,80 @@ func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, n
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calcStopTargets 매수가 기준으로 1차 손절가 / 2차 손절가 / 익절가 계산
|
||||||
|
func calcStopTargets(buyPrice int64, rule *models.AutoTradeRule) (stopLoss1, stopLoss, takeProfit int64) {
|
||||||
|
if rule == nil || buyPrice <= 0 {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
if rule.StopLoss1Count > 0 && rule.StopLoss1Pct != 0 {
|
||||||
|
stopLoss1 = int64(float64(buyPrice) * (1 + rule.StopLoss1Pct/100))
|
||||||
|
}
|
||||||
|
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
|
||||||
|
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopLoss1Limit 규칙의 1차 손절 터치 임계 반환 (규칙 없으면 0)
|
||||||
|
func (s *AutoTradeService) stopLoss1Limit(ruleID string) int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, r := range s.rules {
|
||||||
|
if r.ID == ruleID {
|
||||||
|
return r.StopLoss1Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalExitReason 청산 조건 평가 — 2중 손절 포함
|
||||||
|
// 2차 손절(즉시) → 1차 손절(터치 카운트) → 익절 → 장마감 → 시간초과 순으로 체크
|
||||||
|
func (s *AutoTradeService) evalExitReason(code string, pos *models.AutoTradePosition, curPrice int64,
|
||||||
|
kstNow time.Time, closeTime time.Time, now time.Time) string {
|
||||||
|
|
||||||
|
// 2차 손절: 즉시 매도
|
||||||
|
if curPrice <= pos.StopLoss {
|
||||||
|
return "2차손절"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1차 손절: X회 터치 시 매도
|
||||||
|
if pos.StopLoss1 > 0 && curPrice <= pos.StopLoss1 {
|
||||||
|
rule := s.findRule(pos.RuleID)
|
||||||
|
limit := 0
|
||||||
|
if rule != nil {
|
||||||
|
limit = rule.StopLoss1Count
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
var touches int
|
||||||
|
if p, ok := s.positions[code]; ok && p.Status == "open" {
|
||||||
|
p.StopLoss1Touches++
|
||||||
|
touches = p.StopLoss1Touches
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if limit > 0 && touches >= limit {
|
||||||
|
return "1차손절"
|
||||||
|
}
|
||||||
|
s.addLog("warn", code, fmt.Sprintf("1차손절 터치 %d/%d회 (현재가: %s원, 1차손절가: %s원)",
|
||||||
|
touches, limit, formatComma(curPrice), formatComma(pos.StopLoss1)))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 익절
|
||||||
|
if curPrice >= pos.TakeProfit {
|
||||||
|
return "익절"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 장마감 전 청산
|
||||||
|
s.mu.RLock()
|
||||||
|
rules := s.rules
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if exitBeforeCloseRule(rules, pos) && kstNow.After(closeTime) {
|
||||||
|
return "장마감"
|
||||||
|
}
|
||||||
|
if maxHoldExpired(rules, pos, now) {
|
||||||
|
return "시간초과"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function renderRules(rules) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 space-y-0.5">
|
<div class="text-xs text-gray-500 space-y-0.5">
|
||||||
<p>진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}</p>
|
<p>진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}</p>
|
||||||
<p>청산: 손절${r.stopLossPct}% / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}</p>
|
<p>청산: ${r.stopLoss1Count > 0 ? `1차손절${r.stopLoss1Pct}%[${r.stopLoss1Count}회] / 2차손절${r.stopLossPct}%` : `손절${r.stopLossPct}%`} / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}</p>
|
||||||
<p>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
|
<p>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -173,7 +173,9 @@ function showAddRuleModal() {
|
|||||||
document.getElementById('fRequireBullish').checked = false;
|
document.getElementById('fRequireBullish').checked = false;
|
||||||
document.getElementById('fOrderAmount').value = 500000;
|
document.getElementById('fOrderAmount').value = 500000;
|
||||||
document.getElementById('fMaxPositions').value = 3;
|
document.getElementById('fMaxPositions').value = 3;
|
||||||
document.getElementById('fStopLoss').value = -3;
|
document.getElementById('fStopLoss1').value = -2;
|
||||||
|
document.getElementById('fStopLoss1Count').value = 3;
|
||||||
|
document.getElementById('fStopLoss').value = -4;
|
||||||
document.getElementById('fTakeProfit').value = 5;
|
document.getElementById('fTakeProfit').value = 5;
|
||||||
document.getElementById('fMaxHold').value = 60;
|
document.getElementById('fMaxHold').value = 60;
|
||||||
document.getElementById('fExitBeforeClose').checked = true;
|
document.getElementById('fExitBeforeClose').checked = true;
|
||||||
@@ -190,6 +192,8 @@ function showEditRuleModal(r) {
|
|||||||
document.getElementById('fRequireBullish').checked = r.requireBullish;
|
document.getElementById('fRequireBullish').checked = r.requireBullish;
|
||||||
document.getElementById('fOrderAmount').value = r.orderAmount;
|
document.getElementById('fOrderAmount').value = r.orderAmount;
|
||||||
document.getElementById('fMaxPositions').value = r.maxPositions;
|
document.getElementById('fMaxPositions').value = r.maxPositions;
|
||||||
|
document.getElementById('fStopLoss1').value = r.stopLoss1Pct ?? -2;
|
||||||
|
document.getElementById('fStopLoss1Count').value = r.stopLoss1Count ?? 3;
|
||||||
document.getElementById('fStopLoss').value = r.stopLossPct;
|
document.getElementById('fStopLoss').value = r.stopLossPct;
|
||||||
document.getElementById('fTakeProfit').value = r.takeProfitPct;
|
document.getElementById('fTakeProfit').value = r.takeProfitPct;
|
||||||
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
|
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
|
||||||
@@ -211,6 +215,8 @@ async function submitRule() {
|
|||||||
requireBullish: document.getElementById('fRequireBullish').checked,
|
requireBullish: document.getElementById('fRequireBullish').checked,
|
||||||
orderAmount: parseInt(document.getElementById('fOrderAmount').value),
|
orderAmount: parseInt(document.getElementById('fOrderAmount').value),
|
||||||
maxPositions: parseInt(document.getElementById('fMaxPositions').value),
|
maxPositions: parseInt(document.getElementById('fMaxPositions').value),
|
||||||
|
stopLoss1Pct: parseFloat(document.getElementById('fStopLoss1').value),
|
||||||
|
stopLoss1Count: parseInt(document.getElementById('fStopLoss1Count').value) || 0,
|
||||||
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
|
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
|
||||||
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
|
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
|
||||||
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
|
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
|
||||||
@@ -268,7 +274,8 @@ function renderPositions(positions) {
|
|||||||
<th class="pb-2 font-medium">종목</th>
|
<th class="pb-2 font-medium">종목</th>
|
||||||
<th class="pb-2 font-medium text-right">매수가</th>
|
<th class="pb-2 font-medium text-right">매수가</th>
|
||||||
<th class="pb-2 font-medium text-right">수량</th>
|
<th class="pb-2 font-medium text-right">수량</th>
|
||||||
<th class="pb-2 font-medium text-right">손절가</th>
|
<th class="pb-2 font-medium text-right">1차손절</th>
|
||||||
|
<th class="pb-2 font-medium text-right">2차손절</th>
|
||||||
<th class="pb-2 font-medium text-center">상태</th>
|
<th class="pb-2 font-medium text-center">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -284,7 +291,8 @@ function renderPositions(positions) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-2 text-right text-gray-700">${formatMoney(p.buyPrice)}</td>
|
<td class="py-2 text-right text-gray-700">${formatMoney(p.buyPrice)}</td>
|
||||||
<td class="py-2 text-right text-gray-700">${p.qty}</td>
|
<td class="py-2 text-right text-gray-700">${p.qty}</td>
|
||||||
<td class="py-2 text-right text-blue-500">${formatMoney(p.stopLoss)}</td>
|
<td class="py-2 text-right text-orange-500">${p.stopLoss1 > 0 ? formatMoney(p.stopLoss1) + `<span class="text-xs text-gray-400 ml-0.5">[${p.stopLoss1Touches||0}회]</span>` : '-'}</td>
|
||||||
|
<td class="py-2 text-right text-red-500">${formatMoney(p.stopLoss)}</td>
|
||||||
<td class="py-2 text-center font-medium ${statusCls}">${statusTxt}</td>
|
<td class="py-2 text-center font-medium ${statusCls}">${statusTxt}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -351,27 +351,39 @@
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
function renderSidebar() {
|
function renderSidebar() {
|
||||||
const list = loadList();
|
const list = loadList();
|
||||||
Array.from(sidebarEl.children).forEach(el => {
|
// 테이블 생성
|
||||||
if (el.id !== 'watchlistEmpty') el.remove();
|
let tableEl = sidebarEl.querySelector('table');
|
||||||
});
|
if (tableEl) tableEl.remove();
|
||||||
list.forEach(s => sidebarEl.appendChild(makeSidebarItem(s.code, s.name)));
|
if (list.length > 0) {
|
||||||
|
tableEl = document.createElement('table');
|
||||||
|
tableEl.className = 'w-full text-xs';
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
tbody.className = 'divide-y divide-gray-50';
|
||||||
|
list.forEach(s => tbody.appendChild(makeSidebarItem(s.code, s.name)));
|
||||||
|
tableEl.appendChild(tbody);
|
||||||
|
sidebarEl.insertBefore(tableEl, emptyEl);
|
||||||
|
}
|
||||||
updateEmptyStates();
|
updateEmptyStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSidebarItem(code, name) {
|
function makeSidebarItem(code, name) {
|
||||||
const li = document.createElement('li');
|
const tr = document.createElement('tr');
|
||||||
li.id = `si-${code}`;
|
tr.id = `si-${code}`;
|
||||||
li.className = 'flex items-center justify-between px-3 py-2.5 hover:bg-gray-50 group';
|
tr.className = 'hover:bg-gray-50 group cursor-pointer';
|
||||||
li.innerHTML = `
|
tr.onclick = () => { window.location.href = `/stock/${code}`; };
|
||||||
<a href="/stock/${code}" class="flex-1 min-w-0">
|
tr.innerHTML = `
|
||||||
<p class="text-xs font-medium text-gray-800 truncate">${name}</p>
|
<td class="px-3 py-2">
|
||||||
<p class="text-xs text-gray-400 font-mono">${code}</p>
|
<p class="font-medium text-gray-800 truncate">${name}</p>
|
||||||
</a>
|
<p class="text-gray-400 font-mono">${code}</p>
|
||||||
<button onclick="removeStock('${code}')" title="삭제"
|
</td>
|
||||||
class="ml-2 text-gray-300 hover:text-red-400 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">
|
<td id="si-price-${code}" class="px-2 py-2 text-right font-mono text-gray-400">-</td>
|
||||||
×
|
<td id="si-rate-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
|
||||||
</button>`;
|
<td id="si-cntr-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
|
||||||
return li;
|
<td class="pr-2 py-2 text-center">
|
||||||
|
<button onclick="event.stopPropagation(); removeStock('${code}')" title="삭제"
|
||||||
|
class="text-gray-300 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">×</button>
|
||||||
|
</td>`;
|
||||||
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
@@ -437,17 +449,21 @@
|
|||||||
const rateEl = document.getElementById(`wc-rate-${code}`);
|
const rateEl = document.getElementById(`wc-rate-${code}`);
|
||||||
const volEl = document.getElementById(`wc-vol-${code}`);
|
const volEl = document.getElementById(`wc-vol-${code}`);
|
||||||
const cntrEl = document.getElementById(`wc-cntr-${code}`);
|
const cntrEl = document.getElementById(`wc-cntr-${code}`);
|
||||||
if (!priceEl) return;
|
|
||||||
|
|
||||||
const rate = data.changeRate ?? 0;
|
const rate = data.changeRate ?? 0;
|
||||||
const colorCls = rateClass(rate);
|
const colorCls = rateClass(rate);
|
||||||
const bgCls = rateBadgeClass(rate);
|
const bgCls = rateBadgeClass(rate);
|
||||||
const sign = rate > 0 ? '+' : '';
|
const sign = rate > 0 ? '+' : '';
|
||||||
|
|
||||||
|
// 패널 카드 업데이트
|
||||||
|
if (priceEl) {
|
||||||
priceEl.textContent = fmtNum(data.currentPrice) + '원';
|
priceEl.textContent = fmtNum(data.currentPrice) + '원';
|
||||||
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
|
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
|
||||||
|
}
|
||||||
|
if (rateEl) {
|
||||||
rateEl.textContent = sign + rate.toFixed(2) + '%';
|
rateEl.textContent = sign + rate.toFixed(2) + '%';
|
||||||
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
|
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
|
||||||
|
}
|
||||||
if (volEl) volEl.textContent = fmtNum(data.volume);
|
if (volEl) volEl.textContent = fmtNum(data.volume);
|
||||||
if (cntrEl && data.cntrStr !== undefined) {
|
if (cntrEl && data.cntrStr !== undefined) {
|
||||||
const cs = data.cntrStr;
|
const cs = data.cntrStr;
|
||||||
@@ -455,6 +471,24 @@
|
|||||||
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
|
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사이드바 행 업데이트
|
||||||
|
const siPrice = document.getElementById(`si-price-${code}`);
|
||||||
|
const siRate = document.getElementById(`si-rate-${code}`);
|
||||||
|
const siCntr = document.getElementById(`si-cntr-${code}`);
|
||||||
|
if (siPrice) {
|
||||||
|
siPrice.textContent = fmtNum(data.currentPrice);
|
||||||
|
siPrice.className = `px-2 py-2 text-right font-mono ${colorCls}`;
|
||||||
|
}
|
||||||
|
if (siRate) {
|
||||||
|
siRate.textContent = fmtRate(rate);
|
||||||
|
siRate.className = `px-2 py-2 text-right ${colorCls}`;
|
||||||
|
}
|
||||||
|
if (siCntr && data.cntrStr !== undefined) {
|
||||||
|
const cs = data.cntrStr;
|
||||||
|
siCntr.textContent = fmtCntr(cs);
|
||||||
|
siCntr.className = `px-2 py-2 text-right ${cs >= 100 ? 'text-orange-500 font-bold' : cs > 0 ? 'text-blue-400' : 'text-gray-400'}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 체결강도 히스토리 기록 + 미니 차트 갱신
|
// 체결강도 히스토리 기록 + 미니 차트 갱신
|
||||||
if (data.cntrStr != null && data.cntrStr !== 0) {
|
if (data.cntrStr != null && data.cntrStr !== 0) {
|
||||||
recordCntr(code, data.cntrStr);
|
recordCntr(code, data.cntrStr);
|
||||||
@@ -536,7 +570,7 @@
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
function updateEmptyStates() {
|
function updateEmptyStates() {
|
||||||
const hasItems = cachedList.length > 0;
|
const hasItems = cachedList.length > 0;
|
||||||
emptyEl.classList.toggle('hidden', hasItems);
|
if (emptyEl) emptyEl.classList.toggle('hidden', hasItems);
|
||||||
panelEmpty?.classList.toggle('hidden', hasItems);
|
panelEmpty?.classList.toggle('hidden', hasItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,11 +220,32 @@
|
|||||||
<!-- 청산 조건 -->
|
<!-- 청산 조건 -->
|
||||||
<div class="border-t pt-4">
|
<div class="border-t pt-4">
|
||||||
<p class="text-xs font-semibold text-gray-600 mb-3">청산 조건</p>
|
<p class="text-xs font-semibold text-gray-600 mb-3">청산 조건</p>
|
||||||
|
|
||||||
|
<!-- 2중 손절 설명 -->
|
||||||
|
<div class="mb-3 p-2.5 bg-orange-50 border border-orange-200 rounded-lg text-xs text-orange-700 leading-relaxed">
|
||||||
|
<span class="font-semibold">2중 손절:</span>
|
||||||
|
1차 손절가에 설정 횟수만큼 터치 시 매도,
|
||||||
|
2차 손절가 도달 시 즉시 매도.
|
||||||
|
1차 터치 횟수를 0으로 설정하면 단순 손절.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<!-- 1차 손절 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-700 mb-1">손절 (%)</label>
|
<label class="block text-xs text-gray-700 mb-1">1차 손절 (%)</label>
|
||||||
<input id="fStopLoss" type="number" value="-3" step="0.5"
|
<input id="fStopLoss1" type="number" value="-2" step="0.5"
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-700 mb-1">1차 손절 터치 횟수</label>
|
||||||
|
<input id="fStopLoss1Count" type="number" value="3" min="0" step="1"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400">
|
||||||
|
</div>
|
||||||
|
<!-- 2차 손절 / 익절 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-700 mb-1">2차 손절 / 즉시 매도 (%)</label>
|
||||||
|
<input id="fStopLoss" type="number" value="-4" step="0.5"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-700 mb-1">익절 (%)</label>
|
<label class="block text-xs text-gray-700 mb-1">익절 (%)</label>
|
||||||
|
|||||||
@@ -22,11 +22,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 관심종목 목록 -->
|
<!-- 관심종목 목록 -->
|
||||||
<ul id="watchlistSidebar" class="divide-y divide-gray-50 max-h-[60vh] overflow-y-auto">
|
<table class="w-full text-xs">
|
||||||
<li class="px-4 py-8 text-center text-xs text-gray-400" id="watchlistEmpty">
|
<thead>
|
||||||
|
<tr class="border-b border-gray-100 text-gray-400">
|
||||||
|
<th class="px-3 py-2 text-left font-medium">종목</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">현재가</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">등락</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">강도</th>
|
||||||
|
<th class="w-6"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<div id="watchlistSidebar" class="max-h-[60vh] overflow-y-auto">
|
||||||
|
<div class="px-4 py-8 text-center text-xs text-gray-400" id="watchlistEmpty">
|
||||||
관심종목을 추가해주세요
|
관심종목을 추가해주세요
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user