first commit

This commit is contained in:
hayato5246
2026-03-31 19:32:59 +09:00
commit d10b794c9f
78 changed files with 1671595 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
{
"permissions": {
"allow": [
"WebFetch(domain:openapi.kiwoom.com)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"ka10001\\\\|ka10004\\\\|ka10005\\\\|ka10024\\\\|순위\\\\|등락\\\\|fluctuation\" | head -50)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | awk 'NR>=7650 && NR<=7850')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"ka10004\" | head -10)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | awk 'NR>=6117 && NR<=6500')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | awk 'NR>=6500 && NR<=6800')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | awk 'NR>=6800 && NR<=7100')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | awk 'NR>=7100 && NR<=7400')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"ka10003\" | head -5)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"ping\\\\|Ping\\\\|heartbeat\\\\|PING\\\\|keepalive\\\\|keep.alive\\\\|연결유지\\\\|접속유지\\\\|PINGPONG\\\\|PONG\" | head -20)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"타임아웃\\\\|timeout\\\\|Timeout\\\\|종료\\\\|disconnect\\\\|끊김\\\\|초\\\\|second\" | head -20)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | awk 'NR>=51000 && NR<=51500')",
"Bash(go build:*)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"로그인\\\\|LOGIN\\\\|login\\\\|인증.*websocket\\\\|websocket.*인증\\\\|LOGINREQ\\\\|loginreq\" | head -20)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"websocket\\\\|WebSocket\" | head -30)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"header\\\\|Header\" | grep -i \"json\\\\|body\\\\|message\\\\|format\\\\|wss\\\\|websocket\" | head -20)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>&1 | grep -n \"LOGIN\\\\|login\\\\|LOGINREQ\\\\|trnm.*LOGIN\\\\|접속\\\\|연결.*순서\\\\|순서.*연결\\\\|인증.*방법\\\\|first.*message\\\\|첫.*메시지\" | head -20)",
"Bash(brew install:*)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | head -300)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | grep -A 100 -i \"websocket\\\\|웹소켓\\\\|실시간\\\\|LOGIN\\\\|login\\\\|로그인\" | head -200)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | grep -n \"0B\\\\|REG\\\\|REMOVE\\\\|trnm\\\\|실시간\\\\|WebSocket\\\\|websocket\" | head -50)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | sed -n '49900,50200p')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | grep -n \"0B\\\\|REG\\\\|LOGIN\\\\|연결\\\\|접속\\\\|인증\" | grep -v \"목차\\\\|실시간시세\\\\|순위\" | head -40)",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | sed -n '94200,94450p')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | sed -n '94450,94700p')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | sed -n '94930,95100p')",
"Bash(pdftotext /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.pdf - 2>/dev/null | sed -n '97100,97300p')",
"Bash(pip3 install:*)",
"Bash(pkill -f \"go run main.go\" 2>/dev/null; pkill -f \"stockSearch\" 2>/dev/null; sleep 1; echo \"기존 서버 종료 완료\")",
"Bash(curl -s \"http://localhost:8080/api/ranking?market=J&dir=up\" | python3 -c \"\nimport sys, json\nstocks = json.load\\(sys.stdin\\)\nprint\\('랭킹 종목 목록:'\\)\nfor i, s in enumerate\\(stocks, 1\\):\n print\\(f' {i:2}. {s[\\\\\"code\\\\\"]} {s[\\\\\"name\\\\\"][:10]:10s} {s[\\\\\"currentPrice\\\\\"]:>8,}원 {s[\\\\\"changeRate\\\\\"]:+.2f}%'\\)\n\")",
"Bash(curl:*)",
"Bash(grep:*)",
"Skill(claude-api)",
"Bash(ollama list:*)",
"Bash(go test:*)",
"Bash(docker build:*)",
"Bash(docker compose:*)",
"Bash(tree:*)",
"Bash(go vet:*)"
]
}
}

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
# 민감 정보
.env
# IDE
.idea/
# 빌드 결과물
stocksearch
stockSearch
*.exe
*.out
# 문서
*.pdf
*.txt
kiwoom_api_doc.*
tasks.md
# Git
.git/
.gitignore
# Docker compose
docker-compose.yml
Caddyfile

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
APP_ENV=development
SERVER_PORT=8080
# 키움증권 Open API+ 키 (https://apiportal.koreainvestment.com 에서 발급)
KIWOOM_APP_KEY=your_app_key_here
KIWOOM_APP_SECRET=your_app_secret_here
KIWOOM_BASE_URL=https://openapi.koreainvestment.com:9443
# 캐시 설정
CACHE_TTL_SECONDS=1
# WebSocket 설정
WS_MAX_CLIENTS=100
WS_POLL_INTERVAL_MS=1000

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# 환경변수 (API 키 포함 - 절대 커밋 금지)
.env
# Go 빌드 결과물
stockSearch
*.exe
*.out
# IDE
.idea/
*.iml
# 의존성
vendor/

89
CLAUDE.md Normal file
View File

@@ -0,0 +1,89 @@
# CLAUDE.md
이 파일은 Claude Code(claude.ai/code)가 이 저장소에서 작업할 때 참고하는 가이드입니다.
## 명령어
```bash
# 애플리케이션 실행
go run main.go
# 바이너리 빌드
go build -o stockSearch .
# 전체 테스트 실행
go test ./...
# 단일 테스트 실행
go test -run TestFunctionName ./...
# 린트 실행 (golangci-lint 설치 필요)
golangci-lint run
```
## 작업 방침
- 필요한 명령어(빌드, 테스트 등)는 사용자에게 묻지 않고 직접 실행한다.
- /Users/hayato5246/GolandProjects/stockSearch/kiwoom_api_doc.txt 이 문서는 키움증권 REST API 문서입니다.
- Bash Commmand 실행 시 묻지 말고 직접 실행하고 테스트 까지 처리해서 확인한다.
- 코드 수정시 사용자에게 묻지 말고 직접 수정한다.
## 언어 설정
- 코드 주석은 **한글**로만 작성한다.
- 사용자에 대한 답변은 가능한 모든 부분을 **한글**로 작성한다.
## 프로젝트 정보
- **모듈명**: `stocksearch`
- **Go 버전**: 1.22 이상
- 키움증권 REST API 기반 주식 시세 웹서비스 (api.kiwoom.com)
## 아키텍처
```
main.go → 라우터 설정, WebSocket Hub 고루틴 시작, HTTP 서버 실행
config/ 환경변수 (.env) 로딩
models/ 순수 데이터 구조체 (비즈니스 로직 없음)
services/ 키움 API 클라이언트, 토큰 관리, 캐시, 비즈니스 로직
handlers/ HTTP 핸들러 (HTML 렌더링 / JSON API / WebSocket)
websocket/ Hub 패턴 실시간 시세 (1초 Ticker + gorilla/websocket)
middleware/ 로깅, 패닉 복구
templates/ Go html/template SSR (layout/base.html 상속)
static/ Tailwind CSS, Lightweight Charts, WebSocket 클라이언트 JS
```
**레이어 의존성**: `handlers → services → models`, `websocket → services`
**템플릿 구조**: 각 페이지별 독립 템플릿 세트 파싱 (content 블록 충돌 방지)
- `NewPageHandler()`에서 base.html + 각 페이지 html을 별도 ParseFiles로 파싱
- `render()`에서 `ExecuteTemplate(w, "base.html", data)` 호출
## 주요 설계 사항
- **토큰 관리**: `services/token_service.go` — 서버 시작 시 토큰 발급, 만료 1시간 전 자동 갱신
- 응답 필드: `token` (access token), `expires_dt` (YYYYMMDDHHmmss)
- **캐시**: `services/cache_service.go` — sync.Map 기반 TTL 캐시 (현재가 1초, 차트 5분, 등락률 1분)
- **WebSocket Hub**: `websocket/hub.go` — 1초 Ticker로 구독 종목 시세 조회 → 클라이언트에 브로드캐스트
- **Rate Limit**: KiwoomClient에 `golang.org/x/time/rate` 적용 (초당 18건)
- **관심종목**: 클라이언트 localStorage 저장, WebSocket 실시간 구독
## 키움증권 REST API
- **기본 URL**: `https://api.kiwoom.com`
- **공통 요청**: POST, JSON body, 헤더에 `api-id`, `authorization: Bearer {token}`, `cont-yn`, `next-key`
- **토큰 발급**: `POST /oauth2/token` (appkey + secretkey)
- **현재가** `ka10001`: `POST /api/dostk/stkinfo``cur_prc`, `pred_pre`, `flu_rt`, `trde_qty`, `open_pric`, `high_pric`, `low_pric`, `stk_nm`
- **체결정보** `ka10003`: `POST /api/dostk/stkinfo``cntr_infr[0].cntr_str` (체결강도)
- **일봉** `ka10005`: `POST /api/dostk/mrkcond``stk_ddwkmm[]` (date, open_pric, high_pric, low_pric, close_pric, trde_qty)
- **등락률순위** `ka10027`: `POST /api/dostk/rkinfo``pred_pre_flu_rt_upper[]`
## 환경 설정
`.env` 파일 (`.gitignore`에 등록됨):
```
KIWOOM_APP_KEY=...
KIWOOM_APP_SECRET=...
KIWOOM_BASE_URL=https://api.kiwoom.com
SERVER_PORT=8080
```

808699
CORPCODE.xml Normal file

File diff suppressed because it is too large Load Diff

3
Caddyfile Normal file
View File

@@ -0,0 +1,3 @@
lshfly.duckdns.org {
reverse_proxy stocksearch:8080
}

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# ── 빌드 스테이지 ──────────────────────────────────────────
FROM golang:1.24-alpine AS builder
WORKDIR /app
# 의존성 파일 복사 (vendor 디렉토리 사용)
COPY go.mod go.sum ./
COPY vendor/ vendor/
# 소스 코드 복사
COPY . .
# CGO 비활성화 후 정적 바이너리 빌드
RUN CGO_ENABLED=0 GOOS=linux GOTOOLCHAIN=auto go build -mod=vendor -o stocksearch .
# ── 실행 스테이지 ──────────────────────────────────────────
FROM alpine:latest
# HTTPS 요청을 위한 CA 인증서
RUN apk --no-cache add ca-certificates tzdata
# 한국 시간대 설정
ENV TZ=Asia/Seoul
WORKDIR /app
# 바이너리 복사
COPY --from=builder /app/stocksearch .
# 런타임에 필요한 정적 파일 복사
COPY --from=builder /app/templates/ templates/
COPY --from=builder /app/static/ static/
COPY --from=builder /app/CORPCODE.xml .
EXPOSE 8080
# .env 파일은 컨테이너 실행 시 마운트하거나 환경변수로 주입
ENTRYPOINT ["./stocksearch"]

BIN
charts.pdf Normal file

Binary file not shown.

77
config/config.go Normal file
View File

@@ -0,0 +1,77 @@
package config
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
// Config 애플리케이션 전역 설정
type Config struct {
Env string
ServerPort string
AppKey string
AppSecret string
BaseURL string
DartAPIKey string
NaverClientID string
NaverClientSecret string
GroqAPIKey string // Groq API 키
GroqModel string // Groq 모델명
CacheTTLSeconds int
WSMaxClients int
WSPollIntervalMS int
AdminID string // 관리자 ID
AdminPassword string // 관리자 비밀번호
}
var App *Config
// Load .env 파일을 읽어 설정을 초기화
func Load() {
// 개발 환경에서만 .env 파일 로딩 (.env 없어도 오류 무시)
if err := godotenv.Load(); err != nil {
log.Println("경고: .env 파일이 없습니다. 환경변수를 직접 설정해야 합니다.")
}
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"),
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", ""),
}
}
// IsDevelopment 개발 환경 여부 반환
func (c *Config) IsDevelopment() bool {
return c.Env == "development"
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
if val := os.Getenv(key); val != "" {
if n, err := strconv.Atoi(val); err == nil {
return n
}
}
return defaultVal
}

5
deploy.sh Executable file
View File

@@ -0,0 +1,5 @@
docker build -t lshreg.duckdns.org/stocksearch .
docker push lshreg.duckdns.org/stocksearch
#ssh -i ~/Downloads/ssh-key-2026-03-17.key opc@140.238.15.144 docker compose restart stocksearch

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
stocksearch:
build: .
restart: unless-stopped
volumes:
- ./.env:/app/.env:ro # godotenv.Load()가 파일을 직접 읽도록 마운트
expose:
- "8080"
caddy:
image: caddy:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # Let's Encrypt 인증서 영구 보관
- caddy_config:/config
depends_on:
- stocksearch
volumes:
caddy_data:
caddy_config:

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module stocksearch
go 1.25.0
require (
github.com/gorilla/websocket v1.5.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/time v0.15.0 // indirect
)

8
go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=

115
handlers/auth_handler.go Normal file
View File

@@ -0,0 +1,115 @@
package handlers
import (
"html/template"
"log"
"net/http"
"stocksearch/config"
"stocksearch/middleware"
"stocksearch/services"
)
// AuthHandler 로그인/로그아웃 핸들러
type AuthHandler struct {
sessionSvc *services.SessionService
loginTmpl *template.Template
}
// NewAuthHandler 인증 핸들러 초기화
func NewAuthHandler(sessionSvc *services.SessionService) *AuthHandler {
tmpl, err := template.ParseFiles("templates/pages/login.html")
if err != nil {
log.Fatalf("로그인 템플릿 파싱 실패: %v", err)
}
return &AuthHandler{
sessionSvc: sessionSvc,
loginTmpl: tmpl,
}
}
// LoginPage GET /login — 로그인 폼 렌더링
func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
// 이미 로그인된 경우 메인으로 리다이렉트
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
if h.sessionSvc.Validate(cookie.Value) {
http.Redirect(w, r, "/", http.StatusFound)
return
}
}
// next 파라미터가 없으면 로그인 후 메인 페이지로
next := r.URL.Query().Get("next")
if next == "" {
next = "/"
}
data := map[string]string{
"Next": next,
"Error": "",
}
h.renderLogin(w, data)
}
// Login POST /login — ID/PW 검증 후 세션 발급
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "잘못된 요청입니다.", http.StatusBadRequest)
return
}
id := r.FormValue("id")
password := r.FormValue("password")
next := r.FormValue("next")
if next == "" {
next = "/"
}
// ID/PW 검증
if id != config.App.AdminID || password != config.App.AdminPassword {
data := map[string]string{
"Next": next,
"Error": "아이디 또는 비밀번호가 올바르지 않습니다.",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderLogin(w, data)
return
}
// 세션 생성 및 쿠키 설정
sessionID := h.sessionSvc.Create()
http.SetCookie(w, &http.Cookie{
Name: middleware.SessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400, // 24시간
})
http.Redirect(w, r, next, http.StatusFound)
}
// Logout POST /logout — 세션 삭제 후 /login 리다이렉트
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
h.sessionSvc.Delete(cookie.Value)
}
// 쿠키 만료 처리
http.SetCookie(w, &http.Cookie{
Name: middleware.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
http.Redirect(w, r, "/", http.StatusFound)
}
// renderLogin 로그인 템플릿 렌더링 헬퍼
func (h *AuthHandler) renderLogin(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.loginTmpl.ExecuteTemplate(w, "login.html", data); err != nil {
log.Printf("로그인 템플릿 렌더링 실패: %v", err)
http.Error(w, "페이지를 표시할 수 없습니다.", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,148 @@
package handlers
import (
"encoding/json"
"net/http"
"stocksearch/models"
"stocksearch/services"
)
// AutoTradeHandler 자동매매 REST API 핸들러
type AutoTradeHandler struct {
svc *services.AutoTradeService
}
// NewAutoTradeHandler 핸들러 초기화
func NewAutoTradeHandler(svc *services.AutoTradeService) *AutoTradeHandler {
return &AutoTradeHandler{svc: svc}
}
// GetStatus GET /api/autotrade/status — 엔진 상태 + 오늘 통계
func (h *AutoTradeHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
tradeCount, totalPL := h.svc.GetStats()
activePositions := 0
for _, p := range h.svc.GetPositions() {
if p.Status == "pending" || p.Status == "open" {
activePositions++
}
}
jsonResponse(w, map[string]interface{}{
"running": h.svc.IsRunning(),
"activePositions": activePositions,
"tradeCount": tradeCount,
"totalPL": totalPL,
})
}
// GetRules GET /api/autotrade/rules — 규칙 목록
func (h *AutoTradeHandler) GetRules(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, h.svc.GetRules())
}
// AddRule POST /api/autotrade/rules — 규칙 추가
func (h *AutoTradeHandler) AddRule(w http.ResponseWriter, r *http.Request) {
var rule models.AutoTradeRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
http.Error(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
created := h.svc.AddRule(rule)
w.WriteHeader(http.StatusCreated)
jsonResponse(w, created)
}
// UpdateRule PUT /api/autotrade/rules/{id} — 규칙 수정
func (h *AutoTradeHandler) UpdateRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var rule models.AutoTradeRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
http.Error(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
if !h.svc.UpdateRule(id, rule) {
http.Error(w, "규칙을 찾을 수 없습니다", http.StatusNotFound)
return
}
jsonResponse(w, map[string]bool{"ok": true})
}
// DeleteRule DELETE /api/autotrade/rules/{id} — 규칙 삭제
func (h *AutoTradeHandler) DeleteRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !h.svc.DeleteRule(id) {
http.Error(w, "규칙을 찾을 수 없습니다", http.StatusNotFound)
return
}
jsonResponse(w, map[string]bool{"ok": true})
}
// ToggleRule POST /api/autotrade/rules/{id}/toggle — 규칙 ON/OFF
func (h *AutoTradeHandler) ToggleRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ok, enabled := h.svc.ToggleRule(id)
if !ok {
http.Error(w, "규칙을 찾을 수 없습니다", http.StatusNotFound)
return
}
jsonResponse(w, map[string]bool{"enabled": enabled})
}
// GetPositions GET /api/autotrade/positions — 포지션 목록
func (h *AutoTradeHandler) GetPositions(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, h.svc.GetPositions())
}
// GetLogs GET /api/autotrade/logs — 최근 로그 (?level=action 이면 debug 제외)
func (h *AutoTradeHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
logs := h.svc.GetLogs()
if r.URL.Query().Get("level") == "action" {
filtered := logs[:0:0]
for _, l := range logs {
if l.Level != "debug" {
filtered = append(filtered, l)
}
}
logs = filtered
}
jsonResponse(w, logs)
}
// GetWatchSource GET /api/autotrade/watch-source — 감시 소스 조회
func (h *AutoTradeHandler) GetWatchSource(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, h.svc.GetWatchSource())
}
// SetWatchSource PUT /api/autotrade/watch-source — 감시 소스 설정
func (h *AutoTradeHandler) SetWatchSource(w http.ResponseWriter, r *http.Request) {
var ws models.AutoTradeWatchSource
if err := json.NewDecoder(r.Body).Decode(&ws); err != nil {
http.Error(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
h.svc.SetWatchSource(ws)
jsonResponse(w, map[string]bool{"ok": true})
}
// Start POST /api/autotrade/start — 엔진 시작
func (h *AutoTradeHandler) Start(w http.ResponseWriter, r *http.Request) {
h.svc.Start()
jsonResponse(w, map[string]bool{"running": true})
}
// Stop POST /api/autotrade/stop — 엔진 중지
func (h *AutoTradeHandler) Stop(w http.ResponseWriter, r *http.Request) {
h.svc.Stop()
jsonResponse(w, map[string]bool{"running": false})
}
// Emergency POST /api/autotrade/emergency — 긴급 전량 청산
func (h *AutoTradeHandler) Emergency(w http.ResponseWriter, r *http.Request) {
h.svc.EmergencyStop()
jsonResponse(w, map[string]bool{"ok": true})
}
// jsonResponse JSON 응답 헬퍼
func jsonResponse(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}

186
handlers/order_handler.go Normal file
View File

@@ -0,0 +1,186 @@
package handlers
import (
"encoding/json"
"net/http"
"stocksearch/services"
)
// OrderHandler 주문/계좌 REST API 핸들러
type OrderHandler struct {
orderSvc *services.OrderService
accountSvc *services.AccountService
}
// NewOrderHandler 핸들러 초기화
func NewOrderHandler() *OrderHandler {
return &OrderHandler{
orderSvc: services.GetOrderService(),
accountSvc: services.GetAccountService(),
}
}
// Buy POST /api/order/buy — 매수주문 (kt10000)
func (h *OrderHandler) Buy(w http.ResponseWriter, r *http.Request) {
var req services.OrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
if req.Exchange == "" {
req.Exchange = "KRX"
}
if req.TradeTP == "" {
req.TradeTP = "0"
}
result, err := h.orderSvc.Buy(req)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}
// Sell POST /api/order/sell — 매도주문 (kt10001)
func (h *OrderHandler) Sell(w http.ResponseWriter, r *http.Request) {
var req services.OrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
if req.Exchange == "" {
req.Exchange = "KRX"
}
if req.TradeTP == "" {
req.TradeTP = "0"
}
result, err := h.orderSvc.Sell(req)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}
// Modify PUT /api/order/modify — 정정주문 (kt10002)
func (h *OrderHandler) Modify(w http.ResponseWriter, r *http.Request) {
var req struct {
Exchange string `json:"exchange"`
OrigOrdNo string `json:"origOrdNo"`
Code string `json:"code"`
Qty string `json:"qty"`
Price string `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
if req.Exchange == "" {
req.Exchange = "KRX"
}
result, err := h.orderSvc.Modify(req.Exchange, req.OrigOrdNo, req.Code, req.Qty, req.Price)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}
// Cancel DELETE /api/order — 취소주문 (kt10003)
func (h *OrderHandler) Cancel(w http.ResponseWriter, r *http.Request) {
var req struct {
Exchange string `json:"exchange"`
OrigOrdNo string `json:"origOrdNo"`
Code string `json:"code"`
Qty string `json:"qty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "요청 파싱 실패", http.StatusBadRequest)
return
}
if req.Exchange == "" {
req.Exchange = "KRX"
}
if req.Qty == "" {
req.Qty = "0" // 전량취소
}
result, err := h.orderSvc.Cancel(req.Exchange, req.OrigOrdNo, req.Code, req.Qty)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}
// GetBalance GET /api/account/balance — 계좌 잔고 (kt00018)
func (h *OrderHandler) GetBalance(w http.ResponseWriter, r *http.Request) {
result, err := h.accountSvc.GetBalance()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}
// GetPending GET /api/account/pending — 미체결 주문 (ka10075)
func (h *OrderHandler) GetPending(w http.ResponseWriter, r *http.Request) {
orders, err := h.accountSvc.GetPendingOrders()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if orders == nil {
orders = []services.PendingOrder{}
}
jsonOK(w, orders)
}
// GetHistory GET /api/account/history — 체결내역 (ka10076)
func (h *OrderHandler) GetHistory(w http.ResponseWriter, r *http.Request) {
history, err := h.accountSvc.GetOrderHistory()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if history == nil {
history = []services.OrderHistory{}
}
jsonOK(w, history)
}
// GetDeposit GET /api/account/deposit — 예수금 상세 조회 (kt00001)
func (h *OrderHandler) GetDeposit(w http.ResponseWriter, r *http.Request) {
result, err := h.accountSvc.GetDepositDetail()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}
// GetOrderable GET /api/account/orderable?code=&price=&side= — 주문가능금액/수량 (kt00010)
func (h *OrderHandler) GetOrderable(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
price := r.URL.Query().Get("price")
side := r.URL.Query().Get("side") // buy 또는 sell
if price == "" {
price = "0"
}
// 키움 API: 1=매도, 2=매수
tradeTp := "2"
if side == "sell" {
tradeTp = "1"
}
result, err := h.accountSvc.GetOrderable(code, price, tradeTp)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, result)
}

213
handlers/page_handler.go Normal file
View File

@@ -0,0 +1,213 @@
package handlers
import (
"html/template"
"log"
"net/http"
"stocksearch/middleware"
"stocksearch/services"
)
// PageHandler HTML 페이지 렌더링 핸들러
type PageHandler struct {
// 페이지별 독립 템플릿 세트 (content 블록 충돌 방지)
pageTemplates map[string]*template.Template
stockService *services.StockService
}
// NewPageHandler 페이지별 템플릿을 분리 파싱하여 핸들러 초기화
func NewPageHandler() *PageHandler {
base := "templates/layout/base.html"
pages := []string{"index.html", "stock_detail.html", "theme.html", "kospi200.html", "asset.html", "autotrade.html"}
fns := templateFuncs()
tmplMap := make(map[string]*template.Template, len(pages))
for _, page := range pages {
tmplMap[page] = template.Must(
template.New("").Funcs(fns).ParseFiles(base, "templates/pages/"+page),
)
}
return &PageHandler{
pageTemplates: tmplMap,
stockService: services.GetStockService(),
}
}
// IndexPage GET / - 메인 페이지
func (h *PageHandler) IndexPage(w http.ResponseWriter, r *http.Request) {
data := withLoggedIn(r, map[string]interface{}{
"Title": "주식 시세",
"ActiveMenu": "시세",
})
h.render(w, "index.html", data)
}
// ThemePage GET /theme - 테마 분석 페이지
func (h *PageHandler) ThemePage(w http.ResponseWriter, r *http.Request) {
data := withLoggedIn(r, map[string]interface{}{
"Title": "테마 분석 - 주식 시세",
"ActiveMenu": "테마",
})
h.render(w, "theme.html", data)
}
// Kospi200Page GET /kospi200 - 코스피200 종목 페이지
func (h *PageHandler) Kospi200Page(w http.ResponseWriter, r *http.Request) {
data := withLoggedIn(r, map[string]interface{}{
"Title": "코스피200 - 주식 시세",
"ActiveMenu": "코스피200",
})
h.render(w, "kospi200.html", data)
}
// AssetPage GET /asset - 자산 현황 페이지
func (h *PageHandler) AssetPage(w http.ResponseWriter, r *http.Request) {
data := withLoggedIn(r, map[string]interface{}{
"Title": "자산 현황 - 주식 시세",
"ActiveMenu": "자산",
})
h.render(w, "asset.html", data)
}
// AutoTradePage GET /autotrade - 자동매매 페이지
func (h *PageHandler) AutoTradePage(w http.ResponseWriter, r *http.Request) {
data := withLoggedIn(r, map[string]interface{}{
"Title": "자동매매 - 주식 시세",
"ActiveMenu": "자동매매",
})
h.render(w, "autotrade.html", data)
}
// StockDetailPage GET /stock/{code} - 종목 상세 페이지
func (h *PageHandler) StockDetailPage(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
price, err := h.stockService.GetCurrentPrice(code)
if err != nil {
log.Printf("현재가 조회 실패 [%s]: %v", code, err)
http.Error(w, "종목 정보를 불러올 수 없습니다.", http.StatusInternalServerError)
return
}
data := withLoggedIn(r, map[string]interface{}{
"Title": price.Name + " - 주식 시세",
"Stock": price,
"ActiveMenu": "",
})
h.render(w, "stock_detail.html", data)
}
// withLoggedIn 템플릿 데이터에 LoggedIn 필드 추가
func withLoggedIn(r *http.Request, data map[string]interface{}) map[string]interface{} {
data["LoggedIn"] = middleware.IsLoggedIn(r)
return data
}
// render 템플릿 렌더링 헬퍼
func (h *PageHandler) render(w http.ResponseWriter, name string, data interface{}) {
tmpl, ok := h.pageTemplates[name]
if !ok {
http.Error(w, "페이지를 찾을 수 없습니다.", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
log.Printf("템플릿 렌더링 실패 [%s]: %v", name, err)
http.Error(w, "페이지를 표시할 수 없습니다.", http.StatusInternalServerError)
}
}
// templateFuncs 템플릿에서 사용할 커스텀 함수
func templateFuncs() template.FuncMap {
return template.FuncMap{
// 가격 포맷 (천 단위 콤마)
"formatPrice": func(n int64) string {
return formatNumber(n)
},
// 등락률 부호 포함 문자열
"formatRate": func(f float64) string {
if f >= 0 {
return "+" + formatFloat(f) + "%"
}
return formatFloat(f) + "%"
},
// 등락에 따른 CSS 색상 클래스
"priceClass": func(f float64) string {
if f > 0 {
return "text-red-500" // 상승: 빨강 (한국 관행)
} else if f < 0 {
return "text-blue-500" // 하락: 파랑 (한국 관행)
}
return "text-gray-500"
},
// 체결강도 CSS 클래스 (100 기준)
"cntrClass": func(f float64) string {
if f > 100 {
return "text-red-500"
} else if f < 100 && f > 0 {
return "text-blue-500"
}
return "text-gray-400"
},
// 체결강도 포맷
"formatCntr": func(f float64) string {
return formatFloat(f)
},
}
}
func formatNumber(n int64) string {
s := ""
negative := n < 0
if negative {
n = -n
}
for i, c := range reverse(itoa(n)) {
if i > 0 && i%3 == 0 {
s = "," + s
}
s = string(c) + s
}
if negative {
s = "-" + s
}
return s
}
func formatFloat(f float64) string {
if f < 0 {
f = -f
}
// 소수점 2자리
result := ""
intPart := int64(f)
fracPart := int64((f - float64(intPart)) * 100)
result = itoa(intPart)
if fracPart < 10 {
result += ".0" + itoa(fracPart)
} else {
result += "." + itoa(fracPart)
}
return result
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}

288
handlers/stock_handler.go Normal file
View File

@@ -0,0 +1,288 @@
package handlers
import (
"encoding/json"
"net/http"
"stocksearch/models"
"stocksearch/services"
"strings"
)
// StockHandler JSON REST API 핸들러
type StockHandler struct {
stockService *services.StockService
scannerService *services.ScannerService
indexService *services.IndexService
searchService *services.SearchService
dartService *services.DartService
newsService *services.NewsService
themeService *services.ThemeService
kospi200Service *services.Kospi200Service
watchlistSvc *services.WatchlistService
}
// NewStockHandler 핸들러 초기화
func NewStockHandler(watchlistSvc *services.WatchlistService) *StockHandler {
return &StockHandler{
stockService: services.GetStockService(),
scannerService: services.GetScannerService(),
indexService: services.GetIndexService(),
searchService: services.GetSearchService(),
dartService: services.GetDartService(),
newsService: services.GetNewsService(),
themeService: services.GetThemeService(),
kospi200Service: services.GetKospi200Service(),
watchlistSvc: watchlistSvc,
}
}
// GetCurrentPrice GET /api/stock/{code} - 주식 현재가 JSON 반환
func (h *StockHandler) GetCurrentPrice(w http.ResponseWriter, r *http.Request) {
code := strings.TrimPrefix(r.PathValue("code"), "")
if code == "" {
jsonError(w, "종목코드가 필요합니다.", http.StatusBadRequest)
return
}
price, err := h.stockService.GetCurrentPrice(code)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, price)
}
// GetChart GET /api/stock/{code}/chart?period=daily|minute1|minute5 - 차트 데이터 JSON 반환
func (h *StockHandler) GetDailyChart(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
jsonError(w, "종목코드가 필요합니다.", http.StatusBadRequest)
return
}
period := r.URL.Query().Get("period")
var (
candles []models.CandleData
err error
)
switch period {
case "minute1":
candles, err = h.stockService.GetMinuteChart(code, 1)
case "minute5":
candles, err = h.stockService.GetMinuteChart(code, 5)
default:
candles, err = h.stockService.GetDailyChart(code)
}
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, candles)
}
// GetScannerStatus GET /api/scanner/status - 스캐너 활성화 상태 반환
func (h *StockHandler) GetScannerStatus(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]bool{"enabled": h.scannerService.IsEnabled()})
}
// ToggleScanner POST /api/scanner/toggle - 스캐너 ON/OFF 토글 후 새 상태 반환
func (h *StockHandler) ToggleScanner(w http.ResponseWriter, r *http.Request) {
next := !h.scannerService.IsEnabled()
h.scannerService.SetEnabled(next)
jsonOK(w, map[string]bool{"enabled": next})
}
// GetSignals GET /api/signal - 체결강도 상승 감지 시그널 종목 JSON 반환
func (h *StockHandler) GetSignals(w http.ResponseWriter, r *http.Request) {
signals := h.scannerService.GetSignals()
jsonOK(w, signals)
}
// GetWatchlistSignals GET /api/watchlist-signal?codes=005930,000660,... - 관심종목 복합 분석 JSON 반환
func (h *StockHandler) GetWatchlistSignals(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("codes")
if raw == "" {
jsonOK(w, []services.SignalStock{})
return
}
parts := strings.Split(raw, ",")
// 최대 20개 제한
codes := make([]string, 0, 20)
for _, c := range parts {
c = strings.TrimSpace(c)
if len(c) == 6 {
codes = append(codes, c)
}
if len(codes) >= 20 {
break
}
}
if len(codes) == 0 {
jsonOK(w, []services.SignalStock{})
return
}
signals := h.scannerService.AnalyzeWatchlist(codes)
if signals == nil {
signals = []services.SignalStock{}
}
jsonOK(w, signals)
}
// Search GET /api/search?q=... - 종목명·코드 검색 결과 JSON 반환
func (h *StockHandler) Search(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
results := h.searchService.Search(q)
if results == nil {
results = []services.StockItem{}
}
jsonOK(w, results)
}
// GetIndices GET /api/indices - 코스피·코스닥·다우·나스닥 지수 JSON 반환
func (h *StockHandler) GetIndices(w http.ResponseWriter, r *http.Request) {
quotes, err := h.indexService.GetIndices()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, quotes)
}
// GetNews GET /api/news?name={stockName} - 종목 관련 최근 뉴스 JSON 반환
func (h *StockHandler) GetNews(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
jsonError(w, "종목명이 필요합니다.", http.StatusBadRequest)
return
}
news, err := h.newsService.GetNews(name)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, news)
}
// GetDisclosures GET /api/disclosure?code={stockCode} - DART 최근 공시 JSON 반환
func (h *StockHandler) GetDisclosures(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if len(code) != 6 {
jsonError(w, "올바른 종목코드를 입력해주세요.", http.StatusBadRequest)
return
}
disclosures, err := h.dartService.GetDisclosures(code)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, disclosures)
}
// GetKospi200 GET /api/kospi200 - 코스피200 구성종목 JSON 반환
func (h *StockHandler) GetKospi200(w http.ResponseWriter, r *http.Request) {
stocks, err := h.kospi200Service.GetStocks()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, stocks)
}
// GetThemes GET /api/themes?date=1&sort=3 - 테마그룹 목록 JSON 반환
// sort: 3=상위등락률(기본), 1=상위기간수익률
func (h *StockHandler) GetThemes(w http.ResponseWriter, r *http.Request) {
dateTp := r.URL.Query().Get("date")
if dateTp == "" {
dateTp = "1"
}
sortTp := r.URL.Query().Get("sort")
if sortTp == "" {
sortTp = "3"
}
groups, err := h.themeService.GetThemes(dateTp, sortTp)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, groups)
}
// GetThemeStocks GET /api/themes/{code}?date=1 - 테마구성종목 JSON 반환
func (h *StockHandler) GetThemeStocks(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
jsonError(w, "테마코드가 필요합니다.", http.StatusBadRequest)
return
}
dateTp := r.URL.Query().Get("date")
if dateTp == "" {
dateTp = "1"
}
detail, err := h.themeService.GetThemeStocks(code, dateTp)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, detail)
}
// GetWatchlist GET /api/watchlist — 관심종목 목록 JSON 반환
func (h *StockHandler) GetWatchlist(w http.ResponseWriter, r *http.Request) {
list := h.watchlistSvc.GetAll()
if list == nil {
list = []services.WatchlistItem{}
}
jsonOK(w, list)
}
// AddWatchlist POST /api/watchlist — body: {code, name} 관심종목 추가
func (h *StockHandler) AddWatchlist(w http.ResponseWriter, r *http.Request) {
var body struct {
Code string `json:"code"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "잘못된 요청입니다.", http.StatusBadRequest)
return
}
if len(body.Code) != 6 {
jsonError(w, "올바른 종목코드를 입력해주세요.", http.StatusBadRequest)
return
}
if err := h.watchlistSvc.Add(body.Code, body.Name); err != nil {
jsonError(w, err.Error(), http.StatusConflict)
return
}
jsonOK(w, map[string]bool{"ok": true})
}
// RemoveWatchlist DELETE /api/watchlist/{code} — 관심종목 삭제
func (h *StockHandler) RemoveWatchlist(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if len(code) != 6 {
jsonError(w, "올바른 종목코드를 입력해주세요.", http.StatusBadRequest)
return
}
if err := h.watchlistSvc.Remove(code); err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]bool{"ok": true})
}
// --- JSON 응답 헬퍼 ---
func jsonOK(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, message string, status int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

View File

@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
ws "stocksearch/websocket"
)
// WSHandler WebSocket 핸들러
type WSHandler struct {
hub *ws.Hub
}
// NewWSHandler 핸들러 초기화
func NewWSHandler(hub *ws.Hub) *WSHandler {
return &WSHandler{hub: hub}
}
// ServeWS GET /ws - WebSocket 연결 처리
func (h *WSHandler) ServeWS(w http.ResponseWriter, r *http.Request) {
h.hub.ServeWS(w, r)
}

742742
kiwoom_api_doc.pdf Normal file

File diff suppressed because one or more lines are too long

107497
kiwoom_api_doc.txt Normal file

File diff suppressed because it is too large Load Diff

136
main.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"log"
"net/http"
"stocksearch/config"
"stocksearch/handlers"
"stocksearch/middleware"
"stocksearch/models"
"stocksearch/services"
ws "stocksearch/websocket"
)
func main() {
// 환경변수 로딩
config.Load()
// 키움증권 토큰 발급 (서버 시작 시 즉시 실행)
tokenSvc := services.GetTokenService()
if err := tokenSvc.Start(); err != nil {
log.Fatalf("토큰 발급 실패: %v\n키움증권 API 키를 .env 파일에 설정해주세요.", err)
}
// 체결강도 상승 감지 스캐너 시작 (08:00 KST 이후 10초 주기)
services.GetScannerService().Start()
// 종목 리스트 백그라운드 로딩 (검색용)
services.GetSearchService().Load()
// WebSocket Hub 시작 (내부에서 키움 WS 클라이언트 초기화)
hub := ws.NewHub()
go hub.Run()
// 키움 WS 실시간 연결 시작 (Hub 이벤트 루프 실행 후)
if err := hub.StartKiwoomWS(); err != nil {
log.Printf("키움 WS 초기 연결 실패: %v (자동 재연결 시도)", err)
}
// 서비스 추가
sessionSvc := services.GetSessionService()
watchlistSvc := services.GetWatchlistService()
autoTradeSvc := services.GetAutoTradeService()
// 스캐너 구독 종목 → WebSocket 내부 구독 연결
services.GetScannerService().SetSubscribeCallback(func(codes []string) {
hub.SubscribeInternal(codes)
})
// 자동매매 로그 → WebSocket 브로드캐스트 연결
autoTradeSvc.SetLogBroadcaster(func(l models.AutoTradeLog) {
hub.BroadcastTradeLog(l)
})
// 핸들러 초기화
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 패턴 매칭)
mux := http.NewServeMux()
// --- 인증 라우트 ---
mux.HandleFunc("GET /login", authHandler.LoginPage)
mux.HandleFunc("POST /login", authHandler.Login)
mux.HandleFunc("POST /logout", authHandler.Logout)
// --- 페이지 라우트 ---
mux.HandleFunc("GET /", pageHandler.IndexPage)
mux.HandleFunc("GET /theme", pageHandler.ThemePage)
mux.HandleFunc("GET /kospi200", pageHandler.Kospi200Page)
mux.HandleFunc("GET /asset", pageHandler.AssetPage)
mux.HandleFunc("GET /autotrade", pageHandler.AutoTradePage)
mux.HandleFunc("GET /stock/{code}", pageHandler.StockDetailPage)
// --- REST API 라우트 ---
mux.HandleFunc("GET /api/stock/{code}", stockHandler.GetCurrentPrice)
mux.HandleFunc("GET /api/stock/{code}/chart", stockHandler.GetDailyChart)
mux.HandleFunc("GET /api/scanner/status", stockHandler.GetScannerStatus)
mux.HandleFunc("POST /api/scanner/toggle", stockHandler.ToggleScanner)
mux.HandleFunc("GET /api/signal", stockHandler.GetSignals)
mux.HandleFunc("GET /api/watchlist-signal", stockHandler.GetWatchlistSignals)
mux.HandleFunc("GET /api/indices", stockHandler.GetIndices)
mux.HandleFunc("GET /api/search", stockHandler.Search)
mux.HandleFunc("GET /api/disclosure", stockHandler.GetDisclosures)
mux.HandleFunc("GET /api/news", stockHandler.GetNews)
mux.HandleFunc("GET /api/kospi200", stockHandler.GetKospi200)
mux.HandleFunc("GET /api/themes", stockHandler.GetThemes)
mux.HandleFunc("GET /api/themes/{code}", stockHandler.GetThemeStocks)
mux.HandleFunc("GET /api/watchlist", stockHandler.GetWatchlist)
mux.HandleFunc("POST /api/watchlist", stockHandler.AddWatchlist)
mux.HandleFunc("DELETE /api/watchlist/{code}", stockHandler.RemoveWatchlist)
// --- 주문/계좌 API 라우트 ---
mux.HandleFunc("POST /api/order/buy", orderHandler.Buy)
mux.HandleFunc("POST /api/order/sell", orderHandler.Sell)
mux.HandleFunc("PUT /api/order/modify", orderHandler.Modify)
mux.HandleFunc("DELETE /api/order", orderHandler.Cancel)
mux.HandleFunc("GET /api/account/balance", orderHandler.GetBalance)
mux.HandleFunc("GET /api/account/pending", orderHandler.GetPending)
mux.HandleFunc("GET /api/account/history", orderHandler.GetHistory)
mux.HandleFunc("GET /api/account/deposit", orderHandler.GetDeposit)
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("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)
// --- WebSocket 라우트 ---
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
// --- 정적 파일 ---
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))
addr := "0.0.0.0:" + config.App.ServerPort
log.Printf("서버 시작: http://%s", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("서버 실행 실패: %v", err)
}
}

100
middleware/auth.go Normal file
View File

@@ -0,0 +1,100 @@
package middleware
import (
"context"
"net/http"
"stocksearch/services"
"strings"
)
const SessionCookieName = "ss_session"
// contextKey 컨텍스트 키 타입
type contextKey string
const CtxLoggedIn contextKey = "loggedIn"
// IsLoggedIn 요청 컨텍스트에서 로그인 여부 반환
func IsLoggedIn(r *http.Request) bool {
v, _ := r.Context().Value(CtxLoggedIn).(bool)
return v
}
// publicPaths 로그인 없이 접근 가능한 경로 (전체 일치 또는 prefix)
var publicPaths = []string{
"/login",
"/static/",
// 공개 페이지
"/",
"/theme",
"/kospi200",
"/stock/",
// 공개 API (시세/테마/코스피200 관련)
"/api/stock/",
"/api/indices",
"/api/search",
"/api/scanner/",
"/api/signal",
"/api/watchlist-signal",
"/api/themes",
"/api/themes/",
"/api/kospi200",
"/api/news",
"/api/disclosure",
"/ws",
}
// isPublic 요청 경로가 공개 경로에 해당하는지 판단
func isPublic(path string) bool {
for _, p := range publicPaths {
if strings.HasSuffix(p, "/") {
if strings.HasPrefix(path, p) || path == p[:len(p)-1] {
return true
}
} else {
if path == p {
return true
}
}
}
return false
}
// Auth 세션 쿠키 검증 미들웨어
// - 공개 경로: 로그인 없이 접근 허용
// - 인증 필요 경로(자산/주문/자동매매 등): 미인증 시 페이지는 /login 리다이렉트, API는 401 반환
func Auth(sessionSvc *services.SessionService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 공개 경로: 인증 없이 통과하되 로그인 상태는 컨텍스트에 저장
if isPublic(r.URL.Path) {
cookie, err := r.Cookie(SessionCookieName)
loggedIn := err == nil && sessionSvc.Validate(cookie.Value)
ctx := context.WithValue(r.Context(), CtxLoggedIn, loggedIn)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// 쿠키에서 세션 ID 조회
cookie, err := r.Cookie(SessionCookieName)
loggedIn := err == nil && sessionSvc.Validate(cookie.Value)
if !loggedIn {
// API 경로는 401 JSON 반환, 페이지는 /login 리다이렉트
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"로그인이 필요합니다"}`))
return
}
redirectURL := "/login?next=" + r.URL.RequestURI()
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
// 로그인 상태를 컨텍스트에 저장
ctx := context.WithValue(r.Context(), CtxLoggedIn, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

37
middleware/middleware.go Normal file
View File

@@ -0,0 +1,37 @@
package middleware
import (
"log"
"net/http"
"time"
)
// Logger HTTP 요청 로깅 미들웨어
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.RequestURI, time.Since(start))
})
}
// Recovery 패닉 발생 시 500 응답으로 복구하는 미들웨어
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("패닉 복구: %v", err)
http.Error(w, "내부 서버 오류가 발생했습니다.", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Chain 여러 미들웨어를 순서대로 체인
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}

69
models/autotrade.go Normal file
View File

@@ -0,0 +1,69 @@
package models
import "time"
// AutoTradeRule 자동매매 규칙
type AutoTradeRule struct {
ID string `json:"id"`
Name string `json:"name"`
// 활성화 여부
Enabled bool `json:"enabled"`
// 진입 조건 (ScannerService 신호 기반)
MinRiseScore int `json:"minRiseScore"` // 최소 상승점수 (0~100, 기본 60)
MinCntrStr float64 `json:"minCntrStr"` // 최소 체결강도 (기본 110)
RequireBullish bool `json:"requireBullish"` // AI 호재(Sentiment=="호재") 필요 여부
// 주문 설정
OrderAmount int64 `json:"orderAmount"` // 1종목당 주문금액(원)
MaxPositions int `json:"maxPositions"` // 동시 최대 보유 종목 수 (기본 3)
// 청산 조건
StopLossPct float64 `json:"stopLossPct"` // 손절 % (예: -3.0)
TakeProfitPct float64 `json:"takeProfitPct"` // 익절 % (예: 5.0)
MaxHoldMinutes int `json:"maxHoldMinutes"` // 최대 보유 시간(분, 0=무제한)
ExitBeforeClose bool `json:"exitBeforeClose"` // 장 마감 전 청산(15:20 기준)
CreatedAt time.Time `json:"createdAt"`
}
// 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"` // 절대 익절가
// 상태: "pending"=체결 대기 | "open"=보유중 | "closed"=청산완료
Status string `json:"status"`
ExitTime time.Time `json:"exitTime,omitempty"`
ExitPrice int64 `json:"exitPrice,omitempty"`
// 청산 사유: "익절"|"손절"|"시간초과"|"장마감"|"긴급"
ExitReason string `json:"exitReason,omitempty"`
}
// AutoTradeLog 자동매매 이벤트 로그
type AutoTradeLog struct {
At time.Time `json:"at"`
Level string `json:"level"` // "info"|"warn"|"error"
Message string `json:"message"`
Code string `json:"code"` // 관련 종목코드 (없으면 "")
}
// ThemeRef 감시 소스로 선택된 테마 참조
type ThemeRef struct {
Code string `json:"code"` // 테마 코드
Name string `json:"name"` // 테마 이름 (UI 표시용)
}
// AutoTradeWatchSource 자동매매 감시 소스 설정
type AutoTradeWatchSource struct {
UseScanner bool `json:"useScanner"` // 체결강도 자동감지 사용
SelectedThemes []ThemeRef `json:"selectedThemes"` // 감시할 테마 목록
}

12
models/disclosure.go Normal file
View File

@@ -0,0 +1,12 @@
package models
// Disclosure DART 공시 항목
type Disclosure struct {
RceptNo string `json:"rceptNo"` // 접수번호
CorpName string `json:"corpName"` // 회사명
ReportNm string `json:"reportNm"` // 보고서명 (공시 제목)
RceptDt string `json:"rceptDt"` // 접수일자 (YYYYMMDD)
FlrNm string `json:"flrNm"` // 공시 제출인명
URL string `json:"url"` // DART 상세 URL (서버에서 조합)
Tag string `json:"tag"` // 이벤트 분류 태그
}

15
models/kospi200.go Normal file
View File

@@ -0,0 +1,15 @@
package models
// Kospi200Stock 코스피200 구성종목 (ka20002 응답)
type Kospi200Stock struct {
Code string `json:"code"` // stk_cd
Name string `json:"name"` // stk_nm
CurPrc int64 `json:"curPrc"` // cur_prc
PredPreSig string `json:"predPreSig"` // pred_pre_sig (2:상승 3:보합 5:하락)
PredPre int64 `json:"predPre"` // pred_pre 전일대비
FluRt float64 `json:"fluRt"` // flu_rt 등락률
Volume int64 `json:"volume"` // now_trde_qty 현재거래량
Open int64 `json:"open"` // open_pric
High int64 `json:"high"` // high_pric
Low int64 `json:"low"` // low_pric
}

9
models/news.go Normal file
View File

@@ -0,0 +1,9 @@
package models
// NewsItem 뉴스 기사 항목
type NewsItem struct {
Title string `json:"title"` // 기사 제목 (HTML 태그 제거됨)
URL string `json:"url"` // 원문 URL
PublishedAt string `json:"publishedAt"` // 발행일시 (RFC1123Z)
Source string `json:"source"` // 출처 (도메인)
}

96
models/stock.go Normal file
View File

@@ -0,0 +1,96 @@
package models
import "time"
// StockPrice 주식 현재가 정보
type StockPrice struct {
Code string `json:"code"` // 종목코드 (예: 005930)
Name string `json:"name"` // 종목명 (예: 삼성전자)
CurrentPrice int64 `json:"currentPrice"` // 현재가
ChangePrice int64 `json:"changePrice"` // 전일 대비 등락 금액
ChangeRate float64 `json:"changeRate"` // 등락률 (%)
Volume int64 `json:"volume"` // 누적 거래량 (13)
TradeMoney int64 `json:"tradeMoney"` // 누적 거래대금 (14)
High int64 `json:"high"` // 고가
Low int64 `json:"low"` // 저가
Open int64 `json:"open"` // 시가
TradeTime string `json:"tradeTime"` // 체결시각 (20, HHMMSS)
TradeVolume int64 `json:"tradeVolume"` // 체결량 (15)
AskPrice1 int64 `json:"askPrice1"` // 최우선매도호가 (27)
BidPrice1 int64 `json:"bidPrice1"` // 최우선매수호가 (28)
MarketStatus string `json:"marketStatus"` // 장구분 (290)
CntrStr float64 `json:"cntrStr"` // 체결강도
Market string `json:"market"` // 시장구분 (KOSPI / KOSDAQ)
UpdatedAt time.Time `json:"updatedAt"` // 마지막 업데이트 시각
}
// CandleData 캔들(봉) 데이터 (일봉/분봉 공통)
type CandleData struct {
Time int64 `json:"time"` // Unix 타임스탬프 (Lightweight Charts 형식)
Open int64 `json:"open"`
High int64 `json:"high"`
Low int64 `json:"low"`
Close int64 `json:"close"`
Volume int64 `json:"volume"`
}
// StockInfo 종목 기본 정보 (검색용)
type StockInfo struct {
Code string `json:"code"`
Name string `json:"name"`
Market string `json:"market"` // KOSPI / KOSDAQ
}
// AskingPrice 호가 정보
type AskingPrice struct {
SellPrices []int64 `json:"sellPrices"` // 매도 호가 (낮은→높은 순)
SellVolumes []int64 `json:"sellVolumes"`
BuyPrices []int64 `json:"buyPrices"` // 매수 호가 (높은→낮은 순)
BuyVolumes []int64 `json:"buyVolumes"`
}
// OrderBookEntry 개별 호가 항목
type OrderBookEntry struct {
Price int64 `json:"price"` // 호가
Volume int64 `json:"volume"` // 잔량
}
// OrderBook 실시간 호가창 (0D)
// Asks[0]=매도1호가(최우선), Asks[9]=매도10호가
// Bids[0]=매수1호가(최우선), Bids[9]=매수10호가
type OrderBook struct {
Code string `json:"code"`
AskTime string `json:"askTime"` // 21: 호가시간 (HHMMSS)
Asks []OrderBookEntry `json:"asks"` // 매도호가 1~10
Bids []OrderBookEntry `json:"bids"` // 매수호가 1~10
TotalAskVol int64 `json:"totalAskVol"` // 121: 매도호가총잔량
TotalBidVol int64 `json:"totalBidVol"` // 125: 매수호가총잔량
ExpectedPrc int64 `json:"expectedPrc"` // 23: 예상체결가
ExpectedVol int64 `json:"expectedVol"` // 24: 예상체결수량
}
// ProgramTrading 종목프로그램매매 실시간 데이터 (0w)
type ProgramTrading struct {
Code string `json:"code"`
SellVolume int64 `json:"sellVolume"` // 202: 매도수량
SellAmount int64 `json:"sellAmount"` // 204: 매도금액
BuyVolume int64 `json:"buyVolume"` // 206: 매수수량
BuyAmount int64 `json:"buyAmount"` // 208: 매수금액
NetBuyVolume int64 `json:"netBuyVolume"` // 210: 순매수수량
NetBuyAmount int64 `json:"netBuyAmount"` // 212: 순매수금액
}
// MarketStatus 장운영 상태 (0s)
type MarketStatus struct {
StatusCode string `json:"statusCode"` // 215: 장운영구분 코드
StatusName string `json:"statusName"` // 해석된 이름 (예: 장 중, 장 마감)
Time string `json:"time"` // 20: 체결시간
}
// StockMeta 종목 메타데이터 (0g)
type StockMeta struct {
Code string `json:"code"`
UpperLimit int64 `json:"upperLimit"` // 305: 상한가
LowerLimit int64 `json:"lowerLimit"` // 306: 하한가
BasePrice int64 `json:"basePrice"` // 307: 기준가
}

31
models/theme.go Normal file
View File

@@ -0,0 +1,31 @@
package models
// ThemeGroup 테마그룹 정보 (ka90001 응답)
type ThemeGroup struct {
Code string `json:"code"` // thema_grp_cd
Name string `json:"name"` // thema_nm
StockCount int `json:"stockCount"` // stk_num
FluSig string `json:"fluSig"` // flu_sig (2:상승 5:하락 3:보합)
FluRt float64 `json:"fluRt"` // flu_rt
RisingCount int `json:"risingCount"` // rising_stk_num
FallCount int `json:"fallCount"` // fall_stk_num
PeriodRt float64 `json:"periodRt"` // dt_prft_rt
MainStock string `json:"mainStock"` // main_stk
}
// ThemeDetail 테마구성종목 응답 (ka90002)
type ThemeDetail struct {
FluRt float64 `json:"fluRt"` // 테마 등락률
PeriodRt float64 `json:"periodRt"` // 기간수익률
Stocks []ThemeStock `json:"stocks"`
}
// ThemeStock 테마 구성종목
type ThemeStock struct {
Code string `json:"code"` // stk_cd
Name string `json:"name"` // stk_nm
CurPrc int64 `json:"curPrc"` // cur_prc
FluSig string `json:"fluSig"` // flu_sig
PredPre int64 `json:"predPre"` // pred_pre
FluRt float64 `json:"fluRt"` // flu_rt
}

13
models/token.go Normal file
View File

@@ -0,0 +1,13 @@
package models
import "time"
// TokenResponse 키움증권 토큰 발급 응답
type TokenResponse struct {
Token string `json:"token"` // 액세스 토큰
TokenType string `json:"token_type"` // "bearer"
ExpiresAt string `json:"expires_dt"` // 만료 일시 (YYYYMMDDHHmmss)
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
ExpiresTime time.Time `json:"-"` // 파싱된 만료 시각
}

13
models/websocket.go Normal file
View File

@@ -0,0 +1,13 @@
package models
// WSMessage WebSocket 메시지 공통 구조
type WSMessage struct {
Type string `json:"type"` // "subscribe" | "unsubscribe" | "price" | "error"
Code string `json:"code"` // 종목코드
Data interface{} `json:"data"` // StockPrice 또는 에러 메시지
}
// WSError WebSocket 에러 메시지 데이터
type WSError struct {
Message string `json:"message"`
}

377
services/account_service.go Normal file
View File

@@ -0,0 +1,377 @@
package services
import (
"encoding/json"
"fmt"
)
// AccountService 계좌 조회 서비스 (잔고/미체결/체결내역/주문가능)
type AccountService struct {
client *KiwoomClient
}
var accountService *AccountService
// GetAccountService 계좌 서비스 싱글턴 반환
func GetAccountService() *AccountService {
if accountService == nil {
accountService = &AccountService{client: GetKiwoomClient()}
}
return accountService
}
// --- 미체결 ---
// PendingOrder 미체결 주문 항목
type PendingOrder struct {
OrdNo string `json:"ordNo"` // 주문번호
StkCd string `json:"stkCd"` // 종목코드
StkNm string `json:"stkNm"` // 종목명
OrdQty string `json:"ordQty"` // 주문수량
OrdPric string `json:"ordPric"` // 주문가격
OsoQty string `json:"osoQty"` // 미체결수량
IoTpNm string `json:"ioTpNm"` // 주문구분
TrdeTp string `json:"trdeTp"` // 매매구분 (1:매도, 2:매수)
Tm string `json:"tm"` // 시간
CntrPric string `json:"cntrPric"` // 체결가
CntrQty string `json:"cntrQty"` // 체결량
}
// GetPendingOrders 미체결 주문 조회 (ka10075)
func (s *AccountService) GetPendingOrders() ([]PendingOrder, error) {
body := map[string]string{
"all_stk_tp": "0", // 전체종목
"trde_tp": "0", // 전체
"stk_cd": "",
"stex_tp": "0", // 통합
}
respBody, err := s.client.post("ka10075", "/api/dostk/acnt", body)
if err != nil {
return nil, fmt.Errorf("미체결 조회 실패: %w", err)
}
var result struct {
Oso []struct {
OrdNo string `json:"ord_no"`
StkCd string `json:"stk_cd"`
StkNm string `json:"stk_nm"`
OrdQty string `json:"ord_qty"`
OrdPric string `json:"ord_pric"`
OsoQty string `json:"oso_qty"`
IoTpNm string `json:"io_tp_nm"`
TrdeTp string `json:"trde_tp"`
Tm string `json:"tm"`
CntrPric string `json:"cntr_pric"`
CntrQty string `json:"cntr_qty"`
} `json:"oso"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("미체결 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("미체결 조회 오류: %s", result.ReturnMsg)
}
orders := make([]PendingOrder, 0, len(result.Oso))
for _, o := range result.Oso {
orders = append(orders, PendingOrder{
OrdNo: o.OrdNo,
StkCd: o.StkCd,
StkNm: o.StkNm,
OrdQty: o.OrdQty,
OrdPric: o.OrdPric,
OsoQty: o.OsoQty,
IoTpNm: o.IoTpNm,
TrdeTp: o.TrdeTp,
Tm: o.Tm,
CntrPric: o.CntrPric,
CntrQty: o.CntrQty,
})
}
return orders, nil
}
// --- 체결내역 ---
// OrderHistory 체결내역 항목
type OrderHistory struct {
OrdNo string `json:"ordNo"`
StkNm string `json:"stkNm"`
IoTpNm string `json:"ioTpNm"`
OrdPric string `json:"ordPric"`
OrdQty string `json:"ordQty"`
CntrPric string `json:"cntrPric"`
CntrQty string `json:"cntrQty"`
OsoQty string `json:"osoQty"`
TrdeCmsn string `json:"trdeCmsn"`
TrdeTax string `json:"trdeTax"`
OrdStt string `json:"ordStt"`
TrdeTp string `json:"trdeTp"`
OrdTm string `json:"ordTm"`
StkCd string `json:"stkCd"`
}
// GetOrderHistory 체결내역 조회 (ka10076)
func (s *AccountService) GetOrderHistory() ([]OrderHistory, error) {
body := map[string]string{
"stk_cd": "",
"qry_tp": "0", // 전체
"sell_tp": "0", // 전체
"ord_no": "",
"stex_tp": "0", // 통합
}
respBody, err := s.client.post("ka10076", "/api/dostk/acnt", body)
if err != nil {
return nil, fmt.Errorf("체결내역 조회 실패: %w", err)
}
var result struct {
Cntr []struct {
OrdNo string `json:"ord_no"`
StkNm string `json:"stk_nm"`
IoTpNm string `json:"io_tp_nm"`
OrdPric string `json:"ord_pric"`
OrdQty string `json:"ord_qty"`
CntrPric string `json:"cntr_pric"`
CntrQty string `json:"cntr_qty"`
OsoQty string `json:"oso_qty"`
TdyTrdeCmsn string `json:"tdy_trde_cmsn"`
TdyTrdeTax string `json:"tdy_trde_tax"`
OrdStt string `json:"ord_stt"`
TrdeTp string `json:"trde_tp"`
OrdTm string `json:"ord_tm"`
StkCd string `json:"stk_cd"`
} `json:"cntr"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("체결내역 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("체결내역 조회 오류: %s", result.ReturnMsg)
}
history := make([]OrderHistory, 0, len(result.Cntr))
for _, c := range result.Cntr {
history = append(history, OrderHistory{
OrdNo: c.OrdNo,
StkNm: c.StkNm,
IoTpNm: c.IoTpNm,
OrdPric: c.OrdPric,
OrdQty: c.OrdQty,
CntrPric: c.CntrPric,
CntrQty: c.CntrQty,
OsoQty: c.OsoQty,
TrdeCmsn: c.TdyTrdeCmsn,
TrdeTax: c.TdyTrdeTax,
OrdStt: c.OrdStt,
TrdeTp: c.TrdeTp,
OrdTm: c.OrdTm,
StkCd: c.StkCd,
})
}
return history, nil
}
// --- 잔고 ---
// BalanceStock 잔고 개별 종목
type BalanceStock struct {
StkCd string `json:"stkCd"`
StkNm string `json:"stkNm"`
EvltvPrft string `json:"evltvPrft"` // 평가손익
PrftRt string `json:"prftRt"` // 수익률
PurPric string `json:"purPric"` // 매입가
RmndQty string `json:"rmndQty"` // 보유수량
TrdeAbleQty string `json:"trdeAbleQty"` // 매매가능수량
CurPrc string `json:"curPrc"` // 현재가
PurAmt string `json:"purAmt"` // 매입금액
EvltAmt string `json:"evltAmt"` // 평가금액
}
// BalanceResult 잔고 조회 결과
type BalanceResult struct {
TotPurAmt string `json:"totPurAmt"` // 총매입금액
TotEvltAmt string `json:"totEvltAmt"` // 총평가금액
TotEvltPl string `json:"totEvltPl"` // 총평가손익
TotPrftRt string `json:"totPrftRt"` // 총수익률
PrsmDpstAsetAmt string `json:"prsmDpstAsetAmt"` // 추정예탁자산
Stocks []BalanceStock `json:"stocks"`
}
// GetBalance 계좌 잔고 조회 (kt00018)
func (s *AccountService) GetBalance() (*BalanceResult, error) {
body := map[string]string{
"qry_tp": "2", // 개별
"dmst_stex_tp": "KRX",
}
respBody, err := s.client.post("kt00018", "/api/dostk/acnt", body)
if err != nil {
return nil, fmt.Errorf("잔고 조회 실패: %w", err)
}
var result struct {
TotPurAmt string `json:"tot_pur_amt"`
TotEvltAmt string `json:"tot_evlt_amt"`
TotEvltPl string `json:"tot_evlt_pl"`
TotPrftRt string `json:"tot_prft_rt"`
PrsmDpstAsetAmt string `json:"prsm_dpst_aset_amt"`
AcntEvltRemnIndvTot []struct {
StkCd string `json:"stk_cd"`
StkNm string `json:"stk_nm"`
EvltvPrft string `json:"evltv_prft"`
PrftRt string `json:"prft_rt"`
PurPric string `json:"pur_pric"`
RmndQty string `json:"rmnd_qty"`
TrdeAbleQty string `json:"trde_able_qty"`
CurPrc string `json:"cur_prc"`
PurAmt string `json:"pur_amt"`
EvltAmt string `json:"evlt_amt"`
} `json:"acnt_evlt_remn_indv_tot"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("잔고 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("잔고 조회 오류: %s", result.ReturnMsg)
}
stocks := make([]BalanceStock, 0, len(result.AcntEvltRemnIndvTot))
for _, s := range result.AcntEvltRemnIndvTot {
stocks = append(stocks, BalanceStock{
StkCd: s.StkCd,
StkNm: s.StkNm,
EvltvPrft: s.EvltvPrft,
PrftRt: s.PrftRt,
PurPric: s.PurPric,
RmndQty: s.RmndQty,
TrdeAbleQty: s.TrdeAbleQty,
CurPrc: s.CurPrc,
PurAmt: s.PurAmt,
EvltAmt: s.EvltAmt,
})
}
return &BalanceResult{
TotPurAmt: result.TotPurAmt,
TotEvltAmt: result.TotEvltAmt,
TotEvltPl: result.TotEvltPl,
TotPrftRt: result.TotPrftRt,
PrsmDpstAsetAmt: result.PrsmDpstAsetAmt,
Stocks: stocks,
}, nil
}
// --- 예수금상세 ---
// DepositResult 예수금 상세 결과
type DepositResult struct {
Entr string `json:"entr"` // 예수금
D2Entra string `json:"d2Entra"` // D+2 추정예수금
OrdAlowAmt string `json:"ordAlowAmt"` // 주문가능금액
}
// GetDepositDetail 예수금 상세 조회 (kt00001)
func (s *AccountService) GetDepositDetail() (*DepositResult, error) {
body := map[string]string{
"qry_tp": "3", // 추정조회
}
respBody, err := s.client.post("kt00001", "/api/dostk/acnt", body)
if err != nil {
return nil, fmt.Errorf("예수금 조회 실패: %w", err)
}
var result struct {
Entr string `json:"entr"`
D2Entra string `json:"d2_entra"`
OrdAlowAmt string `json:"ord_alow_amt"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("예수금 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("예수금 조회 오류: %s", result.ReturnMsg)
}
return &DepositResult{
Entr: result.Entr,
D2Entra: result.D2Entra,
OrdAlowAmt: result.OrdAlowAmt,
}, nil
}
// --- 주문가능금액 ---
// OrderableResult 주문가능금액/수량
type OrderableResult struct {
OrdAlowa string `json:"ordAlowa"` // 주문가능현금
Entr string `json:"entr"` // 예수금
OrdAlowq string `json:"ordAlowq"` // 주문가능수량 (증거금100%)
}
// GetOrderable 주문가능금액/수량 조회 (kt00010)
// 모의투자 환경에서 RC7006 오류 시 kt00001(예수금상세) 폴백
// side: "1"=매도, "2"=매수
func (s *AccountService) GetOrderable(code, price, side string) (*OrderableResult, error) {
body := map[string]string{
"stk_cd": code,
"trde_tp": side,
"uv": price,
}
respBody, err := s.client.post("kt00010", "/api/dostk/acnt", body)
if err != nil {
// 네트워크 오류 시 예수금상세(kt00001)로 폴백
return s.orderableFromDeposit()
}
var result struct {
OrdAlowa string `json:"ord_alowa"`
Entr string `json:"entr"`
Profa100OrdAlowq string `json:"profa_100ord_alowq"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("주문가능금액 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
// 모의투자 환경(RC7006) 등 kt00010 미지원 시 kt00001 폴백
return s.orderableFromDeposit()
}
return &OrderableResult{
OrdAlowa: result.OrdAlowa,
Entr: result.Entr,
OrdAlowq: result.Profa100OrdAlowq,
}, nil
}
// orderableFromDeposit kt00001(예수금상세)로 주문가능금액 조회 (kt00010 폴백)
func (s *AccountService) orderableFromDeposit() (*OrderableResult, error) {
dep, err := s.GetDepositDetail()
if err != nil {
return nil, fmt.Errorf("주문가능금액 조회 실패(폴백): %w", err)
}
return &OrderableResult{
OrdAlowa: dep.OrdAlowAmt,
Entr: dep.Entr,
OrdAlowq: "0", // kt00001은 수량 미제공
}, nil
}

View File

@@ -0,0 +1,349 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
const groqAPIURL = "https://api.groq.com/openai/v1/chat/completions"
var (
analysisSvcOnce sync.Once
analysisSvc *AnalysisService
)
// AnalysisService 공시·뉴스를 Groq LLM으로 분석하는 서비스
type AnalysisService struct {
dartSvc *DartService
newsSvc *NewsService
cache *CacheService
groqAPIKey string // Groq API 키
groqModel string // 사용할 모델명
httpClient *http.Client
}
// GetAnalysisService 싱글턴 반환
func GetAnalysisService(groqAPIKey, groqModel string) *AnalysisService {
analysisSvcOnce.Do(func() {
analysisSvc = &AnalysisService{
dartSvc: GetDartService(),
newsSvc: GetNewsService(),
cache: GetCacheService(),
groqAPIKey: groqAPIKey,
groqModel: groqModel,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
})
return analysisSvc
}
// Analyze 공시·뉴스를 Groq LLM으로 분석하여 sentiment, reason 반환
// sentiment: "호재" | "악재" | "중립" | "정보없음"
func (s *AnalysisService) Analyze(code, name string) (string, string) {
kst, _ := time.LoadLocation("Asia/Seoul")
now := time.Now().In(kst)
today := now.Format("20060102")
yesterday := now.AddDate(0, 0, -1).Format("20060102")
// 캐시 확인 (10분 TTL)
cacheKey := "analysis:" + code + today
if cached, ok := s.cache.Get(cacheKey); ok {
if pair, ok := cached.([2]string); ok {
return pair[0], pair[1]
}
}
// 공시 조회 (오늘/어제 필터)
var disclosureTitles []string
disclosures, err := s.dartSvc.GetDisclosures(code)
if err != nil {
log.Printf("분석서비스 공시 조회 실패 [%s]: %v", code, err)
} else {
for _, d := range disclosures {
if d.RceptDt == today || d.RceptDt == yesterday {
disclosureTitles = append(disclosureTitles, d.ReportNm)
}
}
}
// 뉴스 조회 (오늘/어제 필터)
var newsTitles []string
newsItems, err := s.newsSvc.GetNews(name)
if err != nil {
log.Printf("분석서비스 뉴스 조회 실패 [%s]: %v", name, err)
} else {
for _, item := range newsItems {
if isRecentDate(item.PublishedAt, today, yesterday) {
newsTitles = append(newsTitles, item.Title)
}
}
}
// 공시·뉴스 모두 없으면 API 호출 생략
if len(disclosureTitles) == 0 && len(newsTitles) == 0 {
s.cache.Set(cacheKey, [2]string{"정보없음", ""}, 10*time.Minute)
return "정보없음", ""
}
sentiment, reason := s.callGroqAPI(name, disclosureTitles, newsTitles)
s.cache.Set(cacheKey, [2]string{sentiment, reason}, 10*time.Minute)
return sentiment, reason
}
// callGroqAPI Groq API를 호출하여 호재/악재/중립 분류
func (s *AnalysisService) callGroqAPI(name string, disclosures, news []string) (string, string) {
disclosureText := "없음"
if len(disclosures) > 0 {
disclosureText = strings.Join(disclosures, "\n")
}
newsText := "없음"
if len(news) > 0 {
newsText = strings.Join(news, "\n")
}
prompt := fmt.Sprintf(
`당신은 주식 투자 전문가입니다. 아래 [%s] 종목의 공시와 뉴스를 보고`+
` 투자자 관점에서 호재/악재/중립 중 하나로 분류하고, 이유를 15자 이내로 답하세요.`+
` 반드시 JSON만 출력: {"sentiment":"호재","reason":"수주 계약 체결"}`+"\n\n"+
"[공시]\n%s\n\n[뉴스]\n%s",
name, disclosureText, newsText,
)
return s.callGroq(prompt, func(text string) (string, string) {
var result struct {
Sentiment string `json:"sentiment"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(text), &result); err != nil {
log.Printf("Groq 감성 분석 JSON 파싱 실패: %v (text=%s)", err, text)
return "중립", ""
}
switch result.Sentiment {
case "호재", "악재", "중립":
return result.Sentiment, result.Reason
default:
return "중립", ""
}
})
}
// PredictTargetPriceFromSignal 체결강도 급등 종목 시그널로 단기 목표가 추론
func (s *AnalysisService) PredictTargetPriceFromSignal(
code, name string,
currentPrice, high, low, open int64,
changeRate, cntrStr, prevCntrStr float64,
risingCount int,
sentiment, sentimentReason string,
) (int64, string) {
cacheKey := fmt.Sprintf("target:%s:%d", code, currentPrice)
if cached, ok := s.cache.Get(cacheKey); ok {
if pair, ok := cached.([2]interface{}); ok {
return pair[0].(int64), pair[1].(string)
}
}
prompt := fmt.Sprintf(
`당신은 주식 단기 매매 전문가입니다. 체결강도가 연속 상승 중인 [%s](%s) 종목을 매수했을 때 단기(당일~2일) 수익 실현 매도 목표가를 추론하세요.
목표가는 반드시 현재가(%d원)보다 높아야 합니다.
현재가: %d원 / 시가: %d원 / 고가: %d원 / 저가: %d원
등락률: %.2f%% / 체결강도: %.2f(직전 %.2f, %d회 연속 상승)
공시·뉴스 분석: %s (%s)
반드시 JSON만 출력: {"targetPrice":12500,"reason":"체결강도 급등+호재 공시"}`,
name, code,
currentPrice,
currentPrice, open, high, low,
changeRate, cntrStr, prevCntrStr, risingCount,
sentiment, sentimentReason,
)
price, reason := s.callGroq(prompt, func(text string) (string, string) {
var result struct {
TargetPrice int64 `json:"targetPrice"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(text), &result); err != nil {
log.Printf("목표가 추론 JSON 파싱 실패 [%s]: %v (text=%s)", code, err, text)
return "", ""
}
if result.TargetPrice <= currentPrice {
log.Printf("목표가 무효 [%s]: AI 반환값 %d원 ≤ 현재가 %d원", code, result.TargetPrice, currentPrice)
return "", ""
}
return fmt.Sprintf("%d", result.TargetPrice), result.Reason
})
if price == "" {
return 0, ""
}
var targetPrice int64
fmt.Sscanf(price, "%d", &targetPrice)
s.cache.Set(cacheKey, [2]interface{}{targetPrice, reason}, 5*time.Minute)
return targetPrice, reason
}
// PredictNextDayTrend 체결강도·시세·감성 데이터를 바탕으로 익일 주가 추세를 예측
// trend: "상승" | "하락" | "횡보", confidence: "높음" | "보통" | "낮음"
func (s *AnalysisService) PredictNextDayTrend(
code, name string,
currentPrice, high, low, open int64,
changeRate, cntrStr float64,
risingCount int,
sentiment, sentimentReason string,
) (trend, confidence, reason string) {
kst, _ := time.LoadLocation("Asia/Seoul")
today := time.Now().In(kst).Format("20060102")
cacheKey := fmt.Sprintf("nextday:%s:%s", code, today)
if cached, ok := s.cache.Get(cacheKey); ok {
if arr, ok := cached.([3]string); ok {
return arr[0], arr[1], arr[2]
}
}
prompt := fmt.Sprintf(
`당신은 한국 주식 단기 매매 전문가입니다. 아래 데이터를 종합하여 [%s](%s) 종목의 익일(다음 거래일) 주가 추세를 예측하세요.
현재가: %d원 / 시가: %d원 / 고가: %d원 / 저가: %d원
당일 등락률: %.2f%% / 체결강도: %.2f (%d회 연속 상승)
오늘 공시·뉴스 분석: %s (%s)
판단 기준:
- 체결강도 연속 상승·호재 공시·양봉 마감이면 "상승" 가능성
- 체결강도 100 미만·악재 공시·음봉 마감이면 "하락" 가능성
- 신호 혼재 혹은 근거 부족이면 "횡보"
- 신뢰도는 근거가 명확할수록 "높음", 애매하면 "낮음"
반드시 JSON만 출력: {"trend":"상승","confidence":"높음","reason":"체결강도 연속 급등+호재 공시"}`,
name, code,
currentPrice, open, high, low,
changeRate, cntrStr, risingCount,
sentiment, sentimentReason,
)
raw, reason := s.callGroq(prompt, func(text string) (string, string) {
var result struct {
Trend string `json:"trend"`
Confidence string `json:"confidence"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(text), &result); err != nil {
log.Printf("익일 추세 JSON 파싱 실패 [%s]: %v (text=%s)", code, err, text)
return "", ""
}
switch result.Trend {
case "상승", "하락", "횡보":
default:
result.Trend = "횡보"
}
// trend|confidence 를 첫 번째 반환값에 묶어서 전달
return result.Trend + "|" + result.Confidence, result.Reason
})
if raw == "" {
return "횡보", "", ""
}
parts := strings.SplitN(raw, "|", 2)
trend = parts[0]
if len(parts) > 1 {
confidence = parts[1]
}
s.cache.Set(cacheKey, [3]string{trend, confidence, reason}, 30*time.Minute)
return trend, confidence, reason
}
// callGroq Groq API 공통 호출 함수 (OpenAI 호환)
// parseFunc: 응답 텍스트를 파싱하여 결과 반환
func (s *AnalysisService) callGroq(prompt string, parseFunc func(string) (string, string)) (string, string) {
reqBody, _ := json.Marshal(map[string]any{
"model": s.groqModel,
"stream": false,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
})
req, err := http.NewRequest("POST", groqAPIURL, bytes.NewReader(reqBody))
if err != nil {
log.Printf("Groq API 요청 생성 실패: %v", err)
return "중립", ""
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.groqAPIKey)
resp, err := s.httpClient.Do(req)
if err != nil {
log.Printf("Groq API 호출 실패: %v", err)
return "중립", ""
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Printf("Groq API 오류 [%d]: %s", resp.StatusCode, string(body))
return "중립", ""
}
// OpenAI 호환 응답 파싱: choices[0].message.content
var apiResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
log.Printf("Groq API 응답 파싱 실패: %v", err)
return "중립", ""
}
if len(apiResp.Choices) == 0 {
log.Printf("Groq API 응답 choices 비어있음")
return "중립", ""
}
text := strings.TrimSpace(apiResp.Choices[0].Message.Content)
// JSON 블록 추출 (```json ... ``` 감싸진 경우 대비)
if idx := strings.Index(text, "{"); idx >= 0 {
if end := strings.LastIndex(text, "}"); end >= idx {
text = text[idx : end+1]
}
}
if text == "" {
log.Printf("Groq API 응답 비어있음")
return "중립", ""
}
return parseFunc(text)
}
// isRecentDate RFC1123Z 형식 날짜가 today/yesterday 포함 여부 확인
func isRecentDate(pubDate, today, yesterday string) bool {
// RFC1123Z: "Mon, 02 Jan 2006 15:04:05 -0700"
formats := []string{
time.RFC1123Z,
time.RFC1123,
}
for _, f := range formats {
t, err := time.Parse(f, pubDate)
if err == nil {
kst, _ := time.LoadLocation("Asia/Seoul")
d := t.In(kst).Format("20060102")
return d == today || d == yesterday
}
}
return false
}

View File

@@ -0,0 +1,772 @@
package services
import (
"crypto/rand"
"fmt"
"log"
"strconv"
"sync"
"sync/atomic"
"time"
"stocksearch/models"
)
// newUUID UUID v4 생성
func newUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
const (
maxLogEntries = 300 // 최대 로그 보관 건수 (debug 로그 빈도 대비)
cooldownMinutes = 5 // 동일 종목 재진입 쿨다운(분)
exitLoopSec = 5 // 청산 루프 주기(초)
entryLoopSec = 10 // 진입 루프 주기(초)
pendingCheckSec = 30 // pending 확인 주기(초)
)
// AutoTradeService 자동매매 엔진 서비스
type AutoTradeService struct {
scanner *ScannerService
orderSvc *OrderService
accountSvc *AccountService
stockSvc *StockService
themeSvc *ThemeService
mu sync.RWMutex
running int32 // atomic: 1=실행, 0=중지
rules []models.AutoTradeRule
positions map[string]*models.AutoTradePosition // code → 포지션
logs []models.AutoTradeLog // 최근 maxLogEntries건
cooldown map[string]time.Time // code → 마지막 진입 시각
watchSource models.AutoTradeWatchSource // 감시 소스 설정
logBroadcaster func(models.AutoTradeLog) // WS 브로드캐스트 콜백
}
var autoTradeService *AutoTradeService
// GetAutoTradeService 자동매매 서비스 싱글턴 반환
func GetAutoTradeService() *AutoTradeService {
if autoTradeService == nil {
autoTradeService = &AutoTradeService{
scanner: GetScannerService(),
orderSvc: GetOrderService(),
accountSvc: GetAccountService(),
stockSvc: GetStockService(),
themeSvc: GetThemeService(),
positions: make(map[string]*models.AutoTradePosition),
cooldown: make(map[string]time.Time),
watchSource: models.AutoTradeWatchSource{
UseScanner: true,
SelectedThemes: []models.ThemeRef{},
},
}
}
return autoTradeService
}
// SetLogBroadcaster WS 브로드캐스트 콜백 등록 (main.go에서 Hub 주입 시 호출)
func (s *AutoTradeService) SetLogBroadcaster(fn func(models.AutoTradeLog)) {
s.mu.Lock()
s.logBroadcaster = fn
s.mu.Unlock()
}
// GetWatchSource 현재 감시 소스 설정 반환
func (s *AutoTradeService) GetWatchSource() models.AutoTradeWatchSource {
s.mu.RLock()
defer s.mu.RUnlock()
ws := s.watchSource
themes := make([]models.ThemeRef, len(ws.SelectedThemes))
copy(themes, ws.SelectedThemes)
ws.SelectedThemes = themes
return ws
}
// SetWatchSource 감시 소스 설정 업데이트
func (s *AutoTradeService) SetWatchSource(ws models.AutoTradeWatchSource) {
s.mu.Lock()
s.watchSource = ws
s.mu.Unlock()
sources := "없음"
if ws.UseScanner && len(ws.SelectedThemes) > 0 {
names := make([]string, len(ws.SelectedThemes))
for i, t := range ws.SelectedThemes {
names[i] = t.Name
}
sources = fmt.Sprintf("자동감지+테마(%s)", joinStrings(names, ","))
} else if ws.UseScanner {
sources = "체결강도 자동감지"
} else if len(ws.SelectedThemes) > 0 {
names := make([]string, len(ws.SelectedThemes))
for i, t := range ws.SelectedThemes {
names[i] = t.Name
}
sources = fmt.Sprintf("테마(%s)", joinStrings(names, ","))
}
s.addLog("info", "", fmt.Sprintf("감시 소스 변경: %s", sources))
}
// joinStrings 문자열 슬라이스를 구분자로 결합
func joinStrings(ss []string, sep string) string {
result := ""
for i, s := range ss {
if i > 0 {
result += sep
}
result += s
}
return result
}
// getWatchSignals 설정된 감시 소스에서 신호 목록 수집
func (s *AutoTradeService) getWatchSignals() []SignalStock {
s.mu.RLock()
ws := s.watchSource
themes := make([]models.ThemeRef, len(ws.SelectedThemes))
copy(themes, ws.SelectedThemes)
s.mu.RUnlock()
var result []SignalStock
seen := make(map[string]bool)
// 체결강도 자동감지 신호 수집
if ws.UseScanner {
scannerSigs := s.scanner.GetSignals()
s.addLog("debug", "", fmt.Sprintf("스캐너 신호 수신: %d개", len(scannerSigs)))
for _, sig := range scannerSigs {
s.addLog("debug", sig.Code, fmt.Sprintf("스캐너 [%s] 현재가=%s원 체결강도=%.1f RiseScore=%d 유형=%s 등락=%.2f%%",
sig.Name, formatComma(sig.CurrentPrice), sig.CntrStr, sig.RiseScore, sig.SignalType, sig.ChangeRate))
if !seen[sig.Code] {
result = append(result, sig)
seen[sig.Code] = true
}
}
}
// 선택된 테마 종목 분석 (테마당 최대 15종목으로 제한해 API 호출량 억제)
const maxThemeStocks = 15
for _, theme := range themes {
detail, err := s.themeSvc.GetThemeStocks(theme.Code, "D")
if err != nil {
s.addLog("warn", "", fmt.Sprintf("테마 종목 조회 실패 [%s]: %v", theme.Name, err))
continue
}
codes := make([]string, 0, maxThemeStocks)
for _, st := range detail.Stocks {
if !seen[st.Code] {
codes = append(codes, st.Code)
if len(codes) >= maxThemeStocks {
break
}
}
}
if len(codes) == 0 {
s.addLog("debug", "", fmt.Sprintf("테마[%s] 분석 대상 종목 없음 (중복 제외 후)", theme.Name))
continue
}
s.addLog("debug", "", fmt.Sprintf("테마[%s] %d종목 분석 시작", theme.Name, len(codes)))
sigs := s.scanner.AnalyzeWatchlist(codes)
s.addLog("debug", "", fmt.Sprintf("테마[%s] 분석 완료: %d개 신호", theme.Name, len(sigs)))
for _, sig := range sigs {
s.addLog("debug", sig.Code, fmt.Sprintf("테마검증 [%s] 현재가=%s원 체결강도=%.1f RiseScore=%d 유형=%s 등락=%.2f%%",
sig.Name, formatComma(sig.CurrentPrice), sig.CntrStr, sig.RiseScore, sig.SignalType, sig.ChangeRate))
if !seen[sig.Code] {
result = append(result, sig)
seen[sig.Code] = true
}
}
}
return result
}
// Start 자동매매 엔진 시작
func (s *AutoTradeService) Start() {
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
return // 이미 실행 중
}
s.addLog("info", "", "자동매매 엔진 시작")
go s.entryLoop()
go s.exitLoop()
}
// Stop 자동매매 엔진 중지
func (s *AutoTradeService) Stop() {
if atomic.CompareAndSwapInt32(&s.running, 1, 0) {
s.addLog("info", "", "자동매매 엔진 중지")
}
}
// IsRunning 엔진 실행 여부 확인
func (s *AutoTradeService) IsRunning() bool {
return atomic.LoadInt32(&s.running) == 1
}
// EmergencyStop 긴급 청산: 엔진 중지 후 모든 포지션 시장가 매도
func (s *AutoTradeService) EmergencyStop() {
s.Stop()
s.mu.Lock()
codes := make([]string, 0, len(s.positions))
for code, p := range s.positions {
if p.Status == "open" || p.Status == "pending" {
codes = append(codes, code)
}
}
s.mu.Unlock()
for _, code := range codes {
s.mu.RLock()
pos, ok := s.positions[code]
s.mu.RUnlock()
if !ok {
continue
}
if err := s.executeSell(pos, "긴급"); err != nil {
s.addLog("error", code, fmt.Sprintf("긴급청산 실패: %v", err))
}
}
s.addLog("warn", "", "긴급 전량청산 완료")
}
// --- CRUD ---
// AddRule 규칙 추가
func (s *AutoTradeService) AddRule(rule models.AutoTradeRule) models.AutoTradeRule {
rule.ID = newUUID()
rule.CreatedAt = time.Now()
s.mu.Lock()
s.rules = append(s.rules, rule)
s.mu.Unlock()
s.addLog("info", "", fmt.Sprintf("규칙 추가: %s", rule.Name))
return rule
}
// UpdateRule 규칙 수정
func (s *AutoTradeService) UpdateRule(id string, updated models.AutoTradeRule) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.rules {
if r.ID == id {
updated.ID = id
updated.CreatedAt = r.CreatedAt
s.rules[i] = updated
return true
}
}
return false
}
// DeleteRule 규칙 삭제
func (s *AutoTradeService) DeleteRule(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.rules {
if r.ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
return true
}
}
return false
}
// ToggleRule 규칙 활성화/비활성화 토글
func (s *AutoTradeService) ToggleRule(id string) (bool, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.rules {
if r.ID == id {
s.rules[i].Enabled = !r.Enabled
return true, s.rules[i].Enabled
}
}
return false, false
}
// GetRules 규칙 목록 반환
func (s *AutoTradeService) GetRules() []models.AutoTradeRule {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.AutoTradeRule, len(s.rules))
copy(result, s.rules)
return result
}
// GetPositions 포지션 목록 반환
func (s *AutoTradeService) GetPositions() []*models.AutoTradePosition {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*models.AutoTradePosition, 0, len(s.positions))
for _, p := range s.positions {
cp := *p
result = append(result, &cp)
}
return result
}
// GetLogs 최근 로그 반환
func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.AutoTradeLog, len(s.logs))
copy(result, s.logs)
return result
}
// GetStats 오늘 통계 반환 (매매 횟수, 손익)
func (s *AutoTradeService) GetStats() (tradeCount int, totalPL int64) {
today := time.Now().Truncate(24 * time.Hour)
s.mu.RLock()
defer s.mu.RUnlock()
for _, p := range s.positions {
if p.Status == "closed" && !p.ExitTime.Before(today) {
tradeCount++
totalPL += (p.ExitPrice - p.BuyPrice) * p.Qty
}
}
return
}
// --- 내부 고루틴 ---
// entryLoop 10초 주기 진입 루프
func (s *AutoTradeService) entryLoop() {
ticker := time.NewTicker(entryLoopSec * time.Second)
defer ticker.Stop()
for {
if atomic.LoadInt32(&s.running) == 0 {
return
}
<-ticker.C
if atomic.LoadInt32(&s.running) == 0 {
return
}
s.checkEntries()
}
}
// exitLoop 5초 주기 청산 루프
func (s *AutoTradeService) exitLoop() {
ticker := time.NewTicker(exitLoopSec * time.Second)
defer ticker.Stop()
pendingTicker := time.NewTicker(pendingCheckSec * time.Second)
defer pendingTicker.Stop()
for {
if atomic.LoadInt32(&s.running) == 0 {
return
}
select {
case <-ticker.C:
if atomic.LoadInt32(&s.running) == 0 {
return
}
s.checkExits()
case <-pendingTicker.C:
if atomic.LoadInt32(&s.running) == 0 {
return
}
s.checkPending()
}
}
}
// checkEntries 진입 조건 체크 및 매수 주문
func (s *AutoTradeService) checkEntries() {
signals := s.getWatchSignals()
s.mu.RLock()
rules := make([]models.AutoTradeRule, len(s.rules))
copy(rules, s.rules)
s.mu.RUnlock()
// 활성 규칙 수 계산
activeRules := 0
for _, r := range rules {
if r.Enabled {
activeRules++
}
}
s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules))
if len(signals) == 0 {
return
}
for _, rule := range rules {
if !rule.Enabled {
continue
}
for _, sig := range signals {
code := sig.Code
if code == "" {
continue
}
s.mu.RLock()
_, alreadyHeld := s.positions[code]
lastEntry, hasCooldown := s.cooldown[code]
posCount := s.countActivePositions()
s.mu.RUnlock()
// 신호 검토 로그
sentimentStr := sig.Sentiment
if sentimentStr == "" {
sentimentStr = "중립"
}
s.addLog("debug", code, fmt.Sprintf("검토 [%s] RiseScore=%d 체결강도=%.1f 감정=%s 유형=%s",
sig.Name, sig.RiseScore, sig.CntrStr, sentimentStr, sig.SignalType))
// 이미 보유 중인 종목 스킵
if alreadyHeld {
s.addLog("debug", code, "스킵: 이미 보유 중")
continue
}
// 쿨다운 체크 (5분)
if hasCooldown && time.Since(lastEntry) < cooldownMinutes*time.Minute {
remaining := cooldownMinutes*time.Minute - time.Since(lastEntry)
s.addLog("debug", code, fmt.Sprintf("스킵: 쿨다운 %.1f분 남음", remaining.Minutes()))
continue
}
// 최대 보유 종목 수 초과 스킵
if posCount >= rule.MaxPositions {
s.addLog("debug", code, fmt.Sprintf("스킵: 최대 포지션 초과 (%d/%d)", posCount, rule.MaxPositions))
continue
}
// 진입 조건 체크
if sig.RiseScore < rule.MinRiseScore {
s.addLog("debug", code, fmt.Sprintf("스킵: RiseScore 미달 (%d < %d)", sig.RiseScore, rule.MinRiseScore))
continue
}
if sig.CntrStr < rule.MinCntrStr {
s.addLog("debug", code, fmt.Sprintf("스킵: 체결강도 미달 (%.1f < %.1f)", sig.CntrStr, rule.MinCntrStr))
continue
}
if rule.RequireBullish && sig.Sentiment != "호재" {
s.addLog("debug", code, fmt.Sprintf("스킵: AI 호재 없음 (%s)", sentimentStr))
continue
}
// 주문가능금액 확인
curPriceStr := strconv.FormatInt(sig.CurrentPrice, 10)
orderable, err := s.accountSvc.GetOrderable(code, curPriceStr, "2")
if err != nil {
s.addLog("warn", code, fmt.Sprintf("주문가능금액 조회 실패: %v", err))
continue
}
avail, _ := strconv.ParseInt(orderable.OrdAlowa, 10, 64)
if avail < rule.OrderAmount {
s.addLog("warn", code, fmt.Sprintf("주문가능금액 부족: 필요 %d원, 가용 %d원", rule.OrderAmount, avail))
continue
}
// 주문 수량 계산
if sig.CurrentPrice <= 0 {
continue
}
qty := rule.OrderAmount / sig.CurrentPrice
if qty <= 0 {
s.addLog("warn", code, fmt.Sprintf("주문수량 계산 오류: 주문금액 %d원, 현재가 %d원", rule.OrderAmount, sig.CurrentPrice))
continue
}
// 매수 주문 실행 (시장가)
result, err := s.orderSvc.Buy(OrderRequest{
Exchange: "KRX",
Code: code,
Qty: strconv.FormatInt(qty, 10),
Price: "",
TradeTP: "3", // 시장가
})
if err != nil {
s.addLog("error", code, fmt.Sprintf("매수주문 실패: %v", err))
continue
}
// 포지션 등록
pos := &models.AutoTradePosition{
Code: code,
Name: sig.Name,
Qty: qty,
OrderNo: result.OrderNo,
EntryTime: time.Now(),
RuleID: rule.ID,
Status: "pending",
}
s.mu.Lock()
s.positions[code] = pos
s.cooldown[code] = time.Now()
s.mu.Unlock()
s.addLog("info", code, fmt.Sprintf("매수 주문 접수: %s %d주 (주문번호: %s, RiseScore: %d)", sig.Name, qty, result.OrderNo, sig.RiseScore))
}
}
}
// checkExits 청산 조건 체크
func (s *AutoTradeService) checkExits() {
s.mu.RLock()
codes := make([]string, 0, len(s.positions))
for code, p := range s.positions {
if p.Status == "open" {
codes = append(codes, code)
}
}
s.mu.RUnlock()
now := time.Now()
// 장 마감 전 청산 기준: KST 15:20
loc, _ := time.LoadLocation("Asia/Seoul")
kstNow := now.In(loc)
closeTime := time.Date(kstNow.Year(), kstNow.Month(), kstNow.Day(), 15, 20, 0, 0, loc)
for _, code := range codes {
s.mu.RLock()
pos, ok := s.positions[code]
if !ok || pos.Status != "open" {
s.mu.RUnlock()
continue
}
// 포지션 복사
posCopy := *pos
s.mu.RUnlock()
// 현재가 조회
price, err := s.stockSvc.GetCurrentPrice(code)
if err != nil {
log.Printf("[자동매매] 현재가 조회 실패 [%s]: %v", code, err)
continue
}
curPrice := price.CurrentPrice
// 포지션 모니터링 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)))
}
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 = "시간초과"
}
if reason != "" {
if err := s.executeSell(&posCopy, reason); err != nil {
s.addLog("error", code, fmt.Sprintf("%s 매도 실패: %v", reason, err))
}
}
}
}
// checkPending pending 포지션 체결 확인
func (s *AutoTradeService) checkPending() {
s.mu.RLock()
pending := make([]*models.AutoTradePosition, 0)
for _, p := range s.positions {
if p.Status == "pending" {
cp := *p
pending = append(pending, &cp)
}
}
s.mu.RUnlock()
if len(pending) == 0 {
return
}
balance, err := s.accountSvc.GetBalance()
if err != nil {
log.Printf("[자동매매] 잔고 조회 실패: %v", err)
return
}
for _, pos := range pending {
for _, stock := range balance.Stocks {
if stock.StkCd == pos.Code {
buyPrice, _ := strconv.ParseInt(stock.PurPric, 10, 64)
qty, _ := strconv.ParseInt(stock.RmndQty, 10, 64)
if qty <= 0 {
continue
}
// 청산가 계산
rule := s.findRule(pos.RuleID)
var stopLoss, takeProfit int64
if rule != nil && buyPrice > 0 {
stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100))
takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100))
}
s.mu.Lock()
if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" {
p.BuyPrice = buyPrice
p.Qty = qty
p.StopLoss = stopLoss
p.TakeProfit = takeProfit
p.Status = "open"
// ExitBeforeClose 규칙 값 저장
if rule != nil {
// 포지션 자체에 규칙 정보가 없으므로 로그만 기록
}
}
s.mu.Unlock()
s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)", pos.Name, qty, buyPrice, stopLoss, takeProfit))
break
}
}
}
}
// executeSell 매도 주문 실행 및 포지션 업데이트
func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason string) error {
if pos.Qty <= 0 {
return fmt.Errorf("매도 수량 없음")
}
result, err := s.orderSvc.Sell(OrderRequest{
Exchange: "KRX",
Code: pos.Code,
Qty: strconv.FormatInt(pos.Qty, 10),
Price: "",
TradeTP: "3", // 시장가
})
if err != nil {
return err
}
// 현재가 조회 (청산가 근사치)
exitPrice := pos.BuyPrice
if p, err := s.stockSvc.GetCurrentPrice(pos.Code); err == nil {
exitPrice = p.CurrentPrice
}
s.mu.Lock()
if p, ok := s.positions[pos.Code]; ok {
p.Status = "closed"
p.ExitTime = time.Now()
p.ExitPrice = exitPrice
p.ExitReason = reason
}
s.mu.Unlock()
pl := (exitPrice - pos.BuyPrice) * pos.Qty
s.addLog("info", pos.Code, fmt.Sprintf("%s 매도 완료: %s @ %d원 (손익: %+d원, 주문번호: %s)", reason, pos.Name, exitPrice, pl, result.OrderNo))
return nil
}
// countActivePositions pending+open 포지션 수
func (s *AutoTradeService) countActivePositions() int {
count := 0
for _, p := range s.positions {
if p.Status == "pending" || p.Status == "open" {
count++
}
}
return count
}
// findRule ID로 규칙 조회
func (s *AutoTradeService) findRule(id string) *models.AutoTradeRule {
for _, r := range s.rules {
if r.ID == id {
cp := r
return &cp
}
}
return nil
}
// addLog 로그 추가 (최대 maxLogEntries 유지) + WS 브로드캐스트
func (s *AutoTradeService) addLog(level, code, message string) {
entry := models.AutoTradeLog{
At: time.Now(),
Level: level,
Code: code,
Message: message,
}
log.Printf("[자동매매][%s] %s %s", level, code, message)
s.mu.Lock()
s.logs = append([]models.AutoTradeLog{entry}, s.logs...)
if len(s.logs) > maxLogEntries {
s.logs = s.logs[:maxLogEntries]
}
broadcaster := s.logBroadcaster
s.mu.Unlock()
// WS 브로드캐스트 (락 밖에서 호출해 데드락 방지)
if broadcaster != nil {
broadcaster(entry)
}
}
// formatComma 천 단위 콤마 포맷 (서비스 내부용)
func formatComma(n int64) string {
if n == 0 {
return "0"
}
negative := n < 0
if negative {
n = -n
}
s := strconv.FormatInt(n, 10)
result := make([]byte, 0, len(s)+len(s)/3)
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
if negative {
return "-" + string(result)
}
return string(result)
}
// --- 포지션 헬퍼 함수 (규칙 정보 조회용) ---
// exitBeforeCloseRule 포지션이 속한 규칙의 ExitBeforeClose 반환
func exitBeforeCloseRule(rules []models.AutoTradeRule, p *models.AutoTradePosition) bool {
for _, r := range rules {
if r.ID == p.RuleID {
return r.ExitBeforeClose
}
}
return false
}
// maxHoldExpired 최대 보유 시간 초과 여부 반환
func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, now time.Time) bool {
for _, r := range rules {
if r.ID == p.RuleID {
if r.MaxHoldMinutes <= 0 {
return false
}
return now.Sub(p.EntryTime) >= time.Duration(r.MaxHoldMinutes)*time.Minute
}
}
return false
}

68
services/cache_service.go Normal file
View File

@@ -0,0 +1,68 @@
package services
import (
"sync"
"time"
)
// cacheItem 캐시 항목
type cacheItem struct {
value interface{}
expiresAt time.Time
}
// CacheService 인메모리 TTL 캐시
type CacheService struct {
mu sync.RWMutex
items map[string]*cacheItem
}
var cacheSvc *CacheService
var cacheOnce sync.Once
// GetCacheService 캐시 서비스 싱글턴 반환
func GetCacheService() *CacheService {
cacheOnce.Do(func() {
c := &CacheService{items: make(map[string]*cacheItem)}
go c.cleanup() // 만료 항목 주기적 정리
cacheSvc = c
})
return cacheSvc
}
// Set 캐시에 값 저장 (ttl: 유효기간)
func (c *CacheService) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = &cacheItem{
value: value,
expiresAt: time.Now().Add(ttl),
}
}
// Get 캐시에서 값 조회. 없거나 만료됐으면 nil, false 반환
func (c *CacheService) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.items[key]
if !ok || time.Now().After(item.expiresAt) {
return nil, false
}
return item.value, true
}
// cleanup 만료된 캐시 항목을 주기적으로 삭제 (메모리 누수 방지)
func (c *CacheService) cleanup() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}

223
services/dart_service.go Normal file
View File

@@ -0,0 +1,223 @@
package services
import (
"crypto/tls"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"os"
"stocksearch/config"
"stocksearch/models"
"strings"
"sync"
"time"
)
// normalizeStockCode 종목코드에서 6자리 숫자만 추출 (예: "018880_AL" → "018880")
func normalizeStockCode(code string) string {
if idx := strings.Index(code, "_"); idx >= 0 {
return code[:idx]
}
return code
}
var (
dartSvcOnce sync.Once
dartSvc *DartService
)
// DartService DART 공시 조회 서비스
type DartService struct {
httpClient *http.Client
cache *CacheService
apiKey string
corpCodeMap map[string]string // stock_code → corp_code 인메모리 맵
corpCodeMu sync.RWMutex
}
// GetDartService 싱글턴 반환
func GetDartService() *DartService {
dartSvcOnce.Do(func() {
// opendart.fss.or.kr는 구형 TLS 암호화 스위트를 사용하므로 최소 버전을 TLS 1.0으로 설정
// Go 1.22+에서 RSA 키 교환 암호 스위트가 기본 제거됨
// opendart.fss.or.kr은 TLS_RSA_WITH_AES_128_GCM_SHA256을 사용하므로 명시적으로 추가
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
CipherSuites: []uint16{
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
},
},
}
dartSvc = &DartService{
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
cache: GetCacheService(),
apiKey: config.App.DartAPIKey,
corpCodeMap: make(map[string]string),
}
// 서버 시작 시 기업코드 맵 로딩
if err := dartSvc.loadCorpCodeMap(); err != nil {
log.Printf("DART 기업코드 맵 로딩 실패: %v", err)
}
})
return dartSvc
}
// loadCorpCodeMap 로컬 CORPCODE.xml 파일을 파싱하여 stock_code → corp_code 맵 구성
func (s *DartService) loadCorpCodeMap() error {
f, err := os.Open("CORPCODE.xml")
if err != nil {
return fmt.Errorf("CORPCODE.xml 열기 실패: %w", err)
}
defer f.Close()
type corpItem struct {
CorpCode string `xml:"corp_code"`
StockCode string `xml:"stock_code"`
}
type corpResult struct {
List []corpItem `xml:"list"`
}
var result corpResult
if err := xml.NewDecoder(f).Decode(&result); err != nil {
return fmt.Errorf("CORPCODE.xml 디코딩 실패: %w", err)
}
s.corpCodeMu.Lock()
for _, item := range result.List {
sc := strings.TrimSpace(item.StockCode)
cc := strings.TrimSpace(item.CorpCode)
if sc != "" && cc != "" {
s.corpCodeMap[sc] = cc
}
}
s.corpCodeMu.Unlock()
log.Printf("DART 기업코드 맵 로딩 완료: 총 %d건 (상장사 %d건)", len(result.List), len(s.corpCodeMap))
return nil
}
// GetDisclosures 종목코드로 최근 공시 10건 반환 (5분 캐시)
// DART에 등록되지 않은 종목(ETF, 신규상장 등)은 빈 목록 반환
func (s *DartService) GetDisclosures(stockCode string) ([]models.Disclosure, error) {
code := normalizeStockCode(stockCode)
cacheKey := "disclosure:" + code
if cached, ok := s.cache.Get(cacheKey); ok {
return cached.([]models.Disclosure), nil
}
corpCode, err := s.getCorpCode(code)
if err != nil {
// corp_code 없음은 정상 케이스 (ETF, 신규상장 등) - 빈 목록 반환
s.cache.Set(cacheKey, []models.Disclosure{}, 30*time.Minute)
return []models.Disclosure{}, nil
}
list, err := s.fetchList(corpCode)
if err != nil {
return nil, err
}
s.cache.Set(cacheKey, list, 5*time.Minute)
return list, nil
}
// getCorpCode 종목코드로 DART 고유번호 반환 (인메모리 맵 조회)
func (s *DartService) getCorpCode(stockCode string) (string, error) {
s.corpCodeMu.RLock()
corpCode, ok := s.corpCodeMap[stockCode]
s.corpCodeMu.RUnlock()
if ok {
return corpCode, nil
}
return "", fmt.Errorf("corp_code 없음 (종목코드=%s, 맵 크기=%d)", stockCode, len(s.corpCodeMap))
}
// fetchList DART 고유번호로 최근 공시 목록 조회
func (s *DartService) fetchList(corpCode string) ([]models.Disclosure, error) {
listURL := "https://opendart.fss.or.kr/api/list.json" +
"?crtfc_key=" + s.apiKey +
"&corp_code=" + corpCode +
"&page_no=1&page_count=10&sort=date&sort_mth=desc"
resp, err := s.httpClient.Get(listURL)
if err != nil {
return nil, fmt.Errorf("DART list API 요청 실패: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result struct {
Status string `json:"status"`
Message string `json:"message"`
List []struct {
RceptNo string `json:"rcept_no"`
CorpName string `json:"corp_name"`
ReportNm string `json:"report_nm"`
RceptDt string `json:"rcept_dt"`
FlrNm string `json:"flr_nm"`
} `json:"list"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("DART list API 파싱 실패: %w", err)
}
if result.Status != "000" && result.Status != "013" {
// 013: 조회된 데이터가 없음 (정상 케이스)
return nil, fmt.Errorf("DART list API 오류: %s", result.Message)
}
list := make([]models.Disclosure, 0, len(result.List))
for _, item := range result.List {
list = append(list, models.Disclosure{
RceptNo: item.RceptNo,
CorpName: item.CorpName,
ReportNm: item.ReportNm,
RceptDt: item.RceptDt,
FlrNm: item.FlrNm,
URL: "https://dart.fss.or.kr/dsaf001/main.do?rcpNo=" + item.RceptNo,
Tag: tagDisclosure(item.ReportNm),
})
}
return list, nil
}
// tagDisclosure 보고서명 키워드로 이벤트 유형 태깅
func tagDisclosure(reportNm string) string {
type rule struct {
keywords []string
tag string
}
// 우선순위 순서로 검사 (위에서 먼저 매칭되면 반환)
rules := []rule{
{[]string{"사업보고서", "분기보고서", "반기보고서"}, "실적"},
{[]string{"유상증자"}, "유증"},
{[]string{"무상증자"}, "무증"},
{[]string{"단일판매", "공급계약", "수주"}, "수주"},
{[]string{"소송", "판결", "가처분"}, "소송"},
{[]string{"합병", "인수", "양수도", "M&A"}, "M&A"},
{[]string{"최대주주", "주요주주", "지분"}, "지분"},
{[]string{"자기주식"}, "자사주"},
{[]string{"임원", "이사회", "대표이사"}, "경영"},
{[]string{"전환사채", "신주인수권"}, "CB/BW"},
}
for _, r := range rules {
for _, kw := range r.keywords {
if strings.Contains(reportNm, kw) {
return r.tag
}
}
}
return "공시"
}

208
services/index_service.go Normal file
View File

@@ -0,0 +1,208 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// IndexQuote 지수 현재가 정보
type IndexQuote struct {
Name string `json:"name"`
Price float64 `json:"price"`
Change float64 `json:"change"` // 전일대비
ChangeRate float64 `json:"changeRate"` // 등락률(%)
}
// indexCache 지수 캐시 (5초 TTL)
type indexCache struct {
data []IndexQuote
expiresAt time.Time
}
var (
indexSvcOnce sync.Once
indexSvc *IndexService
)
// IndexService 국내/해외 주요 지수 조회 서비스
type IndexService struct {
kiwoom *KiwoomClient
httpClient *http.Client
mu sync.Mutex
cache *indexCache
}
// GetIndexService 싱글턴 반환
func GetIndexService() *IndexService {
indexSvcOnce.Do(func() {
indexSvc = &IndexService{
kiwoom: GetKiwoomClient(),
httpClient: &http.Client{Timeout: 5 * time.Second},
}
})
return indexSvc
}
// GetIndices 코스피·코스닥·다우·나스닥 현재가 반환 (5초 캐시)
func (s *IndexService) GetIndices() ([]IndexQuote, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cache != nil && time.Now().Before(s.cache.expiresAt) {
return s.cache.data, nil
}
quotes := make([]IndexQuote, 0, 4)
// 국내 지수: 코스피(001), 코스닥(101)
domestic := []struct {
name string
mrktTp string
indsCd string
}{
{"KOSPI", "0", "001"},
{"KOSDAQ", "1", "101"},
}
for _, d := range domestic {
q, err := s.fetchKiwoomIndex(d.name, d.mrktTp, d.indsCd)
if err != nil {
log.Printf("지수 조회 실패 (%s): %v", d.name, err)
quotes = append(quotes, IndexQuote{Name: d.name})
continue
}
quotes = append(quotes, q)
}
// 해외 지수: 다우(^DJI), 나스닥(^IXIC)
overseas, err := s.fetchYahooIndices()
if err != nil {
log.Printf("해외 지수 조회 실패: %v", err)
quotes = append(quotes, IndexQuote{Name: "DOW"}, IndexQuote{Name: "NASDAQ"})
} else {
quotes = append(quotes, overseas...)
}
s.cache = &indexCache{data: quotes, expiresAt: time.Now().Add(5 * time.Second)}
return quotes, nil
}
// fetchKiwoomIndex 키움 ka20001로 업종 현재가 조회
func (s *IndexService) fetchKiwoomIndex(name, mrktTp, indsCd string) (IndexQuote, error) {
body := map[string]string{
"mrkt_tp": mrktTp,
"inds_cd": indsCd,
}
raw, err := s.kiwoom.post("ka20001", "/api/dostk/sect", body)
if err != nil {
return IndexQuote{Name: name}, err
}
var resp struct {
CurPrc string `json:"cur_prc"`
PredPre string `json:"pred_pre"`
FluRt string `json:"flu_rt"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return IndexQuote{Name: name}, fmt.Errorf("파싱 실패: %w", err)
}
return IndexQuote{
Name: name,
Price: parseIndexFloat(resp.CurPrc),
Change: parseIndexFloat(resp.PredPre),
ChangeRate: parseIndexFloat(resp.FluRt),
}, nil
}
// fetchYahooIndices Yahoo Finance v8 chart API로 다우·나스닥 조회 (순서 보장)
func (s *IndexService) fetchYahooIndices() ([]IndexQuote, error) {
targets := []struct {
name string
symbol string
}{
{"DOW", "^DJI"},
{"NASDAQ", "^IXIC"},
}
quotes := make([]IndexQuote, 0, len(targets))
for _, t := range targets {
q, err := s.fetchYahooChart(t.name, t.symbol)
if err != nil {
log.Printf("Yahoo Finance %s 조회 실패: %v", t.name, err)
quotes = append(quotes, IndexQuote{Name: t.name}) // 실패 시 0값 플레이스홀더
continue
}
quotes = append(quotes, q)
}
return quotes, nil
}
// fetchYahooChart Yahoo Finance v8 chart API로 단일 지수 조회
func (s *IndexService) fetchYahooChart(name, symbol string) (IndexQuote, error) {
url := "https://query1.finance.yahoo.com/v8/finance/chart/" + symbol + "?interval=1d&range=1d"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := s.httpClient.Do(req)
if err != nil {
return IndexQuote{Name: name}, fmt.Errorf("%s 요청 실패: %w", name, err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result struct {
Chart struct {
Result []struct {
Meta struct {
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
} `json:"meta"`
} `json:"result"`
} `json:"chart"`
}
if err := json.Unmarshal(data, &result); err != nil {
return IndexQuote{Name: name}, fmt.Errorf("%s 파싱 실패: %w", name, err)
}
if len(result.Chart.Result) == 0 {
return IndexQuote{Name: name}, fmt.Errorf("%s 데이터 없음", name)
}
meta := result.Chart.Result[0].Meta
price := meta.RegularMarketPrice
prev := meta.ChartPreviousClose
change := price - prev
var changeRate float64
if prev != 0 {
changeRate = (change / prev) * 100
}
return IndexQuote{
Name: name,
Price: price,
Change: change,
ChangeRate: changeRate,
}, nil
}
// parseIndexFloat 키움 지수 문자열 → float64 (부호 포함)
func parseIndexFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
neg := strings.HasPrefix(s, "-")
s = strings.TrimLeft(s, "+-")
s = strings.ReplaceAll(s, ",", "")
f, _ := strconv.ParseFloat(s, 64)
if neg {
return -f
}
return f
}

575
services/kiwoom_service.go Normal file
View File

@@ -0,0 +1,575 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"stocksearch/config"
"stocksearch/models"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// cntrStrCacheEntry getCntrStr 캐시 항목
type cntrStrCacheEntry struct {
value float64
expiresAt time.Time
}
// KiwoomClient 키움증권 REST API HTTP 클라이언트
type KiwoomClient struct {
httpClient *http.Client
tokenService *TokenService
limiter *rate.Limiter
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
}
var kiwoomClient *KiwoomClient
// GetKiwoomClient 키움 클라이언트 싱글턴 반환
func GetKiwoomClient() *KiwoomClient {
if kiwoomClient == nil {
kiwoomClient = &KiwoomClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
tokenService: GetTokenService(),
// 초당 1건, 버스트 1 → 완전 직렬화 (키움 API 실질 한도 ~1req/s per API ID)
limiter: rate.NewLimiter(rate.Limit(1), 1),
}
}
return kiwoomClient
}
// post 공통 POST 요청 (api-id 헤더, JSON body, Rate Limit 적용, 429 재시도)
func (k *KiwoomClient) post(apiID string, path string, body map[string]string) ([]byte, error) {
const maxRetries = 3
backoff := 1 * time.Second
data, _ := json.Marshal(body)
for attempt := 0; attempt < maxRetries; attempt++ {
if err := k.limiter.Wait(context.Background()); err != nil {
return nil, fmt.Errorf("Rate Limit 대기 실패: %w", err)
}
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("요청 생성 실패: %w", err)
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
req.Header.Set("authorization", "Bearer "+k.tokenService.GetToken())
req.Header.Set("api-id", apiID)
req.Header.Set("cont-yn", "N")
req.Header.Set("next-key", "")
resp, err := k.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("API 요청 실패: %w", err)
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("응답 읽기 실패: %w", err)
}
// 429: 잠시 대기 후 재시도
if resp.StatusCode == http.StatusTooManyRequests {
if attempt < maxRetries-1 {
log.Printf("[키움API] 429 Too Many Requests (api-id=%s), %v 후 재시도 (%d/%d)", apiID, backoff, attempt+1, maxRetries)
time.Sleep(backoff)
backoff *= 2
continue
}
return nil, fmt.Errorf("API 요청 한도 초과 (api-id=%s): %s", apiID, string(respBody))
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API 응답 오류: HTTP %d, body: %s", resp.StatusCode, string(respBody))
}
// HTML 응답 감지 (서버 점검/리다이렉트 시 HTML 반환)
if len(respBody) > 0 && respBody[0] == '<' {
return nil, fmt.Errorf("API 서버 점검 중 (HTML 응답 수신)")
}
return respBody, nil
}
return nil, fmt.Errorf("API 요청 최대 재시도 초과 (api-id=%s)", apiID)
}
// postPaged 연속조회 지원 POST 요청 - 응답 헤더(cont-yn, next-key) 함께 반환
func (k *KiwoomClient) postPaged(apiID, path string, body map[string]string, contYn, nextKey string) ([]byte, string, string, error) {
if err := k.limiter.Wait(context.Background()); err != nil {
return nil, "", "", fmt.Errorf("Rate Limit 대기 실패: %w", err)
}
data, _ := json.Marshal(body)
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
if err != nil {
return nil, "", "", fmt.Errorf("요청 생성 실패: %w", err)
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
req.Header.Set("authorization", "Bearer "+k.tokenService.GetToken())
req.Header.Set("api-id", apiID)
req.Header.Set("cont-yn", contYn)
req.Header.Set("next-key", nextKey)
resp, err := k.httpClient.Do(req)
if err != nil {
return nil, "", "", fmt.Errorf("API 요청 실패: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", "", fmt.Errorf("응답 읽기 실패: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, "", "", fmt.Errorf("API 오류: HTTP %d", resp.StatusCode)
}
return respBody, resp.Header.Get("cont-yn"), resp.Header.Get("next-key"), nil
}
// GetCurrentPrice 주식 기본정보 + 체결강도 조회 (ka10001 + ka10003)
// NXT 거래소 데이터를 우선 조회하고, NXT에 없으면 KRX로 폴백
func (k *KiwoomClient) GetCurrentPrice(stockCode string) (*models.StockPrice, error) {
// 이미 거래소 접미사(_NX, _AL)가 붙어있으면 그대로 조회
if strings.Contains(stockCode, "_") {
return k.fetchPrice(stockCode)
}
// NXT 우선 시도 → 실패하거나 종목명이 비어있으면 KRX 폴백
if price, err := k.fetchPrice(stockCode + "_NX"); err == nil && price.Name != "" {
log.Printf("NXT 가격 사용: %s → %d원", stockCode, price.CurrentPrice)
return price, nil
}
return k.fetchPrice(stockCode)
}
// fetchPrice ka10001로 특정 거래소 종목코드의 현재가 조회
func (k *KiwoomClient) fetchPrice(stkCd string) (*models.StockPrice, error) {
// 브라우저 표시용 종목코드는 거래소 접미사 제거 (005930_NX → 005930)
displayCode := strings.SplitN(stkCd, "_", 2)[0]
body, err := k.post("ka10001", "/api/dostk/stkinfo", map[string]string{
"stk_cd": stkCd,
})
if err != nil {
return nil, err
}
var result struct {
StkNm string `json:"stk_nm"`
CurPrc string `json:"cur_prc"`
PredPre string `json:"pred_pre"`
FluRt string `json:"flu_rt"`
TrdeQty string `json:"trde_qty"`
OpenPric string `json:"open_pric"`
HighPric string `json:"high_pric"`
LowPric string `json:"low_pric"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("현재가 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("현재가 조회 실패: %s", result.ReturnMsg)
}
price := &models.StockPrice{
Code: displayCode,
Name: result.StkNm,
CurrentPrice: absParseIntSafe(result.CurPrc),
ChangePrice: parseIntSafe(result.PredPre),
ChangeRate: parseFloatSafe(result.FluRt),
Volume: absParseIntSafe(result.TrdeQty),
High: absParseIntSafe(result.HighPric),
Low: absParseIntSafe(result.LowPric),
Open: absParseIntSafe(result.OpenPric),
UpdatedAt: time.Now(),
}
// ka10003으로 체결강도 조회 (실패해도 나머지 데이터 반환)
if cntrStr, err := k.getCntrStr(stkCd); err == nil {
price.CntrStr = cntrStr
}
return price, nil
}
// getCntrStr 체결정보에서 최신 체결강도 조회 (ka10003, 5초 캐시)
func (k *KiwoomClient) getCntrStr(stockCode string) (float64, error) {
// 캐시 확인
if v, ok := k.cntrStrCache.Load(stockCode); ok {
entry := v.(cntrStrCacheEntry)
if time.Now().Before(entry.expiresAt) {
return entry.value, nil
}
}
body, err := k.post("ka10003", "/api/dostk/stkinfo", map[string]string{
"stk_cd": stockCode,
})
if err != nil {
return 0, err
}
var result struct {
CntrInfr []struct {
CntrStr string `json:"cntr_str"`
} `json:"cntr_infr"`
ReturnCode int `json:"return_code"`
}
if err := json.Unmarshal(body, &result); err != nil {
return 0, err
}
if result.ReturnCode != 0 || len(result.CntrInfr) == 0 {
return 0, fmt.Errorf("체결강도 없음")
}
val := parseFloatSafe(result.CntrInfr[0].CntrStr)
// 결과 캐시 저장 (30초 — 스캔 3주기(30s) 동안 재호출 없음)
k.cntrStrCache.Store(stockCode, cntrStrCacheEntry{value: val, expiresAt: time.Now().Add(30 * time.Second)})
return val, nil
}
// GetDailyChart 일봉 차트 데이터 조회 (ka10005)
func (k *KiwoomClient) GetDailyChart(stockCode string) ([]models.CandleData, error) {
body, err := k.post("ka10005", "/api/dostk/mrkcond", map[string]string{
"stk_cd": stockCode,
})
if err != nil {
return nil, err
}
var result struct {
StkDdwkmm []struct {
Date string `json:"date"`
OpenPric string `json:"open_pric"`
HighPric string `json:"high_pric"`
LowPric string `json:"low_pric"`
ClosePric string `json:"close_pric"`
TrdeQty string `json:"trde_qty"`
} `json:"stk_ddwkmm"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("일봉 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("일봉 조회 실패: %s", result.ReturnMsg)
}
// 날짜 오름차순 정렬 (API는 내림차순 반환)
candles := make([]models.CandleData, 0, len(result.StkDdwkmm))
for i := len(result.StkDdwkmm) - 1; i >= 0; i-- {
row := result.StkDdwkmm[i]
candles = append(candles, models.CandleData{
Time: parseDateToUnix(row.Date),
Open: absParseIntSafe(row.OpenPric),
High: absParseIntSafe(row.HighPric),
Low: absParseIntSafe(row.LowPric),
Close: absParseIntSafe(row.ClosePric),
Volume: absParseIntSafe(row.TrdeQty),
})
}
return candles, nil
}
// GetMinuteChart 분봉 차트 데이터 조회 (ka10080)
// minutes: 1, 5, 10, 15, 30, 60
func (k *KiwoomClient) GetMinuteChart(stockCode string, minutes int) ([]models.CandleData, error) {
body, err := k.post("ka10080", "/api/dostk/chart", map[string]string{
"stk_cd": stockCode,
"tic_scope": fmt.Sprintf("%d", minutes),
"upd_stkpc_tp": "1",
})
if err != nil {
return nil, err
}
var result struct {
StkMinPoleChartQry []struct {
CurPrc string `json:"cur_prc"`
TrdeQty string `json:"trde_qty"`
CntrTm string `json:"cntr_tm"`
OpenPric string `json:"open_pric"`
HighPric string `json:"high_pric"`
LowPric string `json:"low_pric"`
} `json:"stk_min_pole_chart_qry"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("분봉 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("분봉 조회 실패: %s", result.ReturnMsg)
}
// 시간 오름차순 정렬 (API는 내림차순 반환)
rows := result.StkMinPoleChartQry
candles := make([]models.CandleData, 0, len(rows))
for i := len(rows) - 1; i >= 0; i-- {
row := rows[i]
candles = append(candles, models.CandleData{
Time: parseMinuteCandleTime(row.CntrTm),
Open: absParseIntSafe(row.OpenPric),
High: absParseIntSafe(row.HighPric),
Low: absParseIntSafe(row.LowPric),
Close: absParseIntSafe(row.CurPrc),
Volume: absParseIntSafe(row.TrdeQty),
})
}
return candles, nil
}
// parseMinuteCandleTime 분봉 체결시간(YYYYMMDDHHmmss) → Unix 초 변환
func parseMinuteCandleTime(s string) int64 {
s = strings.TrimSpace(s)
// "YYYYMMDDHHmmss" (14자리) 또는 "HHmmss" (6자리) 처리
var t time.Time
var err error
switch len(s) {
case 14:
t, err = time.ParseInLocation("20060102150405", s, time.Local)
case 12:
t, err = time.ParseInLocation("060102150405", s, time.Local)
default:
return 0
}
if err != nil {
return 0
}
return t.Unix()
}
// GetTopVolumeStocks 거래량 상위 종목 조회 (ka10030)
// market: "J"(KOSPI) → "001", "Q"(KOSDAQ) → "101"
func (k *KiwoomClient) GetTopVolumeStocks(market string, count int) ([]models.StockPrice, error) {
// market 코드 변환
mrktTp := "000"
mktName := "전체"
switch market {
case "J":
mrktTp = "001"
mktName = "KOSPI"
case "Q":
mrktTp = "101"
mktName = "KOSDAQ"
}
body, err := k.post("ka10030", "/api/dostk/rkinfo", map[string]string{
"mrkt_tp": mrktTp,
"sort_tp": "1", // 거래량 기준 정렬
"mang_stk_incls": "1", // 관리종목 미포함
"crd_tp": "0",
"trde_qty_tp": "0",
"pric_tp": "0",
"trde_prica_tp": "0",
"mrkt_open_tp": "0",
"stex_tp": "3", // 통합
})
if err != nil {
return nil, err
}
var result struct {
TdyTrdeQtyUpper []struct {
StkCd string `json:"stk_cd"`
StkNm string `json:"stk_nm"`
CurPrc string `json:"cur_prc"`
PredPre string `json:"pred_pre"`
FluRt string `json:"flu_rt"`
TrdeQty string `json:"trde_qty"`
} `json:"tdy_trde_qty_upper"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("거래량순위 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("거래량순위 조회 실패: %s", result.ReturnMsg)
}
stocks := make([]models.StockPrice, 0, count)
for i, row := range result.TdyTrdeQtyUpper {
if i >= count {
break
}
stocks = append(stocks, models.StockPrice{
Code: row.StkCd,
Name: row.StkNm,
CurrentPrice: absParseIntSafe(row.CurPrc),
ChangePrice: parseIntSafe(row.PredPre),
ChangeRate: parseFloatSafe(row.FluRt),
Volume: absParseIntSafe(row.TrdeQty),
Market: mktName,
UpdatedAt: time.Now(),
})
}
return stocks, nil
}
// getOrderBook 호가잔량 조회 (ka10004) - 총매도잔량, 총매수잔량, 총매도잔량직전대비 반환
// WS 구독 중인 종목은 CacheService 캐시 우선 조회 → REST API 호출 최소화
func (k *KiwoomClient) getOrderBook(stockCode string) (totalAsk, totalBid, askChange int64, err error) {
if cached, ok := GetCacheService().Get("orderbook:" + stockCode); ok {
if ob, ok2 := cached.(*models.OrderBook); ok2 {
return ob.TotalAskVol, ob.TotalBidVol, 0, nil
}
}
body, err := k.post("ka10004", "/api/dostk/mrkcond", map[string]string{
"stk_cd": stockCode,
})
if err != nil {
return 0, 0, 0, err
}
var result struct {
TotSelReq string `json:"tot_sel_req"`
TotBuyReq string `json:"tot_buy_req"`
TotSelReqJub string `json:"tot_sel_req_jub_pre"`
ReturnCode int `json:"return_code"`
}
if err := json.Unmarshal(body, &result); err != nil {
return 0, 0, 0, err
}
if result.ReturnCode != 0 {
return 0, 0, 0, fmt.Errorf("호가 조회 실패 (return_code=%d)", result.ReturnCode)
}
return absParseIntSafe(result.TotSelReq),
absParseIntSafe(result.TotBuyReq),
parseIntSafe(result.TotSelReqJub),
nil
}
// GetTopFluctuation 전일대비 등락률 상위 종목 조회 (ka10027)
// ascending=false: 상승률, ascending=true: 하락률
func (k *KiwoomClient) GetTopFluctuation(market string, ascending bool, count int) ([]models.StockPrice, error) {
sortTp := "1" // 상승률
if ascending {
sortTp = "3" // 하락률
}
// market: "J"(KOSPI) → "001", "Q"(KOSDAQ) → "101", 그 외 "000"(전체)
mrktTp := "000"
mktName := "전체"
switch market {
case "J":
mrktTp = "001"
mktName = "KOSPI"
case "Q":
mrktTp = "101"
mktName = "KOSDAQ"
}
body, err := k.post("ka10027", "/api/dostk/rkinfo", map[string]string{
"mrkt_tp": mrktTp,
"sort_tp": sortTp,
"trde_qty_cnd": "0000", // 거래량 전체
"stk_cnd": "0", // 종목조건 전체
"crd_cnd": "0", // 신용조건 전체
"updown_incls": "1", // 상하한포함
"pric_cnd": "0", // 가격조건 전체
"trde_prica_cnd": "0", // 거래대금조건 전체
"stex_tp": "1", // KRX
})
if err != nil {
return nil, err
}
var result struct {
PredPreFluRtUpper []struct {
StkCd string `json:"stk_cd"`
StkNm string `json:"stk_nm"`
CurPrc string `json:"cur_prc"`
PredPre string `json:"pred_pre"`
FluRt string `json:"flu_rt"`
NowTrdeQty string `json:"now_trde_qty"`
CntrStr string `json:"cntr_str"`
} `json:"pred_pre_flu_rt_upper"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("등락률 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("등락률 조회 실패: %s", result.ReturnMsg)
}
stocks := make([]models.StockPrice, 0, count)
for i, row := range result.PredPreFluRtUpper {
if i >= count {
break
}
stocks = append(stocks, models.StockPrice{
Code: row.StkCd,
Name: row.StkNm,
CurrentPrice: absParseIntSafe(row.CurPrc),
ChangePrice: parseIntSafe(row.PredPre),
ChangeRate: parseFloatSafe(row.FluRt),
Volume: absParseIntSafe(row.NowTrdeQty),
CntrStr: parseFloatSafe(row.CntrStr),
Market: mktName,
UpdatedAt: time.Now(),
})
}
return stocks, nil
}
// --- 유틸 함수 ---
func parseIntSafe(s string) int64 {
s = strings.ReplaceAll(s, ",", "")
s = strings.TrimPrefix(s, "+")
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
return n
}
// absParse 키움 API 가격 필드 파싱 (+/- 부호는 방향 표시용이므로 절댓값 반환)
func absParseIntSafe(s string) int64 {
n := parseIntSafe(s)
if n < 0 {
return -n
}
return n
}
func parseFloatSafe(s string) float64 {
s = strings.ReplaceAll(s, ",", "")
s = strings.TrimPrefix(s, "+")
f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return f
}
// parseDateToUnix YYYYMMDD 형식을 Unix 타임스탬프(초)로 변환
func parseDateToUnix(dateStr string) int64 {
t, err := time.ParseInLocation("20060102", dateStr, time.Local)
if err != nil {
return 0
}
return t.Unix()
}

View File

@@ -0,0 +1,600 @@
package services
import (
"encoding/json"
"fmt"
"log"
"stocksearch/models"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
kiwoomWSURL = "wss://api.kiwoom.com:10000/api/dostk/websocket"
writeTimeout = 10 * time.Second // 쓰기 타임아웃
)
// KiwoomWSClient 키움증권 실시간 WebSocket 클라이언트
type KiwoomWSClient struct {
tokenService *TokenService
conn *websocket.Conn
mu sync.Mutex // conn 보호 + 직렬화된 쓰기 보장
// 현재 구독 중인 종목 코드 집합 (재연결 시 복구용)
subscribed map[string]bool
// REG 배치 전송용: 짧은 시간 내 요청을 모아 단일 REG로 발송
pendingReg []string
regTimer *time.Timer
// 실시간 데이터 수신 콜백
onPrice func(price *models.StockPrice)
onOrderBook func(ob *models.OrderBook)
onProgram func(pg *models.ProgramTrading)
onMarketStatus func(ms *models.MarketStatus)
onMeta func(meta *models.StockMeta)
}
var kiwoomWSOnce sync.Once
var kiwoomWSSvc *KiwoomWSClient
// GetKiwoomWSClient KiwoomWS 클라이언트 싱글턴 반환
func GetKiwoomWSClient(onPrice func(*models.StockPrice)) *KiwoomWSClient {
kiwoomWSOnce.Do(func() {
kiwoomWSSvc = &KiwoomWSClient{
tokenService: GetTokenService(),
subscribed: make(map[string]bool),
onPrice: onPrice,
}
})
return kiwoomWSSvc
}
// SetCallbacks 추가 실시간 데이터 콜백 등록
func (k *KiwoomWSClient) SetCallbacks(
onOrderBook func(*models.OrderBook),
onProgram func(*models.ProgramTrading),
onMarketStatus func(*models.MarketStatus),
onMeta func(*models.StockMeta),
) {
k.mu.Lock()
defer k.mu.Unlock()
k.onOrderBook = onOrderBook
k.onProgram = onProgram
k.onMarketStatus = onMarketStatus
k.onMeta = onMeta
}
// Connect 키움 WS 서버에 연결 후 읽기 루프 시작
func (k *KiwoomWSClient) Connect() error {
conn, err := k.dial()
if err != nil {
return err
}
k.mu.Lock()
k.conn = conn
k.mu.Unlock()
stopCh := make(chan struct{})
go k.readLoop(conn, stopCh)
log.Println("키움 WS 연결 완료")
return nil
}
// dial WSS 연결 수립 후 로그인 패킷 전송
func (k *KiwoomWSClient) dial() (*websocket.Conn, error) {
// HTTP 헤더 없이 연결 (키움 WS는 헤더 인증 불필요)
conn, _, err := websocket.DefaultDialer.Dial(kiwoomWSURL, nil)
if err != nil {
return nil, err
}
// 연결 직후 로그인 패킷 전송 (Bearer 없이 token만)
loginMsg := map[string]string{
"trnm": "LOGIN",
"token": k.tokenService.GetToken(),
}
data, _ := json.Marshal(loginMsg)
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
conn.Close()
return nil, err
}
// 로그인 응답 대기
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, resp, err := conn.ReadMessage()
if err != nil {
conn.Close()
return nil, err
}
var loginResp struct {
Trnm string `json:"trnm"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(resp, &loginResp); err != nil {
conn.Close()
return nil, fmt.Errorf("로그인 응답 파싱 실패: %w", err)
}
if loginResp.Trnm != "LOGIN" || loginResp.ReturnCode != 0 {
conn.Close()
return nil, fmt.Errorf("키움 WS 로그인 실패 [%d]: %s", loginResp.ReturnCode, loginResp.ReturnMsg)
}
// 읽기 데드라인 초기화 (readLoop에서 관리)
conn.SetReadDeadline(time.Time{})
// 장운영 상태(0s) 글로벌 구독 (item 빈 문자열)
k.sendMarketStatusReg(conn)
log.Println("키움 WS 로그인 성공")
return conn, nil
}
// sendMarketStatusReg 장운영 상태(0s) 구독 전송 (item="", 전역)
func (k *KiwoomWSClient) sendMarketStatusReg(conn *websocket.Conn) {
msg := map[string]interface{}{
"trnm": "REG",
"grp_no": "1",
"refresh": "1",
"data": []map[string]interface{}{
{"item": []string{""}, "type": []string{"0s"}},
},
}
data, _ := json.Marshal(msg)
conn.SetWriteDeadline(time.Now().Add(writeTimeout))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("키움 WS 장운영상태 구독 실패: %v", err)
}
}
// SubscribePair KRX + NXT 종목을 debounce 배치 REG로 구독
// 200ms 내 여러 호출이 들어오면 하나의 REG 메시지로 묶어 전송
func (k *KiwoomWSClient) SubscribePair(code string) {
k.mu.Lock()
defer k.mu.Unlock()
nxt := code + "_NX"
if !k.subscribed[code] {
k.subscribed[code] = true
k.pendingReg = append(k.pendingReg, code)
}
if !k.subscribed[nxt] {
k.subscribed[nxt] = true
k.pendingReg = append(k.pendingReg, nxt)
}
k.scheduleFlush()
}
// scheduleFlush REG 배치 전송 타이머 설정 (mu 보유 상태에서 호출)
// 연속 호출 시 타이머를 초기화해 마지막 호출로부터 200ms 후 한 번만 전송
func (k *KiwoomWSClient) scheduleFlush() {
if k.regTimer != nil {
k.regTimer.Stop()
}
k.regTimer = time.AfterFunc(200*time.Millisecond, k.flushPendingReg)
}
// flushPendingReg 누적된 구독 코드를 단일 REG 메시지로 전송
func (k *KiwoomWSClient) flushPendingReg() {
k.mu.Lock()
defer k.mu.Unlock()
if len(k.pendingReg) == 0 {
return
}
codes := k.pendingReg
k.pendingReg = nil
k.sendRegBatch(codes, "1")
log.Printf("키움 WS 배치 구독 전송 (%d개): %v", len(codes), codes)
}
// UnsubscribePair KRX + NXT 종목을 단일 REMOVE 메시지로 동시 해제
func (k *KiwoomWSClient) UnsubscribePair(code string) {
k.mu.Lock()
defer k.mu.Unlock()
nxt := code + "_NX"
var toRemove []string
if k.subscribed[code] {
delete(k.subscribed, code)
toRemove = append(toRemove, code)
}
if k.subscribed[nxt] {
delete(k.subscribed, nxt)
toRemove = append(toRemove, nxt)
}
if len(toRemove) > 0 {
k.sendRemoveBatch(toRemove)
log.Printf("키움 WS 구독 해제: %v", toRemove)
}
}
// sendRegBatch 여러 종목을 단일 REG 메시지로 배치 전송 (mu 보유 상태에서 호출)
// 0B: KRX + NXT 전체, 0D/0H/0w/0g: KRX 코드만
func (k *KiwoomWSClient) sendRegBatch(codes []string, refresh string) {
// KRX 전용 코드 추출 (_NX 등 접미사 없는 코드)
krxCodes := filterKRXCodes(codes)
dataItems := []map[string]interface{}{
{"item": codes, "type": []string{"0B"}},
}
if len(krxCodes) > 0 {
dataItems = append(dataItems, map[string]interface{}{
"item": krxCodes,
"type": []string{"0D", "0H", "0w", "0g"},
})
}
msg := map[string]interface{}{
"trnm": "REG",
"grp_no": "1",
"refresh": refresh,
"data": dataItems,
}
if err := k.write(msg); err != nil {
log.Printf("키움 WS 구독 전송 실패: %v", err)
}
}
// sendRemoveBatch 여러 종목을 단일 REMOVE 메시지로 배치 전송 (mu 보유 상태에서 호출)
func (k *KiwoomWSClient) sendRemoveBatch(codes []string) {
krxCodes := filterKRXCodes(codes)
dataItems := []map[string]interface{}{
{"item": codes, "type": []string{"0B"}},
}
if len(krxCodes) > 0 {
dataItems = append(dataItems, map[string]interface{}{
"item": krxCodes,
"type": []string{"0D", "0H", "0w", "0g"},
})
}
msg := map[string]interface{}{
"trnm": "REMOVE",
"grp_no": "1",
"data": dataItems,
}
if err := k.write(msg); err != nil {
log.Printf("키움 WS 구독해제 전송 실패: %v", err)
}
}
// filterKRXCodes 접미사 없는 KRX 코드만 반환
func filterKRXCodes(codes []string) []string {
var krx []string
for _, c := range codes {
if !strings.Contains(c, "_") {
krx = append(krx, c)
}
}
return krx
}
// write JSON 메시지 전송 (mu 보유 상태에서 호출)
func (k *KiwoomWSClient) write(v interface{}) error {
if k.conn == nil {
return nil
}
data, _ := json.Marshal(v)
k.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
return k.conn.WriteMessage(websocket.TextMessage, data)
}
// readLoop 키움 WS 메시지 수신 루프
func (k *KiwoomWSClient) readLoop(conn *websocket.Conn, stopCh chan struct{}) {
defer func() {
close(stopCh)
k.mu.Lock()
if k.conn == conn {
k.conn = nil
}
k.mu.Unlock()
conn.Close()
log.Println("키움 WS 연결 끊김, 재연결 시도...")
go k.reconnect()
}()
for {
_, data, err := conn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Printf("키움 WS 읽기 오류: %v", err)
}
return
}
k.handleMessage(data)
}
}
// handleMessage 수신 메시지 파싱 및 콜백 호출
func (k *KiwoomWSClient) handleMessage(data []byte) {
var msg struct {
Trnm string `json:"trnm"`
Data []struct {
Type string `json:"type"`
Item string `json:"item"`
Values map[string]string `json:"values"`
} `json:"data"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
switch msg.Trnm {
case "PING":
// 키움 서버 PING 수신 → PONG 응답 전송
k.mu.Lock()
_ = k.write(map[string]string{"trnm": "PONG"})
k.mu.Unlock()
case "REG", "REMOVE":
if msg.ReturnCode != 0 {
log.Printf("키움 WS %s 오류: %s", msg.Trnm, msg.ReturnMsg)
}
case "REAL":
for _, d := range msg.Data {
switch d.Type {
case "0B":
if k.onPrice != nil {
k.onPrice(parseRealPrice(d.Item, d.Values))
}
case "0D":
if k.onOrderBook != nil {
k.onOrderBook(parseOrderBook(d.Item, d.Values))
}
case "0H":
// 예상체결 데이터 → StockPrice 형식으로 통합
if k.onPrice != nil {
k.onPrice(parseExpectedPrice(d.Item, d.Values))
}
case "0w":
if k.onProgram != nil {
k.onProgram(parseProgramTrading(d.Item, d.Values))
}
case "0s":
if k.onMarketStatus != nil {
k.onMarketStatus(parseMarketStatus(d.Values))
}
case "0g":
if k.onMeta != nil {
k.onMeta(parseStockMeta(d.Item, d.Values))
}
}
}
}
}
// reconnect 재연결 및 기존 구독 복구 (지수 백오프)
func (k *KiwoomWSClient) reconnect() {
k.mu.Lock()
codes := make([]string, 0, len(k.subscribed))
for code := range k.subscribed {
codes = append(codes, code)
}
k.mu.Unlock()
delay := 5 * time.Second
for {
time.Sleep(delay)
log.Printf("키움 WS 재연결 시도... (%v 후 다음 시도)", delay*2)
conn, err := k.dial()
if err != nil {
log.Printf("키움 WS 재연결 실패: %v", err)
if delay < 60*time.Second {
delay *= 2
}
continue
}
k.mu.Lock()
k.conn = conn
// 기존 구독 복구: 모든 코드를 단일 REG 메시지로 배치 전송
if len(codes) > 0 {
k.sendRegBatch(codes, "1")
}
k.mu.Unlock()
stopCh := make(chan struct{})
go k.readLoop(conn, stopCh)
log.Printf("키움 WS 재연결 성공, %d개 종목 구독 복구", len(codes))
return
}
}
// parseRealPrice 0B 실시간 주식체결 값 → StockPrice 변환
// 20=체결시간, 10=현재가, 11=전일대비, 12=등락률, 13=누적거래량, 14=누적거래대금
// 15=거래량(체결량), 16=시가, 17=고가, 18=저가, 27=최우선매도호가, 28=최우선매수호가
// 228=체결강도, 290=장구분
// NXT 종목코드(005930_NX)는 _NX 접미사 제거 후 KRX 코드(005930)로 통일
func parseRealPrice(code string, v map[string]string) *models.StockPrice {
normalized := strings.TrimPrefix(code, "A")
normalized = strings.SplitN(normalized, "_", 2)[0] // _NX, _AL 등 접미사 제거
return &models.StockPrice{
Code: normalized,
CurrentPrice: absInt(parseWSInt(v["10"])),
ChangePrice: parseWSInt(v["11"]),
ChangeRate: parseWSFloat(v["12"]),
Volume: absInt(parseWSInt(v["13"])),
TradeMoney: absInt(parseWSInt(v["14"])),
TradeVolume: absInt(parseWSInt(v["15"])),
Open: absInt(parseWSInt(v["16"])),
High: absInt(parseWSInt(v["17"])),
Low: absInt(parseWSInt(v["18"])),
TradeTime: v["20"],
AskPrice1: absInt(parseWSInt(v["27"])),
BidPrice1: absInt(parseWSInt(v["28"])),
CntrStr: parseWSFloat(v["228"]),
MarketStatus: v["290"],
UpdatedAt: time.Now(),
}
}
// parseExpectedPrice 0H 주식예상체결 → StockPrice 변환 (장 전/후 예상체결 시 사용)
func parseExpectedPrice(code string, v map[string]string) *models.StockPrice {
normalized := strings.TrimPrefix(code, "A")
normalized = strings.SplitN(normalized, "_", 2)[0]
return &models.StockPrice{
Code: normalized,
CurrentPrice: absInt(parseWSInt(v["10"])),
ChangePrice: parseWSInt(v["11"]),
ChangeRate: parseWSFloat(v["12"]),
TradeVolume: absInt(parseWSInt(v["15"])),
Volume: absInt(parseWSInt(v["13"])),
TradeTime: v["20"],
UpdatedAt: time.Now(),
}
}
// parseOrderBook 0D 주식호가잔량 → OrderBook 변환
// 41~50=매도호가1~10, 61~70=매도호가수량1~10
// 51~60=매수호가1~10, 71~80=매수호가수량1~10
// 121=매도총잔량, 125=매수총잔량, 23=예상체결가, 24=예상체결수량
func parseOrderBook(code string, v map[string]string) *models.OrderBook {
normalized := strings.TrimPrefix(code, "A")
normalized = strings.SplitN(normalized, "_", 2)[0]
ob := &models.OrderBook{
Code: normalized,
AskTime: v["21"],
}
for i := 1; i <= 10; i++ {
askKey := strconv.Itoa(40 + i) // 41..50
askVolKey := strconv.Itoa(60 + i) // 61..70
bidKey := strconv.Itoa(50 + i) // 51..60
bidVolKey := strconv.Itoa(70 + i) // 71..80
ob.Asks = append(ob.Asks, models.OrderBookEntry{
Price: absInt(parseWSInt(v[askKey])),
Volume: absInt(parseWSInt(v[askVolKey])),
})
ob.Bids = append(ob.Bids, models.OrderBookEntry{
Price: absInt(parseWSInt(v[bidKey])),
Volume: absInt(parseWSInt(v[bidVolKey])),
})
}
ob.TotalAskVol = absInt(parseWSInt(v["121"]))
ob.TotalBidVol = absInt(parseWSInt(v["125"]))
ob.ExpectedPrc = absInt(parseWSInt(v["23"]))
ob.ExpectedVol = absInt(parseWSInt(v["24"]))
return ob
}
// parseProgramTrading 0w 종목프로그램매매 → ProgramTrading 변환
func parseProgramTrading(code string, v map[string]string) *models.ProgramTrading {
normalized := strings.TrimPrefix(code, "A")
normalized = strings.SplitN(normalized, "_", 2)[0]
return &models.ProgramTrading{
Code: normalized,
SellVolume: absInt(parseWSInt(v["202"])),
SellAmount: absInt(parseWSInt(v["204"])),
BuyVolume: absInt(parseWSInt(v["206"])),
BuyAmount: absInt(parseWSInt(v["208"])),
NetBuyVolume: parseWSInt(v["210"]),
NetBuyAmount: parseWSInt(v["212"]),
}
}
// parseMarketStatus 0s 장시작시간 → MarketStatus 변환
func parseMarketStatus(v map[string]string) *models.MarketStatus {
code := v["215"]
return &models.MarketStatus{
StatusCode: code,
StatusName: marketStatusName(code),
Time: v["20"],
}
}
// marketStatusName 장운영구분 코드 → 한글 이름
func marketStatusName(code string) string {
switch code {
case "0":
return "장시작전"
case "2":
return "장마감알림"
case "3":
return "장 중"
case "4":
return "장마감"
case "8":
return "정규장마감"
case "9":
return "전체장마감"
case "a":
return "시간외종가시작"
case "b":
return "시간외종가종료"
case "c":
return "시간외단일가시작"
case "d":
return "시간외단일가종료"
default:
return "장외"
}
}
// parseStockMeta 0g 주식종목정보 → StockMeta 변환
func parseStockMeta(code string, v map[string]string) *models.StockMeta {
normalized := strings.TrimPrefix(code, "A")
normalized = strings.SplitN(normalized, "_", 2)[0]
return &models.StockMeta{
Code: normalized,
UpperLimit: absInt(parseWSInt(v["305"])),
LowerLimit: absInt(parseWSInt(v["306"])),
BasePrice: absInt(parseWSInt(v["307"])),
}
}
func parseWSInt(s string) int64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
neg := strings.HasPrefix(s, "-")
s = strings.TrimLeft(s, "+-")
s = strings.ReplaceAll(s, ",", "")
n, _ := strconv.ParseInt(s, 10, 64)
if neg {
return -n
}
return n
}
func parseWSFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
neg := strings.HasPrefix(s, "-")
s = strings.TrimLeft(s, "+-")
f, _ := strconv.ParseFloat(s, 64)
if neg {
return -f
}
return f
}
func absInt(n int64) int64 {
if n < 0 {
return -n
}
return n
}

View File

@@ -0,0 +1,106 @@
package services
import (
"encoding/json"
"fmt"
"stocksearch/models"
"time"
)
// Kospi200Service 코스피200 종목 조회 서비스
type Kospi200Service struct {
kiwoom *KiwoomClient
cache *CacheService
}
var kospi200Svc *Kospi200Service
// GetKospi200Service 코스피200 서비스 싱글턴 반환
func GetKospi200Service() *Kospi200Service {
if kospi200Svc == nil {
kospi200Svc = &Kospi200Service{
kiwoom: GetKiwoomClient(),
cache: GetCacheService(),
}
}
return kospi200Svc
}
// GetStocks ka20002: 코스피200 구성종목 전체 조회 (연속조회, 캐시 1분)
func (s *Kospi200Service) GetStocks() ([]models.Kospi200Stock, error) {
const cacheKey = "kospi200_stocks"
if cached, ok := s.cache.Get(cacheKey); ok {
if stocks, ok := cached.([]models.Kospi200Stock); ok {
return stocks, nil
}
}
var all []models.Kospi200Stock
contYn, nextKey := "N", ""
for {
body, nextContYn, nextNextKey, err := s.kiwoom.postPaged(
"ka20002", "/api/dostk/sect",
map[string]string{
"mrkt_tp": "0", // 코스피
"inds_cd": "201", // KOSPI200
"stex_tp": "1", // KRX
},
contYn, nextKey,
)
if err != nil {
return nil, fmt.Errorf("코스피200 조회 실패: %w", err)
}
var result struct {
IndsStkpc []struct {
StkCd string `json:"stk_cd"`
StkNm string `json:"stk_nm"`
CurPrc string `json:"cur_prc"`
PredPreSig string `json:"pred_pre_sig"`
PredPre string `json:"pred_pre"`
FluRt string `json:"flu_rt"`
NowTrdeQty string `json:"now_trde_qty"`
OpenPric string `json:"open_pric"`
HighPric string `json:"high_pric"`
LowPric string `json:"low_pric"`
} `json:"inds_stkpc"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("코스피200 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("코스피200 조회 실패: %s", result.ReturnMsg)
}
for _, s := range result.IndsStkpc {
// 종목코드·종목명이 없는 행은 건너뜀
if s.StkCd == "" || s.StkNm == "" {
continue
}
all = append(all, models.Kospi200Stock{
Code: s.StkCd,
Name: s.StkNm,
CurPrc: absParseIntSafe(s.CurPrc),
PredPreSig: s.PredPreSig,
PredPre: parseIntSafe(s.PredPre),
FluRt: parseFloatSafe(s.FluRt),
Volume: absParseIntSafe(s.NowTrdeQty),
Open: absParseIntSafe(s.OpenPric),
High: absParseIntSafe(s.HighPric),
Low: absParseIntSafe(s.LowPric),
})
}
// 연속조회 종료 조건
if nextContYn != "Y" || nextNextKey == "" {
break
}
contYn, nextKey = nextContYn, nextNextKey
}
s.cache.Set(cacheKey, all, time.Minute)
return all, nil
}

171
services/news_service.go Normal file
View File

@@ -0,0 +1,171 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"stocksearch/config"
"stocksearch/models"
"strings"
"sync"
"time"
"unicode"
)
var (
newsSvcOnce sync.Once
newsSvc *NewsService
)
// NewsService 네이버 뉴스 검색 서비스
type NewsService struct {
httpClient *http.Client
cache *CacheService
clientID string
clientSecret string
}
// GetNewsService 싱글턴 반환
func GetNewsService() *NewsService {
newsSvcOnce.Do(func() {
newsSvc = &NewsService{
httpClient: &http.Client{Timeout: 5 * time.Second},
cache: GetCacheService(),
clientID: config.App.NaverClientID,
clientSecret: config.App.NaverClientSecret,
}
})
return newsSvc
}
// GetNews 종목명 기반 최근 뉴스 반환 (3분 캐시, 제목 중복 제거)
func (s *NewsService) GetNews(stockName string) ([]models.NewsItem, error) {
cacheKey := "news:" + stockName
if cached, ok := s.cache.Get(cacheKey); ok {
return cached.([]models.NewsItem), nil
}
items, err := s.fetchNaver(stockName)
if err != nil {
return nil, err
}
items = dedupByTitle(items)
s.cache.Set(cacheKey, items, 3*time.Minute)
log.Printf("네이버 뉴스 조회: 종목=%s, 건수=%d", stockName, len(items))
return items, nil
}
// fetchNaver 네이버 뉴스 검색 API 호출 (최신순 10건)
func (s *NewsService) fetchNaver(query string) ([]models.NewsItem, error) {
if s.clientID == "" {
return nil, fmt.Errorf("NAVER_CLIENT_ID가 설정되지 않았습니다")
}
endpoint := "https://openapi.naver.com/v1/search/news.json"
params := url.Values{}
params.Set("query", query)
params.Set("display", "20") // 중복 제거 후 10건 확보를 위해 여유 있게 조회
params.Set("sort", "date")
req, err := http.NewRequest("GET", endpoint+"?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("네이버 뉴스 요청 생성 실패: %w", err)
}
req.Header.Set("X-Naver-Client-Id", s.clientID)
req.Header.Set("X-Naver-Client-Secret", s.clientSecret)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("네이버 뉴스 API 요청 실패: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result struct {
Items []struct {
Title string `json:"title"`
Link string `json:"link"`
PubDate string `json:"pubDate"`
OriginalLink string `json:"originallink"`
} `json:"items"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("네이버 뉴스 API 파싱 실패: %w", err)
}
items := make([]models.NewsItem, 0, len(result.Items))
for _, it := range result.Items {
link := it.OriginalLink
if link == "" {
link = it.Link
}
items = append(items, models.NewsItem{
Title: stripHTML(it.Title),
URL: link,
PublishedAt: it.PubDate,
Source: extractDomain(link),
})
}
return items, nil
}
// htmlTagRe HTML 태그 제거용 정규식
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
// stripHTML HTML 태그 및 엔티티 제거
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", "\"")
s = strings.ReplaceAll(s, "&#39;", "'")
return strings.TrimSpace(s)
}
// extractDomain URL에서 도메인명만 추출
func extractDomain(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
host := u.Hostname()
// www. 제거
host = strings.TrimPrefix(host, "www.")
return host
}
// normalizeTitle 제목을 소문자·공백 제거하여 중복 비교용 키 생성
func normalizeTitle(title string) string {
var b strings.Builder
for _, r := range strings.ToLower(title) {
if !unicode.IsSpace(r) && !unicode.IsPunct(r) {
b.WriteRune(r)
}
}
return b.String()
}
// dedupByTitle 정규화된 제목 기준으로 중복 기사 제거
func dedupByTitle(items []models.NewsItem) []models.NewsItem {
seen := make(map[string]struct{}, len(items))
out := make([]models.NewsItem, 0, len(items))
for _, item := range items {
key := normalizeTitle(item.Title)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, item)
if len(out) == 10 {
break
}
}
return out
}

132
services/order_service.go Normal file
View File

@@ -0,0 +1,132 @@
package services
import (
"encoding/json"
"fmt"
)
// OrderService 주식 주문 서비스 (매수/매도/정정/취소)
type OrderService struct {
client *KiwoomClient
}
var orderService *OrderService
// GetOrderService 주문 서비스 싱글턴 반환
func GetOrderService() *OrderService {
if orderService == nil {
orderService = &OrderService{client: GetKiwoomClient()}
}
return orderService
}
// OrderRequest 주문 요청 파라미터
type OrderRequest struct {
Exchange string // KRX, NXT, SOR
Code string // 종목코드
Qty string // 주문수량
Price string // 주문단가 (시장가 시 빈 문자열)
TradeTP string // 0:보통, 3:시장가, 5:조건부지정가, 6:최유리, 7:최우선
}
// OrderResult 주문 응답
type OrderResult struct {
OrderNo string `json:"orderNo"`
ReturnCode int `json:"returnCode"`
ReturnMsg string `json:"returnMsg"`
}
// Buy 매수주문 (kt10000)
func (s *OrderService) Buy(req OrderRequest) (*OrderResult, error) {
body := map[string]string{
"dmst_stex_tp": req.Exchange,
"stk_cd": req.Code,
"ord_qty": req.Qty,
"ord_uv": req.Price,
"trde_tp": req.TradeTP,
"cond_uv": "",
}
respBody, err := s.client.post("kt10000", "/api/dostk/ordr", body)
if err != nil {
return nil, fmt.Errorf("매수주문 실패: %w", err)
}
return parseOrderResult(respBody)
}
// Sell 매도주문 (kt10001)
func (s *OrderService) Sell(req OrderRequest) (*OrderResult, error) {
body := map[string]string{
"dmst_stex_tp": req.Exchange,
"stk_cd": req.Code,
"ord_qty": req.Qty,
"ord_uv": req.Price,
"trde_tp": req.TradeTP,
"cond_uv": "",
}
respBody, err := s.client.post("kt10001", "/api/dostk/ordr", body)
if err != nil {
return nil, fmt.Errorf("매도주문 실패: %w", err)
}
return parseOrderResult(respBody)
}
// Modify 정정주문 (kt10002)
func (s *OrderService) Modify(exchange, origOrdNo, code, qty, price string) (*OrderResult, error) {
body := map[string]string{
"dmst_stex_tp": exchange,
"orig_ord_no": origOrdNo,
"stk_cd": code,
"mdfy_qty": qty,
"mdfy_uv": price,
"mdfy_cond_uv": "",
}
respBody, err := s.client.post("kt10002", "/api/dostk/ordr", body)
if err != nil {
return nil, fmt.Errorf("정정주문 실패: %w", err)
}
return parseOrderResult(respBody)
}
// Cancel 취소주문 (kt10003)
// qty: "0" = 전량취소
func (s *OrderService) Cancel(exchange, origOrdNo, code, qty string) (*OrderResult, error) {
body := map[string]string{
"dmst_stex_tp": exchange,
"orig_ord_no": origOrdNo,
"stk_cd": code,
"cncl_qty": qty,
}
respBody, err := s.client.post("kt10003", "/api/dostk/ordr", body)
if err != nil {
return nil, fmt.Errorf("취소주문 실패: %w", err)
}
return parseOrderResult(respBody)
}
// parseOrderResult 주문 응답 JSON 파싱 공통 함수
func parseOrderResult(body []byte) (*OrderResult, error) {
var result struct {
OrdNo string `json:"ord_no"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("주문 응답 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("주문 오류: %s", result.ReturnMsg)
}
return &OrderResult{
OrderNo: result.OrdNo,
ReturnCode: result.ReturnCode,
ReturnMsg: result.ReturnMsg,
}, nil
}

767
services/scanner_service.go Normal file
View File

@@ -0,0 +1,767 @@
package services
import (
"log"
"sort"
"stocksearch/config"
"stocksearch/models"
"sync"
"sync/atomic"
"time"
)
// SignalStock 체결강도 상승 감지 시그널 종목
type SignalStock struct {
models.StockPrice
PrevCntrStr float64 `json:"prevCntrStr"` // 직전 체결강도
RisingCount int `json:"risingCount"` // 연속 상승 횟수 (10초 단위)
DetectedAt time.Time `json:"detectedAt"` // 감지 시각
Sentiment string `json:"sentiment"` // 호재/악재/중립/정보없음
SentimentReason string `json:"sentimentReason"` // 한 줄 이유
TargetPrice int64 `json:"targetPrice"` // AI 추론 목표가
TargetReason string `json:"targetReason"` // 목표가 추론 근거 (한 줄)
RiseScore int `json:"riseScore"` // 상승 확률 점수 (0~100)
RiseLabel string `json:"riseLabel"` // "매우 높음" / "높음" / ""
NextDayTrend string `json:"nextDayTrend"` // 익일 추세: "상승" | "하락" | "횡보"
NextDayConf string `json:"nextDayConf"` // 신뢰도: "높음" | "보통" | "낮음"
NextDayReason string `json:"nextDayReason"` // 익일 추세 근거 (한 줄)
// 복합 분석 지표 (체결강도 + 매도잔량 + 거래량 + 가격위치)
TotalAskVol int64 `json:"totalAskVol"` // 총매도잔량
TotalBidVol int64 `json:"totalBidVol"` // 총매수잔량
AskBidRatio float64 `json:"askBidRatio"` // 매도/매수 잔량비 (1 이상=매도우세)
VolDelta int64 `json:"volDelta"` // 당 구간 거래량 증가분 (10초)
VolRatio float64 `json:"volRatio"` // 거래량 증가율 (직전 평균 대비 배수)
UpperWick float64 `json:"upperWick"` // 윗꼬리 비율 (0=없음, 1=전부 윗꼬리)
PricePos float64 `json:"pricePos"` // 장중 가격 위치 % (0=저가, 100=고가)
SignalType string `json:"signalType"` // "강한매수" | "매수우세" | "물량소화" | "추격위험" | "약한상승"
}
// cntrHistory 종목별 체결강도 이력 (최근 N회)
type cntrHistory struct {
values []float64 // 오래된 것부터, 최신이 마지막
}
func (h *cntrHistory) push(v float64) {
h.values = append(h.values, v)
if len(h.values) > 6 { // 최대 6회(1분) 유지
h.values = h.values[1:]
}
}
// risingCount 직전 N회 연속 상승 횟수 반환 (최소 1회 비교 필요)
func (h *cntrHistory) risingCount() int {
vals := h.values
if len(vals) < 2 {
return 0
}
count := 0
for i := len(vals) - 1; i >= 1; i-- {
if vals[i] > vals[i-1] {
count++
} else {
break
}
}
return count
}
// volumeHist 종목별 거래량 이력 (10초 구간 증가분 추적)
type volumeHist struct {
last int64 // 직전 스캔 누적 거래량
deltas []int64 // 최근 6회 구간 증가분
}
// push 현재 누적 거래량을 기록하고 (구간 증가분, 평균 대비 배수) 반환
func (h *volumeHist) push(current int64) (delta int64, ratio float64) {
if h.last > 0 && current > h.last {
delta = current - h.last
// 비율 계산: 기존 이력 기준 (현재 delta 추가 전)
if len(h.deltas) > 0 {
sum := int64(0)
for _, d := range h.deltas {
sum += d
}
avg := float64(sum) / float64(len(h.deltas))
if avg > 0 {
ratio = float64(delta) / avg
}
}
h.deltas = append(h.deltas, delta)
if len(h.deltas) > 6 {
h.deltas = h.deltas[1:]
}
}
h.last = current
return delta, ratio
}
// ScannerService 체결강도 상승 감지 스캐너 서비스
type ScannerService struct {
kiwoom *KiwoomClient
stockSvc *StockService
analysis *AnalysisService
mu sync.RWMutex
enabled int32 // atomic: 1=켜짐(기본), 0=꺼짐
signals []SignalStock
history map[string]*cntrHistory // 종목별 체결강도 이력
volumeHistory map[string]*volumeHist // 종목별 거래량 이력
signalCache map[string]SignalStock // 종목별 마지막 시그널 (LLM 결과 포함)
signalExpiry map[string]time.Time // 종목별 시그널 만료 시각 (1분)
// 관심종목 전용 이력/캐시
watchlistHistory map[string]*cntrHistory
watchlistVolHistory map[string]*volumeHist
watchlistSignalCache map[string]SignalStock
watchlistSignalExpiry map[string]time.Time
// WS 구독 콜백 (Hub.SubscribeInternal 연결)
subscribeCallback func([]string)
}
var scannerSvc *ScannerService
// GetScannerService 스캐너 서비스 싱글턴 반환
func GetScannerService() *ScannerService {
if scannerSvc == nil {
scannerSvc = &ScannerService{
kiwoom: GetKiwoomClient(),
stockSvc: GetStockService(),
analysis: GetAnalysisService(config.App.GroqAPIKey, config.App.GroqModel),
history: make(map[string]*cntrHistory),
volumeHistory: make(map[string]*volumeHist),
signalCache: make(map[string]SignalStock),
signalExpiry: make(map[string]time.Time),
watchlistHistory: make(map[string]*cntrHistory),
watchlistVolHistory: make(map[string]*volumeHist),
watchlistSignalCache: make(map[string]SignalStock),
watchlistSignalExpiry: make(map[string]time.Time),
}
atomic.StoreInt32(&scannerSvc.enabled, 1) // 기본값: 켜짐
}
return scannerSvc
}
// Start 스캐너 백그라운드 고루틴 시작
func (s *ScannerService) Start() {
go s.run()
}
// SetEnabled 스캐너 활성화 여부 설정
func (s *ScannerService) SetEnabled(on bool) {
if on {
atomic.StoreInt32(&s.enabled, 1)
} else {
atomic.StoreInt32(&s.enabled, 0)
}
}
// IsEnabled 스캐너 활성화 여부 반환
func (s *ScannerService) IsEnabled() bool {
return atomic.LoadInt32(&s.enabled) == 1
}
// SetSubscribeCallback 종목 WS 구독 요청 콜백 설정 (Hub.SubscribeInternal 연결)
func (s *ScannerService) SetSubscribeCallback(fn func([]string)) {
s.mu.Lock()
s.subscribeCallback = fn
s.mu.Unlock()
}
// GetSignals 현재 감지된 시그널 종목 목록 반환
func (s *ScannerService) GetSignals() []SignalStock {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]SignalStock, len(s.signals))
copy(result, s.signals)
return result
}
// run 08:00 KST 이후 10초 주기로 스캔 반복 (enabled=0이면 스캔 건너뜀)
func (s *ScannerService) run() {
kst, _ := time.LoadLocation("Asia/Seoul")
for {
now := time.Now().In(kst)
if now.Hour() < 8 {
next := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, kst)
log.Printf("스캐너: 08:00 KST 대기 중 (%v)", next.Format("2006-01-02 15:04:05"))
time.Sleep(time.Until(next))
}
if s.IsEnabled() {
s.scan()
}
time.Sleep(10 * time.Second)
}
}
// calcRiseScore 4가지 복합 요소 기반 상승 확률 점수 계산 (0~100점)
// A.체결강도(30) + B.연속상승(25) + C.가격위치/캔들(20) + D.거래량건전성(15) + E.매도잔량소화(10)
func calcRiseScore(cntrStr float64, risingCount int, changeRate float64,
high, low, currentPrice int64, volRatio float64,
totalAskVol, totalBidVol int64) int {
score := 0
// A. 체결강도 레벨 (0~30점)
switch {
case cntrStr >= 150:
score += 30
case cntrStr >= 130:
score += 22
case cntrStr >= 110:
score += 14
case cntrStr >= 100:
score += 7
}
// B. 연속 상승 횟수 (0~25점)
switch {
case risingCount >= 5:
score += 25
case risingCount >= 4:
score += 20
case risingCount >= 3:
score += 14
case risingCount >= 2:
score += 8
case risingCount == 1:
score += 3
}
// C. 가격 위치 및 캔들 형태 (0~20점)
// C1. 등락률
switch {
case changeRate >= 3.0:
score += 6
case changeRate >= 1.0:
score += 4
case changeRate >= 0.0:
score += 1
}
// C2. 윗꼬리 비율 (0=없음 → 강한 양봉, 클수록 매도 압력)
if high > low {
upperWick := float64(high-currentPrice) / float64(high-low)
switch {
case upperWick <= 0.10:
score += 10 // 윗꼬리 거의 없음 = 강한 양봉
case upperWick <= 0.25:
score += 6
case upperWick <= 0.40:
score += 2
case upperWick > 0.60:
score -= 8 // 긴 윗꼬리 = 강한 매도 압력
}
// C3. 가격이 고가 80% 이상 위치 → 매수 우세
pricePos := float64(currentPrice-low) / float64(high-low) * 100
if pricePos >= 80 {
score += 4
}
}
// D. 거래량 건전성 (0~15점)
// 2~5배 증가가 최적, 10배+ 과열은 고점 물량털기 가능성으로 감점
switch {
case volRatio >= 2.0 && volRatio < 5.0:
score += 15 // 건강한 거래량 증가
case volRatio >= 1.5 && volRatio < 2.0:
score += 10
case volRatio >= 1.0 && volRatio < 1.5:
score += 6
case volRatio >= 5.0 && volRatio < 10.0:
score += 4 // 과열 초입
case volRatio >= 10.0:
score -= 5 // 폭발적 과열: 고점 물량털기 경계
case volRatio > 0:
score += 2
}
// E. 매도잔량 소화 여부 (0~10점)
if totalAskVol > 0 && totalBidVol > 0 {
bidAskRatio := float64(totalBidVol) / float64(totalAskVol)
switch {
case bidAskRatio >= 1.5:
score += 10 // 매수잔량 압도적: 위 물량 소화 중
case bidAskRatio >= 1.0:
score += 6 // 매수 ≥ 매도
case bidAskRatio >= 0.7:
score += 2
default:
score -= 3 // 매도잔량 크게 우세: 상단 물량 부담
}
}
if score < 0 {
return 0
}
return score
}
// classifySignalType 4가지 요소 조합으로 신호 유형 분류
func classifySignalType(sig *SignalStock) string {
upperWick := sig.UpperWick
askBidRatio := sig.AskBidRatio // 1 이상=매도우세, 1 미만=매수우세
if askBidRatio == 0 {
askBidRatio = 1.0 // 데이터 없으면 중립으로 취급
}
// 추격위험: 체결강도 과열 + 거래량 폭발 + 긴 윗꼬리
// → 단타 추격매수 몰림 후 고점 물량털기 패턴
if sig.CntrStr >= 170 && sig.VolRatio >= 7.0 && upperWick >= 0.4 {
return "추격위험"
}
// 강한매수: 체결강도 강함 + 연속상승 + 가격 우상향 + 윗꼬리 없음 + 매도잔량 소화
// → "사는 쪽이 실제로 강하고, 던지는 물량도 받아내는" 패턴
if sig.CntrStr >= 130 && sig.RisingCount >= 3 &&
sig.ChangeRate >= 1.0 && upperWick <= 0.25 && askBidRatio <= 1.0 {
return "강한매수"
}
// 물량소화: 체결강도 높은데 가격이 제자리 + 긴 윗꼬리
// → 위에서 던지는 물량을 받아내기만 하는 중
if sig.CntrStr >= 120 && sig.ChangeRate <= 0.5 && upperWick >= 0.35 {
return "물량소화"
}
// 약한상승: 거래량 적고 체결강도도 약함
// → 얇은 호가에서 뜬 상승, 쉽게 꺾일 수 있음
if sig.VolRatio < 1.0 && sig.CntrStr < 120 {
return "약한상승"
}
return "매수우세"
}
// scan 거래량 상위 20종목을 조회해 복합 분석으로 시그널 종목 필터링
func (s *ScannerService) scan() {
stocks, err := s.kiwoom.GetTopVolumeStocks("J", 20)
if err != nil {
log.Printf("스캐너 거래량순위 조회 실패: %v", err)
return
}
// 거래량 상위 종목 WS 구독 요청 (캐시 활용을 위해 미리 등록)
s.mu.RLock()
cb := s.subscribeCallback
s.mu.RUnlock()
if cb != nil {
codes := make([]string, len(stocks))
for i, st := range stocks {
codes[i] = st.Code
}
cb(codes)
}
var signals []SignalStock
s.mu.Lock()
for _, stock := range stocks {
// ka10003으로 최신 체결강도 조회; 실패 시 순위 응답값 사용
cntrStr, err := s.kiwoom.getCntrStr(stock.Code)
if err != nil || cntrStr == 0 {
cntrStr = stock.CntrStr
}
// 체결강도 이력 업데이트
h, ok := s.history[stock.Code]
if !ok {
h = &cntrHistory{}
s.history[stock.Code] = h
}
h.push(cntrStr)
rising := h.risingCount()
if rising == 0 {
continue
}
prev := float64(0)
if len(h.values) >= 2 {
prev = h.values[len(h.values)-2]
}
// 거래량 이력 업데이트 → 구간 증가분 및 증가율 계산
vh, ok := s.volumeHistory[stock.Code]
if !ok {
vh = &volumeHist{}
s.volumeHistory[stock.Code] = vh
}
volDelta, volRatio := vh.push(stock.Volume)
// 윗꼬리 비율 및 가격 위치 계산
upperWick, pricePos := 0.5, 50.0 // 데이터 없으면 중간값
if stock.High > stock.Low {
upperWick = float64(stock.High-stock.CurrentPrice) / float64(stock.High-stock.Low)
pricePos = float64(stock.CurrentPrice-stock.Low) / float64(stock.High-stock.Low) * 100
}
stock.CntrStr = cntrStr
signals = append(signals, SignalStock{
StockPrice: stock,
PrevCntrStr: prev,
RisingCount: rising,
DetectedAt: time.Now(),
VolDelta: volDelta,
VolRatio: volRatio,
UpperWick: upperWick,
PricePos: pricePos,
})
}
s.mu.Unlock()
// ── 호가잔량 병렬 조회 (체결강도 상승 종목에 한해) ────────────────
if len(signals) > 0 {
var wg sync.WaitGroup
for i := range signals {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
if err != nil {
return
}
signals[idx].TotalAskVol = ask
signals[idx].TotalBidVol = bid
if bid > 0 {
signals[idx].AskBidRatio = float64(ask) / float64(bid)
}
}(i)
}
wg.Wait()
}
// ── 최종 스코어 및 신호 유형 계산 (호가잔량 포함) ────────────────
for i := range signals {
sig := &signals[i]
sig.RiseScore = calcRiseScore(
sig.CntrStr, sig.RisingCount, sig.ChangeRate,
sig.High, sig.Low, sig.CurrentPrice,
sig.VolRatio, sig.TotalAskVol, sig.TotalBidVol,
)
sig.SignalType = classifySignalType(sig)
switch {
case sig.RiseScore >= 70:
sig.RiseLabel = "매우 높음"
case sig.RiseScore >= 50:
sig.RiseLabel = "높음"
default:
sig.RiseLabel = ""
}
}
// ── 1분 유지 캐시 병합 ────────────────────────────────────────────
const signalTTL = time.Minute
now := time.Now()
s.mu.Lock()
activeCodes := make(map[string]bool, len(signals))
for i := range signals {
code := signals[i].Code
activeCodes[code] = true
s.signalExpiry[code] = now.Add(signalTTL)
// 기존 LLM 결과 재사용 (Groq 재호출 방지)
if cached, ok := s.signalCache[code]; ok && cached.Sentiment != "" {
signals[i].Sentiment = cached.Sentiment
signals[i].SentimentReason = cached.SentimentReason
signals[i].TargetPrice = cached.TargetPrice
signals[i].TargetReason = cached.TargetReason
signals[i].NextDayTrend = cached.NextDayTrend
signals[i].NextDayConf = cached.NextDayConf
signals[i].NextDayReason = cached.NextDayReason
}
}
// 만료 안 된 이전 시그널 병합
for code, expiry := range s.signalExpiry {
if expiry.Before(now) {
delete(s.signalExpiry, code)
delete(s.signalCache, code)
continue
}
if !activeCodes[code] {
signals = append(signals, s.signalCache[code])
}
}
s.mu.Unlock()
// ── RiseScore 내림차순 정렬 (동점 시 체결강도 기준) ──────────────
sort.Slice(signals, func(i, j int) bool {
if signals[i].RiseScore != signals[j].RiseScore {
return signals[i].RiseScore > signals[j].RiseScore
}
return signals[i].CntrStr > signals[j].CntrStr
})
// ── LLM 병렬 분석 (shouldAnalyze 통과 종목만, 5초 타임아웃) ──────
if len(signals) > 0 {
var wg sync.WaitGroup
done := make(chan struct{})
for i := range signals {
if !shouldAnalyze(&signals[i]) {
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
sig := &signals[idx]
sentiment, reason := s.analysis.Analyze(sig.Code, sig.Name)
sig.Sentiment = sentiment
sig.SentimentReason = reason
targetPrice, targetReason := s.analysis.PredictTargetPriceFromSignal(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.PrevCntrStr, sig.RisingCount,
sentiment, reason,
)
sig.TargetPrice = targetPrice
sig.TargetReason = targetReason
trend, conf, trendReason := s.analysis.PredictNextDayTrend(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.RisingCount,
sentiment, reason,
)
sig.NextDayTrend = trend
sig.NextDayConf = conf
sig.NextDayReason = trendReason
}(i)
}
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
log.Printf("스캐너: 감성 분석 5초 타임아웃 (일부 미완료 가능)")
}
}
s.mu.Lock()
for _, sig := range signals {
s.signalCache[sig.Code] = sig
}
s.signals = signals
s.mu.Unlock()
log.Printf("스캐너: 거래량상위20 체결강도 체크 완료 → 시그널 %d개", len(signals))
}
// shouldAnalyze LLM 분석 호출 여부 판단 (Groq API 호출량 절감)
// 상승 확률이 높은 종목만 통과: RiseScore 50+, 2회 이상 연속 상승, 체결강도 100+, 등락률 0% 이상
func shouldAnalyze(sig *SignalStock) bool {
return sig.RiseScore >= 50 &&
sig.RisingCount >= 2 &&
sig.CntrStr >= 100 &&
sig.ChangeRate >= 0
}
// AnalyzeWatchlist 관심종목 코드 목록에 대해 복합 분석 수행 후 SignalStock 반환
func (s *ScannerService) AnalyzeWatchlist(codes []string) []SignalStock {
// 분석 전 WS 구독 요청 (다음 사이클부터 캐시 활용 가능)
s.mu.RLock()
cb := s.subscribeCallback
s.mu.RUnlock()
if cb != nil && len(codes) > 0 {
cb(codes)
}
type interim struct {
code string
cntrStr float64
prev float64
rising int
volDelta int64
volRatio float64
price *models.StockPrice
}
// Phase 1: 현재가/체결강도/거래량 이력 순차 수집
// GetCurrentPrice 내부에서 getCntrStr(ka10003)을 이미 호출하므로 중복 호출 없음
items := make([]interim, 0, len(codes))
for _, code := range codes {
// 현재가 조회 (캐시 활용, 내부적으로 체결강도도 포함)
sp, err := s.stockSvc.GetCurrentPrice(code)
if err != nil {
log.Printf("관심종목 현재가 조회 실패 [%s]: %v", code, err)
continue
}
cntrStr := sp.CntrStr
// 체결강도 이력 업데이트
s.mu.Lock()
h, ok := s.watchlistHistory[code]
if !ok {
h = &cntrHistory{}
s.watchlistHistory[code] = h
}
h.push(cntrStr)
rising := h.risingCount()
prev := float64(0)
if len(h.values) >= 2 {
prev = h.values[len(h.values)-2]
}
// 거래량 이력 업데이트
vh, ok := s.watchlistVolHistory[code]
if !ok {
vh = &volumeHist{}
s.watchlistVolHistory[code] = vh
}
volDelta, volRatio := vh.push(sp.Volume)
s.mu.Unlock()
items = append(items, interim{
code: code,
cntrStr: cntrStr,
prev: prev,
rising: rising,
volDelta: volDelta,
volRatio: volRatio,
price: sp,
})
}
// Phase 2: SignalStock 슬라이스 구성
signals := make([]SignalStock, 0, len(items))
for _, it := range items {
sp := it.price
upperWick, pricePos := 0.5, 50.0
if sp.High > sp.Low {
upperWick = float64(sp.High-sp.CurrentPrice) / float64(sp.High-sp.Low)
pricePos = float64(sp.CurrentPrice-sp.Low) / float64(sp.High-sp.Low) * 100
}
signals = append(signals, SignalStock{
StockPrice: *sp,
PrevCntrStr: it.prev,
RisingCount: it.rising,
DetectedAt: time.Now(),
VolDelta: it.volDelta,
VolRatio: it.volRatio,
UpperWick: upperWick,
PricePos: pricePos,
})
}
// Phase 3: 호가잔량 병렬 조회
if len(signals) > 0 {
var wg sync.WaitGroup
for i := range signals {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
if err != nil {
return
}
signals[idx].TotalAskVol = ask
signals[idx].TotalBidVol = bid
if bid > 0 {
signals[idx].AskBidRatio = float64(ask) / float64(bid)
}
}(i)
}
wg.Wait()
}
// Phase 4: 스코어 및 신호 유형 계산
for i := range signals {
sig := &signals[i]
sig.RiseScore = calcRiseScore(
sig.CntrStr, sig.RisingCount, sig.ChangeRate,
sig.High, sig.Low, sig.CurrentPrice,
sig.VolRatio, sig.TotalAskVol, sig.TotalBidVol,
)
sig.SignalType = classifySignalType(sig)
switch {
case sig.RiseScore >= 70:
sig.RiseLabel = "매우 높음"
case sig.RiseScore >= 50:
sig.RiseLabel = "높음"
default:
sig.RiseLabel = ""
}
}
// Phase 5: 1분 TTL 캐시 병합 (LLM 결과 재사용)
const signalTTL = time.Minute
now := time.Now()
s.mu.Lock()
for i := range signals {
code := signals[i].Code
s.watchlistSignalExpiry[code] = now.Add(signalTTL)
if cached, ok := s.watchlistSignalCache[code]; ok && cached.Sentiment != "" {
signals[i].Sentiment = cached.Sentiment
signals[i].SentimentReason = cached.SentimentReason
signals[i].TargetPrice = cached.TargetPrice
signals[i].TargetReason = cached.TargetReason
signals[i].NextDayTrend = cached.NextDayTrend
signals[i].NextDayConf = cached.NextDayConf
signals[i].NextDayReason = cached.NextDayReason
}
}
// 만료된 캐시 항목 정리
for code, expiry := range s.watchlistSignalExpiry {
if expiry.Before(now) {
delete(s.watchlistSignalExpiry, code)
delete(s.watchlistSignalCache, code)
}
}
s.mu.Unlock()
// Phase 6: LLM 병렬 분석 (shouldAnalyze 통과 종목만, 5초 타임아웃)
if len(signals) > 0 {
var wg sync.WaitGroup
done := make(chan struct{})
for i := range signals {
if !shouldAnalyze(&signals[i]) {
continue
}
wg.Add(1)
go func(idx int) {
defer wg.Done()
sig := &signals[idx]
sentiment, reason := s.analysis.Analyze(sig.Code, sig.Name)
sig.Sentiment = sentiment
sig.SentimentReason = reason
targetPrice, targetReason := s.analysis.PredictTargetPriceFromSignal(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.PrevCntrStr, sig.RisingCount,
sentiment, reason,
)
sig.TargetPrice = targetPrice
sig.TargetReason = targetReason
trend, conf, trendReason := s.analysis.PredictNextDayTrend(
sig.Code, sig.Name,
sig.CurrentPrice, sig.High, sig.Low, sig.Open,
sig.ChangeRate, sig.CntrStr, sig.RisingCount,
sentiment, reason,
)
sig.NextDayTrend = trend
sig.NextDayConf = conf
sig.NextDayReason = trendReason
}(i)
}
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
log.Printf("관심종목 분석: 감성 분석 5초 타임아웃")
}
}
// Phase 7: 결과 캐시 저장
s.mu.Lock()
for _, sig := range signals {
s.watchlistSignalCache[sig.Code] = sig
}
s.mu.Unlock()
return signals
}

155
services/search_service.go Normal file
View File

@@ -0,0 +1,155 @@
package services
import (
"encoding/json"
"log"
"strings"
"sync"
"unicode"
)
// StockItem 검색용 종목 정보
type StockItem struct {
Code string `json:"code"`
Name string `json:"name"`
Market string `json:"market"`
}
// SearchService 종목 검색 서비스 (메모리 캐시)
type SearchService struct {
kiwoom *KiwoomClient
mu sync.RWMutex
stocks []StockItem // 전체 종목 리스트 캐시
}
var (
searchSvcOnce sync.Once
searchSvc *SearchService
)
// GetSearchService 싱글턴 반환
func GetSearchService() *SearchService {
searchSvcOnce.Do(func() {
searchSvc = &SearchService{kiwoom: GetKiwoomClient()}
})
return searchSvc
}
// Load 코스피·코스닥 전체 종목 목록을 키움 ka10099로 로딩
// 서버 시작 시 1회 호출 (고루틴)
func (s *SearchService) Load() {
go func() {
stocks, err := s.fetchAll()
if err != nil {
log.Printf("종목 리스트 로딩 실패: %v", err)
return
}
s.mu.Lock()
s.stocks = stocks
s.mu.Unlock()
log.Printf("종목 리스트 로딩 완료: %d개", len(stocks))
}()
}
// Search 종목명 또는 코드로 검색 (최대 10건)
func (s *SearchService) Search(q string) []StockItem {
s.mu.RLock()
defer s.mu.RUnlock()
q = strings.TrimSpace(q)
if q == "" || len(s.stocks) == 0 {
return nil
}
qLower := strings.ToLower(q)
isDigit := isAllDigit(q)
var results []StockItem
for _, st := range s.stocks {
var match bool
if isDigit {
// 숫자면 코드 전방일치
match = strings.HasPrefix(st.Code, q)
} else {
// 문자면 종목명 포함검색
match = strings.Contains(strings.ToLower(st.Name), qLower)
}
if match {
results = append(results, st)
if len(results) >= 10 {
break
}
}
}
return results
}
// fetchAll ka10099 연속조회로 코스피+코스닥 전체 종목 수집
func (s *SearchService) fetchAll() ([]StockItem, error) {
var all []StockItem
for _, mrkt := range []struct{ tp, name string }{
{"0", "KOSPI"},
{"10", "KOSDAQ"},
} {
items, err := s.fetchMarket(mrkt.tp, mrkt.name)
if err != nil {
log.Printf("종목 리스트 조회 실패 (%s): %v", mrkt.name, err)
continue
}
all = append(all, items...)
}
return all, nil
}
// fetchMarket 특정 시장의 전체 종목을 연속조회로 수집
func (s *SearchService) fetchMarket(mrktTp, marketName string) ([]StockItem, error) {
body := map[string]string{"mrkt_tp": mrktTp}
var items []StockItem
contYn := "N"
nextKey := ""
for {
raw, respContYn, respNextKey, err := s.kiwoom.postPaged("ka10099", "/api/dostk/stkinfo", body, contYn, nextKey)
if err != nil {
return items, err
}
var resp struct {
List []struct {
Code string `json:"code"`
Name string `json:"name"`
State string `json:"state"`
} `json:"list"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return items, err
}
for _, it := range resp.List {
items = append(items, StockItem{
Code: it.Code,
Name: it.Name,
Market: marketName,
})
}
// 연속조회 종료 조건
if respContYn != "Y" || respNextKey == "" {
break
}
contYn = "Y"
nextKey = respNextKey
}
return items, nil
}
func isAllDigit(s string) bool {
for _, r := range s {
if !unicode.IsDigit(r) {
return false
}
}
return true
}

View File

@@ -0,0 +1,75 @@
package services
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// SessionService 인메모리 세션 스토어
type SessionService struct {
sessions sync.Map // sessionID → time.Time(만료시각)
ttl time.Duration // 세션 유효 기간
}
var sessionServiceInstance *SessionService
var sessionServiceOnce sync.Once
// GetSessionService 세션 서비스 싱글톤 반환
func GetSessionService() *SessionService {
sessionServiceOnce.Do(func() {
sessionServiceInstance = NewSessionService()
})
return sessionServiceInstance
}
// NewSessionService 세션 서비스 초기화 (TTL 24시간)
func NewSessionService() *SessionService {
svc := &SessionService{ttl: 24 * time.Hour}
go svc.cleanup()
return svc
}
// Create 새 세션 생성 → sessionID(UUID) 반환
func (s *SessionService) Create() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
id := hex.EncodeToString(b)
s.sessions.Store(id, time.Now().Add(s.ttl))
return id
}
// Validate 세션 유효성 검사 (만료 시 자동 삭제)
func (s *SessionService) Validate(id string) bool {
val, ok := s.sessions.Load(id)
if !ok {
return false
}
expiry, ok := val.(time.Time)
if !ok || time.Now().After(expiry) {
s.sessions.Delete(id)
return false
}
return true
}
// Delete 세션 삭제 (로그아웃)
func (s *SessionService) Delete(id string) {
s.sessions.Delete(id)
}
// cleanup 만료된 세션을 주기적으로 정리 (1시간 주기)
func (s *SessionService) cleanup() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
s.sessions.Range(func(key, val any) bool {
if expiry, ok := val.(time.Time); ok && now.After(expiry) {
s.sessions.Delete(key)
}
return true
})
}
}

103
services/stock_service.go Normal file
View File

@@ -0,0 +1,103 @@
package services
import (
"fmt"
"stocksearch/models"
"time"
)
// StockService 주식 비즈니스 로직 (캐시 + 키움 API 조합)
type StockService struct {
kiwoom *KiwoomClient
cache *CacheService
}
var stockSvc *StockService
// GetStockService 주식 서비스 싱글턴 반환
func GetStockService() *StockService {
if stockSvc == nil {
stockSvc = &StockService{
kiwoom: GetKiwoomClient(),
cache: GetCacheService(),
}
}
return stockSvc
}
// GetCurrentPrice 현재가 조회 (1초 캐시 적용)
func (s *StockService) GetCurrentPrice(stockCode string) (*models.StockPrice, error) {
cacheKey := "price:" + stockCode
if cached, ok := s.cache.Get(cacheKey); ok {
if price, ok := cached.(*models.StockPrice); ok {
return price, nil
}
}
price, err := s.kiwoom.GetCurrentPrice(stockCode)
if err != nil {
return nil, fmt.Errorf("현재가 조회 실패 [%s]: %w", stockCode, err)
}
s.cache.Set(cacheKey, price, 30*time.Second)
return price, nil
}
// GetDailyChart 일봉 데이터 조회 (5분 캐시 적용)
func (s *StockService) GetDailyChart(stockCode string) ([]models.CandleData, error) {
cacheKey := "chart:daily:" + stockCode
if cached, ok := s.cache.Get(cacheKey); ok {
if candles, ok := cached.([]models.CandleData); ok {
return candles, nil
}
}
candles, err := s.kiwoom.GetDailyChart(stockCode)
if err != nil {
return nil, fmt.Errorf("일봉 조회 실패 [%s]: %w", stockCode, err)
}
s.cache.Set(cacheKey, candles, 5*time.Minute)
return candles, nil
}
// GetMinuteChart 분봉 데이터 조회 (30초 캐시 적용)
func (s *StockService) GetMinuteChart(stockCode string, minutes int) ([]models.CandleData, error) {
cacheKey := fmt.Sprintf("chart:minute%d:%s", minutes, stockCode)
if cached, ok := s.cache.Get(cacheKey); ok {
if candles, ok := cached.([]models.CandleData); ok {
return candles, nil
}
}
candles, err := s.kiwoom.GetMinuteChart(stockCode, minutes)
if err != nil {
return nil, fmt.Errorf("분봉 조회 실패 [%s]: %w", stockCode, err)
}
s.cache.Set(cacheKey, candles, 30*time.Second)
return candles, nil
}
// GetTopFluctuation 상위 등락률 종목 조회 (1분 캐시 적용)
func (s *StockService) GetTopFluctuation(market string, ascending bool, count int) ([]models.StockPrice, error) {
dir := "up"
if ascending {
dir = "down"
}
cacheKey := fmt.Sprintf("fluctuation:%s:%s:%d", market, dir, count)
if cached, ok := s.cache.Get(cacheKey); ok {
if stocks, ok := cached.([]models.StockPrice); ok {
return stocks, nil
}
}
stocks, err := s.kiwoom.GetTopFluctuation(market, ascending, count)
if err != nil {
return nil, fmt.Errorf("등락률 조회 실패: %w", err)
}
s.cache.Set(cacheKey, stocks, 1*time.Minute)
return stocks, nil
}

152
services/theme_service.go Normal file
View File

@@ -0,0 +1,152 @@
package services
import (
"encoding/json"
"fmt"
"stocksearch/models"
"strings"
"time"
)
// ThemeService 테마 분석 서비스
type ThemeService struct {
kiwoom *KiwoomClient
cache *CacheService
}
var themeSvc *ThemeService
// GetThemeService 테마 서비스 싱글턴 반환
func GetThemeService() *ThemeService {
if themeSvc == nil {
themeSvc = &ThemeService{
kiwoom: GetKiwoomClient(),
cache: GetCacheService(),
}
}
return themeSvc
}
// GetThemes ka90001: 테마그룹 목록 조회 (캐시 1분)
// sortTp: "3"=상위등락률, "1"=상위기간수익률
func (s *ThemeService) GetThemes(dateTp, sortTp string) ([]models.ThemeGroup, error) {
cacheKey := fmt.Sprintf("themes:%s:%s", dateTp, sortTp)
if cached, ok := s.cache.Get(cacheKey); ok {
if groups, ok := cached.([]models.ThemeGroup); ok {
return groups, nil
}
}
body, err := s.kiwoom.post("ka90001", "/api/dostk/thme", map[string]string{
"qry_tp": "0", // 전체검색
"stk_cd": "",
"date_tp": dateTp,
"thema_nm": "",
"flu_pl_amt_tp": sortTp,
"stex_tp": "1", // KRX
})
if err != nil {
return nil, fmt.Errorf("테마 목록 조회 실패: %w", err)
}
var result struct {
ThemaGrp []struct {
ThemaGrpCd string `json:"thema_grp_cd"`
ThemaNm string `json:"thema_nm"`
StkNum string `json:"stk_num"`
FluSig string `json:"flu_sig"`
FluRt string `json:"flu_rt"`
RisingStkNum string `json:"rising_stk_num"`
FallStkNum string `json:"fall_stk_num"`
DtPrftRt string `json:"dt_prft_rt"`
MainStk string `json:"main_stk"`
} `json:"thema_grp"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("테마 목록 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("테마 목록 조회 실패: %s", result.ReturnMsg)
}
groups := make([]models.ThemeGroup, 0, len(result.ThemaGrp))
for _, g := range result.ThemaGrp {
groups = append(groups, models.ThemeGroup{
Code: g.ThemaGrpCd,
Name: g.ThemaNm,
StockCount: int(parseIntSafe(g.StkNum)),
FluSig: g.FluSig,
FluRt: parseFloatSafe(strings.TrimPrefix(g.FluRt, "+")),
RisingCount: int(parseIntSafe(g.RisingStkNum)),
FallCount: int(parseIntSafe(g.FallStkNum)),
PeriodRt: parseFloatSafe(strings.TrimPrefix(g.DtPrftRt, "+")),
MainStock: g.MainStk,
})
}
s.cache.Set(cacheKey, groups, time.Minute)
return groups, nil
}
// GetThemeStocks ka90002: 테마구성종목 조회 (캐시 2분)
func (s *ThemeService) GetThemeStocks(themeCode, dateTp string) (*models.ThemeDetail, error) {
cacheKey := fmt.Sprintf("theme_stocks:%s:%s", themeCode, dateTp)
if cached, ok := s.cache.Get(cacheKey); ok {
if detail, ok := cached.(*models.ThemeDetail); ok {
return detail, nil
}
}
body, err := s.kiwoom.post("ka90002", "/api/dostk/thme", map[string]string{
"thema_grp_cd": themeCode,
"date_tp": dateTp,
"stex_tp": "1", // KRX
})
if err != nil {
return nil, fmt.Errorf("테마 구성종목 조회 실패: %w", err)
}
var result struct {
FluRt string `json:"flu_rt"`
DtPrftRt string `json:"dt_prft_rt"`
ThemaCompStk []struct {
StkCd string `json:"stk_cd"`
StkNm string `json:"stk_nm"`
CurPrc string `json:"cur_prc"`
FluSig string `json:"flu_sig"`
PredPre string `json:"pred_pre"`
FluRt string `json:"flu_rt"`
} `json:"thema_comp_stk"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("테마 구성종목 파싱 실패: %w", err)
}
if result.ReturnCode != 0 {
return nil, fmt.Errorf("테마 구성종목 조회 실패: %s", result.ReturnMsg)
}
stocks := make([]models.ThemeStock, 0, len(result.ThemaCompStk))
for _, s := range result.ThemaCompStk {
stocks = append(stocks, models.ThemeStock{
Code: s.StkCd,
Name: s.StkNm,
CurPrc: absParseIntSafe(s.CurPrc),
FluSig: s.FluSig,
PredPre: parseIntSafe(s.PredPre),
FluRt: parseFloatSafe(strings.TrimPrefix(s.FluRt, "+")),
})
}
detail := &models.ThemeDetail{
FluRt: parseFloatSafe(strings.TrimPrefix(result.FluRt, "+")),
PeriodRt: parseFloatSafe(strings.TrimPrefix(result.DtPrftRt, "+")),
Stocks: stocks,
}
s.cache.Set(cacheKey, detail, 2*time.Minute)
return detail, nil
}

129
services/token_service.go Normal file
View File

@@ -0,0 +1,129 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"stocksearch/config"
"stocksearch/models"
"sync"
"time"
)
// TokenService 키움증권 액세스 토큰 관리 (싱글턴)
type TokenService struct {
mu sync.RWMutex
token *models.TokenResponse
}
var tokenSvc *TokenService
var tokenOnce sync.Once
// GetTokenService 토큰 서비스 싱글턴 반환
func GetTokenService() *TokenService {
tokenOnce.Do(func() {
tokenSvc = &TokenService{}
})
return tokenSvc
}
// Start 서버 시작 시 토큰 발급 후 자동 갱신 고루틴 실행
func (s *TokenService) Start() error {
if err := s.refresh(); err != nil {
return fmt.Errorf("초기 토큰 발급 실패: %w", err)
}
go s.autoRefresh()
return nil
}
// GetToken 현재 유효한 액세스 토큰 반환
func (s *TokenService) GetToken() string {
s.mu.RLock()
defer s.mu.RUnlock()
if s.token == nil {
return ""
}
return s.token.Token
}
// refresh 키움증권 API로 토큰 발급
func (s *TokenService) refresh() error {
cfg := config.App
body := map[string]string{
"grant_type": "client_credentials",
"appkey": cfg.AppKey,
"secretkey": cfg.AppSecret,
}
data, _ := json.Marshal(body)
resp, err := http.Post(
cfg.BaseURL+"/oauth2/token",
"application/json;charset=UTF-8",
bytes.NewReader(data),
)
if err != nil {
return fmt.Errorf("토큰 발급 요청 실패: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("토큰 발급 응답 오류: HTTP %d, 응답: %s", resp.StatusCode, string(respBody))
}
// HTML 응답 시 서버 점검 중으로 판단
contentType := resp.Header.Get("Content-Type")
if len(respBody) > 0 && respBody[0] == '<' || (contentType != "" && !bytes.Contains([]byte(contentType), []byte("json"))) {
return fmt.Errorf("키움증권 서버 점검 중 (HTML 응답): %s", contentType)
}
var tokenResp models.TokenResponse
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return fmt.Errorf("토큰 응답 파싱 실패: %w", err)
}
if tokenResp.ReturnCode != 0 {
return fmt.Errorf("토큰 발급 실패: %s", tokenResp.ReturnMsg)
}
// expires_dt: YYYYMMDDHHmmss 형식 파싱
expiresTime, err := time.ParseInLocation("20060102150405", tokenResp.ExpiresAt, time.Local)
if err != nil {
// 파싱 실패 시 12시간 후를 기본값으로 설정
expiresTime = time.Now().Add(12 * time.Hour)
}
tokenResp.ExpiresTime = expiresTime
s.mu.Lock()
s.token = &tokenResp
s.mu.Unlock()
log.Printf("토큰 발급 완료 (만료: %s)", expiresTime.Format("2006-01-02 15:04:05"))
return nil
}
// autoRefresh 만료 1시간 전에 자동으로 토큰 갱신
func (s *TokenService) autoRefresh() {
for {
s.mu.RLock()
expiresTime := s.token.ExpiresTime
s.mu.RUnlock()
// 만료 1시간 전에 갱신
refreshAt := expiresTime.Add(-1 * time.Hour)
waitDuration := time.Until(refreshAt)
if waitDuration < 0 {
waitDuration = 10 * time.Second // 이미 만료됐으면 즉시 재시도
}
time.Sleep(waitDuration)
if err := s.refresh(); err != nil {
log.Printf("토큰 자동 갱신 실패: %v (10초 후 재시도)", err)
time.Sleep(10 * time.Second)
}
}
}

View File

@@ -0,0 +1,101 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// WatchlistItem 관심종목 항목
type WatchlistItem struct {
Code string `json:"code"`
Name string `json:"name"`
}
// WatchlistService 관심종목 JSON 파일 CRUD
type WatchlistService struct {
mu sync.RWMutex
path string
list []WatchlistItem
}
var watchlistServiceInstance *WatchlistService
var watchlistServiceOnce sync.Once
// GetWatchlistService 관심종목 서비스 싱글톤 반환
func GetWatchlistService() *WatchlistService {
watchlistServiceOnce.Do(func() {
watchlistServiceInstance = NewWatchlistService("data/watchlist.json")
})
return watchlistServiceInstance
}
// NewWatchlistService 관심종목 서비스 초기화 (파일 로드)
func NewWatchlistService(path string) *WatchlistService {
svc := &WatchlistService{path: path}
svc.load()
return svc
}
// GetAll 전체 관심종목 반환
func (s *WatchlistService) GetAll() []WatchlistItem {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]WatchlistItem, len(s.list))
copy(result, s.list)
return result
}
// Add 관심종목 추가 (중복 방지)
func (s *WatchlistService) Add(code, name string) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, item := range s.list {
if item.Code == code {
return fmt.Errorf("이미 추가된 종목입니다: %s", code)
}
}
s.list = append(s.list, WatchlistItem{Code: code, Name: name})
return s.save()
}
// Remove 관심종목 삭제
func (s *WatchlistService) Remove(code string) error {
s.mu.Lock()
defer s.mu.Unlock()
newList := make([]WatchlistItem, 0, len(s.list))
for _, item := range s.list {
if item.Code != code {
newList = append(newList, item)
}
}
s.list = newList
return s.save()
}
// load 파일에서 관심종목 로드 (파일 없으면 빈 배열 초기화)
func (s *WatchlistService) load() {
data, err := os.ReadFile(s.path)
if err != nil {
s.list = []WatchlistItem{}
return
}
if err := json.Unmarshal(data, &s.list); err != nil {
s.list = []WatchlistItem{}
}
}
// save 관심종목을 JSON 파일에 저장 (내부용)
func (s *WatchlistService) save() error {
// data/ 디렉토리 자동 생성
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s.list, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o644)
}

30
static/css/custom.css Normal file
View File

@@ -0,0 +1,30 @@
/* 로딩 스피너 */
.spinner {
border: 3px solid #f3f4f6;
border-top-color: #3b82f6;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 실시간 업데이트 깜빡임 효과 */
.flash-up {
animation: flashUp 0.5s ease-out;
}
.flash-down {
animation: flashDown 0.5s ease-out;
}
@keyframes flashUp {
0% { background-color: #fee2e2; }
100% { background-color: transparent; }
}
@keyframes flashDown {
0% { background-color: #dbeafe; }
100% { background-color: transparent; }
}

308
static/js/asset.js Normal file
View File

@@ -0,0 +1,308 @@
/**
* 자산 현황 페이지 로직
* - 요약 카드, 예수금, 보유 종목, 미체결/체결내역
*/
// -----------------------------------
// 초기화
// -----------------------------------
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadCash();
loadPending();
});
// -----------------------------------
// 요약 카드 + 보유 종목 (GET /api/account/balance)
// -----------------------------------
async function loadSummary() {
try {
const res = await fetch('/api/account/balance');
const data = await res.json();
if (!res.ok) {
renderSummaryError(data.error || '잔고 조회 실패');
return;
}
renderSummaryCards(data);
renderHoldings(data.stocks);
} catch (e) {
renderSummaryError('네트워크 오류: ' + e.message);
}
}
function renderSummaryCards(data) {
const plRate = parseFloat(data.totPrftRt || '0');
const plAmt = parseInt(data.totEvltPl || '0');
const plClass = plRate >= 0 ? 'text-red-500' : 'text-blue-500';
const cards = [
{
label: '추정예탁자산',
value: parseInt(data.prsmDpstAsetAmt || '0').toLocaleString('ko-KR') + '원',
cls: 'text-gray-800',
},
{
label: '총평가금액',
value: parseInt(data.totEvltAmt || '0').toLocaleString('ko-KR') + '원',
cls: 'text-gray-800',
},
{
label: '총평가손익',
value: (plAmt >= 0 ? '+' : '') + plAmt.toLocaleString('ko-KR') + '원',
cls: plClass,
},
{
label: '수익률',
value: (plRate >= 0 ? '+' : '') + plRate.toFixed(2) + '%',
cls: plClass,
},
];
document.getElementById('summaryCards').innerHTML = cards.map(c => `
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<p class="text-xs text-gray-400 mb-1">${c.label}</p>
<p class="text-lg font-bold ${c.cls}">${c.value}</p>
</div>
`).join('');
}
function renderSummaryError(msg) {
document.getElementById('summaryCards').innerHTML = `
<div class="col-span-4 text-sm text-red-400 text-center py-4">${msg}</div>
`;
document.getElementById('holdingsTable').innerHTML = '';
}
function renderHoldings(stocks) {
const tbody = document.getElementById('holdingsTable');
if (!stocks || stocks.length === 0) {
tbody.innerHTML = '<div class="px-5 py-10 text-center text-sm text-gray-400">보유 종목이 없습니다.</div>';
return;
}
tbody.innerHTML = stocks.map(s => {
const prft = parseFloat(s.prftRt || '0');
const evlt = parseInt(s.evltvPrft || '0');
const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500';
const sign = prft >= 0 ? '+' : '';
return `
<div class="grid grid-cols-[1fr_80px_90px_90px_100px_80px] text-sm px-5 py-3 border-b border-gray-50 hover:bg-gray-50 gap-2 items-center">
<a href="/stock/${s.stkCd}" class="font-medium text-gray-800 hover:text-blue-600 truncate">${s.stkNm}</a>
<span class="text-right text-gray-600">${parseInt(s.rmndQty || '0').toLocaleString('ko-KR')}주</span>
<span class="text-right text-gray-600">${parseInt(s.purPric || '0').toLocaleString('ko-KR')}</span>
<span class="text-right text-gray-600">${parseInt(s.curPrc || '0').toLocaleString('ko-KR')}</span>
<span class="text-right ${cls}">${(evlt >= 0 ? '+' : '') + evlt.toLocaleString('ko-KR')}원</span>
<span class="text-right ${cls}">${sign}${prft.toFixed(2)}%</span>
</div>`;
}).join('');
}
// -----------------------------------
// 예수금 카드 (GET /api/account/deposit — kt00001)
// -----------------------------------
async function loadCash() {
try {
const res = await fetch('/api/account/deposit');
const data = await res.json();
if (!res.ok) {
document.getElementById('cashEntr').textContent = '조회 실패';
document.getElementById('cashOrdAlowa').textContent = '조회 실패';
return;
}
// entr: 예수금, d2Entra: D+2 추정예수금, ordAlowAmt: 주문가능금액
document.getElementById('cashEntr').textContent =
data.d2Entra ? parseInt(data.d2Entra).toLocaleString('ko-KR') + '원' : '-';
document.getElementById('cashOrdAlowa').textContent =
data.ordAlowAmt ? parseInt(data.ordAlowAmt).toLocaleString('ko-KR') + '원' : '-';
} catch (e) {
document.getElementById('cashEntr').textContent = '오류';
document.getElementById('cashOrdAlowa').textContent = '오류';
}
}
// -----------------------------------
// 탭 전환
// -----------------------------------
function showAssetTab(tab) {
['pending', 'history'].forEach(t => {
const btn = document.getElementById('asset' + capitalize(t) + 'Tab');
const panel = document.getElementById('asset' + capitalize(t) + 'Panel');
const active = t === tab;
if (btn) {
btn.classList.toggle('border-b-2', active);
btn.classList.toggle('border-blue-500', active);
btn.classList.toggle('text-blue-600', active);
btn.classList.toggle('text-gray-500', !active);
}
if (panel) panel.classList.toggle('hidden', !active);
});
if (tab === 'pending') loadPending();
if (tab === 'history') loadHistory();
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// -----------------------------------
// 미체결 목록 (GET /api/account/pending)
// -----------------------------------
async function loadPending() {
const panel = document.getElementById('assetPendingPanel');
if (!panel) return;
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>';
try {
const res = await fetch('/api/account/pending');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">${list.error || '조회 실패'}</div>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6">미체결 주문이 없습니다.</div>';
return;
}
panel.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-500 bg-gray-50 border-b border-gray-100">
<th class="px-3 py-2 text-left font-medium">종목명</th>
<th class="px-3 py-2 text-center font-medium">구분</th>
<th class="px-3 py-2 text-right font-medium">주문가</th>
<th class="px-3 py-2 text-right font-medium">미체결/주문</th>
<th class="px-3 py-2 text-center font-medium">취소</th>
</tr>
</thead>
<tbody>
${list.map(o => {
const isBuy = o.trdeTp === '2';
const cls = isBuy ? 'text-red-500' : 'text-blue-500';
const label = isBuy ? '매수' : '매도';
return `
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="px-3 py-2.5 font-medium text-gray-800">${o.stkNm}</td>
<td class="px-3 py-2.5 text-center ${cls}">${label}</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.ordPric || '0').toLocaleString('ko-KR')}원</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.osoQty || '0').toLocaleString('ko-KR')} / ${parseInt(o.ordQty || '0').toLocaleString('ko-KR')}주</td>
<td class="px-3 py-2.5 text-center">
<button onclick="assetCancelOrder('${o.ordNo}','${o.stkCd}')"
class="px-2.5 py-1 text-xs rounded border border-gray-300 text-gray-600 hover:bg-gray-100">취소</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">오류: ${e.message}</div>`;
}
}
// -----------------------------------
// 미체결 취소
// -----------------------------------
async function assetCancelOrder(ordNo, stkCd) {
if (!confirm('전량 취소하시겠습니까?')) return;
try {
const res = await fetch('/api/order', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origOrdNo: ordNo, code: stkCd, qty: '0', exchange: 'KRX' }),
});
const data = await res.json();
if (!res.ok || data.error) {
showAssetToast(data.error || '취소 실패', 'error');
return;
}
showAssetToast('취소 주문 접수 완료', 'success');
loadPending();
} catch (e) {
showAssetToast('네트워크 오류', 'error');
}
}
// -----------------------------------
// 체결내역 (GET /api/account/history)
// -----------------------------------
async function loadHistory() {
const panel = document.getElementById('assetHistoryPanel');
if (!panel) return;
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>';
try {
const res = await fetch('/api/account/history');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">${list.error || '조회 실패'}</div>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6">체결 내역이 없습니다.</div>';
return;
}
panel.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-500 bg-gray-50 border-b border-gray-100">
<th class="px-3 py-2 text-left font-medium">종목명</th>
<th class="px-3 py-2 text-center font-medium">구분</th>
<th class="px-3 py-2 text-right font-medium">체결가</th>
<th class="px-3 py-2 text-right font-medium">체결수량</th>
<th class="px-3 py-2 text-right font-medium">수수료+세금</th>
</tr>
</thead>
<tbody>
${list.map(o => {
const isBuy = o.trdeTp === '2';
const cls = isBuy ? 'text-red-500' : 'text-blue-500';
const label = isBuy ? '매수' : '매도';
const fee = (parseInt(o.trdeCmsn || '0') + parseInt(o.trdeTax || '0')).toLocaleString('ko-KR');
return `
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="px-3 py-2.5 font-medium text-gray-800">${o.stkNm}</td>
<td class="px-3 py-2.5 text-center ${cls}">${label}</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.cntrPric || '0').toLocaleString('ko-KR')}원</td>
<td class="px-3 py-2.5 text-right">${parseInt(o.cntrQty || '0').toLocaleString('ko-KR')}주</td>
<td class="px-3 py-2.5 text-right text-gray-500">${fee}원</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
} catch (e) {
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">오류: ${e.message}</div>`;
}
}
// -----------------------------------
// 토스트 알림
// -----------------------------------
function showAssetToast(msg, type) {
const toast = document.createElement('div');
toast.className = `fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-lg text-sm font-medium shadow-lg transition-opacity
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 3000);
}

566
static/js/autotrade.js Normal file
View File

@@ -0,0 +1,566 @@
// 자동매매 페이지 클라이언트 스크립트
let logLevelFilter = 'all'; // 'all' | 'action'
let watchSource = { useScanner: true, selectedThemes: [] };
let localLogs = []; // 클라이언트 로그 버퍼 (오래된 것 → 최신 순)
let tradeLogWS = null;
document.addEventListener('DOMContentLoaded', () => {
loadStatus();
loadPositions();
loadLogs(); // 기존 로그 초기 로드 (HTTP)
loadRules();
loadThemeList();
loadWatchSource();
connectTradeLogWS(); // WS 실시간 연결
// 자동스크롤 체크박스: ON 전환 시 즉시 최신으로 이동
const chk = document.getElementById('autoScrollChk');
if (chk) {
chk.addEventListener('change', () => {
if (chk.checked) scrollLogsToBottom();
});
}
// 상태: 3초 주기, 포지션: 5초 주기 (로그는 WS로 대체)
setInterval(loadStatus, 3000);
setInterval(loadPositions, 5000);
});
// --- 엔진 상태 ---
async function loadStatus() {
try {
const res = await fetch('/api/autotrade/status');
const data = await res.json();
updateStatusUI(data);
} catch (e) {
console.error('상태 조회 실패:', e);
}
}
function updateStatusUI(data) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (data.running) {
dot.className = 'w-3 h-3 rounded-full bg-green-500';
text.textContent = '● 실행중';
text.className = 'text-sm font-medium text-green-600';
} else {
dot.className = 'w-3 h-3 rounded-full bg-gray-300';
text.textContent = '○ 중지';
text.className = 'text-sm font-medium text-gray-500';
}
document.getElementById('statActivePos').textContent = data.activePositions ?? 0;
document.getElementById('statTradeCount').textContent = data.tradeCount ?? 0;
const pl = data.totalPL ?? 0;
const plEl = document.getElementById('statTotalPL');
plEl.textContent = formatMoney(pl) + '원';
plEl.className = 'text-2xl font-bold ' + (pl > 0 ? 'text-red-500' : pl < 0 ? 'text-blue-500' : 'text-gray-800');
}
// --- 엔진 제어 ---
async function startEngine() {
if (!confirm('자동매매 엔진을 시작하시겠습니까?')) return;
try {
await fetch('/api/autotrade/start', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('엔진 시작 실패: ' + e.message);
}
}
async function stopEngine() {
if (!confirm('자동매매 엔진을 중지하시겠습니까?')) return;
try {
await fetch('/api/autotrade/stop', { method: 'POST' });
await loadStatus();
} catch (e) {
alert('엔진 중지 실패: ' + e.message);
}
}
async function emergencyStop() {
if (!confirm('⚠ 긴급청산: 모든 포지션을 즉시 시장가 매도합니다.\n계속하시겠습니까?')) return;
try {
await fetch('/api/autotrade/emergency', { method: 'POST' });
await loadStatus();
await loadPositions();
} catch (e) {
alert('긴급청산 실패: ' + e.message);
}
}
// --- 규칙 ---
async function loadRules() {
try {
const res = await fetch('/api/autotrade/rules');
const rules = await res.json();
renderRules(rules);
} catch (e) {
console.error('규칙 조회 실패:', e);
}
}
function renderRules(rules) {
const el = document.getElementById('rulesList');
if (!rules || rules.length === 0) {
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">규칙이 없습니다.</p>';
return;
}
el.innerHTML = rules.map(r => `
<div class="border border-gray-100 rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-800">${escHtml(r.name)}</span>
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleRule('${r.id}')"
class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-checked:bg-blue-600 rounded-full peer
peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors relative
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
peer-checked:after:translate-x-4"></div>
</label>
</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>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
</div>
<div class="flex gap-2">
<button onclick="showEditRuleModal(${JSON.stringify(r).replace(/"/g, '&quot;')})"
class="px-2 py-1 text-xs text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition-colors">수정</button>
<button onclick="deleteRule('${r.id}')"
class="px-2 py-1 text-xs text-red-500 border border-red-200 rounded hover:bg-red-50 transition-colors">삭제</button>
</div>
</div>
`).join('');
}
async function deleteRule(id) {
if (!confirm('규칙을 삭제하시겠습니까?')) return;
try {
await fetch('/api/autotrade/rules/' + id, { method: 'DELETE' });
await loadRules();
} catch (e) {
alert('삭제 실패: ' + e.message);
}
}
async function toggleRule(id) {
try {
await fetch('/api/autotrade/rules/' + id + '/toggle', { method: 'POST' });
await loadRules();
} catch (e) {
alert('토글 실패: ' + e.message);
}
}
// --- 모달 ---
function showAddRuleModal() {
document.getElementById('modalTitle').textContent = '규칙 추가';
document.getElementById('ruleId').value = '';
document.getElementById('fName').value = '';
document.getElementById('fRiseScore').value = 60;
document.getElementById('riseScoreVal').textContent = '60';
document.getElementById('fCntrStr').value = 110;
document.getElementById('fRequireBullish').checked = false;
document.getElementById('fOrderAmount').value = 500000;
document.getElementById('fMaxPositions').value = 3;
document.getElementById('fStopLoss').value = -3;
document.getElementById('fTakeProfit').value = 5;
document.getElementById('fMaxHold').value = 60;
document.getElementById('fExitBeforeClose').checked = true;
document.getElementById('ruleModal').classList.remove('hidden');
}
function showEditRuleModal(r) {
document.getElementById('modalTitle').textContent = '규칙 수정';
document.getElementById('ruleId').value = r.id;
document.getElementById('fName').value = r.name;
document.getElementById('fRiseScore').value = r.minRiseScore;
document.getElementById('riseScoreVal').textContent = r.minRiseScore;
document.getElementById('fCntrStr').value = r.minCntrStr;
document.getElementById('fRequireBullish').checked = r.requireBullish;
document.getElementById('fOrderAmount').value = r.orderAmount;
document.getElementById('fMaxPositions').value = r.maxPositions;
document.getElementById('fStopLoss').value = r.stopLossPct;
document.getElementById('fTakeProfit').value = r.takeProfitPct;
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
document.getElementById('fExitBeforeClose').checked = r.exitBeforeClose;
document.getElementById('ruleModal').classList.remove('hidden');
}
function hideModal() {
document.getElementById('ruleModal').classList.add('hidden');
}
async function submitRule() {
const id = document.getElementById('ruleId').value;
const rule = {
name: document.getElementById('fName').value.trim(),
enabled: true,
minRiseScore: parseInt(document.getElementById('fRiseScore').value),
minCntrStr: parseFloat(document.getElementById('fCntrStr').value),
requireBullish: document.getElementById('fRequireBullish').checked,
orderAmount: parseInt(document.getElementById('fOrderAmount').value),
maxPositions: parseInt(document.getElementById('fMaxPositions').value),
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
exitBeforeClose: document.getElementById('fExitBeforeClose').checked,
};
if (!rule.name) { alert('규칙명을 입력해주세요.'); return; }
try {
if (id) {
await fetch('/api/autotrade/rules/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
} else {
await fetch('/api/autotrade/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
}
hideModal();
await loadRules();
} catch (e) {
alert('저장 실패: ' + e.message);
}
}
// --- 포지션 ---
async function loadPositions() {
try {
const res = await fetch('/api/autotrade/positions');
const positions = await res.json();
renderPositions(positions);
} catch (e) {
console.error('포지션 조회 실패:', e);
}
}
function renderPositions(positions) {
const el = document.getElementById('positionsList');
const active = (positions || []).filter(p => p.status === 'open' || p.status === 'pending');
if (active.length === 0) {
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">보유 포지션 없음</p>';
return;
}
el.innerHTML = `
<table class="w-full text-xs">
<thead>
<tr class="text-left text-gray-400 border-b border-gray-100">
<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-center">상태</th>
</tr>
</thead>
<tbody>
${active.map(p => {
const statusCls = p.status === 'open' ? 'text-green-600' : 'text-yellow-600';
const statusTxt = p.status === 'open' ? '보유중' : '체결대기';
return `
<tr class="border-b border-gray-50">
<td class="py-2">
<div class="font-medium text-gray-800">${escHtml(p.name)}</div>
<div class="text-gray-400">${p.code}</div>
</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-center font-medium ${statusCls}">${statusTxt}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
// --- 감시 소스 ---
async function loadThemeList() {
try {
const res = await fetch('/api/themes?date=1&sort=3');
const themes = await res.json();
const sel = document.getElementById('themeSelect');
if (!sel || !themes) return;
// 가나다순 정렬 후 추가
const sorted = [...themes].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
sel.innerHTML = '<option value="">테마를 선택하세요...</option>';
sorted.forEach(t => {
const opt = document.createElement('option');
opt.value = t.code;
opt.textContent = `${t.name} (${t.fluRt >= 0 ? '+' : ''}${t.fluRt.toFixed(2)}%)`;
opt.dataset.name = t.name;
sel.appendChild(opt);
});
} catch (e) {
console.error('테마 목록 조회 실패:', e);
}
}
async function loadWatchSource() {
try {
const res = await fetch('/api/autotrade/watch-source');
watchSource = await res.json();
renderWatchSource();
} catch (e) {
console.error('감시 소스 조회 실패:', e);
}
}
async function saveWatchSource() {
const scannerChk = document.getElementById('wsScanner');
watchSource.useScanner = scannerChk ? scannerChk.checked : true;
try {
await fetch('/api/autotrade/watch-source', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(watchSource),
});
} catch (e) {
alert('감시 소스 저장 실패: ' + e.message);
}
}
function renderWatchSource() {
const scannerChk = document.getElementById('wsScanner');
if (scannerChk) scannerChk.checked = watchSource.useScanner;
const container = document.getElementById('selectedThemes');
const noMsg = document.getElementById('noThemeMsg');
if (!container) return;
const themes = watchSource.selectedThemes || [];
if (themes.length === 0) {
container.innerHTML = '<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>';
return;
}
container.innerHTML = themes.map(t => `
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
${escHtml(t.name)}
<button onclick="removeTheme('${escHtml(t.code)}')" class="hover:text-blue-900 text-blue-400 font-bold leading-none">×</button>
</span>
`).join('');
}
function addSelectedTheme() {
const sel = document.getElementById('themeSelect');
if (!sel || !sel.value) return;
const code = sel.value;
const name = sel.options[sel.selectedIndex]?.dataset.name || sel.options[sel.selectedIndex]?.text || code;
if (!watchSource.selectedThemes) watchSource.selectedThemes = [];
if (watchSource.selectedThemes.some(t => t.code === code)) {
alert('이미 추가된 테마입니다.');
return;
}
watchSource.selectedThemes.push({ code, name });
renderWatchSource();
saveWatchSource();
sel.value = '';
}
function removeTheme(code) {
if (!watchSource.selectedThemes) return;
watchSource.selectedThemes = watchSource.selectedThemes.filter(t => t.code !== code);
renderWatchSource();
saveWatchSource();
}
// --- 로그 ---
// --- WS 실시간 로그 ---
function connectTradeLogWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
tradeLogWS = new WebSocket(`${proto}//${location.host}/ws`);
tradeLogWS.onopen = () => {
updateWSStatus(true);
};
tradeLogWS.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type !== 'tradelog') return;
const log = msg.data;
localLogs.push(log);
if (localLogs.length > 300) localLogs.shift();
// 필터 조건에 맞으면 DOM에 행 추가
if (logLevelFilter !== 'action' || log.level !== 'debug') {
appendLogRow(log);
}
updateLogTime();
};
tradeLogWS.onclose = () => {
updateWSStatus(false);
// 3초 후 재연결 + 로그 재로드
setTimeout(() => {
loadLogs();
connectTradeLogWS();
}, 3000);
};
tradeLogWS.onerror = () => {
tradeLogWS.close();
};
}
function updateWSStatus(connected) {
const el = document.getElementById('wsStatus');
if (!el) return;
el.textContent = connected ? '● 실시간' : '○ 연결중...';
el.className = connected
? 'text-xs text-green-500 font-medium'
: 'text-xs text-gray-400';
}
function scrollLogsToBottom() {
const wrapper = document.getElementById('logsWrapper');
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
}
function updateLogTime() {
const el = document.getElementById('logUpdateTime');
if (el) el.textContent = new Date().toLocaleTimeString('ko-KR',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' 갱신';
}
function buildLogRow(l) {
let rowCls, levelCls, msgCls;
if (l.level === 'debug') {
rowCls = 'border-b border-gray-50 bg-gray-50';
levelCls = 'text-gray-400';
msgCls = 'text-gray-400';
} else if (l.level === 'error') {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-red-500';
msgCls = 'text-gray-700';
} else if (l.level === 'warn') {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-yellow-600';
msgCls = 'text-gray-700';
} else {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-gray-500';
msgCls = 'text-gray-700';
}
const time = new Date(l.at).toLocaleTimeString('ko-KR',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' });
return `<tr class="${rowCls}">
<td class="py-1.5 px-1 text-gray-400">${time}</td>
<td class="py-1.5 px-1 font-medium ${levelCls}">${l.level}</td>
<td class="py-1.5 px-1 text-gray-600">${escHtml(l.code)}</td>
<td class="py-1.5 px-1 ${msgCls}">${escHtml(l.message)}</td>
</tr>`;
}
function appendLogRow(log) {
const tbody = document.getElementById('logsList');
if (!tbody) return;
// 빈 상태 메시지 제거
if (tbody.firstElementChild?.tagName === 'TR' &&
tbody.firstElementChild.querySelector('td[colspan]')) {
tbody.innerHTML = '';
}
tbody.insertAdjacentHTML('beforeend', buildLogRow(log));
// 표시 행 수 300개 초과 시 맨 위 행 제거
while (tbody.children.length > 300) tbody.removeChild(tbody.firstChild);
// 자동스크롤
const chk = document.getElementById('autoScrollChk');
if (chk && chk.checked) scrollLogsToBottom();
}
function setLogFilter(level) {
logLevelFilter = level;
const allBtn = document.getElementById('filterAll');
const actionBtn = document.getElementById('filterAction');
if (allBtn && actionBtn) {
if (level === 'all') {
allBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
} else {
allBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
}
}
// 버퍼에서 필터 적용 후 전체 재렌더
renderLogsFromBuffer();
}
async function loadLogs() {
try {
const res = await fetch('/api/autotrade/logs');
const logs = await res.json();
// 서버는 최신순으로 반환 → 뒤집어 오래된 것부터 저장
localLogs = (logs || []).slice(0, 300).reverse();
renderLogsFromBuffer();
} catch (e) {
console.error('로그 초기 로드 실패:', e);
}
}
function renderLogsFromBuffer() {
const tbody = document.getElementById('logsList');
if (!tbody) return;
const filtered = logLevelFilter === 'action'
? localLogs.filter(l => l.level !== 'debug')
: localLogs;
// 최근 300개 표시
const items = filtered.slice(-300);
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = items.map(l => buildLogRow(l)).join('');
updateLogTime();
scrollLogsToBottom();
}
// --- 유틸 ---
function formatMoney(n) {
if (!n) return '0';
return n.toLocaleString('ko-KR');
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

312
static/js/chart.js Normal file
View File

@@ -0,0 +1,312 @@
/**
* StockChart - TradingView Lightweight Charts 기반 주식 차트
* 틱(기본) / 일봉 / 1분봉 / 5분봉 전환, 실시간 업데이트 지원
*/
let candleChart = null; // 캔들 차트 인스턴스 (lazy)
let candleSeries = null; // 캔들스틱 시리즈
let maSeries = {}; // 이동평균선 시리즈 { 5, 20, 60 }
let tickChart = null; // 틱 차트 인스턴스
let tickSeries = null; // 틱 라인 시리즈
let currentPeriod = 'minute1';
// 틱 데이터 버퍼
const tickBuffer = [];
// 공통 차트 옵션 (autoSize로 컨테이너 크기 자동 추적)
const CHART_BASE_OPTIONS = {
autoSize: true,
layout: {
background: { color: '#ffffff' },
textColor: '#374151',
},
grid: {
vertLines: { color: '#f3f4f6' },
horzLines: { color: '#f3f4f6' },
},
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
rightPriceScale: { borderColor: '#e5e7eb' },
timeScale: {
borderColor: '#e5e7eb',
timeVisible: true,
},
};
// 틱 차트 초기화
function initTickChart() {
const container = document.getElementById('tickChartContainer');
if (!container || tickChart) return;
tickChart = LightweightCharts.createChart(container, CHART_BASE_OPTIONS);
tickSeries = tickChart.addLineSeries({
color: '#6366f1',
lineWidth: 2,
crosshairMarkerVisible: true,
lastValueVisible: true,
priceLineVisible: false,
});
if (tickBuffer.length > 0) {
tickSeries.setData([...tickBuffer]);
tickChart.timeScale().fitContent();
}
}
// SMA 계산: closes 배열과 period를 받아 [{time, value}] 반환
function calcSMA(candles, period) {
const result = [];
for (let i = period - 1; i < candles.length; i++) {
let sum = 0;
for (let j = i - period + 1; j <= i; j++) sum += candles[j].close;
result.push({ time: candles[i].time, value: Math.round(sum / period) });
}
return result;
}
// 캔들 차트 lazy 초기화
function ensureCandleChart() {
if (candleChart) return;
const container = document.getElementById('chartContainer');
if (!container) return;
candleChart = LightweightCharts.createChart(container, CHART_BASE_OPTIONS);
candleSeries = candleChart.addCandlestickSeries({
upColor: '#ef4444',
downColor: '#3b82f6',
borderUpColor: '#ef4444',
borderDownColor: '#3b82f6',
wickUpColor: '#ef4444',
wickDownColor: '#3b82f6',
});
// 이동평균선 시리즈 추가 (20분/60분/120분)
maSeries[20] = candleChart.addLineSeries({ color: '#f59e0b', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
maSeries[60] = candleChart.addLineSeries({ color: '#10b981', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
maSeries[120] = candleChart.addLineSeries({ color: '#8b5cf6', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false });
}
// 캔들 데이터 로딩 + 이동평균선 계산
async function loadChart(period) {
if (!STOCK_CODE) return;
const endpoint = period === 'daily'
? `/api/stock/${STOCK_CODE}/chart`
: `/api/stock/${STOCK_CODE}/chart?period=${period}`;
try {
const resp = await fetch(endpoint);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const candles = await resp.json();
if (!candles || candles.length === 0) return;
const data = candles.map(c => ({
time: c.time, open: c.open, high: c.high, low: c.low, close: c.close,
}));
candleSeries.setData(data);
// 이동평균선 업데이트
[20, 60, 120].forEach(n => {
if (maSeries[n]) maSeries[n].setData(calcSMA(data, n));
});
candleChart.timeScale().fitContent();
} catch (err) {
console.error('차트 데이터 로딩 실패:', err);
}
}
// 탭 UI 업데이트
function updateTabUI(period) {
['daily', 'minute1', 'minute5', 'tick'].forEach(p => {
const tab = document.getElementById(`tab-${p}`);
if (!tab) return;
tab.className = p === period
? 'px-4 py-1.5 text-sm rounded-full bg-blue-500 text-white font-medium'
: 'px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200';
});
}
// 탭 전환
function switchChart(period) {
currentPeriod = period;
updateTabUI(period);
const candleEl = document.getElementById('chartContainer');
const tickEl = document.getElementById('tickChartContainer');
if (period === 'tick') {
if (candleEl) candleEl.style.display = 'none';
if (tickEl) tickEl.style.display = 'block';
if (!tickChart) initTickChart();
if (tickSeries && tickBuffer.length > 0) {
tickSeries.setData([...tickBuffer]);
tickChart.timeScale().fitContent();
}
} else {
if (tickEl) tickEl.style.display = 'none';
if (candleEl) candleEl.style.display = 'block';
ensureCandleChart();
loadChart(period);
}
}
// WebSocket 체결 수신 시 틱 버퍼에 추가
function appendTick(price) {
if (!price.currentPrice) return;
const now = Math.floor(Date.now() / 1000);
const point = { time: now, value: price.currentPrice };
if (tickBuffer.length > 0 && tickBuffer[tickBuffer.length - 1].time === now) {
tickBuffer[tickBuffer.length - 1].value = price.currentPrice;
} else {
tickBuffer.push(point);
if (tickBuffer.length > 1000) tickBuffer.shift();
}
if (currentPeriod === 'tick' && tickSeries) {
try { tickSeries.update(point); } catch (_) {}
}
}
// 실시간 현재가로 마지막 캔들 업데이트
function updateLastCandle(price) {
if (!candleSeries || !price.currentPrice) return;
const now = Math.floor(Date.now() / 1000);
try {
candleSeries.update({
time: now,
open: price.open || price.currentPrice,
high: price.high || price.currentPrice,
low: price.low || price.currentPrice,
close: price.currentPrice,
});
} catch (_) {}
}
// 체결시각 포맷 (HHMMSS → HH:MM:SS)
function formatTradeTime(t) {
if (!t || t.length < 6) return '-';
return `${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}`;
}
// 거래대금 포맷
function formatTradeMoney(n) {
if (!n) return '-';
if (n >= 1000000000000) return (n / 1000000000000).toFixed(2) + '조';
if (n >= 100000000) return (n / 100000000).toFixed(1) + '억';
if (n >= 10000) return Math.round(n / 10000) + '만';
return n.toLocaleString('ko-KR');
}
// 현재가 DOM 업데이트
function updatePriceUI(price) {
const priceEl = document.getElementById('currentPrice');
const changeEl = document.getElementById('changeInfo');
const updatedAtEl = document.getElementById('updatedAt');
const highEl = document.getElementById('highPrice');
const lowEl = document.getElementById('lowPrice');
const volumeEl = document.getElementById('volume');
if (!priceEl) return;
const prevPrice = parseInt(priceEl.dataset.raw || '0');
const isUp = price.currentPrice > prevPrice;
const isDown = price.currentPrice < prevPrice;
priceEl.textContent = formatNumber(price.currentPrice) + '원';
priceEl.dataset.raw = price.currentPrice;
const sign = price.changePrice >= 0 ? '+' : '';
changeEl.textContent = `${sign}${formatNumber(price.changePrice)}원 (${sign}${price.changeRate.toFixed(2)}%)`;
changeEl.className = price.changeRate > 0 ? 'text-lg mt-1 text-red-500'
: price.changeRate < 0 ? 'text-lg mt-1 text-blue-500'
: 'text-lg mt-1 text-gray-500';
if (highEl) highEl.textContent = formatNumber(price.high) + '원';
if (lowEl) lowEl.textContent = formatNumber(price.low) + '원';
if (volumeEl) volumeEl.textContent = formatNumber(price.volume);
const tradeTimeEl = document.getElementById('tradeTime');
if (tradeTimeEl && price.tradeTime) tradeTimeEl.textContent = formatTradeTime(price.tradeTime);
const tradeVolEl = document.getElementById('tradeVolume');
if (tradeVolEl && price.tradeVolume) tradeVolEl.textContent = formatNumber(price.tradeVolume);
const tradeMoneyEl = document.getElementById('tradeMoney');
if (tradeMoneyEl) tradeMoneyEl.textContent = formatTradeMoney(price.tradeMoney);
const ask1El = document.getElementById('askPrice1');
const bid1El = document.getElementById('bidPrice1');
if (ask1El && price.askPrice1) ask1El.textContent = formatNumber(price.askPrice1) + '원';
if (bid1El && price.bidPrice1) bid1El.textContent = formatNumber(price.bidPrice1) + '원';
if (updatedAtEl) {
const d = new Date(price.updatedAt);
updatedAtEl.textContent = `${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')} 기준`;
}
if (isUp) {
priceEl.classList.remove('flash-down');
void priceEl.offsetWidth;
priceEl.classList.add('flash-up');
} else if (isDown) {
priceEl.classList.remove('flash-up');
void priceEl.offsetWidth;
priceEl.classList.add('flash-down');
}
updateLastCandle(price);
appendTick(price);
}
// 장운영 상태 업데이트
function updateMarketStatus(ms) {
const el = document.getElementById('marketStatusBadge');
if (!el) return;
el.textContent = ms.statusName || '장 중';
const code = ms.statusCode;
el.className = 'px-2 py-0.5 rounded text-xs font-medium ';
if (code === '3') el.className += 'bg-green-100 text-green-700';
else if (['4','8','9'].includes(code)) el.className += 'bg-gray-100 text-gray-600';
else if (['a','b','c','d'].includes(code)) el.className += 'bg-yellow-100 text-yellow-700';
else el.className += 'bg-blue-50 text-blue-600';
}
// 종목 메타 업데이트
function updateStockMeta(meta) {
const upperEl = document.getElementById('upperLimit');
const lowerEl = document.getElementById('lowerLimit');
const baseEl = document.getElementById('basePrice');
if (upperEl && meta.upperLimit) upperEl.textContent = formatNumber(meta.upperLimit) + '원';
if (lowerEl && meta.lowerLimit) lowerEl.textContent = formatNumber(meta.lowerLimit) + '원';
if (baseEl && meta.basePrice) baseEl.textContent = formatNumber(meta.basePrice) + '원';
}
// 숫자 천 단위 콤마 포맷
function formatNumber(n) {
return Math.abs(n).toLocaleString('ko-KR');
}
// DOMContentLoaded: WebSocket만 먼저 연결
document.addEventListener('DOMContentLoaded', () => {
if (typeof STOCK_CODE === 'undefined' || !STOCK_CODE) return;
stockWS.subscribe(STOCK_CODE);
stockWS.onPrice(STOCK_CODE, updatePriceUI);
stockWS.onOrderBook(STOCK_CODE, renderOrderBook);
stockWS.onProgram(STOCK_CODE, renderProgram);
stockWS.onMeta(STOCK_CODE, updateStockMeta);
stockWS.onMarket(updateMarketStatus);
initOrderBook();
updateTabUI('minute1');
});
// window.load: 레이아웃 완전 확정 후 차트 초기화
window.addEventListener('load', () => {
if (typeof STOCK_CODE === 'undefined' || !STOCK_CODE) return;
// 1분봉 캔들 차트를 기본으로 초기화
const candleEl = document.getElementById('chartContainer');
if (candleEl) candleEl.style.display = 'block';
const tickEl = document.getElementById('tickChartContainer');
if (tickEl) tickEl.style.display = 'none';
ensureCandleChart();
loadChart('minute1');
});

60
static/js/disclosure.js Normal file
View File

@@ -0,0 +1,60 @@
// YYYYMMDD → YYYY.MM.DD
function formatDate(d) {
return d ? `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}` : '-';
}
// 태그별 색상 매핑
const TAG_COLORS = {
'실적': 'bg-blue-100 text-blue-700',
'유증': 'bg-red-100 text-red-700',
'무증': 'bg-orange-100 text-orange-700',
'수주': 'bg-green-100 text-green-700',
'소송': 'bg-red-100 text-red-700',
'M&A': 'bg-purple-100 text-purple-700',
'지분': 'bg-indigo-100 text-indigo-700',
'자사주':'bg-teal-100 text-teal-700',
'경영': 'bg-gray-100 text-gray-600',
'CB/BW': 'bg-yellow-100 text-yellow-700',
'공시': 'bg-gray-100 text-gray-500',
};
function tagBadge(tag) {
const cls = TAG_COLORS[tag] || TAG_COLORS['공시'];
return `<span class="text-xs font-medium px-2 py-0.5 rounded-full ${cls} shrink-0">${tag}</span>`;
}
async function loadDisclosures() {
try {
const resp = await fetch(`/api/disclosure?code=${STOCK_CODE}`);
if (!resp.ok) throw new Error();
const list = await resp.json();
document.getElementById('disclosureLoading').classList.add('hidden');
if (!list || list.length === 0) {
document.getElementById('disclosureEmpty').classList.remove('hidden');
return;
}
const ul = document.getElementById('disclosureList');
list.forEach(item => {
const li = document.createElement('li');
li.className = 'py-3';
li.innerHTML = `<a href="${item.url}" target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 hover:bg-gray-50 px-1 rounded transition-colors">
${tagBadge(item.tag)}
<span class="text-sm text-gray-800 flex-1 min-w-0 truncate">${item.reportNm}</span>
<span class="text-xs text-gray-400 shrink-0">${formatDate(item.rceptDt)}</span>
</a>`;
ul.appendChild(li);
});
ul.classList.remove('hidden');
} catch {
document.getElementById('disclosureLoading').classList.add('hidden');
document.getElementById('disclosureError').classList.remove('hidden');
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof STOCK_CODE !== 'undefined') loadDisclosures();
});

56
static/js/indices.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* 주요 지수 티커 (코스피·코스닥·다우·나스닥)
* - 10초 주기 폴링
* - 내비게이션 바 하단 어두운 띠에 표시
*/
(function () {
const ticker = document.getElementById('indexTicker');
const INTERVAL = 10 * 1000;
function colorClass(rate) {
if (rate > 0) return 'text-red-400';
if (rate < 0) return 'text-blue-400';
return 'text-gray-400';
}
function arrow(rate) {
if (rate > 0) return '▲';
if (rate < 0) return '▼';
return '';
}
function fmtPrice(name, price) {
if (!price) return '-';
// 코스피·코스닥은 소수점 2자리, 해외는 정수 + 소수점 2자리
return price.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function render(quotes) {
if (!quotes || quotes.length === 0) return;
ticker.innerHTML = quotes.map(q => {
const cls = colorClass(q.changeRate);
const arr = arrow(q.changeRate);
const rate = q.changeRate != null ? (q.changeRate >= 0 ? '+' : '') + q.changeRate.toFixed(2) + '%' : '-';
return `
<span class="shrink-0 flex items-center gap-1.5">
<span class="text-gray-400 font-medium">${q.name}</span>
<span class="font-mono font-semibold">${fmtPrice(q.name, q.price)}</span>
<span class="${cls} font-mono">${arr} ${rate}</span>
</span>`;
}).join('<span class="text-gray-600 shrink-0">|</span>');
}
async function fetch_() {
try {
const resp = await fetch('/api/indices');
if (!resp.ok) return;
const data = await resp.json();
render(data);
} catch (e) {
// 조용히 실패 (티커 미표시)
}
}
fetch_();
setInterval(fetch_, INTERVAL);
})();

195
static/js/kospi200.js Normal file
View File

@@ -0,0 +1,195 @@
/**
* 코스피200 종목 목록 페이지
* - /api/kospi200 폴링 (1분 캐시 기반)
* - 정렬: 등락률 / 거래량 / 현재가
* - 필터: 전체 / 상승 / 하락
* - 종목명 검색
*/
(function () {
let allStocks = [];
let currentSort = 'fluRt'; // fluRt | volume | curPrc
let currentDir = 'all'; // all | up | down
let sortDesc = true; // 기본 내림차순
const listEl = document.getElementById('k200List');
const countEl = document.getElementById('k200Count');
const searchEl = document.getElementById('k200Search');
const updatedEl = document.getElementById('lastUpdated');
// ── 포맷 유틸 ────────────────────────────────────────────────
function fmtNum(n) {
if (n == null || n === 0) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
const sign = f > 0 ? '+' : '';
return sign + f.toFixed(2) + '%';
}
function fmtDiff(n) {
if (n == null || n === 0) return '0';
const sign = n > 0 ? '+' : '';
return sign + Math.abs(n).toLocaleString('ko-KR');
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBg(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ── 행 렌더 ──────────────────────────────────────────────────
function makeRow(s, rank) {
const cls = rateClass(s.fluRt);
const bgCls = rateBg(s.fluRt);
return `
<a href="/stock/${s.code}"
class="grid grid-cols-[2.5rem_1fr_1fr_90px_90px_100px_90px_90px_90px] gap-2
px-4 py-2.5 hover:bg-gray-50 transition-colors text-sm items-center">
<span class="text-xs text-gray-400 text-center">${rank}</span>
<div class="min-w-0">
<p class="font-medium text-gray-800 truncate">${s.name}</p>
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
</div>
<div class="text-right font-semibold ${cls}">${fmtNum(s.curPrc)}원</div>
<div class="text-right text-xs ${cls}">${fmtDiff(s.predPre)}</div>
<div class="text-right">
<span class="text-xs px-1.5 py-0.5 rounded font-semibold ${bgCls}">${fmtRate(s.fluRt)}</span>
</div>
<div class="text-right text-xs text-gray-500">${fmtNum(s.volume)}</div>
<div class="text-right text-xs text-gray-400">${fmtNum(s.open)}</div>
<div class="text-right text-xs text-red-400">${fmtNum(s.high)}</div>
<div class="text-right text-xs text-blue-400">${fmtNum(s.low)}</div>
</a>`;
}
// ── 필터 + 정렬 + 렌더 ───────────────────────────────────────
function renderList() {
const q = searchEl.value.trim();
let filtered = allStocks;
// 상승/하락 필터
if (currentDir === 'up') filtered = filtered.filter(s => s.fluRt > 0);
if (currentDir === 'down') filtered = filtered.filter(s => s.fluRt < 0);
// 종목명 검색
if (q) filtered = filtered.filter(s => s.name.includes(q) || s.code.includes(q));
// 정렬
filtered = [...filtered].sort((a, b) => {
const diff = b[currentSort] - a[currentSort];
return sortDesc ? diff : -diff;
});
countEl.textContent = `${filtered.length}개 종목`;
if (filtered.length === 0) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">조건에 맞는 종목이 없습니다.</div>`;
return;
}
listEl.innerHTML = filtered.map((s, i) => makeRow(s, i + 1)).join('');
}
// ── 데이터 로드 ───────────────────────────────────────────────
async function loadData() {
try {
const resp = await fetch('/api/kospi200');
if (!resp.ok) throw new Error('조회 실패');
allStocks = await resp.json();
updatedEl.textContent = new Date().toTimeString().slice(0, 8) + ' 기준';
renderList();
} catch (e) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
console.error('코스피200 조회 실패:', e);
}
}
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
function updateColHeaders() {
document.querySelectorAll('.col-sort').forEach(el => {
const arrow = el.querySelector('.sort-arrow');
if (!arrow) return;
if (el.dataset.col === currentSort) {
arrow.textContent = sortDesc ? '▼' : '▲';
arrow.className = 'sort-arrow text-blue-400';
el.classList.add('text-blue-500');
el.classList.remove('text-gray-500');
} else {
arrow.textContent = '';
arrow.className = 'sort-arrow';
el.classList.remove('text-blue-500');
el.classList.add('text-gray-500');
}
});
}
// ── 정렬 탭 이벤트 ───────────────────────────────────────────
document.querySelectorAll('.sort-tab').forEach(btn => {
btn.addEventListener('click', () => {
if (currentSort === btn.dataset.sort) {
sortDesc = !sortDesc;
} else {
currentSort = btn.dataset.sort;
sortDesc = true;
}
document.querySelectorAll('.sort-tab').forEach(b => {
const active = b.dataset.sort === currentSort;
b.className = active
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
updateColHeaders();
renderList();
});
});
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
document.querySelectorAll('.col-sort').forEach(el => {
el.addEventListener('click', () => {
const col = el.dataset.col;
if (currentSort === col) {
sortDesc = !sortDesc;
} else {
currentSort = col;
sortDesc = true;
}
// 상단 정렬 탭 동기화
document.querySelectorAll('.sort-tab').forEach(b => {
const active = b.dataset.sort === currentSort;
b.className = active
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
updateColHeaders();
renderList();
});
});
// ── 방향 필터 탭 이벤트 ──────────────────────────────────────
document.querySelectorAll('.dir-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentDir = btn.dataset.dir;
document.querySelectorAll('.dir-tab').forEach(b => {
const active = b.dataset.dir === currentDir;
b.className = active
? 'dir-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
renderList();
});
});
// ── 검색 이벤트 ──────────────────────────────────────────────
searchEl.addEventListener('input', renderList);
// ── 초기 로드 + 1분 자동 갱신 ────────────────────────────────
updateColHeaders();
loadData();
setInterval(loadData, 60 * 1000);
})();

49
static/js/news.js Normal file
View File

@@ -0,0 +1,49 @@
// RFC1123Z → "MM/DD HH:MM" 형식으로 변환
function formatNewsDate(s) {
if (!s) return '';
const d = new Date(s);
if (isNaN(d)) return s;
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${min}`;
}
async function loadNews() {
try {
const resp = await fetch(`/api/news?name=${encodeURIComponent(STOCK_NAME)}`);
if (!resp.ok) throw new Error();
const list = await resp.json();
document.getElementById('newsLoading').classList.add('hidden');
if (!list || list.length === 0) {
document.getElementById('newsEmpty').classList.remove('hidden');
return;
}
const ul = document.getElementById('newsList');
list.forEach(item => {
const li = document.createElement('li');
li.className = 'py-3';
li.innerHTML = `<a href="${item.url}" target="_blank" rel="noopener noreferrer"
class="flex items-start gap-3 hover:bg-gray-50 px-1 rounded transition-colors">
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-800 truncate">${item.title}</p>
<p class="text-xs text-gray-400 mt-0.5">${item.source}</p>
</div>
<span class="text-xs text-gray-400 shrink-0 mt-0.5">${formatNewsDate(item.publishedAt)}</span>
</a>`;
ul.appendChild(li);
});
ul.classList.remove('hidden');
} catch {
document.getElementById('newsLoading').classList.add('hidden');
document.getElementById('newsError').classList.remove('hidden');
}
}
document.addEventListener('DOMContentLoaded', () => {
if (typeof STOCK_NAME !== 'undefined') loadNews();
});

487
static/js/order.js Normal file
View File

@@ -0,0 +1,487 @@
/**
* 주문창 UI 전체 로직
* - 매수/매도 탭, 주문유형, 단가/수량 입력, 주문 제출
* - 미체결/체결/잔고 탭
*/
// 현재 활성 탭: 'buy' | 'sell'
let orderSide = 'buy';
// 주문가능수량 (퍼센트 버튼용)
let orderableQty = 0;
// 현재 종목 코드 (전역에서 STOCK_CODE 사용)
// -----------------------------------
// 호가 단위(틱) 계산
// -----------------------------------
function getTickSize(price) {
if (price < 2000) return 1;
if (price < 5000) return 5;
if (price < 20000) return 10;
if (price < 50000) return 50;
if (price < 200000) return 100;
if (price < 500000) return 500;
return 1000;
}
// -----------------------------------
// 초기화
// -----------------------------------
function initOrder() {
updateOrderSide('buy');
loadOrderable();
loadPendingTab();
}
// -----------------------------------
// 매수/매도 탭 전환
// -----------------------------------
function updateOrderSide(side) {
orderSide = side;
const buyTab = document.getElementById('orderBuyTab');
const sellTab = document.getElementById('orderSellTab');
const submitBtn = document.getElementById('orderSubmitBtn');
if (side === 'buy') {
buyTab.classList.add('bg-red-500', 'text-white');
buyTab.classList.remove('bg-gray-100', 'text-gray-600');
sellTab.classList.add('bg-gray-100', 'text-gray-600');
sellTab.classList.remove('bg-blue-500', 'text-white');
submitBtn.textContent = '매수';
submitBtn.className = 'w-full py-2.5 text-sm font-bold rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors';
} else {
sellTab.classList.add('bg-blue-500', 'text-white');
sellTab.classList.remove('bg-gray-100', 'text-gray-600');
buyTab.classList.add('bg-gray-100', 'text-gray-600');
buyTab.classList.remove('bg-red-500', 'text-white');
submitBtn.textContent = '매도';
submitBtn.className = 'w-full py-2.5 text-sm font-bold rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors';
}
// 폼 초기화
resetOrderForm();
loadOrderable();
}
function resetOrderForm() {
const priceEl = document.getElementById('orderPrice');
const qtyEl = document.getElementById('orderQty');
if (priceEl) priceEl.value = '';
if (qtyEl) qtyEl.value = '';
updateOrderTotal();
hideOrderConfirm();
}
// -----------------------------------
// 주문유형 변경 처리
// -----------------------------------
function onTradeTypeChange() {
const tp = document.getElementById('orderTradeType').value;
const priceEl = document.getElementById('orderPrice');
const priceUpBtn = document.getElementById('priceUpBtn');
const priceDownBtn = document.getElementById('priceDownBtn');
// 시장가(3)일 때 단가 입력 비활성화
const isMarket = (tp === '3');
priceEl.disabled = isMarket;
priceUpBtn.disabled = isMarket;
priceDownBtn.disabled = isMarket;
if (isMarket) {
priceEl.value = '';
priceEl.placeholder = '시장가';
updateOrderTotal();
} else {
priceEl.placeholder = '단가 입력';
}
}
// -----------------------------------
// 단가 ▲▼ 버튼
// -----------------------------------
function adjustPrice(direction) {
const el = document.getElementById('orderPrice');
// 입력란이 비어 있으면 현재가를 기준으로 시작
let price = parseInt(el.value, 10);
if (!price || isNaN(price)) {
const rawEl = document.getElementById('currentPrice');
price = rawEl ? parseInt(rawEl.dataset.raw || '0', 10) : 0;
}
const tick = getTickSize(price);
price += direction * tick;
if (price < 1) price = 1;
el.value = price;
updateOrderTotal();
loadOrderable();
}
// -----------------------------------
// 호가창 클릭 → 단가 자동 입력
// -----------------------------------
window.setOrderPrice = function(price) {
const priceEl = document.getElementById('orderPrice');
if (!priceEl || priceEl.disabled) return;
priceEl.value = price;
updateOrderTotal();
loadOrderable();
};
// -----------------------------------
// 총 주문금액 실시간 표시
// -----------------------------------
function updateOrderTotal() {
const price = parseInt(document.getElementById('orderPrice').value.replace(/,/g, ''), 10) || 0;
const qty = parseInt(document.getElementById('orderQty').value.replace(/,/g, ''), 10) || 0;
const total = price * qty;
const el = document.getElementById('orderTotal');
if (el) el.textContent = total > 0 ? total.toLocaleString('ko-KR') + '원' : '-';
}
// -----------------------------------
// 수량 퍼센트 버튼
// -----------------------------------
function setQtyPercent(pct) {
if (orderableQty <= 0) return;
const qty = Math.floor(orderableQty * pct / 100);
const el = document.getElementById('orderQty');
if (el) el.value = qty > 0 ? qty : 0;
updateOrderTotal();
}
// -----------------------------------
// 주문가능금액/수량 조회
// -----------------------------------
async function loadOrderable() {
const code = typeof STOCK_CODE !== 'undefined' ? STOCK_CODE : '';
const price = document.getElementById('orderPrice')?.value || '0';
const side = orderSide === 'buy' ? 'buy' : 'sell';
try {
const res = await fetch(`/api/account/orderable?code=${code}&price=${price}&side=${side}`);
const data = await res.json();
const qtyEl = document.getElementById('orderableQty');
const amtEl = document.getElementById('orderableAmt');
const entrEl = document.getElementById('orderableEntr');
if (data.ordAlowq) {
orderableQty = parseInt(data.ordAlowq.replace(/,/g, ''), 10) || 0;
if (qtyEl) qtyEl.textContent = orderableQty.toLocaleString('ko-KR') + '주';
} else {
orderableQty = 0;
if (qtyEl) qtyEl.textContent = '-';
}
if (amtEl) amtEl.textContent = data.ordAlowa ? parseInt(data.ordAlowa.replace(/,/g, ''), 10).toLocaleString('ko-KR') + '원' : '-';
if (entrEl) entrEl.textContent = data.entr ? parseInt(data.entr.replace(/,/g, ''), 10).toLocaleString('ko-KR') + '원' : '-';
} catch (e) {
// 조회 실패 시 조용히 처리
}
}
// -----------------------------------
// 주문 확인 메시지 표시/숨김
// -----------------------------------
function showOrderConfirm() {
const price = parseInt(document.getElementById('orderPrice').value.replace(/,/g, ''), 10) || 0;
const qty = parseInt(document.getElementById('orderQty').value.replace(/,/g, ''), 10) || 0;
const tp = document.getElementById('orderTradeType').value;
const name = typeof STOCK_NAME !== 'undefined' ? STOCK_NAME : '';
if (qty <= 0) {
showOrderToast('수량을 입력해주세요.', 'error');
return;
}
if (tp !== '3' && price <= 0) {
showOrderToast('단가를 입력해주세요.', 'error');
return;
}
const sideText = orderSide === 'buy' ? '매수' : '매도';
const priceText = tp === '3' ? '시장가' : price.toLocaleString('ko-KR') + '원';
const msg = `${name} ${qty.toLocaleString('ko-KR')}${priceText} ${sideText} 하시겠습니까?`;
const confirmEl = document.getElementById('orderConfirmMsg');
const confirmBox = document.getElementById('orderConfirmBox');
if (confirmEl) confirmEl.textContent = msg;
if (confirmBox) confirmBox.classList.remove('hidden');
document.getElementById('orderSubmitBtn').classList.add('hidden');
}
function hideOrderConfirm() {
const confirmBox = document.getElementById('orderConfirmBox');
const submitBtn = document.getElementById('orderSubmitBtn');
if (confirmBox) confirmBox.classList.add('hidden');
if (submitBtn) submitBtn.classList.remove('hidden');
}
// -----------------------------------
// 주문 제출
// -----------------------------------
async function submitOrder() {
const price = document.getElementById('orderPrice').value || '';
const qty = document.getElementById('orderQty').value || '';
const tradeTP = document.getElementById('orderTradeType').value;
const exchange = document.querySelector('input[name="orderExchange"]:checked')?.value || 'KRX';
const code = typeof STOCK_CODE !== 'undefined' ? STOCK_CODE : '';
hideOrderConfirm();
const payload = {
exchange: exchange,
code: code,
qty: qty.replace(/,/g, ''),
price: tradeTP === '3' ? '' : price.replace(/,/g, ''),
tradeTP: tradeTP,
};
const url = orderSide === 'buy' ? '/api/order/buy' : '/api/order/sell';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) {
showOrderToast(data.error || '주문 실패', 'error');
return;
}
const sideText = orderSide === 'buy' ? '매수' : '매도';
showOrderToast(`${sideText} 주문 접수 완료 (주문번호: ${data.orderNo})`, 'success');
resetOrderForm();
loadOrderable();
// 미체결 탭 갱신
loadPendingTab();
if (document.getElementById('pendingTab')?.classList.contains('active-tab')) {
showAccountTab('pending');
}
} catch (e) {
showOrderToast('네트워크 오류: ' + e.message, 'error');
}
}
// -----------------------------------
// 토스트 알림
// -----------------------------------
function showOrderToast(msg, type) {
const toast = document.createElement('div');
toast.className = `fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-lg text-sm font-medium shadow-lg transition-opacity
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 3000);
}
// -----------------------------------
// 계좌 탭 전환
// -----------------------------------
function showAccountTab(tab) {
['pending', 'history', 'balance'].forEach(t => {
const btn = document.getElementById(t + 'Tab');
const panel = document.getElementById(t + 'Panel');
if (btn) {
if (t === tab) {
btn.classList.add('border-b-2', 'border-blue-500', 'text-blue-600', 'active-tab');
btn.classList.remove('text-gray-500');
} else {
btn.classList.remove('border-b-2', 'border-blue-500', 'text-blue-600', 'active-tab');
btn.classList.add('text-gray-500');
}
}
if (panel) panel.classList.toggle('hidden', t !== tab);
});
if (tab === 'pending') loadPendingTab();
if (tab === 'history') loadHistoryTab();
if (tab === 'balance') loadBalanceTab();
}
// -----------------------------------
// 미체결 탭
// -----------------------------------
async function loadPendingTab() {
const panel = document.getElementById('pendingPanel');
if (!panel) return;
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
try {
const res = await fetch('/api/account/pending');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${list.error || '조회 실패'}</p>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">미체결 내역이 없습니다.</p>';
return;
}
panel.innerHTML = list.map(o => {
const isBuy = o.trdeTp === '2';
const sideClass = isBuy ? 'text-red-500' : 'text-blue-500';
const sideText = isBuy ? '매수' : '매도';
return `
<div class="flex items-center justify-between py-2 border-b border-gray-100 text-xs gap-2">
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 truncate">${o.stkNm}</p>
<p class="${sideClass}">${sideText} · ${parseInt(o.ordPric||0).toLocaleString('ko-KR')}원</p>
<p class="text-gray-400">미체결 ${parseInt(o.osoQty||0).toLocaleString('ko-KR')}/${parseInt(o.ordQty||0).toLocaleString('ko-KR')}주</p>
</div>
<div class="flex gap-1 shrink-0">
<button onclick="cancelOrder('${o.ordNo}','${o.stkCd}')"
class="px-2 py-1 text-xs rounded border border-gray-300 text-gray-600 hover:bg-gray-100">취소</button>
</div>
</div>`;
}).join('');
} catch (e) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
}
}
// -----------------------------------
// 미체결 취소
// -----------------------------------
async function cancelOrder(ordNo, stkCd) {
if (!confirm('전량 취소하시겠습니까?')) return;
try {
const res = await fetch('/api/order', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origOrdNo: ordNo, code: stkCd, qty: '0', exchange: 'KRX' }),
});
const data = await res.json();
if (!res.ok || data.error) {
showOrderToast(data.error || '취소 실패', 'error');
return;
}
showOrderToast('취소 주문 접수 완료', 'success');
loadPendingTab();
} catch (e) {
showOrderToast('네트워크 오류', 'error');
}
}
// -----------------------------------
// 체결내역 탭
// -----------------------------------
async function loadHistoryTab() {
const panel = document.getElementById('historyPanel');
if (!panel) return;
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
try {
const res = await fetch('/api/account/history');
const list = await res.json();
if (!res.ok) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${list.error || '조회 실패'}</p>`;
return;
}
if (!list || list.length === 0) {
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">체결 내역이 없습니다.</p>';
return;
}
panel.innerHTML = list.map(o => {
const isBuy = o.trdeTp === '2';
const sideClass = isBuy ? 'text-red-500' : 'text-blue-500';
const sideText = isBuy ? '매수' : '매도';
const fee = (parseInt(o.trdeCmsn||0) + parseInt(o.trdeTax||0)).toLocaleString('ko-KR');
return `
<div class="py-2 border-b border-gray-100 text-xs">
<div class="flex justify-between">
<span class="font-semibold text-gray-800">${o.stkNm}</span>
<span class="${sideClass}">${sideText}</span>
</div>
<div class="flex justify-between text-gray-500 mt-0.5">
<span>체결가 ${parseInt(o.cntrPric||0).toLocaleString('ko-KR')}× ${parseInt(o.cntrQty||0).toLocaleString('ko-KR')}주</span>
<span>수수료+세금 ${fee}원</span>
</div>
</div>`;
}).join('');
} catch (e) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
}
}
// -----------------------------------
// 잔고 탭
// -----------------------------------
async function loadBalanceTab() {
const panel = document.getElementById('balancePanel');
if (!panel) return;
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
try {
const res = await fetch('/api/account/balance');
const data = await res.json();
if (!res.ok) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${data.error || '조회 실패'}</p>`;
return;
}
const plClass = parseFloat(data.totPrftRt || '0') >= 0 ? 'text-red-500' : 'text-blue-500';
let html = `
<div class="grid grid-cols-2 gap-2 text-xs mb-3 pb-2 border-b border-gray-100">
<div>
<p class="text-gray-400">추정예탁자산</p>
<p class="font-semibold">${parseInt(data.prsmDpstAsetAmt||0).toLocaleString('ko-KR')}원</p>
</div>
<div>
<p class="text-gray-400">총평가손익</p>
<p class="font-semibold ${plClass}">${parseInt(data.totEvltPl||0).toLocaleString('ko-KR')}원</p>
</div>
<div>
<p class="text-gray-400">총평가금액</p>
<p class="font-semibold">${parseInt(data.totEvltAmt||0).toLocaleString('ko-KR')}원</p>
</div>
<div>
<p class="text-gray-400">수익률</p>
<p class="font-semibold ${plClass}">${parseFloat(data.totPrftRt||0).toFixed(2)}%</p>
</div>
</div>`;
if (!data.stocks || data.stocks.length === 0) {
html += '<p class="text-xs text-gray-400 text-center py-2">보유 종목이 없습니다.</p>';
} else {
html += data.stocks.map(s => {
const prft = parseFloat(s.prftRt || '0');
const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500';
return `
<div class="py-2 border-b border-gray-100 text-xs">
<div class="flex justify-between">
<span class="font-semibold text-gray-800">${s.stkNm}</span>
<span class="${cls}">${prft >= 0 ? '+' : ''}${prft.toFixed(2)}%</span>
</div>
<div class="flex justify-between text-gray-500 mt-0.5">
<span>${parseInt(s.rmndQty||0).toLocaleString('ko-KR')}주 / 평단 ${parseInt(s.purPric||0).toLocaleString('ko-KR')}원</span>
<span class="${cls}">${parseInt(s.evltvPrft||0).toLocaleString('ko-KR')}원</span>
</div>
</div>`;
}).join('');
}
panel.innerHTML = html;
} catch (e) {
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
}
}
// -----------------------------------
// DOM 준비 후 초기화
// -----------------------------------
document.addEventListener('DOMContentLoaded', initOrder);

145
static/js/orderbook.js Normal file
View File

@@ -0,0 +1,145 @@
/**
* 실시간 호가창 (OrderBook) 렌더러
* 0D: 주식호가잔량, 0w: 프로그램매매
*/
// 호가창 초기화
function initOrderBook() {
renderOrderBook(null);
renderProgram(null);
}
// 호가창 렌더링
// asks[0] = 매도1호가(최우선, 가장 낮은 매도가), asks[9] = 매도10호가
// bids[0] = 매수1호가(최우선, 가장 높은 매수가), bids[9] = 매수10호가
function renderOrderBook(ob) {
const tbody = document.getElementById('orderbookBody');
if (!tbody) return;
if (!ob) {
tbody.innerHTML = `<tr><td colspan="3" class="text-center py-4 text-xs text-gray-400">호가 데이터 수신 대기 중...</td></tr>`;
return;
}
// 최대 잔량 (진행바 비율 계산용)
const maxVol = Math.max(
...ob.asks.map(a => a.volume),
...ob.bids.map(b => b.volume),
1
);
let html = '';
// 매도호가: 10호가부터 1호가 순으로 위에서 아래 (asks[9]→asks[0])
for (let i = 9; i >= 0; i--) {
const ask = ob.asks[i] || { price: 0, volume: 0 };
const pct = Math.round((ask.volume / maxVol) * 100);
html += `
<tr class="ask-row border-b border-gray-50 hover:bg-red-50 transition-colors cursor-pointer" data-price="${ask.price}">
<td class="py-1.5 px-2 text-right">
<div class="relative h-6 flex items-center justify-end">
<div class="absolute right-0 top-0 h-full bg-red-100 rounded-l" style="width:${pct}%"></div>
<span class="relative text-xs text-gray-600 font-mono z-10">${ask.volume > 0 ? ask.volume.toLocaleString('ko-KR') : ''}</span>
</div>
</td>
<td class="py-1.5 px-2 text-center">
<span class="text-sm font-bold text-red-500">${ask.price > 0 ? ask.price.toLocaleString('ko-KR') : '-'}</span>
</td>
<td class="py-1.5 px-2"></td>
</tr>`;
}
// 예상체결 행 (스프레드 사이)
if (ob.expectedPrc > 0) {
html += `
<tr class="bg-yellow-50 border-y-2 border-yellow-300">
<td class="py-1.5 px-2 text-right text-xs text-yellow-700">예상</td>
<td class="py-1.5 px-2 text-center text-sm font-bold text-yellow-700">${ob.expectedPrc.toLocaleString('ko-KR')}</td>
<td class="py-1.5 px-2 text-left text-xs text-yellow-700">${ob.expectedVol > 0 ? ob.expectedVol.toLocaleString('ko-KR') : ''}</td>
</tr>`;
}
// 매수호가: 1호가부터 10호가 순으로 위에서 아래 (bids[0]→bids[9])
for (let i = 0; i < ob.bids.length; i++) {
const bid = ob.bids[i] || { price: 0, volume: 0 };
const pct = Math.round((bid.volume / maxVol) * 100);
html += `
<tr class="bid-row border-b border-gray-50 hover:bg-blue-50 transition-colors cursor-pointer" data-price="${bid.price}">
<td class="py-1.5 px-2"></td>
<td class="py-1.5 px-2 text-center">
<span class="text-sm font-bold text-blue-500">${bid.price > 0 ? bid.price.toLocaleString('ko-KR') : '-'}</span>
</td>
<td class="py-1.5 px-2 text-left">
<div class="relative h-6 flex items-center">
<div class="absolute left-0 top-0 h-full bg-blue-100 rounded-r" style="width:${pct}%"></div>
<span class="relative text-xs text-gray-600 font-mono z-10">${bid.volume > 0 ? bid.volume.toLocaleString('ko-KR') : ''}</span>
</div>
</td>
</tr>`;
}
tbody.innerHTML = html;
// 호가행 클릭 → 주문창 단가 자동 입력
tbody.querySelectorAll('tr[data-price]').forEach(row => {
row.addEventListener('click', () => {
const price = parseInt(row.dataset.price, 10);
if (price > 0 && window.setOrderPrice) window.setOrderPrice(price);
});
});
// 총잔량 업데이트
const totalAsk = document.getElementById('totalAskVol');
const totalBid = document.getElementById('totalBidVol');
if (totalAsk) totalAsk.textContent = ob.totalAskVol.toLocaleString('ko-KR');
if (totalBid) totalBid.textContent = ob.totalBidVol.toLocaleString('ko-KR');
// 호가 시간 업데이트
const askTime = document.getElementById('askTime');
if (askTime && ob.askTime && ob.askTime.length >= 6) {
const t = ob.askTime;
askTime.textContent = `${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}`;
}
}
// 프로그램 매매 렌더링
function renderProgram(pg) {
const container = document.getElementById('programTrading');
if (!container) return;
if (!pg) {
container.innerHTML = `<span class="text-xs text-gray-400">프로그램 매매 데이터 수신 대기 중...</span>`;
return;
}
const netClass = pg.netBuyVolume >= 0 ? 'text-red-500' : 'text-blue-500';
const netSign = pg.netBuyVolume >= 0 ? '+' : '';
container.innerHTML = `
<div class="grid grid-cols-3 gap-3 text-xs">
<div class="text-center">
<p class="text-gray-400 mb-0.5">매도</p>
<p class="font-semibold text-blue-500">${(pg.sellVolume||0).toLocaleString('ko-KR')}</p>
<p class="text-gray-500">${formatMoney(pg.sellAmount)}원</p>
</div>
<div class="text-center border-x border-gray-100">
<p class="text-gray-400 mb-0.5">순매수</p>
<p class="font-semibold ${netClass}">${netSign}${(pg.netBuyVolume||0).toLocaleString('ko-KR')}</p>
<p class="${netClass}">${netSign}${formatMoney(pg.netBuyAmount)}원</p>
</div>
<div class="text-center">
<p class="text-gray-400 mb-0.5">매수</p>
<p class="font-semibold text-red-500">${(pg.buyVolume||0).toLocaleString('ko-KR')}</p>
<p class="text-gray-500">${formatMoney(pg.buyAmount)}원</p>
</div>
</div>`;
}
// 금액을 억/만 단위로 포맷
function formatMoney(n) {
if (!n) return '0';
const abs = Math.abs(n);
if (abs >= 100000000) return (n / 100000000).toFixed(1) + '억';
if (abs >= 10000) return Math.round(n / 10000) + '만';
return n.toLocaleString('ko-KR');
}

140
static/js/ranking.js Normal file
View File

@@ -0,0 +1,140 @@
/**
* 상승률 TOP 10 실시간 갱신
* - 페이지 로드 시 즉시 조회 + WS 개별 구독
* - 30초마다 랭킹 순위 재조회 (종목 변동 반영)
* - WS로 현재가/등락률/거래량/체결강도 1초 단위 갱신
*/
(function () {
const gridEl = document.getElementById('rankingGrid');
const updatedEl = document.getElementById('rankingUpdatedAt');
const INTERVAL = 30 * 1000; // 30초 (순위 재조회 주기)
// 현재 구독 중인 종목 코드 목록 (재조회 시 해제용)
let currentCodes = [];
// --- 숫자 포맷 ---
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
const sign = f >= 0 ? '+' : '';
return sign + f.toFixed(2) + '%';
}
function fmtCntr(f) {
if (!f) return '-';
return f.toFixed(2);
}
// --- 등락률에 따른 CSS 클래스 ---
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
function cntrClass(f) {
if (f > 100) return 'text-red-500';
if (f > 0 && f < 100) return 'text-blue-500';
return 'text-gray-400';
}
// --- 카드 HTML 생성 (실시간 업데이트용 ID 포함) ---
function makeCard(s) {
const colorCls = rateClass(s.changeRate);
return `
<a href="/stock/${s.code}" id="rk-${s.code}"
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100">
<div class="flex justify-between items-start mb-2">
<span class="text-xs text-gray-400 font-mono">${s.code}</span>
<span id="rk-rate-${s.code}" class="text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
${fmtRate(s.changeRate)}
</span>
</div>
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${s.name}</p>
<p id="rk-price-${s.code}" class="text-lg font-bold ${colorCls}">${fmtNum(s.currentPrice)}원</p>
<div class="flex justify-between text-xs mt-1">
<span class="text-gray-400">거래량 <span id="rk-vol-${s.code}">${fmtNum(s.volume)}</span></span>
<span id="rk-cntr-${s.code}" class="${cntrClass(s.cntrStr)}">체결강도 ${fmtCntr(s.cntrStr)}</span>
</div>
</a>`;
}
// --- WS 실시간 카드 업데이트 ---
function updateCard(code, data) {
const priceEl = document.getElementById(`rk-price-${code}`);
const rateEl = document.getElementById(`rk-rate-${code}`);
const volEl = document.getElementById(`rk-vol-${code}`);
const cntrEl = document.getElementById(`rk-cntr-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
const colorCls = rateClass(rate);
const sign = rate >= 0 ? '+' : '';
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-lg font-bold ${colorCls}`;
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
cntrEl.textContent = '체결강도 ' + fmtCntr(data.cntrStr);
cntrEl.className = cntrClass(data.cntrStr);
}
}
// --- 이전 구독 해제 후 새 종목 구독 ---
function resubscribe(newCodes) {
// 빠진 종목 구독 해제
currentCodes.forEach(code => {
if (!newCodes.includes(code)) {
stockWS.unsubscribe(code);
}
});
// 새로 추가된 종목 구독
newCodes.forEach(code => {
if (!currentCodes.includes(code)) {
stockWS.subscribe(code);
stockWS.onPrice(code, data => updateCard(code, data));
}
});
currentCodes = newCodes;
}
// --- 랭킹 조회 및 렌더링 ---
async function fetchAndRender() {
try {
const resp = await fetch('/api/ranking?market=J&dir=up');
if (!resp.ok) throw new Error('조회 실패');
const stocks = await resp.json();
if (!Array.isArray(stocks) || stocks.length === 0) {
gridEl.innerHTML = '<p class="col-span-5 text-gray-400 text-center py-8">데이터가 없습니다.</p>';
return;
}
gridEl.innerHTML = stocks.map(makeCard).join('');
// WS 구독 갱신
resubscribe(stocks.map(s => s.code));
// 순위 갱신 시각 표시
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
updatedEl.textContent = `${hh}:${mm}:${ss} 기준`;
} catch (e) {
console.error('랭킹 조회 실패:', e);
}
}
// 초기 로드 + 30초마다 순위 재조회
fetchAndRender();
setInterval(fetchAndRender, INTERVAL);
})();

68
static/js/search.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* 종목 검색 자동완성 (Debounce 300ms)
*/
(function () {
const input = document.getElementById('searchInput');
const dropdown = document.getElementById('searchDropdown');
if (!input || !dropdown) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = input.value.trim();
if (q.length < 1) {
dropdown.classList.add('hidden');
return;
}
debounceTimer = setTimeout(() => fetchSuggestions(q), 300);
});
// 외부 클릭 시 드롭다운 닫기
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// 엔터 시 검색 결과 페이지 이동
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const q = input.value.trim();
if (q) location.href = `/search?q=${encodeURIComponent(q)}`;
}
});
async function fetchSuggestions(q) {
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (!resp.ok) return;
const results = await resp.json();
renderDropdown(results);
} catch (e) {
console.error('검색 요청 실패:', e);
}
}
function renderDropdown(results) {
if (!results || results.length === 0) {
dropdown.classList.add('hidden');
return;
}
dropdown.innerHTML = results.slice(0, 8).map(item => `
<a href="/stock/${item.code}"
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-50 last:border-0">
<div>
<span class="font-medium text-gray-800 text-sm">${item.name}</span>
<span class="text-xs text-gray-400 ml-2">${item.code}</span>
</div>
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full">${item.market}</span>
</a>
`).join('');
dropdown.classList.remove('hidden');
}
})();

457
static/js/signal.js Normal file
View File

@@ -0,0 +1,457 @@
/**
* 체결강도 상승 감지 실시간 표시
* - 10초마다 /api/signal 폴링
* - 감지된 종목 WS 구독으로 실시간 가격/체결강도 갱신
* - 각 카드에 체결강도 히스토리 미니 라인차트 표시
*/
(function () {
const gridEl = document.getElementById('signalGrid');
const emptyEl = document.getElementById('signalEmpty');
const updatedEl = document.getElementById('signalUpdatedAt');
const INTERVAL = 10 * 1000;
const MAX_HISTORY = 60; // 최대 60개 포인트 유지
let currentCodes = [];
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
const cntrHistory = new Map(); // code → number[]
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
}
function fmtCntr(f) {
return f ? f.toFixed(2) : '-';
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// 체결강도 히스토리에 값 추가
function recordCntr(code, value) {
if (value == null || value === 0) return;
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
const arr = cntrHistory.get(code);
arr.push(value);
if (arr.length > MAX_HISTORY) arr.shift();
}
// canvas에 체결강도 라인차트 그리기
function drawChart(code) {
const canvas = document.getElementById(`sg-chart-${code}`);
if (!canvas) return;
const data = cntrHistory.get(code) || [];
if (data.length < 2) return;
// canvas 픽셀 크기를 실제 표시 크기에 맞춤 (선명도 유지)
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(...data);
const max = Math.max(...data);
const range = max - min || 1;
const step = w / (data.length - 1);
const getY = val => h - pad - ((val - min) / range) * drawH;
// 그라디언트 필
const lastX = (data.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();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(lastX, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// 라인
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 1.5 * dpr;
ctx.lineJoin = 'round';
ctx.stroke();
// 마지막 포인트 강조
const lastVal = data[data.length - 1];
ctx.beginPath();
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#f97316';
ctx.fill();
}
// 신호 유형 뱃지 HTML 생성
function signalTypeBadge(s) {
if (!s.signalType) return '';
const map = {
'강한매수': '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',
};
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
}
// 1시간 이내 상승 확률 뱃지 HTML 생성
function riseProbBadge(s) {
if (!s.riseLabel) return '';
const isVeryHigh = s.riseLabel === '매우 높음';
const cls = isVeryHigh
? 'bg-emerald-500 text-white border border-emerald-600'
: 'bg-teal-100 text-teal-700 border border-teal-200';
const icon = isVeryHigh ? '🚀' : '📈';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
}
// 호재/악재/중립 뱃지 HTML 생성 ("정보없음"은 빈 문자열 반환)
function sentimentBadge(s) {
const map = {
'호재': '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',
};
const cls = map[s.sentiment];
if (!cls) return '';
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
}
// 연속 상승 횟수에 따른 뱃지 텍스트
function risingBadge(n) {
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
}
// 복합 지표 섹션 HTML 생성 (거래량 배수 · 매도잔량비 · 가격 위치)
function complexIndicators(s) {
const rows = [];
// 거래량 증가율
if (s.volRatio > 0) {
let volCls = 'text-gray-500';
let volLabel = `${s.volRatio.toFixed(1)}`;
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
else if (s.volRatio >= 5) volCls = 'text-orange-400';
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
else if (s.volRatio >= 1) volCls = 'text-green-500';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">거래량 증가</span>
<span class="${volCls}">${volLabel}</span>
</div>`);
}
// 매도/매수 잔량비
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
const ratio = s.askBidRatio.toFixed(2);
let ratioCls = 'text-gray-500';
let ratioLabel = `${ratio} (`;
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">매도/매수 잔량</span>
<span class="${ratioCls} text-xs">${ratioLabel}</span>
</div>`);
}
// 가격 위치 (장중 저가~고가 내 %)
if (s.pricePos !== undefined) {
const pos = s.pricePos.toFixed(0);
let posCls = 'text-gray-500';
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
else if (s.pricePos >= 60) posCls = 'text-orange-400';
else if (s.pricePos <= 30) posCls = 'text-blue-400';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">가격 위치</span>
<span class="${posCls}">${pos}%</span>
</div>`);
}
if (rows.length === 0) return '';
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
}
// 익일 추세 예상 리포팅 HTML 생성
function nextDayBadge(s) {
const trendMap = {
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
};
// LLM 결과가 없으면 "분석 중..." 표시
if (!s.nextDayTrend) {
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
</div>
</div>`;
}
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
const confBadge = s.nextDayConf
? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>`
: '';
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
${style.icon} ${s.nextDayTrend}${confBadge}
</span>
</div>
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
</div>`;
}
// AI 목표가 뱃지 HTML 생성
function targetPriceBadge(s) {
if (!s.targetPrice || s.targetPrice === 0) return '';
const diff = s.targetPrice - s.currentPrice;
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
const sign = diff >= 0 ? '+' : '';
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
<span class="text-xs text-gray-400">AI 목표가</span>
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
</span>
</div>`;
}
// 시그널 종목 카드 HTML 생성
function makeCard(s) {
const diff = s.cntrStr - s.prevCntrStr;
const rising = s.risingCount || 1;
return `
<a href="/stock/${s.code}" id="sg-${s.code}"
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-5 border border-orange-100">
<div class="flex justify-between items-start mb-3">
<span class="text-sm text-gray-400 font-mono">${s.code}</span>
<div class="flex items-center gap-1.5 flex-wrap justify-end">
${signalTypeBadge(s)}
${riseProbBadge(s)}
${risingBadge(rising)}
${sentimentBadge(s)}
<span id="sg-rate-${s.code}" class="text-sm px-2.5 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
${fmtRate(s.changeRate)}
</span>
</div>
</div>
<p class="font-bold text-gray-800 text-base truncate mb-1">${s.name}</p>
<p id="sg-price-${s.code}" class="text-2xl font-bold ${rateClass(s.changeRate)} mb-3">${fmtNum(s.currentPrice)}원</p>
<div class="pt-3 border-t border-gray-50 text-sm space-y-1.5">
<div class="flex justify-between">
<span class="text-gray-400">체결강도</span>
<span id="sg-cntr-${s.code}" class="font-bold text-orange-500">${fmtCntr(s.cntrStr)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">직전 대비</span>
<span class="text-gray-500">${fmtCntr(s.prevCntrStr)} → <span class="text-green-500 font-semibold">+${diff.toFixed(2)}</span></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">거래량</span>
<span id="sg-vol-${s.code}" class="text-gray-600">${fmtNum(s.volume)}</span>
</div>
${complexIndicators(s)}
${targetPriceBadge(s)}
${nextDayBadge(s)}
</div>
<!-- 체결강도 미니 라인차트 -->
<canvas id="sg-chart-${s.code}"
style="width:100%;height:48px;display:block;margin-top:12px;"
class="rounded-sm"></canvas>
</a>`;
}
// 카드 삽입 후 canvas 초기화 및 초기값 기록
function initChart(code, cntrStr) {
recordCntr(code, cntrStr);
drawChart(code);
}
// WebSocket 실시간 가격 수신 시 카드 업데이트
function updateCard(code, data) {
const priceEl = document.getElementById(`sg-price-${code}`);
const rateEl = document.getElementById(`sg-rate-${code}`);
const cntrEl = document.getElementById(`sg-cntr-${code}`);
const volEl = document.getElementById(`sg-vol-${code}`);
if (!priceEl) return;
const rate = data.changeRate ?? 0;
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-lg font-bold ${rateClass(rate)}`;
rateEl.textContent = fmtRate(rate);
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
if (cntrEl && data.cntrStr !== undefined) {
cntrEl.textContent = fmtCntr(data.cntrStr);
}
if (volEl) volEl.textContent = fmtNum(data.volume);
// 체결강도 히스토리 기록 후 차트 갱신
if (data.cntrStr != null && data.cntrStr !== 0) {
recordCntr(code, data.cntrStr);
drawChart(code);
}
}
// 이전 구독 종목 해제 후 신규 종목 구독
function resubscribe(newCodes) {
currentCodes.forEach(code => {
if (!newCodes.includes(code)) stockWS.unsubscribe(code);
});
newCodes.forEach(code => {
if (!currentCodes.includes(code)) {
stockWS.subscribe(code);
stockWS.onPrice(code, data => updateCard(code, data));
}
});
currentCodes = newCodes;
}
// /api/signal 조회 후 그리드 렌더링
async function fetchAndRender() {
try {
const resp = await fetch('/api/signal');
if (!resp.ok) throw new Error('조회 실패');
const signals = await resp.json();
const now = new Date();
updatedEl.textContent = now.toTimeString().slice(0, 8) + ' 기준';
if (!Array.isArray(signals) || signals.length === 0) {
gridEl.innerHTML = '';
if (emptyEl) emptyEl.classList.remove('hidden');
resubscribe([]);
return;
}
if (emptyEl) emptyEl.classList.add('hidden');
gridEl.innerHTML = signals.map(makeCard).join('');
// 카드 삽입 후 각 종목 초기 체결강도 기록 및 차트 초기화
signals.forEach(s => initChart(s.code, s.cntrStr));
resubscribe(signals.map(s => s.code));
} catch (e) {
console.error('시그널 조회 실패:', e);
}
}
let pollTimer = null;
function startPolling() {
if (pollTimer) return;
fetchAndRender();
pollTimer = setInterval(fetchAndRender, INTERVAL);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
// 그리드 비우기
gridEl.innerHTML = '';
if (emptyEl) {
emptyEl.textContent = '스캐너가 꺼져 있습니다. 버튼을 눌러 켜주세요.';
emptyEl.classList.remove('hidden');
}
resubscribe([]);
}
// 토글 버튼 상태 적용
const toggleBtn = document.getElementById('scannerToggleBtn');
function applyState(enabled) {
if (!toggleBtn) return;
if (enabled) {
toggleBtn.textContent = '● ON';
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-green-100 text-green-700 border-green-300';
startPolling();
} else {
toggleBtn.textContent = '○ OFF';
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-gray-100 text-gray-400 border-gray-300';
stopPolling();
}
}
// 페이지 로드 시 백엔드 상태 조회
fetch('/api/scanner/status')
.then(r => r.json())
.then(d => applyState(d.enabled))
.catch(() => applyState(true)); // 실패 시 켜짐으로 fallback
// 토글 버튼 클릭
if (toggleBtn) {
toggleBtn.addEventListener('click', async () => {
try {
const resp = await fetch('/api/scanner/toggle', { method: 'POST' });
const data = await resp.json();
applyState(data.enabled);
} catch (e) {
console.error('스캐너 토글 실패:', e);
}
});
}
})();
// 시그널 판단 기준 모달 열기/닫기
(function () {
const btn = document.getElementById('signalGuideBtn');
const modal = document.getElementById('signalGuideModal');
const close = document.getElementById('signalGuideClose');
if (!btn || !modal) return;
btn.addEventListener('click', () => modal.classList.remove('hidden'));
close.addEventListener('click', () => modal.classList.add('hidden'));
// 모달 바깥 클릭 시 닫기
modal.addEventListener('click', e => {
if (e.target === modal) modal.classList.add('hidden');
});
// ESC 키로 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') modal.classList.add('hidden');
});
})();

255
static/js/theme.js Normal file
View File

@@ -0,0 +1,255 @@
/**
* 테마 분석 페이지
* - 테마 목록 조회 (ka90001)
* - 테마 구성종목 조회 (ka90002)
*/
(function () {
let currentDate = '1';
let currentSort = '3'; // 3=등락률순, 1=기간수익률순
let selectedCode = null;
let selectedName = null;
let allThemes = [];
let clientSortCol = null; // 헤더 클릭 정렬 컬럼 (null=서버 순서 유지)
let clientSortDesc = true;
const listEl = document.getElementById('themeList');
const countEl = document.getElementById('themeCount');
const searchEl = document.getElementById('themeSearch');
const emptyEl = document.getElementById('themeDetailEmpty');
const contentEl = document.getElementById('themeDetailContent');
const loadingEl = document.getElementById('themeDetailLoading');
const nameEl = document.getElementById('detailThemeName');
const fluRtEl = document.getElementById('detailFluRt');
const periodRtEl = document.getElementById('detailPeriodRt');
const stockListEl = document.getElementById('detailStockList');
// ── 포맷 유틸 ────────────────────────────────────────────────
function fmtRate(f) {
if (f == null) return '-';
const sign = f >= 0 ? '+' : '';
return sign + f.toFixed(2) + '%';
}
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBg(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ── 테마 목록 행 렌더 ────────────────────────────────────────
function makeRow(t, rank) {
const fluCls = rateClass(t.fluRt);
const periodCls = t.periodRt >= 0 ? 'text-purple-600' : 'text-blue-500';
const isSelected = t.code === selectedCode;
return `
<div data-code="${t.code}" data-name="${t.name}"
class="theme-row grid grid-cols-[2fr_1fr_80px_80px_80px_80px] gap-0
px-4 py-3 cursor-pointer transition-colors text-sm
${isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'}">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs text-gray-400 w-5 shrink-0">${rank}</span>
<span class="font-medium text-gray-800 truncate">${t.name}</span>
</div>
<div class="text-xs text-gray-500 truncate self-center">${t.mainStock || '-'}</div>
<div class="text-right self-center font-semibold ${fluCls}">${fmtRate(t.fluRt)}</div>
<div class="text-right self-center font-semibold ${periodCls}">${fmtRate(t.periodRt)}</div>
<div class="text-center self-center text-gray-500">${t.stockCount}</div>
<div class="text-center self-center text-xs">
<span class="text-red-400">${t.risingCount}▲</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-blue-400">${t.fallCount}▼</span>
</div>
</div>`;
}
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
function updateThemeColHeaders() {
document.querySelectorAll('.theme-col-sort').forEach(el => {
const arrow = el.querySelector('.sort-arrow');
if (!arrow) return;
if (el.dataset.col === clientSortCol) {
arrow.textContent = clientSortDesc ? '▼' : '▲';
arrow.className = 'sort-arrow text-blue-400';
el.classList.add('text-blue-500');
el.classList.remove('text-gray-500');
} else {
arrow.textContent = '';
arrow.className = 'sort-arrow';
el.classList.remove('text-blue-500');
el.classList.add('text-gray-500');
}
});
}
// ── 클라이언트 정렬 적용 ─────────────────────────────────────
function applyClientSort(themes) {
if (!clientSortCol) return themes;
return [...themes].sort((a, b) => {
const av = a[clientSortCol], bv = b[clientSortCol];
if (typeof av === 'string') {
const cmp = av.localeCompare(bv, 'ko');
return clientSortDesc ? cmp : -cmp;
}
const diff = bv - av;
return clientSortDesc ? diff : -diff;
});
}
// ── 테마 목록 렌더 ───────────────────────────────────────────
function renderList(themes) {
const q = searchEl.value.trim();
let filtered = q
? themes.filter(t => t.name.includes(q))
: themes;
filtered = applyClientSort(filtered);
countEl.textContent = `${filtered.length}개 테마`;
if (filtered.length === 0) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">검색 결과가 없습니다.</div>`;
return;
}
listEl.innerHTML = filtered.map((t, i) => makeRow(t, i + 1)).join('');
// 행 클릭 이벤트
listEl.querySelectorAll('.theme-row').forEach(row => {
row.addEventListener('click', () => {
const code = row.dataset.code;
const name = row.dataset.name;
if (code === selectedCode) return;
selectedCode = code;
selectedName = name;
// 선택 상태 표시 갱신
listEl.querySelectorAll('.theme-row').forEach(r => {
r.classList.toggle('bg-blue-50', r.dataset.code === code);
r.classList.toggle('hover:bg-gray-50', r.dataset.code !== code);
});
loadDetail(code, name);
});
});
}
// ── 테마 목록 조회 ───────────────────────────────────────────
async function loadThemes() {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>`;
try {
const resp = await fetch(`/api/themes?date=${currentDate}&sort=${currentSort}`);
if (!resp.ok) throw new Error('조회 실패');
allThemes = await resp.json();
renderList(allThemes);
} catch (e) {
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
console.error('테마 목록 조회 실패:', e);
}
}
// ── 구성종목 조회 ─────────────────────────────────────────────
async function loadDetail(code, name) {
emptyEl.classList.add('hidden');
contentEl.classList.add('hidden');
loadingEl.classList.remove('hidden');
try {
const resp = await fetch(`/api/themes/${code}?date=${currentDate}`);
if (!resp.ok) throw new Error('조회 실패');
const data = await resp.json();
nameEl.textContent = name;
fluRtEl.textContent = fmtRate(data.fluRt);
fluRtEl.className = 'font-semibold ' + rateClass(data.fluRt);
periodRtEl.textContent = fmtRate(data.periodRt);
const stocks = data.stocks || [];
if (stocks.length === 0) {
stockListEl.innerHTML = `<div class="px-4 py-8 text-center text-xs text-gray-400">구성종목이 없습니다.</div>`;
} else {
stockListEl.innerHTML = stocks.map(s => {
const cls = rateClass(s.fluRt);
const bgCls = rateBg(s.fluRt);
const sign = s.predPre >= 0 ? '+' : '';
return `
<a href="/stock/${s.code}"
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 transition-colors">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">${s.name}</p>
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
</div>
<div class="text-right shrink-0 ml-3">
<p class="text-sm font-semibold ${cls}">${fmtNum(s.curPrc)}원</p>
<span class="text-xs px-1.5 py-0.5 rounded ${bgCls}">${sign}${fmtRate(s.fluRt)}</span>
</div>
</a>`;
}).join('');
}
loadingEl.classList.add('hidden');
contentEl.classList.remove('hidden');
} catch (e) {
loadingEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
emptyEl.innerHTML = '<p class="text-red-400">구성종목을 불러오지 못했습니다.</p>';
console.error('테마 구성종목 조회 실패:', e);
}
}
// ── 날짜 탭 이벤트 ───────────────────────────────────────────
document.querySelectorAll('.date-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentDate = btn.dataset.date;
document.querySelectorAll('.date-tab').forEach(b => {
b.className = b.dataset.date === currentDate
? 'date-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
loadThemes();
});
});
// ── 정렬 탭 이벤트 (서버 사이드) ─────────────────────────────
document.querySelectorAll('.sort-tab').forEach(btn => {
btn.addEventListener('click', () => {
currentSort = btn.dataset.sort;
// 헤더 클라이언트 정렬 초기화 (서버 순서 우선)
clientSortCol = null;
document.querySelectorAll('.sort-tab').forEach(b => {
b.className = b.dataset.sort === currentSort
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
});
updateThemeColHeaders();
loadThemes();
});
});
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
document.querySelectorAll('.theme-col-sort').forEach(el => {
el.addEventListener('click', () => {
const col = el.dataset.col;
if (clientSortCol === col) {
clientSortDesc = !clientSortDesc;
} else {
clientSortCol = col;
clientSortDesc = true;
}
updateThemeColHeaders();
renderList(allThemes);
});
});
// ── 검색 필터 이벤트 ─────────────────────────────────────────
searchEl.addEventListener('input', () => renderList(allThemes));
// ── 초기 로드 ────────────────────────────────────────────────
loadThemes();
})();

598
static/js/watchlist.js Normal file
View File

@@ -0,0 +1,598 @@
/**
* 관심종목 관리 + WebSocket 실시간 시세 + 10초 폴링 분석
* - 서버 API (/api/watchlist)에 종목 저장
* - WS 구독으로 1초마다 현재가/등락률/체결강도 + 미니 차트 갱신
* - 10초마다 /api/watchlist-signal 폴링으로 복합 분석 뱃지 갱신
*/
(function () {
const MAX_HISTORY = 60; // 체결강도 히스토리 최대 포인트
const SIGNAL_INTERVAL = 10 * 1000; // 10초 폴링
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
const cntrHistory = new Map(); // code → number[]
// --- 서버 API 헬퍼 ---
// 메모리 캐시 (서버 동기화용)
let cachedList = [];
async function loadFromServer() {
try {
const resp = await fetch('/api/watchlist');
if (!resp.ok) return [];
const list = await resp.json();
cachedList = Array.isArray(list) ? list : [];
return cachedList;
} catch {
return cachedList;
}
}
function loadList() {
return cachedList;
}
async function addToServer(code, name) {
const resp = await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, name }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || '추가 실패');
}
cachedList.push({ code, name });
}
async function removeFromServer(code) {
await fetch(`/api/watchlist/${code}`, { method: 'DELETE' });
cachedList = cachedList.filter(s => s.code !== code);
}
// --- DOM 요소 ---
const input = document.getElementById('watchlistInput');
const addBtn = document.getElementById('watchlistAddBtn');
const msgEl = document.getElementById('watchlistMsg');
const sidebarEl = document.getElementById('watchlistSidebar');
const emptyEl = document.getElementById('watchlistEmpty');
const panelEl = document.getElementById('watchlistPanel');
const panelEmpty = document.getElementById('watchlistPanelEmpty');
const wsStatusEl = document.getElementById('wsStatus');
// --- WS 상태 표시 ---
function setWsStatus(text, color) {
if (!wsStatusEl) return;
wsStatusEl.textContent = text;
wsStatusEl.className = `text-xs font-normal ml-1 ${color}`;
}
// ─────────────────────────────────────────────
// 포맷 유틸
// ─────────────────────────────────────────────
function fmtNum(n) {
if (n == null) return '-';
return Math.abs(n).toLocaleString('ko-KR');
}
function fmtRate(f) {
if (f == null) return '-';
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
}
function fmtCntr(f) {
return f ? f.toFixed(2) : '-';
}
function rateClass(f) {
if (f > 0) return 'text-red-500';
if (f < 0) return 'text-blue-500';
return 'text-gray-500';
}
function rateBadgeClass(f) {
if (f > 0) return 'bg-red-50 text-red-500';
if (f < 0) return 'bg-blue-50 text-blue-500';
return 'bg-gray-100 text-gray-500';
}
// ─────────────────────────────────────────────
// 뱃지 HTML 생성 함수 (signal.js와 동일)
// ─────────────────────────────────────────────
function signalTypeBadge(s) {
if (!s.signalType) return '';
const map = {
'강한매수': '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',
};
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
}
function riseProbBadge(s) {
if (!s.riseLabel) return '';
const isVeryHigh = s.riseLabel === '매우 높음';
const cls = isVeryHigh
? 'bg-emerald-500 text-white border border-emerald-600'
: 'bg-teal-100 text-teal-700 border border-teal-200';
const icon = isVeryHigh ? '🚀' : '📈';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
}
function risingBadge(n) {
if (!n) return '';
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
}
function sentimentBadge(s) {
const map = {
'호재': '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',
};
const cls = map[s.sentiment];
if (!cls) return '';
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
}
function complexIndicators(s) {
const rows = [];
if (s.volRatio > 0) {
let volCls = 'text-gray-500';
let volLabel = `${s.volRatio.toFixed(1)}`;
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
else if (s.volRatio >= 5) volCls = 'text-orange-400';
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
else if (s.volRatio >= 1) volCls = 'text-green-500';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">거래량 증가</span>
<span class="${volCls}">${volLabel}</span>
</div>`);
}
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
const ratio = s.askBidRatio.toFixed(2);
let ratioCls = 'text-gray-500';
let ratioLabel = `${ratio} (`;
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">매도/매수 잔량</span>
<span class="${ratioCls} text-xs">${ratioLabel}</span>
</div>`);
}
if (s.pricePos !== undefined) {
const pos = s.pricePos.toFixed(0);
let posCls = 'text-gray-500';
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
else if (s.pricePos >= 60) posCls = 'text-orange-400';
else if (s.pricePos <= 30) posCls = 'text-blue-400';
rows.push(`<div class="flex justify-between">
<span class="text-gray-400">가격 위치</span>
<span class="${posCls}">${pos}%</span>
</div>`);
}
if (rows.length === 0) return '';
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
}
function targetPriceBadge(s) {
if (!s.targetPrice || s.targetPrice === 0) return '';
const diff = s.targetPrice - s.currentPrice;
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
const sign = diff >= 0 ? '+' : '';
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
<span class="text-xs text-gray-400">AI 목표가</span>
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
</span>
</div>`;
}
function nextDayBadge(s) {
const trendMap = {
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
};
if (!s.nextDayTrend) {
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
</div>
</div>`;
}
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
const confBadge = s.nextDayConf ? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>` : '';
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
return `<div class="mt-2 pt-2 border-t border-gray-100">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-400">익일 추세</span>
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
${style.icon} ${s.nextDayTrend}${confBadge}
</span>
</div>
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
</div>`;
}
// ─────────────────────────────────────────────
// 체결강도 히스토리 + 미니 차트
// ─────────────────────────────────────────────
function recordCntr(code, value) {
if (value == null || value === 0) return;
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
const arr = cntrHistory.get(code);
arr.push(value);
if (arr.length > MAX_HISTORY) arr.shift();
}
function drawChart(code) {
const canvas = document.getElementById(`wc-chart-${code}`);
if (!canvas) return;
const data = cntrHistory.get(code) || [];
if (data.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(...data);
const max = Math.max(...data);
const range = max - min || 1;
const step = w / (data.length - 1);
const getY = val => h - pad - ((val - min) / range) * drawH;
// 그라디언트 필
const lastX = (data.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();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(lastX, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// 라인
ctx.beginPath();
data.forEach((val, i) => {
const x = i * step;
const y = getY(val);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 1.5 * dpr;
ctx.lineJoin = 'round';
ctx.stroke();
// 마지막 포인트 강조
const lastVal = data[data.length - 1];
ctx.beginPath();
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
ctx.fillStyle = '#f97316';
ctx.fill();
}
// ─────────────────────────────────────────────
// 관심종목 추가 / 삭제
// ─────────────────────────────────────────────
async function addStock(code) {
code = code.trim().toUpperCase();
if (!/^\d{6}$/.test(code)) {
showMsg('6자리 숫자 종목코드를 입력해주세요.');
return;
}
if (loadList().find(s => s.code === code)) {
showMsg('이미 추가된 종목입니다.');
return;
}
showMsg('조회 중...', false);
try {
const resp = await fetch(`/api/stock/${code}`);
if (!resp.ok) throw new Error('조회 실패');
const data = await resp.json();
if (!data.name) throw new Error('종목 없음');
await addToServer(code, data.name);
hideMsg();
input.value = '';
renderSidebar();
addPanelCard(code, data.name);
updateCard(code, data);
subscribeCode(code);
} catch (e) {
showMsg(e.message || '종목을 찾을 수 없습니다.');
}
}
async function removeStock(code) {
await removeFromServer(code);
stockWS.unsubscribe(code);
document.getElementById(`si-${code}`)?.remove();
document.getElementById(`wc-${code}`)?.remove();
cntrHistory.delete(code);
updateEmptyStates();
}
// ─────────────────────────────────────────────
// 사이드바 렌더링
// ─────────────────────────────────────────────
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)));
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;
}
// ─────────────────────────────────────────────
// 패널 카드 추가 (signal.js makeCard 구조와 동일)
// ─────────────────────────────────────────────
function addPanelCard(code, name) {
if (document.getElementById(`wc-${code}`)) return;
const card = document.createElement('div');
card.id = `wc-${code}`;
card.className = 'bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100 relative';
card.innerHTML = `
<!-- 헤더: 종목코드 + 분석 뱃지 + 등락률 -->
<div class="flex justify-between items-start mb-2">
<a href="/stock/${code}" class="text-xs text-gray-400 font-mono hover:text-blue-500">${code}</a>
<div id="wc-badges-${code}" class="flex items-center gap-1 flex-wrap justify-end">
<span id="wc-rate-${code}" class="text-xs px-2 py-0.5 rounded-full font-semibold bg-gray-100 text-gray-500">-</span>
</div>
</div>
<!-- 종목명 + 현재가 -->
<a href="/stock/${code}" class="block">
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${name}</p>
<p id="wc-price-${code}" class="text-xl font-bold text-gray-900 mb-2">-</p>
</a>
<!-- info 섹션 -->
<div class="pt-2 border-t border-gray-50 text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-400">체결강도</span>
<span id="wc-cntr-${code}" class="font-bold text-orange-500">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">직전 대비</span>
<span id="wc-prev-${code}" class="text-gray-500">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">거래량</span>
<span id="wc-vol-${code}" class="text-gray-600">-</span>
</div>
<!-- 복합 지표 (10초 폴링 갱신) -->
<div id="wc-complex-${code}"></div>
<!-- AI 목표가 (10초 폴링 갱신) -->
<div id="wc-target-${code}"></div>
<!-- 익일 추세 (10초 폴링 갱신) -->
<div id="wc-nextday-${code}"></div>
</div>
<!-- 체결강도 미니 라인차트 -->
<canvas id="wc-chart-${code}"
style="width:100%;height:48px;display:block;margin-top:10px;"
class="rounded-sm"></canvas>
<!-- 삭제 버튼 -->
<button onclick="removeStock('${code}')" title="삭제"
class="absolute top-3 right-3 text-gray-200 hover:text-red-400 text-lg leading-none opacity-0 hover:opacity-100 transition-opacity" style="font-size:18px;">×</button>`;
panelEl.appendChild(card);
updateEmptyStates();
}
// ─────────────────────────────────────────────
// WS 실시간 카드 업데이트 (1초)
// ─────────────────────────────────────────────
function updateCard(code, data) {
const priceEl = document.getElementById(`wc-price-${code}`);
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 (volEl) volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
const cs = data.cntrStr;
cntrEl.textContent = fmtCntr(cs);
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
}
// 체결강도 히스토리 기록 + 미니 차트 갱신
if (data.cntrStr != null && data.cntrStr !== 0) {
recordCntr(code, data.cntrStr);
drawChart(code);
}
}
// ─────────────────────────────────────────────
// 10초 폴링 분석 결과 DOM 갱신
// ─────────────────────────────────────────────
function updateAnalysis(code, sig) {
// 뱃지 영역 갱신 (등락률 뱃지는 유지하면서 분석 뱃지 앞에 삽입)
const badgesEl = document.getElementById(`wc-badges-${code}`);
const rateEl = document.getElementById(`wc-rate-${code}`);
if (badgesEl && rateEl) {
// 기존 분석 뱃지 제거
badgesEl.querySelectorAll('.analysis-badge').forEach(el => el.remove());
// 분석 뱃지 생성 후 등락률 앞에 삽입
const frag = document.createRange().createContextualFragment(
[
signalTypeBadge(sig),
riseProbBadge(sig),
risingBadge(sig.risingCount),
sentimentBadge(sig),
].filter(Boolean).join('')
);
// analysis-badge 클래스 추가 (제거 시 사용)
frag.querySelectorAll('span').forEach(el => el.classList.add('analysis-badge'));
badgesEl.insertBefore(frag, rateEl);
}
// 직전 대비 갱신
const prevEl = document.getElementById(`wc-prev-${code}`);
if (prevEl && sig.prevCntrStr != null && sig.cntrStr != null) {
const diff = sig.cntrStr - sig.prevCntrStr;
prevEl.innerHTML = `${fmtCntr(sig.prevCntrStr)} → <span class="${diff >= 0 ? 'text-green-500' : 'text-blue-400'} font-semibold">${diff >= 0 ? '+' : ''}${diff.toFixed(2)}</span>`;
}
// 복합 지표 갱신
const complexEl = document.getElementById(`wc-complex-${code}`);
if (complexEl) complexEl.innerHTML = complexIndicators(sig);
// AI 목표가 갱신
const targetEl = document.getElementById(`wc-target-${code}`);
if (targetEl) targetEl.innerHTML = targetPriceBadge(sig);
// 익일 추세 갱신
const nextEl = document.getElementById(`wc-nextday-${code}`);
if (nextEl) nextEl.innerHTML = nextDayBadge(sig);
}
// ─────────────────────────────────────────────
// WS 구독
// ─────────────────────────────────────────────
function subscribeCode(code) {
stockWS.subscribe(code);
stockWS.onPrice(code, (data) => updateCard(code, data));
}
// ─────────────────────────────────────────────
// 10초 폴링: /api/watchlist-signal
// ─────────────────────────────────────────────
async function fetchWatchlistSignal() {
const codes = cachedList.map(s => s.code).join(',');
if (!codes) return;
try {
const resp = await fetch(`/api/watchlist-signal?codes=${codes}`);
if (!resp.ok) throw new Error('조회 실패');
const signals = await resp.json();
if (!Array.isArray(signals)) return;
signals.forEach(sig => updateAnalysis(sig.code, sig));
} catch (e) {
console.error('관심종목 분석 조회 실패:', e);
}
}
// ─────────────────────────────────────────────
// 빈 상태 처리
// ─────────────────────────────────────────────
function updateEmptyStates() {
const hasItems = cachedList.length > 0;
emptyEl.classList.toggle('hidden', hasItems);
panelEmpty?.classList.toggle('hidden', hasItems);
}
// ─────────────────────────────────────────────
// 메시지
// ─────────────────────────────────────────────
function showMsg(text, isError = true) {
msgEl.textContent = text;
msgEl.className = `text-xs mt-1 ${isError ? 'text-red-500' : 'text-gray-400'}`;
msgEl.classList.remove('hidden');
}
function hideMsg() { msgEl.classList.add('hidden'); }
// ─────────────────────────────────────────────
// WS 상태 모니터링
// ─────────────────────────────────────────────
function monitorWS() {
setInterval(() => {
if (stockWS.ws?.readyState === WebSocket.OPEN) {
setWsStatus('● 실시간', 'text-green-500 font-normal ml-1');
} else {
setWsStatus('○ 연결 중...', 'text-gray-400 font-normal ml-1');
}
}, 1000);
}
// ─────────────────────────────────────────────
// 이벤트 바인딩
// ─────────────────────────────────────────────
addBtn.addEventListener('click', () => addStock(input.value));
input.addEventListener('keydown', e => { if (e.key === 'Enter') addStock(input.value); });
// removeStock을 전역으로 노출 (onclick 속성에서 호출)
window.removeStock = removeStock;
// ─────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────
monitorWS();
// 서버에서 관심종목 로드 후 카드 생성 + WS 구독
(async function init() {
await loadFromServer();
renderSidebar();
cachedList.forEach(s => {
addPanelCard(s.code, s.name);
subscribeCode(s.code);
fetch(`/api/stock/${s.code}`)
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) updateCard(s.code, data); })
.catch(() => {});
});
updateEmptyStates();
// 10초 폴링 시작 (즉시 1회 + 10초 주기)
fetchWatchlistSignal();
setInterval(fetchWatchlistSignal, SIGNAL_INTERVAL);
})();
})();

148
static/js/websocket.js Normal file
View File

@@ -0,0 +1,148 @@
/**
* StockWebSocket - 키움 주식 실시간 시세 WebSocket 클라이언트
* 자동 재연결 (지수 백오프), 구독 목록 자동 복구 지원
*/
class StockWebSocket {
constructor() {
this.ws = null;
this.subscriptions = new Set(); // 현재 구독 중인 종목 코드
// 메시지 타입별 핸들러 맵: type → { code → callbacks[] }
this.handlers = {
price: new Map(),
orderbook: new Map(),
program: new Map(),
meta: new Map(),
};
// 전역 핸들러 (코드 무관한 메시지용)
this.globalHandlers = {
market: [],
};
this.reconnectDelay = 1000; // 초기 재연결 대기 시간 (ms)
this.maxReconnectDelay = 30000; // 최대 재연결 대기 시간 (ms)
this.intentionalClose = false;
this.connect();
}
connect() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${location.host}/ws`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('WebSocket 연결됨');
this.reconnectDelay = 1000; // 성공 시 재연결 대기 시간 초기화
// 기존 구독 목록 자동 복구
this.subscriptions.forEach(code => this._send({ type: 'subscribe', code }));
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this._handleMessage(msg);
} catch (e) {
console.error('메시지 파싱 실패:', e);
}
};
this.ws.onclose = () => {
if (!this.intentionalClose) {
console.log(`WebSocket 연결 끊김. ${this.reconnectDelay}ms 후 재연결...`);
setTimeout(() => this.connect(), this.reconnectDelay);
// 지수 백오프
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
};
this.ws.onerror = (err) => {
console.error('WebSocket 오류:', err);
};
}
// 종목 구독
subscribe(code) {
this.subscriptions.add(code);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this._send({ type: 'subscribe', code });
}
}
// 종목 구독 해제
unsubscribe(code) {
this.subscriptions.delete(code);
Object.values(this.handlers).forEach(map => map.delete(code));
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this._send({ type: 'unsubscribe', code });
}
}
// 현재가 수신 콜백 등록
onPrice(code, callback) {
this._addCodeHandler('price', code, callback);
}
// 호가창 수신 콜백 등록
onOrderBook(code, callback) {
this._addCodeHandler('orderbook', code, callback);
}
// 프로그램 매매 수신 콜백 등록
onProgram(code, callback) {
this._addCodeHandler('program', code, callback);
}
// 종목 메타 수신 콜백 등록
onMeta(code, callback) {
this._addCodeHandler('meta', code, callback);
}
// 장운영 상태 수신 콜백 등록 (전역)
onMarket(callback) {
this.globalHandlers.market.push(callback);
}
// 내부: 코드별 핸들러 등록
_addCodeHandler(type, code, callback) {
if (!this.handlers[type]) return;
if (!this.handlers[type].has(code)) {
this.handlers[type].set(code, []);
}
this.handlers[type].get(code).push(callback);
}
// 내부: 메시지 처리
_handleMessage(msg) {
const { type, code, data } = msg;
if (type === 'market') {
this.globalHandlers.market.forEach(fn => fn(data));
return;
}
if (type === 'error') {
console.warn(`서버 오류 [${code}]:`, data?.message);
return;
}
if (this.handlers[type] && code) {
const callbacks = this.handlers[type].get(code) || [];
callbacks.forEach(fn => fn(data));
}
}
// 내부: JSON 메시지 전송
_send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
close() {
this.intentionalClose = true;
this.ws?.close();
}
}
// 전역 인스턴스 (stock_detail.html에서 사용)
const stockWS = new StockWebSocket();

0
tasks.md Normal file
View File

163
templates/layout/base.html Normal file
View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- TradingView Lightweight Charts v4 (v5는 API 호환성 문제로 고정) -->
<script src="https://unpkg.com/lightweight-charts@4.2.1/dist/lightweight-charts.standalone.production.js"></script>
<link rel="stylesheet" href="/static/css/custom.css">
<style>
/* 상승/하락 색상 (한국 주식 관행) */
.price-up { color: #ef4444; } /* 빨강 */
.price-down { color: #3b82f6; } /* 파랑 */
.price-flat { color: #6b7280; } /* 회색 */
/* 사이드바 토글 애니메이션 */
#sidebarWrapper {
display: flex;
overflow: hidden;
transition: width 0.25s ease, opacity 0.25s ease, margin 0.25s ease;
width: 240px;
opacity: 1;
}
#sidebarWrapper.sidebar-hidden {
width: 0;
opacity: 0;
margin-right: 0 !important;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 네비게이션 -->
<nav class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- 사이드바 토글 버튼 -->
<button id="sidebarToggle" title="사이드바 열기/닫기"
class="mr-2 p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 transition-colors shrink-0">
<!-- 사이드바 열림: 왼쪽 화살표(닫기) -->
<svg id="sidebarIconOpen" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
<!-- 사이드바 닫힘: 오른쪽 화살표(열기) -->
<svg id="sidebarIconClose" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
<!-- 로고 -->
<a href="/" class="text-lg font-bold text-gray-800 hover:text-blue-600 shrink-0 mr-2">
📈 주식 시세
</a>
<!-- 네비 메뉴 -->
<nav class="flex items-center gap-0.5 shrink-0">
<a href="/" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
{{ if eq .ActiveMenu "시세" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">시세</a>
<a href="/theme" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
{{ if eq .ActiveMenu "테마" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">테마</a>
<a href="/kospi200" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
{{ if eq .ActiveMenu "코스피200" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">코스피200</a>
{{ if .LoggedIn }}
<a href="/asset" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
{{ if eq .ActiveMenu "자산" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">자산</a>
<a href="/autotrade" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
{{ if eq .ActiveMenu "자동매매" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">자동매매</a>
{{ end }}
</nav>
<!-- 검색창 -->
<div class="flex-1 max-w-md mx-4 relative">
<input
id="searchInput"
type="text"
placeholder="종목명 또는 코드 검색"
autocomplete="off"
class="w-full px-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-400"
>
<!-- 자동완성 드롭다운 -->
<div id="searchDropdown" class="hidden absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-60 overflow-y-auto"></div>
</div>
<!-- 로그인/로그아웃 버튼 -->
{{ if .LoggedIn }}
<form method="POST" action="/logout" class="shrink-0">
<button type="submit"
class="text-xs px-3 py-1.5 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-800 font-medium transition-colors">
로그아웃
</button>
</form>
{{ else }}
<a href="/login" class="shrink-0 text-xs px-3 py-1.5 rounded-lg text-blue-600 hover:bg-blue-50 font-medium transition-colors">
로그인
</a>
{{ end }}
</div>
</div>
</nav>
<!-- 지수 티커 바 -->
<div class="bg-gray-800 text-white text-xs">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-8 flex items-center gap-6 overflow-x-auto" id="indexTicker">
<span class="text-gray-400 shrink-0">로딩 중...</span>
</div>
</div>
<!-- 메인 콘텐츠 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex gap-6 items-start">
<div id="sidebarWrapper">
{{ block "sidebar" . }}{{ end }}
</div>
<main class="flex-1 min-w-0">
{{ block "content" . }}{{ end }}
</main>
</div>
<!-- 공통 JavaScript -->
<script src="/static/js/websocket.js"></script>
<script src="/static/js/search.js"></script>
<script src="/static/js/indices.js"></script>
{{ block "scripts" . }}{{ end }}
<script>
(function () {
const wrapper = document.getElementById('sidebarWrapper');
const toggle = document.getElementById('sidebarToggle');
const iconOpen = document.getElementById('sidebarIconOpen');
const iconClose = document.getElementById('sidebarIconClose');
if (!wrapper || !toggle) return;
// 사이드바가 없는 페이지(sidebar 블록이 비어 있음)는 버튼 숨김
if (!wrapper.children.length) {
toggle.classList.add('hidden');
return;
}
const STORAGE_KEY = 'sidebar_visible';
function applyState(visible) {
if (visible) {
wrapper.classList.remove('sidebar-hidden');
iconOpen.classList.remove('hidden');
iconClose.classList.add('hidden');
} else {
wrapper.classList.add('sidebar-hidden');
iconOpen.classList.add('hidden');
iconClose.classList.remove('hidden');
}
localStorage.setItem(STORAGE_KEY, visible ? '1' : '0');
}
// 저장된 상태 복원 (기본값: 표시)
const saved = localStorage.getItem(STORAGE_KEY);
applyState(saved !== '0');
toggle.addEventListener('click', () => {
const isHidden = wrapper.classList.contains('sidebar-hidden');
applyState(isHidden); // 현재 숨겨져 있으면 보이게
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,98 @@
{{ template "base.html" . }}
{{ define "sidebar" }}{{ end }}
{{ define "content" }}
<div class="space-y-5">
<!-- 페이지 제목 -->
<h1 class="text-xl font-bold text-gray-800">자산 현황</h1>
<!-- 요약 카드 4개 -->
<div id="summaryCards" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
<p class="text-lg font-bold text-gray-300">-</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
<p class="text-lg font-bold text-gray-300">-</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
<p class="text-lg font-bold text-gray-300">-</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
<p class="text-lg font-bold text-gray-300">-</p>
</div>
</div>
<!-- 예수금 카드 -->
<div id="cashCard" class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">예수금</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-400 mb-1">D+2 추정예수금</p>
<p id="cashEntr" class="text-base font-bold text-gray-800">-</p>
</div>
<div>
<p class="text-xs text-gray-400 mb-1">주문가능현금</p>
<p id="cashOrdAlowa" class="text-base font-bold text-gray-800">-</p>
</div>
</div>
</div>
<!-- 보유 종목 테이블 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-100">
<h2 class="text-sm font-semibold text-gray-700">보유 종목</h2>
</div>
<!-- 테이블 헤더 -->
<div class="grid grid-cols-[1fr_80px_90px_90px_100px_80px] text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100 px-5 py-2.5 gap-2">
<span>종목명</span>
<span class="text-right">보유수량</span>
<span class="text-right">평균단가</span>
<span class="text-right">현재가</span>
<span class="text-right">평가손익</span>
<span class="text-right">수익률</span>
</div>
<!-- 종목 목록 -->
<div id="holdingsTable">
<div class="px-5 py-10 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>
</div>
</div>
<!-- 미체결/체결내역 탭 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<!-- 탭 버튼 -->
<div class="flex border-b border-gray-100">
<button id="assetPendingTab"
onclick="showAssetTab('pending')"
class="px-5 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
미체결
</button>
<button id="assetHistoryTab"
onclick="showAssetTab('history')"
class="px-5 py-3 text-sm font-medium text-gray-500 hover:text-gray-700">
체결내역
</button>
</div>
<!-- 미체결 패널 -->
<div id="assetPendingPanel" class="p-5">
<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>
</div>
<!-- 체결내역 패널 -->
<div id="assetHistoryPanel" class="p-5 hidden">
<div class="text-sm text-gray-400 text-center py-6">탭을 클릭하면 조회됩니다.</div>
</div>
</div>
</div>
{{ end }}
{{ define "scripts" }}
<script src="/static/js/asset.js"></script>
{{ end }}

View File

@@ -0,0 +1,266 @@
{{ template "base.html" . }}
{{ define "sidebar" }}{{ end }}
{{ define "content" }}
<div class="space-y-5">
<!-- 페이지 제목 + 상태바 -->
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-xl font-bold text-gray-800">자동매매</h1>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span id="statusDot" class="w-3 h-3 rounded-full bg-gray-300"></span>
<span id="statusText" class="text-sm font-medium text-gray-600">중지</span>
</div>
<button onclick="startEngine()" id="btnStart"
class="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
시작
</button>
<button onclick="stopEngine()" id="btnStop"
class="px-4 py-2 text-sm font-medium bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
중지
</button>
<button onclick="emergencyStop()"
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
⚠ 긴급청산
</button>
</div>
</div>
<!-- 요약 카드 3개 -->
<div class="grid grid-cols-3 gap-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<p class="text-xs text-gray-500 mb-1">진행중 포지션</p>
<p id="statActivePos" class="text-2xl font-bold text-gray-800">0</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<p class="text-xs text-gray-500 mb-1">오늘 매매 횟수</p>
<p id="statTradeCount" class="text-2xl font-bold text-gray-800">0</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<p class="text-xs text-gray-500 mb-1">오늘 손익</p>
<p id="statTotalPL" class="text-2xl font-bold text-gray-800">0원</p>
</div>
</div>
<!-- 감시 소스 설정 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-800">감시 소스 설정</h2>
<span class="text-xs text-gray-400">매수 신호를 가져올 종목 범위를 선택하세요</span>
</div>
<div class="flex flex-col lg:flex-row gap-4">
<!-- 체결강도 자동감지 토글 -->
<div class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50 min-w-[200px]">
<div class="flex-1">
<p class="text-sm font-medium text-gray-800">체결강도 자동감지</p>
<p class="text-xs text-gray-400">거래량 상위 20종목 실시간 분석</p>
</div>
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" id="wsScanner" checked onchange="saveWatchSource()"
class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-checked:bg-blue-600 rounded-full peer
peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors relative
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:after:translate-x-5"></div>
</label>
</div>
<!-- 테마 선택 -->
<div class="flex-1 p-3 rounded-lg border border-gray-100 bg-gray-50">
<p class="text-sm font-medium text-gray-800 mb-2">테마 감시</p>
<!-- 테마 드롭다운 + 추가 버튼 -->
<div class="flex gap-2 mb-2">
<select id="themeSelect"
class="flex-1 px-3 py-1.5 text-xs border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-400">
<option value="">테마를 선택하세요...</option>
</select>
<button onclick="addSelectedTheme()"
class="px-3 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors whitespace-nowrap">
+ 추가
</button>
</div>
<!-- 선택된 테마 태그 목록 -->
<div id="selectedThemes" class="flex flex-wrap gap-1.5 min-h-[28px]">
<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>
</div>
</div>
</div>
</div>
<!-- 규칙 + 포지션 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 매매 규칙 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-800">매매 규칙</h2>
<button onclick="showAddRuleModal()"
class="px-3 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
+ 규칙 추가
</button>
</div>
<div id="rulesList" class="space-y-3">
<p class="text-xs text-gray-400 text-center py-4">규칙이 없습니다.</p>
</div>
</div>
<!-- 현재 포지션 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-800">현재 포지션</h2>
<span class="text-xs text-gray-400">5초 자동 갱신</span>
</div>
<div id="positionsList">
<p class="text-xs text-gray-400 text-center py-4">보유 포지션 없음</p>
</div>
</div>
</div>
<!-- 매매 로그 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-gray-800">매매 로그</h2>
<span id="wsStatus" class="text-xs text-gray-400">○ 연결중...</span>
</div>
<div class="flex gap-1 text-xs">
<button onclick="setLogFilter('all')" id="filterAll"
class="px-3 py-1 rounded-full bg-gray-800 text-white font-medium">전체</button>
<button onclick="setLogFilter('action')" id="filterAction"
class="px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200">매매만</button>
</div>
<label class="flex items-center gap-1 text-xs text-gray-400 cursor-pointer">
<input type="checkbox" id="autoScrollChk" checked class="w-3 h-3 accent-blue-500">
자동스크롤
</label>
<span id="logUpdateTime" class="text-xs text-gray-400"></span>
</div>
<div id="logsWrapper" class="overflow-y-auto rounded border border-gray-100" style="max-height:480px;">
<table class="w-full text-xs">
<thead class="sticky top-0 bg-white">
<tr class="text-left text-gray-400 border-b border-gray-100">
<th class="pb-2 pt-2 px-1 font-medium w-36">시각</th>
<th class="pb-2 pt-2 px-1 font-medium w-16">레벨</th>
<th class="pb-2 pt-2 px-1 font-medium w-20">종목</th>
<th class="pb-2 pt-2 px-1 font-medium">내용</th>
</tr>
</thead>
<tbody id="logsList">
<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 규칙 추가/수정 모달 -->
<div id="ruleModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<h3 id="modalTitle" class="text-base font-semibold text-gray-800">규칙 추가</h3>
<button onclick="hideModal()" class="text-gray-400 hover:text-gray-600 text-lg"></button>
</div>
<div class="space-y-4">
<input type="hidden" id="ruleId">
<!-- 규칙명 -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">규칙명</label>
<input id="fName" type="text" placeholder="예: 강한매수 전략"
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">
</div>
<!-- 진입 조건 -->
<div class="border-t pt-4">
<p class="text-xs font-semibold text-gray-600 mb-3">진입 조건</p>
<div class="space-y-3">
<div>
<label class="flex items-center justify-between text-xs text-gray-700 mb-1">
<span>최소 상승점수 (RiseScore)</span>
<span id="riseScoreVal" class="font-semibold text-blue-600">60</span>
</label>
<input id="fRiseScore" type="range" min="0" max="100" value="60"
oninput="document.getElementById('riseScoreVal').textContent=this.value"
class="w-full accent-blue-600">
</div>
<div>
<label class="block text-xs text-gray-700 mb-1">최소 체결강도</label>
<input id="fCntrStr" type="number" value="110" step="1" min="0"
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">
</div>
<div class="flex items-center gap-2">
<input id="fRequireBullish" type="checkbox" class="w-4 h-4 accent-blue-600">
<label class="text-xs text-gray-700">AI 호재 신호 필요</label>
</div>
</div>
</div>
<!-- 주문 설정 -->
<div class="border-t pt-4">
<p class="text-xs font-semibold text-gray-600 mb-3">주문 설정</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-700 mb-1">1회 주문금액 (원)</label>
<input id="fOrderAmount" type="number" value="500000" step="10000" min="10000"
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">
</div>
<div>
<label class="block text-xs text-gray-700 mb-1">최대 보유 종목 수</label>
<input id="fMaxPositions" type="number" value="3" min="1" max="20"
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">
</div>
</div>
</div>
<!-- 청산 조건 -->
<div class="border-t pt-4">
<p class="text-xs font-semibold text-gray-600 mb-3">청산 조건</p>
<div class="grid grid-cols-2 gap-3">
<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">
</div>
<div>
<label class="block text-xs text-gray-700 mb-1">익절 (%)</label>
<input id="fTakeProfit" type="number" value="5" 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">
</div>
<div>
<label class="block text-xs text-gray-700 mb-1">최대 보유 시간 (분, 0=무제한)</label>
<input id="fMaxHold" type="number" value="60" min="0"
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">
</div>
<div class="flex items-end pb-2">
<div class="flex items-center gap-2">
<input id="fExitBeforeClose" type="checkbox" checked class="w-4 h-4 accent-blue-600">
<label class="text-xs text-gray-700">장마감 전 청산 (15:20)</label>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-2">
<button type="button" onclick="submitRule()"
class="flex-1 py-2.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
저장
</button>
<button type="button" onclick="hideModal()"
class="flex-1 py-2.5 text-sm font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
취소
</button>
</div>
</div>
</div>
</div>
</div>
{{ end }}
{{ define "scripts" }}
<script src="/static/js/autotrade.js"></script>
{{ end }}

338
templates/pages/index.html Normal file
View File

@@ -0,0 +1,338 @@
{{ template "base.html" . }}
{{ define "sidebar" }}
<aside class="w-60 shrink-0 sticky top-20 self-start">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 border-b border-gray-100">
<h2 class="font-bold text-gray-800 text-sm">관심종목</h2>
</div>
<!-- 종목 추가 입력 -->
<div class="p-3 border-b border-gray-100">
<div class="flex gap-2">
<input id="watchlistInput" type="text" placeholder="종목코드 (예: 005930)"
maxlength="6"
class="flex-1 text-xs px-2 py-1.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-400 font-mono">
<button id="watchlistAddBtn"
class="text-xs px-2.5 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium shrink-0">
추가
</button>
</div>
<p id="watchlistMsg" class="text-xs text-red-500 mt-1 hidden"></p>
</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">
관심종목을 추가해주세요
</li>
</ul>
</div>
</aside>
{{ end }}
{{ define "content" }}
<div class="space-y-8">
<!-- 체결강도 상승 감지 -->
<section>
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
<span class="text-orange-500"></span> 체결강도 상승 감지
<span class="text-xs font-normal text-gray-400 ml-1">(거래량 상위 20 | 10초 갱신)</span>
<button id="signalGuideBtn"
class="w-5 h-5 rounded-full bg-gray-200 text-gray-500 text-xs font-bold hover:bg-orange-100 hover:text-orange-600 transition-colors flex items-center justify-center"
title="판단 기준 보기">?</button>
<button id="scannerToggleBtn"
class="text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-green-100 text-green-700 border-green-300"
title="스캐너 ON/OFF">● ON</button>
<span id="signalUpdatedAt" class="text-xs font-normal text-gray-400 ml-auto"></span>
</h2>
<!-- 시그널 판단 기준 모달 -->
<div id="signalGuideModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 hidden"
role="dialog" aria-modal="true">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 pt-5 pb-3 border-b border-gray-100 sticky top-0 bg-white">
<h3 class="text-base font-bold text-gray-800">⚡ 시그널 판단 기준</h3>
<button id="signalGuideClose" class="text-gray-400 hover:text-gray-600 text-xl font-bold leading-none"></button>
</div>
<div class="px-6 py-5 space-y-6 text-sm text-gray-700">
<!-- 스코어 설명 -->
<div>
<p class="font-semibold text-gray-800 mb-2">📊 상승 확률 스코어 (0~100점)</p>
<p class="text-xs text-gray-400 mb-3">4가지 복합 요소를 동시에 만족해야 진짜 상승 확률이 높습니다.</p>
<table class="w-full text-xs border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500">
<th class="text-left px-3 py-2 border border-gray-100 font-semibold">요소</th>
<th class="text-center px-3 py-2 border border-gray-100 font-semibold w-16">배점</th>
<th class="text-left px-3 py-2 border border-gray-100 font-semibold">핵심 로직</th>
</tr>
</thead>
<tbody>
<tr>
<td class="px-3 py-2 border border-gray-100 font-medium">A. 체결강도 레벨</td>
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~30</td>
<td class="px-3 py-2 border border-gray-100 text-gray-500">150+: 30점 · 130+: 22점 · 110+: 14점</td>
</tr>
<tr class="bg-gray-50">
<td class="px-3 py-2 border border-gray-100 font-medium">B. 연속 상승 횟수</td>
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~25</td>
<td class="px-3 py-2 border border-gray-100 text-gray-500">5회+: 25점 · 3회+: 14점 · 1회: 3점</td>
</tr>
<tr>
<td class="px-3 py-2 border border-gray-100 font-medium">C. 가격 위치/캔들</td>
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~20</td>
<td class="px-3 py-2 border border-gray-100 text-gray-500">윗꼬리 10% 미만: +10점 · 60% 이상: 8점</td>
</tr>
<tr class="bg-gray-50">
<td class="px-3 py-2 border border-gray-100 font-medium">D. 거래량 건전성</td>
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~15</td>
<td class="px-3 py-2 border border-gray-100 text-gray-500">2~5배 증가: +15점 · 10배 이상: 5점</td>
</tr>
<tr>
<td class="px-3 py-2 border border-gray-100 font-medium">E. 매도잔량 소화</td>
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~10</td>
<td class="px-3 py-2 border border-gray-100 text-gray-500">매수잔량 압도적: +10점 · 매도 우세: 3점</td>
</tr>
</tbody>
</table>
</div>
<!-- 신호 유형 설명 -->
<div>
<p class="font-semibold text-gray-800 mb-2">🏷️ 신호 유형 분류</p>
<div class="space-y-2">
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-red-50">
<span class="bg-red-600 text-white text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">강한매수</span>
<span class="text-gray-600">체결강도 130+ · 3회 이상 연속 상승 · 윗꼬리 없음 · 매도잔량 소화 중 → 실매수 강하고 던지는 물량도 받아내는 패턴</span>
</div>
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-orange-50">
<span class="bg-orange-400 text-white text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">매수우세</span>
<span class="text-gray-600">기본 상승 패턴. 강한매수 조건 미충족이나 전반적으로 매수세가 우위</span>
</div>
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-yellow-50">
<span class="bg-yellow-100 text-yellow-700 border border-yellow-300 text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">물량소화</span>
<span class="text-gray-600">체결강도는 높은데 가격이 제자리 + 긴 윗꼬리 → 위에서 파는 물량을 받아내기만 하는 중. 돌파 여부 확인 필요</span>
</div>
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-gray-100">
<span class="bg-gray-800 text-white text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">추격위험</span>
<span class="text-gray-600">체결강도 170+ · 거래량 7배 이상 · 긴 윗꼬리 동시 출현 → 단타 추격매수 몰림 후 고점 물량털기 가능성 경계</span>
</div>
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-gray-50">
<span class="bg-gray-100 text-gray-500 border border-gray-300 text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">약한상승</span>
<span class="text-gray-600">거래량 증가 없음 · 체결강도 120 미만 → 얇은 호가에서 뜬 상승, 소량 매도에도 쉽게 꺾일 수 있음</span>
</div>
</div>
</div>
<!-- 지표별 상세 설명 -->
<div>
<p class="font-semibold text-gray-800 mb-3">🔍 카드 지표 상세 설명</p>
<div class="space-y-3">
<!-- 체결강도 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">체결강도</span>
<span class="text-xs text-gray-400">· 매수/매도 힘의 비율</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
<p>10초마다 직전 체결강도와 비교해 <span class="font-semibold text-orange-500">연속으로 상승하는 종목</span>만 표시합니다.</p>
<p class="text-gray-400">100 = 매수·매도 균형 / 100 초과 = 매수 우위 / 100 미만 = 매도 우위</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span class="bg-red-50 text-red-500 px-2 py-0.5 rounded">150+ 강한 매수세</span>
<span class="bg-orange-50 text-orange-500 px-2 py-0.5 rounded">130~150 매수 우세</span>
<span class="bg-yellow-50 text-yellow-600 px-2 py-0.5 rounded">100~130 매수 경향</span>
<span class="bg-blue-50 text-blue-400 px-2 py-0.5 rounded">100 미만 매도 경향</span>
</div>
</div>
</div>
<!-- 연속 상승 뱃지 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">연속 상승 뱃지</span>
<span class="text-xs text-gray-400">· 10초 단위 체결강도 상승 횟수</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1.5">
<p>직전 측정값 대비 체결강도가 <span class="font-semibold">끊기지 않고 몇 번 연속으로 올랐는지</span>를 나타냅니다.</p>
<div class="flex flex-wrap gap-1.5">
<span class="bg-red-500 text-white px-2 py-0.5 rounded font-bold">🔥N연속</span>
<span class="text-gray-500 self-center">4회 이상 — 강한 지속 매수세</span>
</div>
<div class="flex flex-wrap gap-1.5">
<span class="bg-orange-400 text-white px-2 py-0.5 rounded font-bold">▲N연속</span>
<span class="text-gray-500 self-center">2~3회 — 매수세 형성 중</span>
</div>
<div class="flex flex-wrap gap-1.5">
<span class="bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded font-bold">↑상승</span>
<span class="text-gray-500 self-center">1회 — 초기 매수 감지</span>
</div>
</div>
</div>
<!-- 거래량 증가율 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">거래량 증가</span>
<span class="text-xs text-gray-400">· 직전 평균 대비 현재 구간 배수</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
<p>직전 6회(1분) 구간 평균 거래량 대비 <span class="font-semibold">현재 10초 구간에서 얼마나 폭발적으로 거래됐는지</span>를 나타냅니다.</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span class="bg-green-50 text-green-600 font-semibold px-2 py-0.5 rounded">2~5배 최적</span>
<span class="bg-orange-50 text-orange-400 px-2 py-0.5 rounded">5~10배 과열 초입</span>
<span class="bg-gray-100 text-gray-400 px-2 py-0.5 rounded line-through">10배+ ⚠과열</span>
</div>
<p class="text-gray-400 mt-1">※ 10배 이상은 고점 물량털기 가능성이 높아 스코어가 감점됩니다.</p>
</div>
</div>
<!-- 매도/매수 잔량비 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">매도/매수 잔량</span>
<span class="text-xs text-gray-400">· 호가창 전체 잔량 비율</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
<p>10단계 매도호가 총잔량 ÷ 10단계 매수호가 총잔량입니다. <span class="font-semibold">1보다 작을수록 매수 우위</span>, 클수록 위에 팔 물량이 많다는 의미입니다.</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span class="bg-green-50 text-green-600 font-semibold px-2 py-0.5 rounded">0.7 미만 — 매수 강세</span>
<span class="bg-green-50 text-green-500 px-2 py-0.5 rounded">0.7~1.0 — 매수 우세</span>
<span class="bg-gray-50 text-gray-500 px-2 py-0.5 rounded">1.0~1.5 — 균형</span>
<span class="bg-blue-50 text-blue-500 px-2 py-0.5 rounded">1.5+ — 매도 우세</span>
</div>
</div>
</div>
<!-- 가격 위치 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">가격 위치</span>
<span class="text-xs text-gray-400">· 장중 저가~고가 내 현재가 위치 %</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
<p>오늘 장중 저가를 0%, 고가를 100%로 봤을 때 <span class="font-semibold">현재가가 어디에 있는지</span>를 나타냅니다.</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span class="bg-red-50 text-red-500 font-semibold px-2 py-0.5 rounded">80%+ 고가권 · 강한 매수</span>
<span class="bg-orange-50 text-orange-400 px-2 py-0.5 rounded">60~80% 상단부</span>
<span class="bg-gray-50 text-gray-500 px-2 py-0.5 rounded">30~60% 중간대</span>
<span class="bg-blue-50 text-blue-400 px-2 py-0.5 rounded">30% 미만 저가권</span>
</div>
<p class="text-gray-400 mt-1">※ 체결강도가 강한데 가격 위치가 낮으면 저점 반등 초기 신호일 수 있습니다.</p>
</div>
</div>
<!-- AI 감성 분석 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">AI 감성 분석</span>
<span class="text-xs text-gray-400">· 최신 뉴스 기반 호재/악재 판단</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1.5">
<p>상승 확률 50점 이상 · 2회 이상 연속 상승 · 체결강도 100 이상 종목에 한해 AI가 최신 뉴스를 분석합니다.</p>
<div class="flex flex-wrap gap-1.5">
<span class="bg-green-100 text-green-700 border border-green-200 px-2 py-0.5 rounded font-semibold">호재</span>
<span class="text-gray-500 self-center">긍정적 뉴스·공시 감지</span>
</div>
<div class="flex flex-wrap gap-1.5">
<span class="bg-red-100 text-red-600 border border-red-200 px-2 py-0.5 rounded font-semibold">악재</span>
<span class="text-gray-500 self-center">부정적 뉴스·공시 감지</span>
</div>
<div class="flex flex-wrap gap-1.5">
<span class="bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-semibold">중립</span>
<span class="text-gray-500 self-center">특이 뉴스 없음</span>
</div>
<p class="text-gray-400">※ 뱃지에 마우스를 올리면 AI가 판단한 근거 한 줄이 표시됩니다.</p>
</div>
</div>
<!-- AI 목표가 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">AI 목표가</span>
<span class="text-xs text-gray-400">· 단기 기술적 목표가 추론</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
<p>현재가·고가·저가·체결강도·연속상승 횟수·AI 감성을 종합해 <span class="font-semibold text-purple-600">단기 목표가</span>를 추론합니다. 투자 조언이 아닌 참고용 수치입니다.</p>
<p class="text-gray-400">※ 뱃지에 마우스를 올리면 목표가 추론 근거가 표시됩니다.</p>
</div>
</div>
<!-- 익일 추세 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">익일 추세</span>
<span class="text-xs text-gray-400">· 다음 거래일 방향성 예측</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1.5">
<p>당일 기술 지표와 AI 감성 분석을 결합해 다음 거래일 방향성을 예측합니다. 신뢰도(높음/보통/낮음)를 함께 표시합니다.</p>
<div class="flex flex-wrap gap-1.5">
<span class="bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded font-bold">▲ 상승</span>
<span class="bg-blue-50 text-blue-500 border border-blue-200 px-2 py-0.5 rounded font-bold">▼ 하락</span>
<span class="bg-gray-50 text-gray-500 border border-gray-200 px-2 py-0.5 rounded font-bold">─ 횡보</span>
</div>
<p class="text-gray-400">※ AI 예측은 참고용이며, 실제 시장 상황에 따라 달라질 수 있습니다.</p>
</div>
</div>
<!-- 체결강도 미니차트 -->
<div class="rounded-lg border border-gray-100 overflow-hidden">
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
<span class="font-semibold text-gray-800 text-xs">체결강도 차트</span>
<span class="text-xs text-gray-400">· 카드 하단 주황색 라인</span>
</div>
<div class="px-3 py-2.5 text-xs text-gray-600">
<p>시그널 감지 이후 <span class="font-semibold text-orange-500">10초마다 수집된 체결강도 흐름</span>을 라인차트로 표시합니다. 우상향이면 매수세가 지속되는 중입니다. 최대 60포인트(10분) 유지됩니다.</p>
</div>
</div>
</div>
</div>
<!-- 한 줄 기준 -->
<div class="bg-orange-50 rounded-xl p-4 border border-orange-100">
<p class="font-semibold text-orange-700 mb-1">💡 진짜 상승 한 줄 기준</p>
<p class="text-orange-600">가격이 오르면서 · 거래량이 받쳐주고 · 체결강도가 유지되고 · 위 매도물량이 실제로 소화되는 흐름</p>
</div>
<!-- 면책 안내 -->
<div class="bg-gray-50 rounded-xl p-4 border border-gray-100">
<p class="font-semibold text-gray-600 mb-1">⚠️ 투자 유의사항</p>
<p class="text-gray-500 text-xs leading-relaxed">이 서비스의 모든 지표와 AI 분석은 <span class="font-semibold">참고용 정보</span>이며 투자 권유나 매매 신호가 아닙니다. 투자 결정은 본인의 판단과 책임 하에 이루어져야 하며, 모든 투자 손실에 대한 책임은 투자자 본인에게 있습니다.</p>
</div>
</div>
</div>
</div>
<div id="signalGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<p class="col-span-3 text-gray-400 text-center py-6 text-sm" id="signalEmpty">
08:00 이후 거래량 상위 종목에서 체결강도 100 이상 + 상승 종목을 표시합니다.
</p>
</div>
</section>
<!-- 관심종목 실시간 패널 -->
<section>
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
<span class="text-yellow-500"></span> 관심종목 실시간
<span class="text-xs font-normal text-gray-400 ml-1" id="wsStatus">연결 중...</span>
</h2>
<div id="watchlistPanel" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div id="watchlistPanelEmpty" class="col-span-3 text-center py-12 text-gray-400 text-sm bg-white rounded-xl border border-dashed border-gray-200">
좌측 메뉴에서 관심종목을 추가하면 실시간 시세가 표시됩니다.
</div>
</div>
</section>
</div>
{{ end }}
{{ define "scripts" }}
<script src="/static/js/signal.js"></script>
<script src="/static/js/watchlist.js"></script>
{{ end }}

View File

@@ -0,0 +1,61 @@
{{ template "base.html" . }}
{{ define "sidebar" }}{{ end }}
{{ define "content" }}
<div class="space-y-4">
<!-- 헤더 + 필터 바 -->
<div class="flex flex-wrap items-center gap-3">
<h1 class="text-xl font-bold text-gray-800 flex items-center gap-2">
<span class="text-blue-600">🔵</span> 코스피200
<span id="lastUpdated" class="text-xs font-normal text-gray-400"></span>
</h1>
<!-- 정렬 -->
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
<button data-sort="fluRt" class="sort-tab px-3 py-1.5 bg-blue-500 text-white">등락률</button>
<button data-sort="volume" class="sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">거래량</button>
<button data-sort="curPrc" class="sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">현재가</button>
</div>
<!-- 상승/하락/전체 필터 -->
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
<button data-dir="all" class="dir-tab px-3 py-1.5 bg-blue-500 text-white">전체</button>
<button data-dir="up" class="dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">상승</button>
<button data-dir="down" class="dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">하락</button>
</div>
<!-- 종목 검색 -->
<input id="k200Search" type="text" placeholder="종목명 검색..."
class="text-xs px-3 py-1.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-400 w-36">
<span id="k200Count" class="text-xs text-gray-400 ml-auto"></span>
</div>
<!-- 종목 테이블 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<!-- 테이블 헤더 -->
<div class="grid grid-cols-[2.5rem_1fr_1fr_90px_90px_100px_90px_90px_90px] text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100 px-4 py-2.5 gap-2">
<span class="text-center">#</span>
<span>종목명</span>
<span data-col="curPrc" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">현재가 <span class="sort-arrow"></span></span>
<span data-col="predPre" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">전일대비 <span class="sort-arrow"></span></span>
<span data-col="fluRt" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">등락률 <span class="sort-arrow text-blue-400"></span></span>
<span data-col="volume" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">거래량 <span class="sort-arrow"></span></span>
<span data-col="open" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">시가 <span class="sort-arrow"></span></span>
<span data-col="high" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">고가 <span class="sort-arrow"></span></span>
<span data-col="low" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">저가 <span class="sort-arrow"></span></span>
</div>
<!-- 종목 목록 -->
<div id="k200List" class="divide-y divide-gray-50">
<div class="px-4 py-12 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>
</div>
</div>
</div>
{{ end }}
{{ define "scripts" }}
<script src="/static/js/kospi200.js"></script>
{{ end }}

View File

@@ -0,0 +1,59 @@
{{define "login.html"}}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - 주식 시세</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-lg w-full max-w-sm p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-800">📈 주식 시세</h1>
<p class="text-sm text-gray-400 mt-1">로그인이 필요한 서비스입니다.</p>
</div>
{{ if .Error }}
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ .Error }}</p>
</div>
{{ end }}
<form method="POST" action="/login" class="space-y-4">
<input type="hidden" name="next" value="{{ .Next }}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">아이디</label>
<input
type="text"
name="id"
placeholder="아이디를 입력하세요"
autocomplete="username"
required
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input
type="password"
name="password"
placeholder="비밀번호를 입력하세요"
autocomplete="current-password"
required
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
>
</div>
<button
type="submit"
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg transition-colors mt-2">
로그인
</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,407 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="space-y-4">
<!-- 현재가 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<!-- 종목명 + 장운영 상태 -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-400 font-mono">{{ .Stock.Code }}</span>
<h1 class="text-xl font-bold text-gray-900">{{ .Stock.Name }}</h1>
<span id="marketStatusBadge" class="px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">장 중</span>
<button id="watchlistToggleBtn" onclick="toggleWatchlist()"
class="ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors">
</button>
</div>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<!-- 현재가 & 등락 -->
<div>
<p id="currentPrice" class="text-4xl font-bold text-gray-900" data-raw="{{ .Stock.CurrentPrice }}">
{{ formatPrice .Stock.CurrentPrice }}원
</p>
<p id="changeInfo" class="text-lg mt-1 {{ priceClass .Stock.ChangeRate }}">
{{ if gt .Stock.ChangePrice 0 }}+{{ end }}{{ formatPrice .Stock.ChangePrice }}원
({{ formatRate .Stock.ChangeRate }})
</p>
<p id="updatedAt" class="text-xs text-gray-400 mt-1">장 중</p>
</div>
<!-- 최우선 호가 -->
<div class="flex gap-4 text-sm">
<div class="text-center">
<p class="text-xs text-gray-400 mb-1">최우선매도</p>
<p id="askPrice1" class="font-semibold text-red-500">-원</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-400 mb-1">최우선매수</p>
<p id="bidPrice1" class="font-semibold text-blue-500">-원</p>
</div>
</div>
</div>
<!-- 요약 그리드 -->
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3 mt-5 pt-4 border-t border-gray-100 text-sm">
<div>
<p class="text-xs text-gray-400">시가</p>
<p id="openPrice" class="font-semibold text-gray-800">{{ formatPrice .Stock.Open }}원</p>
</div>
<div>
<p class="text-xs text-gray-400">고가</p>
<p id="highPrice" class="font-semibold text-red-500">{{ formatPrice .Stock.High }}원</p>
</div>
<div>
<p class="text-xs text-gray-400">저가</p>
<p id="lowPrice" class="font-semibold text-blue-500">{{ formatPrice .Stock.Low }}원</p>
</div>
<div>
<p class="text-xs text-gray-400">거래량</p>
<p id="volume" class="font-semibold text-gray-800">{{ formatPrice .Stock.Volume }}</p>
</div>
<div>
<p class="text-xs text-gray-400">거래대금</p>
<p id="tradeMoney" class="font-semibold text-gray-800">-</p>
</div>
<div>
<p class="text-xs text-gray-400">체결량</p>
<p id="tradeVolume" class="font-semibold text-gray-800">-</p>
</div>
</div>
<!-- 2행: 기준가/상하한가/체결시각 -->
<div class="grid grid-cols-3 sm:grid-cols-4 gap-3 mt-3 pt-3 border-t border-gray-50 text-sm">
<div>
<p class="text-xs text-gray-400">기준가</p>
<p id="basePrice" class="font-semibold text-gray-800">-</p>
</div>
<div>
<p class="text-xs text-gray-400">상한가</p>
<p id="upperLimit" class="font-semibold text-red-500">-</p>
</div>
<div>
<p class="text-xs text-gray-400">하한가</p>
<p id="lowerLimit" class="font-semibold text-blue-500">-</p>
</div>
<div>
<p class="text-xs text-gray-400">체결시각</p>
<p id="tradeTime" class="font-semibold text-gray-800">-</p>
</div>
</div>
</div>
<!-- 차트 + 호가창 (좌우 분할) -->
<div class="flex flex-col xl:flex-row gap-4">
<!-- 차트 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100 flex-1 min-w-0">
<!-- 차트 탭 -->
<div class="flex gap-2 mb-4">
<button onclick="switchChart('daily')" id="tab-daily"
class="px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200">
일봉
</button>
<button onclick="switchChart('minute1')" id="tab-minute1"
class="px-4 py-1.5 text-sm rounded-full bg-blue-500 text-white font-medium">
1분
</button>
<button onclick="switchChart('minute5')" id="tab-minute5"
class="px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200">
5분
</button>
<button onclick="switchChart('tick')" id="tab-tick"
class="px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200">
</button>
</div>
<!-- 이동평균선 범례 -->
<div class="flex gap-3 mb-2 text-xs">
<span class="flex items-center gap-1"><span class="inline-block w-4 bg-amber-400" style="height:1px"></span>MA20</span>
<span class="flex items-center gap-1"><span class="inline-block w-4 bg-emerald-500" style="height:1px"></span>MA60</span>
<span class="flex items-center gap-1"><span class="inline-block w-4 bg-violet-500" style="height:1px"></span>MA120</span>
</div>
<!-- 캔들 차트 컨테이너 (기본 표시) -->
<div id="chartContainer" class="w-full" style="height: 380px;"></div>
<!-- 틱 차트 컨테이너 (기본 숨김) -->
<div id="tickChartContainer" class="w-full" style="height: 380px; display: none;"></div>
</div>
<!-- 우측 컬럼: 로그인 시 호가창+주문+계좌, 비로그인 시 넓은 호가창만 -->
<div class="{{ if .LoggedIn }}xl:w-80{{ else }}xl:w-96{{ end }} shrink-0 flex flex-col gap-3">
<!-- 호가창 -->
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-semibold text-gray-800">호가창</h2>
<span id="askTime" class="text-xs text-gray-400">-</span>
</div>
<!-- 총잔량 헤더 -->
<div class="grid grid-cols-3 text-xs text-gray-400 mb-1 px-2">
<span class="text-right"><span id="totalAskVol" class="text-red-400 font-semibold">-</span></span>
<span class="text-center">호가</span>
<span class="text-left"><span id="totalBidVol" class="text-blue-400 font-semibold">-</span></span>
</div>
<table class="w-full">
<thead>
<tr class="text-xs text-gray-400 border-b border-gray-200">
<th class="py-1.5 px-2 text-right font-normal">매도잔량</th>
<th class="py-1.5 px-2 text-center font-normal">호가</th>
<th class="py-1.5 px-2 text-left font-normal">매수잔량</th>
</tr>
</thead>
<tbody id="orderbookBody">
<tr>
<td colspan="3" class="text-center py-6 text-xs text-gray-400">호가 데이터 수신 대기 중...</td>
</tr>
</tbody>
</table>
</div>
{{ if .LoggedIn }}
<!-- 주문창 패널 -->
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
<!-- 매수/매도 탭 -->
<div class="flex gap-1 mb-3">
<button id="orderBuyTab" onclick="updateOrderSide('buy')"
class="flex-1 py-1.5 text-sm font-semibold rounded-lg bg-red-500 text-white transition-colors">
매수
</button>
<button id="orderSellTab" onclick="updateOrderSide('sell')"
class="flex-1 py-1.5 text-sm font-semibold rounded-lg bg-gray-100 text-gray-600 transition-colors">
매도
</button>
</div>
<!-- 거래소 구분 -->
<div class="flex gap-3 mb-3 text-xs">
<label class="flex items-center gap-1 cursor-pointer">
<input type="radio" name="orderExchange" value="KRX" checked class="accent-blue-500">
<span>KRX</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="radio" name="orderExchange" value="NXT" class="accent-blue-500">
<span>NXT</span>
</label>
</div>
<!-- 주문유형 -->
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">주문유형</label>
<select id="orderTradeType" onchange="onTradeTypeChange()"
class="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-blue-400">
<option value="0">지정가</option>
<option value="3">시장가</option>
<option value="6">최유리지정가</option>
<option value="7">최우선지정가</option>
<option value="5">조건부지정가</option>
</select>
</div>
<!-- 단가 입력 -->
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">단가</label>
<div class="flex gap-1">
<input id="orderPrice" type="number" min="0" placeholder="단가 입력"
oninput="updateOrderTotal(); loadOrderable()"
class="flex-1 text-xs border border-gray-200 rounded-lg px-2 py-1.5 text-right focus:outline-none focus:border-blue-400">
<button id="priceUpBtn" onclick="adjustPrice(1)"
class="px-2 py-1 text-xs rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"></button>
<button id="priceDownBtn" onclick="adjustPrice(-1)"
class="px-2 py-1 text-xs rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"></button>
</div>
</div>
<!-- 수량 입력 -->
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">수량</label>
<input id="orderQty" type="number" min="0" placeholder="수량 입력"
oninput="updateOrderTotal()"
class="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 text-right focus:outline-none focus:border-blue-400">
<!-- 퍼센트 버튼 -->
<div class="flex gap-1 mt-1">
<button onclick="setQtyPercent(10)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">10%</button>
<button onclick="setQtyPercent(25)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">25%</button>
<button onclick="setQtyPercent(50)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">50%</button>
<button onclick="setQtyPercent(100)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">100%</button>
</div>
</div>
<!-- 총금액 + 주문가능 정보 -->
<div class="mb-3 text-xs space-y-1">
<div class="flex justify-between">
<span class="text-gray-400">총 주문금액</span>
<span id="orderTotal" class="font-semibold text-gray-800">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">주문가능현금</span>
<span id="orderableAmt" class="text-gray-600">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">예수금</span>
<span id="orderableEntr" class="text-gray-600">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">주문가능수량</span>
<span id="orderableQty" class="text-gray-600">-</span>
</div>
</div>
<!-- 확인 메시지 -->
<div id="orderConfirmBox" class="hidden mb-3 p-2 bg-gray-50 rounded-lg border border-gray-200 text-xs text-center">
<p id="orderConfirmMsg" class="text-gray-700 mb-2"></p>
<div class="flex gap-2">
<button onclick="hideOrderConfirm()"
class="flex-1 py-1.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 text-xs">취소</button>
<button onclick="submitOrder()"
class="flex-1 py-1.5 rounded bg-blue-500 text-white hover:bg-blue-600 text-xs font-semibold">확인</button>
</div>
</div>
<!-- 주문 버튼 -->
<button id="orderSubmitBtn" onclick="showOrderConfirm()"
class="w-full py-2.5 text-sm font-bold rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors">
매수
</button>
</div>
<!-- 계좌 정보 탭 (미체결/체결/잔고) -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100">
<!-- 탭 헤더 -->
<div class="flex border-b border-gray-100 text-xs font-medium">
<button id="pendingTab" onclick="showAccountTab('pending')"
class="flex-1 py-2.5 text-center border-b-2 border-blue-500 text-blue-600 active-tab transition-colors">
미체결
</button>
<button id="historyTab" onclick="showAccountTab('history')"
class="flex-1 py-2.5 text-center text-gray-500 hover:text-gray-700 transition-colors">
체결
</button>
<button id="balanceTab" onclick="showAccountTab('balance')"
class="flex-1 py-2.5 text-center text-gray-500 hover:text-gray-700 transition-colors">
잔고
</button>
</div>
<!-- 탭 패널 -->
<div class="p-3 max-h-64 overflow-y-auto">
<div id="pendingPanel">
<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>
</div>
<div id="historyPanel" class="hidden"></div>
<div id="balancePanel" class="hidden"></div>
</div>
</div>
{{ else }}
<!-- 비로그인: 로그인 유도 배너 -->
<div class="bg-gray-50 rounded-xl border border-gray-200 p-5 text-center">
<p class="text-sm text-gray-500 mb-3">주문 기능은 로그인 후 이용 가능합니다.</p>
<a href="/login?next={{ .Stock.Code }}"
class="inline-block px-5 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
로그인
</a>
</div>
{{ end }}
</div><!-- /우측 컬럼 -->
</div>
<!-- 프로그램 매매 + 체결강도 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 프로그램 매매 -->
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
<h2 class="text-sm font-semibold text-gray-800 mb-3">프로그램 매매</h2>
<div id="programTrading">
<span class="text-xs text-gray-400">프로그램 매매 데이터 수신 대기 중...</span>
</div>
</div>
<!-- 체결강도 & 예상체결 -->
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
<h2 class="text-sm font-semibold text-gray-800 mb-3">체결 정보</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-xs text-gray-400">체결강도</p>
<p id="cntrStr" class="font-semibold text-gray-800">{{ printf "%.1f" .Stock.CntrStr }}%</p>
</div>
<div>
<p class="text-xs text-gray-400">시가총액</p>
<p id="marketCap" class="font-semibold text-gray-800">-</p>
</div>
</div>
</div>
</div>
<!-- 최근 공시 -->
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<h2 class="text-base font-semibold text-gray-800 mb-4">최근 공시</h2>
<div id="disclosureLoading" class="text-center py-6 text-sm text-gray-400">공시 정보를 불러오는 중...</div>
<ul id="disclosureList" class="hidden divide-y divide-gray-100"></ul>
<div id="disclosureError" class="hidden text-center py-6 text-sm text-gray-400">공시 정보를 불러올 수 없습니다.</div>
<div id="disclosureEmpty" class="hidden text-center py-6 text-sm text-gray-400">최근 공시 내역이 없습니다.</div>
</div>
<!-- 관련 뉴스 -->
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<h2 class="text-base font-semibold text-gray-800 mb-4">관련 뉴스</h2>
<div id="newsLoading" class="text-center py-6 text-sm text-gray-400">뉴스를 불러오는 중...</div>
<ul id="newsList" class="hidden divide-y divide-gray-100"></ul>
<div id="newsError" class="hidden text-center py-6 text-sm text-gray-400">뉴스를 불러올 수 없습니다.</div>
<div id="newsEmpty" class="hidden text-center py-6 text-sm text-gray-400">관련 뉴스가 없습니다.</div>
</div>
</div>
<!-- 종목 코드를 JavaScript에 전달 -->
<script>
const STOCK_CODE = "{{ .Stock.Code }}";
const STOCK_NAME = "{{ .Stock.Name }}";
const STORAGE_KEY = 'watchlist_v1';
function loadWatchlist() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
catch { return []; }
}
function saveWatchlist(list) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
function isWatched() {
return loadWatchlist().some(s => s.code === STOCK_CODE);
}
function renderWatchlistBtn() {
const btn = document.getElementById('watchlistToggleBtn');
if (!btn) return;
if (isWatched()) {
btn.innerHTML = '★ 관심종목 해제';
btn.className = 'ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors bg-yellow-50 text-yellow-600 border-yellow-300 hover:bg-yellow-100';
} else {
btn.innerHTML = '☆ 관심종목 추가';
btn.className = 'ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors bg-white text-gray-500 border-gray-300 hover:bg-gray-50';
}
}
function toggleWatchlist() {
const list = loadWatchlist();
const idx = list.findIndex(s => s.code === STOCK_CODE);
if (idx >= 0) {
list.splice(idx, 1);
} else {
list.push({ code: STOCK_CODE, name: STOCK_NAME });
}
saveWatchlist(list);
renderWatchlistBtn();
}
renderWatchlistBtn();
</script>
{{ end }}
{{ define "scripts" }}
<script src="/static/js/orderbook.js"></script>
<script src="/static/js/chart.js"></script>
<script src="/static/js/disclosure.js"></script>
<script src="/static/js/news.js"></script>
{{ if .LoggedIn }}<script src="/static/js/order.js"></script>{{ end }}
{{ end }}

View File

@@ -0,0 +1,86 @@
{{ template "base.html" . }}
{{ define "sidebar" }}{{ end }}
{{ define "content" }}
<div class="space-y-4">
<!-- 헤더 + 필터 바 -->
<div class="flex flex-wrap items-center gap-3">
<h1 class="text-xl font-bold text-gray-800 flex items-center gap-2">
<span class="text-purple-500">📊</span> 테마 분석
</h1>
<!-- 날짜 구간 탭 -->
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
<button data-date="1" class="date-tab px-3 py-1.5 bg-blue-500 text-white">1일</button>
<button data-date="5" class="date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">5일</button>
<button data-date="20" class="date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">20일</button>
</div>
<!-- 정렬 탭 -->
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
<button data-sort="3" class="sort-tab px-3 py-1.5 bg-blue-500 text-white">등락률순</button>
<button data-sort="1" class="sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">기간수익률순</button>
</div>
<!-- 테마 검색 -->
<input id="themeSearch" type="text" placeholder="테마명 검색..."
class="text-xs px-3 py-1.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-400 w-36">
<span id="themeCount" class="text-xs text-gray-400 ml-auto"></span>
</div>
<!-- 2열 레이아웃: 테마 목록 + 구성종목 패널 -->
<div class="flex gap-5 items-start">
<!-- 좌: 테마 목록 -->
<div class="flex-1 min-w-0">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<!-- 테이블 헤더 -->
<div class="grid grid-cols-[2fr_1fr_80px_80px_80px_80px] gap-0 text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100 px-4 py-2.5">
<span data-col="name" class="theme-col-sort cursor-pointer select-none hover:text-blue-500 transition-colors">테마명 <span class="sort-arrow"></span></span>
<span>주력종목</span>
<span data-col="fluRt" class="theme-col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">등락률 <span class="sort-arrow"></span></span>
<span data-col="periodRt" class="theme-col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">기간수익률 <span class="sort-arrow"></span></span>
<span data-col="stockCount" class="theme-col-sort text-center cursor-pointer select-none hover:text-blue-500 transition-colors">종목수 <span class="sort-arrow"></span></span>
<span data-col="risingCount" class="theme-col-sort text-center cursor-pointer select-none hover:text-blue-500 transition-colors">상승/하락 <span class="sort-arrow"></span></span>
</div>
<!-- 테마 목록 -->
<div id="themeList" class="divide-y divide-gray-50">
<div class="px-4 py-10 text-center text-sm text-gray-400">데이터를 불러오는 중...</div>
</div>
</div>
</div>
<!-- 우: 구성종목 패널 (sticky) -->
<div id="themeDetailPanel"
class="w-80 shrink-0 sticky top-20 self-start bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<!-- 초기 안내 -->
<div id="themeDetailEmpty" class="px-6 py-12 text-center text-sm text-gray-400">
<p class="text-2xl mb-2">👈</p>
<p>테마를 클릭하면<br>구성종목을 확인할 수 있습니다.</p>
</div>
<!-- 구성종목 내용 (로드 후 표시) -->
<div id="themeDetailContent" class="hidden">
<div class="px-4 py-3 border-b border-gray-100 bg-gray-50">
<p id="detailThemeName" class="font-bold text-gray-800 text-sm truncate"></p>
<div class="flex items-center gap-3 mt-1 text-xs">
<span>등락률 <span id="detailFluRt" class="font-semibold"></span></span>
<span>기간수익률 <span id="detailPeriodRt" class="font-semibold text-purple-600"></span></span>
</div>
</div>
<div id="detailStockList" class="divide-y divide-gray-50 max-h-[70vh] overflow-y-auto"></div>
</div>
<div id="themeDetailLoading" class="hidden px-6 py-10 text-center text-sm text-gray-400">
<div class="animate-pulse">조회 중...</div>
</div>
</div>
</div>
</div>
{{ end }}
{{ define "scripts" }}
<script src="/static/js/theme.js"></script>
{{ end }}

95
websocket/client.go Normal file
View File

@@ -0,0 +1,95 @@
package websocket
import (
"encoding/json"
"log"
"stocksearch/models"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second // 쓰기 타임아웃
pongWait = 60 * time.Second // Pong 대기 시간
pingPeriod = (pongWait * 9) / 10 // Ping 전송 주기
maxMsgSize = 512 // 최대 메시지 크기 (바이트)
)
// Client WebSocket 개별 클라이언트
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte // 쓰기 버퍼 채널 (슬로우 클라이언트 방어)
}
// readPump 클라이언트로부터 메시지 수신 (고루틴으로 실행)
// 읽기와 쓰기를 분리해 gorilla/websocket 동시 호출 방지
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMsgSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket 읽기 오류: %v", err)
}
break
}
// 클라이언트로부터 구독/해제 메시지 처리
var msg models.WSMessage
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("메시지 파싱 실패: %v", err)
continue
}
switch msg.Type {
case "subscribe":
c.hub.subscribe <- &SubscribeMsg{Client: c, Code: msg.Code}
case "unsubscribe":
c.hub.unsubscribeCode <- &SubscribeMsg{Client: c, Code: msg.Code}
}
}
}
// writePump 클라이언트에게 메시지 전송 (고루틴으로 실행)
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// Hub가 채널을 닫음
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
// Ping 전송으로 연결 유지
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

291
websocket/hub.go Normal file
View File

@@ -0,0 +1,291 @@
package websocket
import (
"encoding/json"
"log"
"net/http"
"stocksearch/models"
"stocksearch/services"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
// 개발 환경에서 CORS 허용
CheckOrigin: func(r *http.Request) bool { return true },
}
// SubscribeMsg 구독/해제 요청 메시지
type SubscribeMsg struct {
Client *Client
Code string
}
// Hub WebSocket 연결 및 실시간 시세 관리
type Hub struct {
// 클라이언트 → 구독 종목 코드 집합
clients map[*Client]map[string]bool
// 종목 코드 → 구독 클라이언트 수 (키움 WS 구독 관리용)
codeCounts map[string]int
// 채널
register chan *Client
unregister chan *Client
subscribe chan *SubscribeMsg
unsubscribeCode chan *SubscribeMsg
priceUpdates chan *models.StockPrice // 키움 WS에서 수신한 실시간 시세 (0B/0H)
orderBookUpdates chan *models.OrderBook // 실시간 호가창 (0D)
programUpdates chan *models.ProgramTrading // 프로그램 매매 (0w)
marketUpdates chan *models.MarketStatus // 장운영 상태 (0s)
metaUpdates chan *models.StockMeta // 종목 메타 (0g)
tradeLogs chan []byte // 자동매매 로그 브로드캐스트
kiwoomWS *services.KiwoomWSClient
internalSubscribe chan []string // 스캐너/자동매매 전용 구독 요청 채널
}
// NewHub Hub 초기화 (키움 WS 클라이언트 주입)
func NewHub() *Hub {
hub := &Hub{
clients: make(map[*Client]map[string]bool),
codeCounts: make(map[string]int),
register: make(chan *Client),
unregister: make(chan *Client),
subscribe: make(chan *SubscribeMsg),
unsubscribeCode: make(chan *SubscribeMsg),
priceUpdates: make(chan *models.StockPrice, 256),
orderBookUpdates: make(chan *models.OrderBook, 256),
programUpdates: make(chan *models.ProgramTrading, 128),
marketUpdates: make(chan *models.MarketStatus, 32),
metaUpdates: make(chan *models.StockMeta, 64),
tradeLogs: make(chan []byte, 64),
internalSubscribe: make(chan []string, 32),
}
// 키움 WS 클라이언트 생성 (가격 수신 시 채널로 전달)
hub.kiwoomWS = services.GetKiwoomWSClient(func(price *models.StockPrice) {
select {
case hub.priceUpdates <- price:
default:
// 버퍼 꽉 찼으면 드롭
}
})
// 추가 실시간 데이터 콜백 등록
hub.kiwoomWS.SetCallbacks(
func(ob *models.OrderBook) {
select {
case hub.orderBookUpdates <- ob:
default:
}
},
func(pg *models.ProgramTrading) {
select {
case hub.programUpdates <- pg:
default:
}
},
func(ms *models.MarketStatus) {
select {
case hub.marketUpdates <- ms:
default:
}
},
func(meta *models.StockMeta) {
select {
case hub.metaUpdates <- meta:
default:
}
},
)
return hub
}
// Run Hub 이벤트 루프 실행 (고루틴으로 실행)
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = make(map[string]bool)
case client := <-h.unregister:
if codes, ok := h.clients[client]; ok {
for code := range codes {
h.decreaseCount(code)
}
delete(h.clients, client)
close(client.send)
}
case msg := <-h.subscribe:
if codes, ok := h.clients[msg.Client]; ok {
if !codes[msg.Code] {
codes[msg.Code] = true
h.increaseCount(msg.Code)
log.Printf("브라우저 구독: %s", msg.Code)
}
}
case msg := <-h.unsubscribeCode:
if codes, ok := h.clients[msg.Client]; ok {
if codes[msg.Code] {
delete(codes, msg.Code)
h.decreaseCount(msg.Code)
}
}
case price := <-h.priceUpdates:
h.broadcastToCode(price.Code, "price", price)
services.GetCacheService().Set("price:"+price.Code, price, 10*time.Second)
case ob := <-h.orderBookUpdates:
h.broadcastToCode(ob.Code, "orderbook", ob)
services.GetCacheService().Set("orderbook:"+ob.Code, ob, 10*time.Second)
case codes := <-h.internalSubscribe:
for _, code := range codes {
h.codeCounts[code]++
if h.codeCounts[code] == 1 {
h.kiwoomWS.SubscribePair(code)
log.Printf("내부 구독 등록: %s", code)
}
}
case pg := <-h.programUpdates:
h.broadcastToCode(pg.Code, "program", pg)
case ms := <-h.marketUpdates:
h.broadcastToAll("market", ms)
case meta := <-h.metaUpdates:
h.broadcastToCode(meta.Code, "meta", meta)
case raw := <-h.tradeLogs:
// 자동매매 로그: 모든 클라이언트에 브로드캐스트
for client := range h.clients {
select {
case client.send <- raw:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
// SubscribeInternal 스캐너/자동매매 전용 WS 구독 (클라이언트 없이)
func (h *Hub) SubscribeInternal(codes []string) {
select {
case h.internalSubscribe <- codes:
default:
}
}
// BroadcastTradeLog 자동매매 로그를 모든 WS 클라이언트에 전송
func (h *Hub) BroadcastTradeLog(l models.AutoTradeLog) {
msg := models.WSMessage{Type: "tradelog", Data: l}
raw, err := json.Marshal(msg)
if err != nil {
return
}
select {
case h.tradeLogs <- raw:
default:
// 버퍼 꽉 찼으면 드롭
}
}
// increaseCount 종목 구독 수 증가 → 0→1 시 키움 WS 구독 등록 (KRX + NXT 단일 REG)
func (h *Hub) increaseCount(code string) {
h.codeCounts[code]++
if h.codeCounts[code] == 1 {
h.kiwoomWS.SubscribePair(code) // KRX + NXT 단일 REG
}
}
// decreaseCount 종목 구독 수 감소 → 0 시 키움 WS 구독 해제 (KRX + NXT 단일 REMOVE)
func (h *Hub) decreaseCount(code string) {
h.codeCounts[code]--
if h.codeCounts[code] <= 0 {
delete(h.codeCounts, code)
h.kiwoomWS.UnsubscribePair(code) // KRX + NXT 단일 REMOVE
}
}
// broadcastToCode 특정 종목 구독 클라이언트에게만 메시지 전송
func (h *Hub) broadcastToCode(code string, msgType string, data interface{}) {
msg := models.WSMessage{
Type: msgType,
Code: code,
Data: data,
}
raw, err := json.Marshal(msg)
if err != nil {
return
}
for client, codes := range h.clients {
if !codes[code] {
continue
}
select {
case client.send <- raw:
default:
// 슬로우 클라이언트 연결 해제
close(client.send)
delete(h.clients, client)
}
}
}
// broadcastToAll 모든 클라이언트에게 메시지 전송 (장운영 상태 등 전역 이벤트)
func (h *Hub) broadcastToAll(msgType string, data interface{}) {
msg := models.WSMessage{
Type: msgType,
Code: "",
Data: data,
}
raw, err := json.Marshal(msg)
if err != nil {
return
}
for client := range h.clients {
select {
case client.send <- raw:
default:
close(client.send)
delete(h.clients, client)
}
}
}
// StartKiwoomWS 키움 WS 실시간 연결 시작
func (h *Hub) StartKiwoomWS() error {
return h.kiwoomWS.Connect()
}
// ServeWS HTTP 요청을 WebSocket으로 업그레이드 후 클라이언트 등록
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket 업그레이드 실패: %v", err)
return
}
client := &Client{
hub: h,
conn: conn,
send: make(chan []byte, 256),
}
h.register <- client
go client.writePump()
go client.readPump()
}