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

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

View File

@@ -40,7 +40,8 @@
"Bash(docker compose:*)",
"Bash(tree:*)",
"Bash(go vet:*)",
"Bash(python3:*)"
"Bash(python3:*)",
"Bash(head:*)"
]
}
}

View File

@@ -23,3 +23,8 @@ tasks.md
# Docker compose
docker-compose.yml
Caddyfile
# Frontend
frontend/node_modules/
frontend/build/
frontend/.svelte-kit/

View File

@@ -12,3 +12,10 @@ CACHE_TTL_SECONDS=1
# WebSocket 설정
WS_MAX_CLIENTS=100
WS_POLL_INTERVAL_MS=1000
# CORS 설정 (SvelteKit 개발 서버용, 프로덕션에서는 비워둠)
CORS_ORIGIN=http://localhost:5173
# 관리자 계정
ADMIN_ID=admin
ADMIN_PASSWORD=

View File

@@ -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
WORKDIR /app
@@ -32,6 +43,9 @@ COPY --from=builder /app/templates/ templates/
COPY --from=builder /app/static/ static/
COPY --from=builder /app/CORPCODE.xml .
# 프론트엔드 빌드 결과물 복사
COPY --from=frontend /app/build frontend/build/
EXPOSE 8080
# .env 파일은 컨테이너 실행 시 마운트하거나 환경변수로 주입

View File

@@ -25,6 +25,7 @@ type Config struct {
WSPollIntervalMS int
AdminID string // 관리자 ID
AdminPassword string // 관리자 비밀번호
CORSOrigin string // CORS 허용 오리진 (예: http://localhost:5173)
}
var App *Config
@@ -37,21 +38,22 @@ func Load() {
}
App = &Config{
Env: getEnv("APP_ENV", "development"),
ServerPort: getEnv("SERVER_PORT", "8080"),
AppKey: getEnv("KIWOOM_APP_KEY", ""),
AppSecret: getEnv("KIWOOM_APP_SECRET", ""),
BaseURL: getEnv("KIWOOM_BASE_URL", "https://openapi.koreainvestment.com:9443"),
Env: getEnv("APP_ENV", "development"),
ServerPort: getEnv("SERVER_PORT", "8080"),
AppKey: getEnv("KIWOOM_APP_KEY", ""),
AppSecret: getEnv("KIWOOM_APP_SECRET", ""),
BaseURL: getEnv("KIWOOM_BASE_URL", "https://openapi.koreainvestment.com:9443"),
DartAPIKey: getEnv("DART_API_KEY", ""),
NaverClientID: getEnv("NAVER_CLIENT_ID", ""),
NaverClientSecret: getEnv("NAVER_CLIENT_SECRET", ""),
GroqAPIKey: getEnv("GROQ_API_KEY", ""),
GroqModel: getEnv("GROQ_MODEL", "llama-3.3-70b-versatile"),
CacheTTLSeconds: getEnvInt("CACHE_TTL_SECONDS", 1),
WSMaxClients: getEnvInt("WS_MAX_CLIENTS", 100),
WSPollIntervalMS: getEnvInt("WS_POLL_INTERVAL_MS", 1000),
AdminID: getEnv("ADMIN_ID", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
CacheTTLSeconds: getEnvInt("CACHE_TTL_SECONDS", 1),
WSMaxClients: getEnvInt("WS_MAX_CLIENTS", 100),
WSPollIntervalMS: getEnvInt("WS_POLL_INTERVAL_MS", 1000),
AdminID: getEnv("ADMIN_ID", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
CORSOrigin: getEnv("CORS_ORIGIN", ""),
}
}

10
data/watchlist.json Normal file
View File

@@ -0,0 +1,10 @@
[
{
"code": "000660",
"name": "SK하이닉스"
},
{
"code": "005930",
"name": "삼성전자"
}
]

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
build/
.svelte-kit/
.vscode/

23
frontend/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

23
frontend/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View 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
View 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
View 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
View 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>

View 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 }),
}),
}

View 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' }),
}

View 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()
}

View 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' }),
}

View 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[]
}

View 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

View 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>

View 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>

View 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}

View 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>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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()

View 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
View 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'
}

View 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>

View 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')
}
}

View 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>

View 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}

View File

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

View 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}

View 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}

View 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>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css'
let { children } = $props()
</script>
{@render children()}

View File

@@ -0,0 +1,3 @@
// SPA 모드: prerendering 비활성화
export const prerender = false
export const ssr = false

View 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>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

25
frontend/svelte.config.js Normal file
View 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
View 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
View 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 },
}
}
});

View File

@@ -55,9 +55,9 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
id := r.FormValue("id")
id := r.FormValue("id")
password := r.FormValue("password")
next := r.FormValue("next")
next := r.FormValue("next")
if next == "" {
next = "/"
}
@@ -87,6 +87,18 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
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 리다이렉트
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {

View File

@@ -141,6 +141,20 @@ func (h *AutoTradeHandler) Emergency(w http.ResponseWriter, r *http.Request) {
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 응답 헬퍼
func jsonResponse(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")

61
main.go
View File

@@ -3,6 +3,7 @@ package main
import (
"log"
"net/http"
"os"
"stocksearch/config"
"stocksearch/handlers"
"stocksearch/middleware"
@@ -37,9 +38,9 @@ func main() {
}
// 서비스 추가
sessionSvc := services.GetSessionService()
watchlistSvc := services.GetWatchlistService()
autoTradeSvc := services.GetAutoTradeService()
sessionSvc := services.GetSessionService()
watchlistSvc := services.GetWatchlistService()
autoTradeSvc := services.GetAutoTradeService()
// 스캐너 구독 종목 → WebSocket 내부 구독 연결
services.GetScannerService().SetSubscribeCallback(func(codes []string) {
@@ -52,11 +53,11 @@ func main() {
})
// 핸들러 초기화
pageHandler := handlers.NewPageHandler()
stockHandler := handlers.NewStockHandler(watchlistSvc)
wsHandler := handlers.NewWSHandler(hub)
authHandler := handlers.NewAuthHandler(sessionSvc)
orderHandler := handlers.NewOrderHandler()
pageHandler := handlers.NewPageHandler()
stockHandler := handlers.NewStockHandler(watchlistSvc)
wsHandler := handlers.NewWSHandler(hub)
authHandler := handlers.NewAuthHandler(sessionSvc)
orderHandler := handlers.NewOrderHandler()
autoTradeHandler := handlers.NewAutoTradeHandler(autoTradeSvc)
// 라우터 설정 (Go 1.22 패턴 매칭)
@@ -66,6 +67,7 @@ func main() {
mux.HandleFunc("GET /login", authHandler.LoginPage)
mux.HandleFunc("POST /login", authHandler.Login)
mux.HandleFunc("POST /logout", authHandler.Logout)
mux.HandleFunc("GET /api/auth/check", authHandler.CheckSession)
// --- 페이지 라우트 ---
mux.HandleFunc("GET /", pageHandler.IndexPage)
@@ -105,19 +107,20 @@ func main() {
mux.HandleFunc("GET /api/account/orderable", orderHandler.GetOrderable)
// --- 자동매매 API 라우트 ---
mux.HandleFunc("GET /api/autotrade/status", autoTradeHandler.GetStatus)
mux.HandleFunc("GET /api/autotrade/rules", autoTradeHandler.GetRules)
mux.HandleFunc("POST /api/autotrade/rules", autoTradeHandler.AddRule)
mux.HandleFunc("PUT /api/autotrade/rules/{id}", autoTradeHandler.UpdateRule)
mux.HandleFunc("DELETE /api/autotrade/rules/{id}", autoTradeHandler.DeleteRule)
mux.HandleFunc("GET /api/autotrade/status", autoTradeHandler.GetStatus)
mux.HandleFunc("GET /api/autotrade/rules", autoTradeHandler.GetRules)
mux.HandleFunc("POST /api/autotrade/rules", autoTradeHandler.AddRule)
mux.HandleFunc("PUT /api/autotrade/rules/{id}", autoTradeHandler.UpdateRule)
mux.HandleFunc("DELETE /api/autotrade/rules/{id}", autoTradeHandler.DeleteRule)
mux.HandleFunc("POST /api/autotrade/rules/{id}/toggle", autoTradeHandler.ToggleRule)
mux.HandleFunc("GET /api/autotrade/positions", autoTradeHandler.GetPositions)
mux.HandleFunc("GET /api/autotrade/logs", autoTradeHandler.GetLogs)
mux.HandleFunc("GET /api/autotrade/watch-source", autoTradeHandler.GetWatchSource)
mux.HandleFunc("PUT /api/autotrade/watch-source", autoTradeHandler.SetWatchSource)
mux.HandleFunc("POST /api/autotrade/start", autoTradeHandler.Start)
mux.HandleFunc("POST /api/autotrade/stop", autoTradeHandler.Stop)
mux.HandleFunc("POST /api/autotrade/emergency", autoTradeHandler.Emergency)
mux.HandleFunc("GET /api/autotrade/positions", autoTradeHandler.GetPositions)
mux.HandleFunc("GET /api/autotrade/logs", autoTradeHandler.GetLogs)
mux.HandleFunc("GET /api/autotrade/watch-source", autoTradeHandler.GetWatchSource)
mux.HandleFunc("PUT /api/autotrade/watch-source", autoTradeHandler.SetWatchSource)
mux.HandleFunc("POST /api/autotrade/start", autoTradeHandler.Start)
mux.HandleFunc("POST /api/autotrade/stop", autoTradeHandler.Stop)
mux.HandleFunc("POST /api/autotrade/emergency", autoTradeHandler.Emergency)
mux.HandleFunc("POST /api/autotrade/positions/{code}/close", autoTradeHandler.ClosePosition)
// --- WebSocket 라우트 ---
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
@@ -125,8 +128,22 @@ func main() {
// --- 정적 파일 ---
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// 미들웨어 체인 적용 (Auth → Logger → Recovery 순)
handler := middleware.Chain(mux, middleware.Recovery, middleware.Logger, middleware.Auth(sessionSvc))
// --- SvelteKit 빌드 정적 서빙 (SPA fallback 포함) ---
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
log.Printf("서버 시작: http://%s", addr)

View File

@@ -41,6 +41,7 @@ var publicPaths = []string{
"/api/kospi200",
"/api/news",
"/api/disclosure",
"/api/auth/check",
"/ws",
}

29
middleware/cors.go Normal file
View 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)
})
}

View File

@@ -20,7 +20,12 @@ type AutoTradeRule struct {
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)
MaxHoldMinutes int `json:"maxHoldMinutes"` // 최대 보유 시간(분, 0=무제한)
ExitBeforeClose bool `json:"exitBeforeClose"` // 장 마감 전 청산(15:20 기준)
@@ -30,15 +35,17 @@ type AutoTradeRule struct {
// AutoTradePosition 자동매매 포지션
type AutoTradePosition struct {
Code string `json:"code"`
Name string `json:"name"`
BuyPrice int64 `json:"buyPrice"` // 매수 체결가
Qty int64 `json:"qty"` // 수량
OrderNo string `json:"orderNo"` // 매수 주문번호
EntryTime time.Time `json:"entryTime"` // 진입 시각
RuleID string `json:"ruleId"` // 규칙 ID
StopLoss int64 `json:"stopLoss"` // 절대 손절가
TakeProfit int64 `json:"takeProfit"` // 절대 익절가
Code string `json:"code"`
Name string `json:"name"`
BuyPrice int64 `json:"buyPrice"` // 매수 체결가
Qty int64 `json:"qty"` // 수량
OrderNo string `json:"orderNo"` // 매수 주문번호
EntryTime time.Time `json:"entryTime"` // 진입 시각
RuleID string `json:"ruleId"` // 규칙 ID
StopLoss1 int64 `json:"stopLoss1"` // 절대 1차 손절가 (0=비활성)
StopLoss1Touches int `json:"stopLoss1Touches"` // 1차 손절가 터치 누적 횟수
StopLoss int64 `json:"stopLoss"` // 절대 2차 손절가 (즉시 매도)
TakeProfit int64 `json:"takeProfit"` // 절대 익절가
// 상태: "pending"=체결 대기 | "open"=보유중 | "closed"=청산완료
Status string `json:"status"`
@@ -51,9 +58,9 @@ type AutoTradePosition struct {
// AutoTradeLog 자동매매 이벤트 로그
type AutoTradeLog struct {
At time.Time `json:"at"`
Level string `json:"level"` // "info"|"warn"|"error"
Level string `json:"level"` // "info"|"warn"|"error"
Message string `json:"message"`
Code string `json:"code"` // 관련 종목코드 (없으면 "")
Code string `json:"code"` // 관련 종목코드 (없으면 "")
}
// ThemeRef 감시 소스로 선택된 테마 참조

View File

@@ -380,11 +380,11 @@ func (s *AutoTradeService) exitLoop() {
// checkEntries 진입 조건 체크 및 매수 주문
func (s *AutoTradeService) checkEntries() {
signals := s.getWatchSignals()
// 규칙 및 현재 포지션 수 선조회
s.mu.RLock()
rules := make([]models.AutoTradeRule, len(s.rules))
copy(rules, s.rules)
posCount := s.countActivePositions()
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))
if len(signals) == 0 {
@@ -497,13 +512,13 @@ func (s *AutoTradeService) checkEntries() {
}
// 포지션 등록 (현재가 기준 예상 손절/익절가 미리 계산 → UI에 0원 방지)
estStop := int64(float64(sig.CurrentPrice) * (1 + rule.StopLossPct/100))
estProfit := int64(float64(sig.CurrentPrice) * (1 + rule.TakeProfitPct/100))
estStop1, estStop, estProfit := calcStopTargets(sig.CurrentPrice, &rule)
pos := &models.AutoTradePosition{
Code: code,
Name: sig.Name,
Qty: qty,
BuyPrice: sig.CurrentPrice,
StopLoss1: estStop1,
StopLoss: estStop,
TakeProfit: estProfit,
OrderNo: result.OrderNo,
@@ -561,21 +576,13 @@ func (s *AutoTradeService) checkExits() {
// 포지션 모니터링 debug 로그
if posCopy.BuyPrice > 0 {
pl := (float64(curPrice) - float64(posCopy.BuyPrice)) / float64(posCopy.BuyPrice) * 100
s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (손절=%s 익절=%s)",
posCopy.Name, formatComma(curPrice), pl, formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit)))
s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (1차손절=%s[%d/%d회] 2차손절=%s 익절=%s)",
posCopy.Name, formatComma(curPrice), pl,
formatComma(posCopy.StopLoss1), posCopy.StopLoss1Touches, s.stopLoss1Limit(posCopy.RuleID),
formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit)))
}
var reason string
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 = "시간초과"
}
reason := s.evalExitReason(code, &posCopy, curPrice, kstNow, closeTime, now)
if reason != "" {
if err := s.executeSell(&posCopy, reason); err != nil {
@@ -629,23 +636,27 @@ func (s *AutoTradeService) checkPending() {
}
rule := s.findRule(pos.RuleID)
var stopLoss, takeProfit int64
var stopLoss1, stopLoss, takeProfit int64
if rule != nil && buyPrice > 0 {
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
stopLoss1, stopLoss, takeProfit = calcStopTargets(buyPrice, rule)
}
s.mu.Lock()
if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" {
p.BuyPrice = buyPrice
p.StopLoss1 = stopLoss1
p.StopLoss = stopLoss
p.TakeProfit = takeProfit
p.Status = "open"
}
s.mu.Unlock()
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)",
pos.Name, pos.Qty, buyPrice, stopLoss, takeProfit))
sl1Count := 0
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
}
// 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 포지션 수
func (s *AutoTradeService) countActivePositions() int {
count := 0
@@ -799,3 +824,80 @@ func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, n
}
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 ""
}

View File

@@ -129,7 +129,7 @@ function renderRules(rules) {
</div>
<div class="text-xs text-gray-500 space-y-0.5">
<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>
</div>
<div class="flex gap-2">
@@ -173,7 +173,9 @@ function showAddRuleModal() {
document.getElementById('fRequireBullish').checked = false;
document.getElementById('fOrderAmount').value = 500000;
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('fMaxHold').value = 60;
document.getElementById('fExitBeforeClose').checked = true;
@@ -190,6 +192,8 @@ function showEditRuleModal(r) {
document.getElementById('fRequireBullish').checked = r.requireBullish;
document.getElementById('fOrderAmount').value = r.orderAmount;
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('fTakeProfit').value = r.takeProfitPct;
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
@@ -211,6 +215,8 @@ async function submitRule() {
requireBullish: document.getElementById('fRequireBullish').checked,
orderAmount: parseInt(document.getElementById('fOrderAmount').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),
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').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 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>
</tr>
</thead>
@@ -284,7 +291,8 @@ function renderPositions(positions) {
</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-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>
</tr>
`;

View File

@@ -351,27 +351,39 @@
// ─────────────────────────────────────────────
function renderSidebar() {
const list = loadList();
Array.from(sidebarEl.children).forEach(el => {
if (el.id !== 'watchlistEmpty') el.remove();
});
list.forEach(s => sidebarEl.appendChild(makeSidebarItem(s.code, s.name)));
// 테이블 생성
let tableEl = sidebarEl.querySelector('table');
if (tableEl) tableEl.remove();
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();
}
function makeSidebarItem(code, name) {
const li = document.createElement('li');
li.id = `si-${code}`;
li.className = 'flex items-center justify-between px-3 py-2.5 hover:bg-gray-50 group';
li.innerHTML = `
<a href="/stock/${code}" class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-800 truncate">${name}</p>
<p class="text-xs text-gray-400 font-mono">${code}</p>
</a>
<button onclick="removeStock('${code}')" title="삭제"
class="ml-2 text-gray-300 hover:text-red-400 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">
×
</button>`;
return li;
const tr = document.createElement('tr');
tr.id = `si-${code}`;
tr.className = 'hover:bg-gray-50 group cursor-pointer';
tr.onclick = () => { window.location.href = `/stock/${code}`; };
tr.innerHTML = `
<td class="px-3 py-2">
<p class="font-medium text-gray-800 truncate">${name}</p>
<p class="text-gray-400 font-mono">${code}</p>
</td>
<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>
<td id="si-cntr-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
<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 volEl = document.getElementById(`wc-vol-${code}`);
const cntrEl = document.getElementById(`wc-cntr-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
const colorCls = rateClass(rate);
const bgCls = rateBadgeClass(rate);
const sign = rate > 0 ? '+' : '';
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
// 패널 카드 업데이트
if (priceEl) {
priceEl.textContent = fmtNum(data.currentPrice) + '';
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
}
if (rateEl) {
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
}
if (volEl) volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
const cs = data.cntrStr;
@@ -455,6 +471,24 @@
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) {
recordCntr(code, data.cntrStr);
@@ -536,7 +570,7 @@
// ─────────────────────────────────────────────
function updateEmptyStates() {
const hasItems = cachedList.length > 0;
emptyEl.classList.toggle('hidden', hasItems);
if (emptyEl) emptyEl.classList.toggle('hidden', hasItems);
panelEmpty?.classList.toggle('hidden', hasItems);
}

View File

@@ -220,11 +220,32 @@
<!-- 청산 조건 -->
<div class="border-t pt-4">
<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">
<!-- 1차 손절 -->
<div>
<label class="block text-xs text-gray-700 mb-1">손절 (%)</label>
<input id="fStopLoss" type="number" value="-3" 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">
<label class="block text-xs text-gray-700 mb-1">1차 손절 (%)</label>
<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-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>
<label class="block text-xs text-gray-700 mb-1">익절 (%)</label>

View File

@@ -22,11 +22,22 @@
</div>
<!-- 관심종목 목록 -->
<ul id="watchlistSidebar" class="divide-y divide-gray-50 max-h-[60vh] overflow-y-auto">
<li class="px-4 py-8 text-center text-xs text-gray-400" id="watchlistEmpty">
<table class="w-full text-xs">
<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>
</ul>
</div>
</div>
</div>
</aside>
{{ end }}