first commit
This commit is contained in:
45
.claude/settings.local.json
Normal file
45
.claude/settings.local.json
Normal 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
25
.dockerignore
Normal 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
14
.env.example
Normal 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
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 환경변수 (API 키 포함 - 절대 커밋 금지)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Go 빌드 결과물
|
||||||
|
stockSearch
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# 의존성
|
||||||
|
vendor/
|
||||||
89
CLAUDE.md
Normal file
89
CLAUDE.md
Normal 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
808699
CORPCODE.xml
Normal file
File diff suppressed because it is too large
Load Diff
3
Caddyfile
Normal file
3
Caddyfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
lshfly.duckdns.org {
|
||||||
|
reverse_proxy stocksearch:8080
|
||||||
|
}
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal 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
BIN
charts.pdf
Normal file
Binary file not shown.
77
config/config.go
Normal file
77
config/config.go
Normal 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
5
deploy.sh
Executable 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
26
docker-compose.yml
Normal 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
10
go.mod
Normal 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
8
go.sum
Normal 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
115
handlers/auth_handler.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
handlers/autotrade_handler.go
Normal file
148
handlers/autotrade_handler.go
Normal 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
186
handlers/order_handler.go
Normal 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
213
handlers/page_handler.go
Normal 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
288
handlers/stock_handler.go
Normal 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})
|
||||||
|
}
|
||||||
21
handlers/websocket_handler.go
Normal file
21
handlers/websocket_handler.go
Normal 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
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
107497
kiwoom_api_doc.txt
Normal file
File diff suppressed because it is too large
Load Diff
136
main.go
Normal file
136
main.go
Normal 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
100
middleware/auth.go
Normal 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
37
middleware/middleware.go
Normal 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
69
models/autotrade.go
Normal 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
12
models/disclosure.go
Normal 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
15
models/kospi200.go
Normal 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
9
models/news.go
Normal 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
96
models/stock.go
Normal 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
31
models/theme.go
Normal 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
13
models/token.go
Normal 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
13
models/websocket.go
Normal 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
377
services/account_service.go
Normal 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
|
||||||
|
}
|
||||||
349
services/analysis_service.go
Normal file
349
services/analysis_service.go
Normal 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
|
||||||
|
}
|
||||||
772
services/autotrade_service.go
Normal file
772
services/autotrade_service.go
Normal 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
68
services/cache_service.go
Normal 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
223
services/dart_service.go
Normal 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
208
services/index_service.go
Normal 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
575
services/kiwoom_service.go
Normal 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()
|
||||||
|
}
|
||||||
600
services/kiwoom_ws_service.go
Normal file
600
services/kiwoom_ws_service.go
Normal 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
|
||||||
|
}
|
||||||
106
services/kospi200_service.go
Normal file
106
services/kospi200_service.go
Normal 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
171
services/news_service.go
Normal 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, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, """, "\"")
|
||||||
|
s = strings.ReplaceAll(s, "'", "'")
|
||||||
|
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
132
services/order_service.go
Normal 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
767
services/scanner_service.go
Normal 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
155
services/search_service.go
Normal 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
|
||||||
|
}
|
||||||
75
services/session_service.go
Normal file
75
services/session_service.go
Normal 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
103
services/stock_service.go
Normal 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
152
services/theme_service.go
Normal 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
129
services/token_service.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
services/watchlist_service.go
Normal file
101
services/watchlist_service.go
Normal 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
30
static/css/custom.css
Normal 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
308
static/js/asset.js
Normal 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
566
static/js/autotrade.js
Normal 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, '"')})"
|
||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
312
static/js/chart.js
Normal file
312
static/js/chart.js
Normal 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
60
static/js/disclosure.js
Normal 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
56
static/js/indices.js
Normal 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
195
static/js/kospi200.js
Normal 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
49
static/js/news.js
Normal 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
487
static/js/order.js
Normal 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
145
static/js/orderbook.js
Normal 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
140
static/js/ranking.js
Normal 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
68
static/js/search.js
Normal 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
457
static/js/signal.js
Normal 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
255
static/js/theme.js
Normal 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
598
static/js/watchlist.js
Normal 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
148
static/js/websocket.js
Normal 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();
|
||||||
163
templates/layout/base.html
Normal file
163
templates/layout/base.html
Normal 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>
|
||||||
98
templates/pages/asset.html
Normal file
98
templates/pages/asset.html
Normal 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 }}
|
||||||
266
templates/pages/autotrade.html
Normal file
266
templates/pages/autotrade.html
Normal 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
338
templates/pages/index.html
Normal 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 }}
|
||||||
61
templates/pages/kospi200.html
Normal file
61
templates/pages/kospi200.html
Normal 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 }}
|
||||||
59
templates/pages/login.html
Normal file
59
templates/pages/login.html
Normal 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}}
|
||||||
407
templates/pages/stock_detail.html
Normal file
407
templates/pages/stock_detail.html
Normal 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 }}
|
||||||
86
templates/pages/theme.html
Normal file
86
templates/pages/theme.html
Normal 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
95
websocket/client.go
Normal 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
291
websocket/hub.go
Normal 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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user