자산 현황 및 자동매매 페이지 제거:
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 11m20s

- `/templates/pages/asset.html`, `/templates/pages/autotrade.html` HTML 템플릿 삭제.
- `/static/js/asset.js`, `/static/js/autotrade.js` 클라이언트 스크립트 제거.
- 관련 함수 및 초기화 로직 삭제 (자산 조회 및 자동매매 기능 비활성화).
This commit is contained in:
hayato5246
2026-04-07 21:43:24 +09:00
parent 5a29d50752
commit 5aeb5f2b80
47 changed files with 279 additions and 6361 deletions

View File

@@ -19,3 +19,6 @@ CORS_ORIGIN=http://localhost:5173
# 관리자 계정
ADMIN_ID=admin
ADMIN_PASSWORD=
# PostgreSQL (미설정 시 메모리 모드)
DATABASE_URL=postgres://postgres:password@localhost:5432/stocksearch?sslmode=disable

View File

@@ -38,9 +38,7 @@ 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 .
# 프론트엔드 빌드 결과물 복사

View File

@@ -26,6 +26,7 @@ type Config struct {
AdminID string // 관리자 ID
AdminPassword string // 관리자 비밀번호
CORSOrigin string // CORS 허용 오리진 (예: http://localhost:5173)
DatabaseURL string // PostgreSQL 연결 URL
}
var App *Config
@@ -54,6 +55,7 @@ func Load() {
AdminID: getEnv("ADMIN_ID", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
CORSOrigin: getEnv("CORS_ORIGIN", ""),
DatabaseURL: getEnv("DATABASE_URL", ""),
}
}

View File

@@ -36,6 +36,10 @@ export const autotradeApi = {
getPositions: () =>
apiFetch<AutoTradePosition[]>('/api/autotrade/positions'),
// 거래 내역 (종료된 포지션)
getTrades: () =>
apiFetch<AutoTradePosition[]>('/api/autotrade/trades'),
// 이벤트 로그
getLogs: () =>
apiFetch<AutoTradeLog[]>('/api/autotrade/logs'),

View File

@@ -137,9 +137,9 @@ export interface AutoTradeLog {
export interface AutoTradeStatus {
running: boolean
positions: number
todayTrades: number
todayProfit: number
activePositions: number
tradeCount: number
totalPL: number
}
export interface WatchlistItem {

View File

@@ -14,7 +14,8 @@
let rules = $state<AutoTradeRule[]>([])
let positions = $state<AutoTradePosition[]>([])
let logs = $state<AutoTradeLog[]>([])
let activeTab = $state<'rules' | 'positions' | 'logs'>('rules')
let activeTab = $state<'rules' | 'positions' | 'trades' | 'logs'>('rules')
let trades = $state<AutoTradePosition[]>([])
let showRuleModal = $state(false)
let editingRule = $state<Partial<AutoTradeRule> | null>(null)
let loading = $state(true)
@@ -55,10 +56,11 @@
async function loadAll() {
try {
const [s, r, p, l, ws, themes] = await Promise.all([
const [s, r, p, t, l, ws, themes] = await Promise.all([
autotradeApi.getStatus(),
autotradeApi.getRules(),
autotradeApi.getPositions(),
autotradeApi.getTrades(),
autotradeApi.getLogs(),
autotradeApi.getWatchSource(),
stockApi.getThemes(),
@@ -66,6 +68,7 @@
status = s
rules = r
positions = p
trades = t ?? []
logs = l
watchSource = ws
allThemes = themes
@@ -239,13 +242,11 @@
<span class="w-2 h-2 rounded-full {status.running ? 'bg-green-400 animate-pulse' : 'bg-gray-600'}"></span>
{status.running ? '실행 중' : '중지됨'}
</span>
<span class="text-sm text-gray-400">포지션 {status.positions}</span>
<span class="text-sm text-gray-400">오늘 {status.todayTrades}</span>
{#if status.todayProfit !== 0}
<span class="text-sm {priceClass(status.todayProfit)}">
오늘 손익 {status.todayProfit >= 0 ? '+' : ''}{formatPrice(status.todayProfit)}
</span>
{/if}
<span class="text-sm text-gray-400">포지션 {status.activePositions}</span>
<span class="text-sm text-gray-400">오늘 {status.tradeCount}</span>
<span class="text-sm {status.totalPL !== 0 ? priceClass(status.totalPL) : 'text-gray-400'}">
오늘 손익 {status.totalPL >= 0 ? '+' : ''}{formatPrice(status.totalPL)}
</span>
{/if}
</div>
@@ -339,7 +340,7 @@
<!-- 탭 -->
<div class="flex gap-1 mb-4 border-b border-gray-700 pb-1">
{#each [['rules', `규칙 (${rules.length})`], ['positions', `포지션 (${positions.length})`], ['logs', '로그']] as [tab, label]}
{#each [['rules', `규칙 (${rules.length})`], ['positions', `포지션 (${positions.length})`], ['trades', `거래 (${trades.length})`], ['logs', '로그']] as [tab, label]}
<button
onclick={() => { activeTab = tab as typeof activeTab }}
class="px-4 py-2 text-sm transition-colors {activeTab === tab ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-400 hover:text-white'}"
@@ -457,6 +458,62 @@
</div>
{/if}
<!-- 거래 탭 -->
{:else if activeTab === 'trades'}
{#if trades.length === 0}
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500">거래 내역 없음</div>
{:else}
<div class="bg-gray-800 rounded-xl overflow-hidden">
<table>
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400">종목</th>
<th class="px-3 py-3 text-center text-xs font-medium text-gray-400">구분</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">수량</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">매수가</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">매도가</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">손익</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">수익률</th>
<th class="px-3 py-3 text-center text-xs font-medium text-gray-400">사유</th>
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">청산시각</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
{#each trades as trade (trade.orderNo + (trade.exitTime ?? ''))}
{@const pl = ((trade.exitPrice ?? 0) - trade.buyPrice) * trade.qty}
{@const plRate = trade.buyPrice > 0 ? ((trade.exitPrice ?? 0) - trade.buyPrice) / trade.buyPrice * 100 : 0}
<tr class="hover:bg-gray-700">
<td class="px-4 py-3">
<div class="text-sm font-medium text-white">{trade.name}</div>
<div class="text-xs text-gray-500">{trade.code}</div>
</td>
<td class="px-3 py-3 text-center">
<span class="text-xs px-2 py-0.5 rounded bg-blue-900/50 text-blue-300">매도</span>
</td>
<td class="px-3 py-3 text-right text-sm text-gray-300">{trade.qty}</td>
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">{formatPrice(trade.buyPrice)}</td>
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(pl)}">{formatPrice(trade.exitPrice ?? 0)}</td>
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(pl)}">
{pl >= 0 ? '+' : ''}{formatPrice(pl)}
</td>
<td class="px-3 py-3 text-right text-sm {priceClass(plRate)}">
{plRate >= 0 ? '+' : ''}{plRate.toFixed(2)}%
</td>
<td class="px-3 py-3 text-center">
<span class="text-xs px-2 py-0.5 rounded {
trade.exitReason === '익절' ? 'bg-red-900/50 text-red-300' :
trade.exitReason === '손절' ? 'bg-blue-900/50 text-blue-300' :
'bg-gray-700 text-gray-400'
}">{trade.exitReason ?? '-'}</span>
</td>
<td class="px-3 py-3 text-right text-xs text-gray-400">{formatTime(trade.exitTime ?? '')}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- 로그 탭 -->
{:else if activeTab === 'logs'}
<div class="bg-gray-800 rounded-xl p-4 font-mono text-xs space-y-1 max-h-[60vh] overflow-auto">

9
go.mod
View File

@@ -3,8 +3,9 @@ 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
github.com/gorilla/websocket v1.5.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.12.3
)
require golang.org/x/net v0.17.0 // indirect

4
go.sum
View File

@@ -2,7 +2,7 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
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=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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=

View File

@@ -1,8 +1,6 @@
package handlers
import (
"html/template"
"log"
"net/http"
"stocksearch/config"
"stocksearch/middleware"
@@ -12,40 +10,11 @@ import (
// 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)
return &AuthHandler{sessionSvc: sessionSvc}
}
// Login POST /login — ID/PW 검증 후 세션 발급
@@ -64,12 +33,9 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// ID/PW 검증
if id != config.App.AdminID || password != config.App.AdminPassword {
data := map[string]string{
"Next": next,
"Error": "아이디 또는 비밀번호가 올바르지 않습니다.",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
h.renderLogin(w, data)
_, _ = w.Write([]byte(`{"error":"아이디 또는 비밀번호가 올바르지 않습니다."}`))
return
}
@@ -99,13 +65,12 @@ func (h *AuthHandler) CheckSession(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// Logout POST /logout — 세션 삭제 후 /login 리다이렉트
// Logout POST /logout — 세션 삭제
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: "",
@@ -114,14 +79,5 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
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)
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -92,6 +92,11 @@ func (h *AutoTradeHandler) GetPositions(w http.ResponseWriter, r *http.Request)
jsonResponse(w, h.svc.GetPositions())
}
// GetTrades GET /api/autotrade/trades — 종료된 거래 내역
func (h *AutoTradeHandler) GetTrades(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, h.svc.GetTrades(100))
}
// GetLogs GET /api/autotrade/logs — 최근 로그 (?level=action 이면 debug 제외)
func (h *AutoTradeHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
logs := h.svc.GetLogs()

View File

@@ -1,213 +0,0 @@
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)
}

18
main.go
View File

@@ -16,6 +16,9 @@ func main() {
// 환경변수 로딩
config.Load()
// PostgreSQL 초기화 (DATABASE_URL 미설정 시 메모리 모드)
services.InitDB()
// 키움증권 토큰 발급 (서버 시작 시 즉시 실행)
tokenSvc := services.GetTokenService()
if err := tokenSvc.Start(); err != nil {
@@ -53,7 +56,6 @@ func main() {
})
// 핸들러 초기화
pageHandler := handlers.NewPageHandler()
stockHandler := handlers.NewStockHandler(watchlistSvc)
wsHandler := handlers.NewWSHandler(hub)
authHandler := handlers.NewAuthHandler(sessionSvc)
@@ -64,19 +66,10 @@ func main() {
mux := http.NewServeMux()
// --- 인증 라우트 ---
mux.HandleFunc("GET /login", authHandler.LoginPage)
mux.HandleFunc("POST /login", authHandler.Login)
mux.HandleFunc("POST /logout", authHandler.Logout)
mux.HandleFunc("GET /api/auth/check", authHandler.CheckSession)
// --- 페이지 라우트 ---
mux.HandleFunc("GET /", pageHandler.IndexPage)
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)
@@ -114,6 +107,7 @@ func main() {
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/trades", autoTradeHandler.GetTrades)
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)
@@ -125,16 +119,12 @@ func main() {
// --- WebSocket 라우트 ---
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
// --- 정적 파일 ---
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// --- SvelteKit 빌드 정적 서빙 (SPA fallback 포함) ---
if _, err := os.Stat("frontend/build"); err == nil {
spa := http.FileServer(http.Dir("frontend/build"))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := "frontend/build" + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) {
// SPA fallback: 파일 없으면 index.html 서빙
http.ServeFile(w, r, "frontend/build/index.html")
return
}

View File

@@ -23,7 +23,6 @@ func IsLoggedIn(r *http.Request) bool {
// publicPaths 로그인 없이 접근 가능한 경로 (전체 일치 또는 prefix)
var publicPaths = []string{
"/login",
"/static/",
// 공개 페이지
"/",
"/theme",

View File

@@ -35,6 +35,7 @@ type AutoTradeRule struct {
// AutoTradePosition 자동매매 포지션
type AutoTradePosition struct {
DBID int `json:"-"` // DB 시퀀스 ID (API 노출 안함)
Code string `json:"code"`
Name string `json:"name"`
BuyPrice int64 `json:"buyPrice"` // 매수 체결가

View File

@@ -66,6 +66,23 @@ func GetAutoTradeService() *AutoTradeService {
SelectedThemes: []models.ThemeRef{},
},
}
// DB에서 데이터 복원
if rules := dbLoadRules(); len(rules) > 0 {
autoTradeService.rules = rules
log.Printf("DB에서 규칙 %d개 로드", len(rules))
}
if positions := dbLoadActivePositions(); len(positions) > 0 {
autoTradeService.positions = positions
log.Printf("DB에서 활성 포지션 %d개 로드", len(positions))
}
if ws := dbLoadWatchSource(); ws != nil {
autoTradeService.watchSource = *ws
log.Printf("DB에서 감시소스 로드")
}
if logs := dbLoadRecentLogs(maxLogEntries); len(logs) > 0 {
autoTradeService.logs = logs
log.Printf("DB에서 로그 %d건 로드", len(logs))
}
}
return autoTradeService
}
@@ -93,6 +110,7 @@ func (s *AutoTradeService) SetWatchSource(ws models.AutoTradeWatchSource) {
s.mu.Lock()
s.watchSource = ws
s.mu.Unlock()
dbUpsertWatchSource(ws)
sources := "없음"
if ws.UseScanner && len(ws.SelectedThemes) > 0 {
@@ -245,6 +263,7 @@ func (s *AutoTradeService) AddRule(rule models.AutoTradeRule) models.AutoTradeRu
s.mu.Lock()
s.rules = append(s.rules, rule)
s.mu.Unlock()
dbInsertRule(rule)
s.addLog("info", "", fmt.Sprintf("규칙 추가: %s", rule.Name))
return rule
}
@@ -258,6 +277,7 @@ func (s *AutoTradeService) UpdateRule(id string, updated models.AutoTradeRule) b
updated.ID = id
updated.CreatedAt = r.CreatedAt
s.rules[i] = updated
dbUpdateRule(updated)
return true
}
}
@@ -271,6 +291,7 @@ func (s *AutoTradeService) DeleteRule(id string) bool {
for i, r := range s.rules {
if r.ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
dbDeleteRule(id)
return true
}
}
@@ -284,6 +305,7 @@ func (s *AutoTradeService) ToggleRule(id string) (bool, bool) {
for i, r := range s.rules {
if r.ID == id {
s.rules[i].Enabled = !r.Enabled
dbUpdateRule(s.rules[i])
return true, s.rules[i].Enabled
}
}
@@ -311,6 +333,24 @@ func (s *AutoTradeService) GetPositions() []*models.AutoTradePosition {
return result
}
// GetTrades 종료된 거래 내역 반환 (DB에서 조회, 없으면 메모리에서 필터)
func (s *AutoTradeService) GetTrades(limit int) []*models.AutoTradePosition {
if trades := dbLoadClosedPositions(limit); trades != nil {
return trades
}
// DB 없으면 메모리에서 closed 포지션 반환
s.mu.RLock()
defer s.mu.RUnlock()
var result []*models.AutoTradePosition
for _, p := range s.positions {
if p.Status == "closed" {
cp := *p
result = append(result, &cp)
}
}
return result
}
// GetLogs 최근 로그 반환
func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
s.mu.RLock()
@@ -322,6 +362,11 @@ func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
// GetStats 오늘 통계 반환 (매매 횟수, 손익)
func (s *AutoTradeService) GetStats() (tradeCount int, totalPL int64) {
// DB가 있으면 DB에서 조회 (종료된 포지션 포함)
if db != nil {
return dbGetTodayStats()
}
// DB 없으면 메모리에서 계산
today := time.Now().Truncate(24 * time.Hour)
s.mu.RLock()
defer s.mu.RUnlock()
@@ -532,6 +577,7 @@ func (s *AutoTradeService) checkEntries() {
s.cooldown[code] = time.Now()
s.mu.Unlock()
dbInsertPosition(pos)
s.addLog("info", code, fmt.Sprintf("매수 주문 접수: %s %d주 (주문번호: %s, RiseScore: %d)", sig.Name, qty, result.OrderNo, sig.RiseScore))
}
}
@@ -648,6 +694,7 @@ func (s *AutoTradeService) checkPending() {
p.StopLoss = stopLoss
p.TakeProfit = takeProfit
p.Status = "open"
dbUpdatePosition(p)
}
s.mu.Unlock()
@@ -709,6 +756,7 @@ func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason str
p.ExitTime = time.Now()
p.ExitPrice = exitPrice
p.ExitReason = reason
dbUpdatePosition(p)
}
s.mu.Unlock()
@@ -771,6 +819,11 @@ func (s *AutoTradeService) addLog(level, code, message string) {
broadcaster := s.logBroadcaster
s.mu.Unlock()
// debug 로그는 DB에 저장하지 않음 (빈도 높음)
if level != "debug" {
dbInsertLog(entry)
}
// WS 브로드캐스트 (락 밖에서 호출해 데드락 방지)
if broadcaster != nil {
broadcaster(entry)
@@ -873,6 +926,7 @@ func (s *AutoTradeService) evalExitReason(code string, pos *models.AutoTradePosi
if p, ok := s.positions[code]; ok && p.Status == "open" {
p.StopLoss1Touches++
touches = p.StopLoss1Touches
dbUpdatePosition(p)
}
s.mu.Unlock()

View File

@@ -2,7 +2,6 @@ package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -14,8 +13,6 @@ import (
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// cntrStrCacheEntry getCntrStr 캐시 항목
@@ -24,14 +21,30 @@ type cntrStrCacheEntry struct {
expiresAt time.Time
}
// apiJob 큐에 넣을 API 요청 작업
type apiJob struct {
req *http.Request
result chan apiResult
}
// apiResult 워커의 응답
type apiResult struct {
resp *http.Response
err error
}
// KiwoomClient 키움증권 REST API HTTP 클라이언트
// 모든 API 호출은 단일 워커 큐를 통해 순차 처리 (429 방지)
// 1초 윈도우 내 처리 시간 합산 → 남은 시간 대기 후 다음 배치
type KiwoomClient struct {
httpClient *http.Client
tokenService *TokenService
limiter *rate.Limiter
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
queue chan apiJob // API 요청 큐
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
}
const apiQueueSize = 256 // 큐 버퍼 크기
var kiwoomClient *KiwoomClient
// GetKiwoomClient 키움 클라이언트 싱글턴 반환
@@ -40,14 +53,39 @@ func GetKiwoomClient() *KiwoomClient {
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),
queue: make(chan apiJob, apiQueueSize),
}
go kiwoomClient.worker()
}
return kiwoomClient
}
// post 공통 POST 요청 (api-id 헤더, JSON body, Rate Limit 적용, 429 재시도)
// worker 단일 고루틴이 큐에서 작업을 꺼내 순차 실행
// 1건 실행 후 (1초 - 처리시간)만큼 대기 → 다음 1건
func (k *KiwoomClient) worker() {
for job := range k.queue {
start := time.Now()
resp, err := k.httpClient.Do(job.req)
elapsed := time.Since(start)
job.result <- apiResult{resp: resp, err: err}
// 1초 - 처리시간 = 대기시간 (처리가 1초 이상이면 대기 없이 즉시 다음)
if wait := time.Second - elapsed; wait > 0 {
time.Sleep(wait)
}
}
}
// enqueue HTTP 요청을 큐에 넣고 응답 대기
func (k *KiwoomClient) enqueue(req *http.Request) (*http.Response, error) {
ch := make(chan apiResult, 1)
k.queue <- apiJob{req: req, result: ch}
res := <-ch
return res.resp, res.err
}
// post 공통 POST 요청 (api-id 헤더, JSON body, 큐 기반 순차 처리, 429 재시도)
func (k *KiwoomClient) post(apiID string, path string, body map[string]string) ([]byte, error) {
const maxRetries = 3
backoff := 1 * time.Second
@@ -55,10 +93,6 @@ func (k *KiwoomClient) post(apiID string, path string, body map[string]string) (
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)
@@ -70,7 +104,7 @@ func (k *KiwoomClient) post(apiID string, path string, body map[string]string) (
req.Header.Set("cont-yn", "N")
req.Header.Set("next-key", "")
resp, err := k.httpClient.Do(req)
resp, err := k.enqueue(req)
if err != nil {
return nil, fmt.Errorf("API 요청 실패: %w", err)
}
@@ -109,10 +143,6 @@ func (k *KiwoomClient) post(apiID string, path string, body map[string]string) (
// 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 {
@@ -125,13 +155,13 @@ func (k *KiwoomClient) postPaged(apiID, path string, body map[string]string, con
req.Header.Set("cont-yn", contYn)
req.Header.Set("next-key", nextKey)
resp, err := k.httpClient.Do(req)
resp, err := k.enqueue(req)
if err != nil {
return nil, "", "", fmt.Errorf("API 요청 실패: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, "", "", fmt.Errorf("응답 읽기 실패: %w", err)
}
@@ -171,14 +201,14 @@ func (k *KiwoomClient) fetchPrice(stkCd string) (*models.StockPrice, error) {
}
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"`
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"`
}
@@ -259,12 +289,12 @@ func (k *KiwoomClient) GetDailyChart(stockCode string) ([]models.CandleData, 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"`
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"`
TrdeQty string `json:"trde_qty"`
} `json:"stk_ddwkmm"`
ReturnCode int `json:"return_code"`
ReturnMsg string `json:"return_msg"`
@@ -378,15 +408,15 @@ func (k *KiwoomClient) GetTopVolumeStocks(market string, count int) ([]models.St
}
body, err := k.post("ka10030", "/api/dostk/rkinfo", map[string]string{
"mrkt_tp": mrktTp,
"sort_tp": "1", // 거래량 기준 정렬
"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", // 통합
"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
@@ -485,15 +515,15 @@ func (k *KiwoomClient) GetTopFluctuation(market string, ascending bool, count in
}
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
"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

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log"
"stocksearch/config"
"stocksearch/models"
"strconv"
"strings"
@@ -14,10 +15,18 @@ import (
)
const (
kiwoomWSURL = "wss://api.kiwoom.com:10000/api/dostk/websocket"
writeTimeout = 10 * time.Second // 쓰기 타임아웃
)
// kiwoomWSURL 키움 WS 서버 URL (모의투자 여부에 따라 분기)
func kiwoomWSURL() string {
base := config.App.BaseURL
if strings.Contains(base, "mockapi") {
return "wss://mockapi.kiwoom.com:10000/api/dostk/websocket"
}
return "wss://api.kiwoom.com:10000/api/dostk/websocket"
}
// KiwoomWSClient 키움증권 실시간 WebSocket 클라이언트
type KiwoomWSClient struct {
tokenService *TokenService
@@ -90,7 +99,7 @@ func (k *KiwoomWSClient) Connect() error {
// dial WSS 연결 수립 후 로그인 패킷 전송
func (k *KiwoomWSClient) dial() (*websocket.Conn, error) {
// HTTP 헤더 없이 연결 (키움 WS는 헤더 인증 불필요)
conn, _, err := websocket.DefaultDialer.Dial(kiwoomWSURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(kiwoomWSURL(), nil)
if err != nil {
return nil, err
}

View File

@@ -101,7 +101,7 @@ type ScannerService struct {
stockSvc *StockService
analysis *AnalysisService
mu sync.RWMutex
enabled int32 // atomic: 1=켜짐(기본), 0=꺼짐
enabled int32 // atomic: 1=켜짐(기본), 0=꺼짐
signals []SignalStock
history map[string]*cntrHistory // 종목별 체결강도 이력
volumeHistory map[string]*volumeHist // 종목별 거래량 이력
@@ -406,25 +406,17 @@ func (s *ScannerService) scan() {
}
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)
// ── 호가잔량 순차 조회 (체결강도 상승 종목에 한해) ────────────────
for i := range signals {
ask, bid, _, err := s.kiwoom.getOrderBook(signals[i].Code)
if err != nil {
continue
}
signals[i].TotalAskVol = ask
signals[i].TotalBidVol = bid
if bid > 0 {
signals[i].AskBidRatio = float64(ask) / float64(bid)
}
wg.Wait()
}
// ── 최종 스코어 및 신호 유형 계산 (호가잔량 포함) ────────────────
@@ -642,25 +634,17 @@ func (s *ScannerService) AnalyzeWatchlist(codes []string) []SignalStock {
})
}
// 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)
// Phase 3: 호가잔량 순차 조회
for i := range signals {
ask, bid, _, err := s.kiwoom.getOrderBook(signals[i].Code)
if err != nil {
continue
}
signals[i].TotalAskVol = ask
signals[i].TotalBidVol = bid
if bid > 0 {
signals[i].AskBidRatio = float64(ask) / float64(bid)
}
wg.Wait()
}
// Phase 4: 스코어 및 신호 유형 계산

View File

@@ -1,30 +0,0 @@
/* 로딩 스피너 */
.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; }
}

View File

@@ -1,308 +0,0 @@
/**
* 자산 현황 페이지 로직
* - 요약 카드, 예수금, 보유 종목, 미체결/체결내역
*/
// -----------------------------------
// 초기화
// -----------------------------------
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);
}

View File

@@ -1,574 +0,0 @@
// 자동매매 페이지 클라이언트 스크립트
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.stopLoss1Count > 0 ? `1차손절${r.stopLoss1Pct}%[${r.stopLoss1Count}회] / 2차손절${r.stopLossPct}%` : `손절${r.stopLossPct}%`} / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}</p>
<p>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
</div>
<div class="flex gap-2">
<button onclick="showEditRuleModal(${JSON.stringify(r).replace(/"/g, '&quot;')})"
class="px-2 py-1 text-xs text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition-colors">수정</button>
<button onclick="deleteRule('${r.id}')"
class="px-2 py-1 text-xs text-red-500 border border-red-200 rounded hover:bg-red-50 transition-colors">삭제</button>
</div>
</div>
`).join('');
}
async function deleteRule(id) {
if (!confirm('규칙을 삭제하시겠습니까?')) return;
try {
await fetch('/api/autotrade/rules/' + id, { method: 'DELETE' });
await loadRules();
} catch (e) {
alert('삭제 실패: ' + e.message);
}
}
async function toggleRule(id) {
try {
await fetch('/api/autotrade/rules/' + id + '/toggle', { method: 'POST' });
await loadRules();
} catch (e) {
alert('토글 실패: ' + e.message);
}
}
// --- 모달 ---
function showAddRuleModal() {
document.getElementById('modalTitle').textContent = '규칙 추가';
document.getElementById('ruleId').value = '';
document.getElementById('fName').value = '';
document.getElementById('fRiseScore').value = 60;
document.getElementById('riseScoreVal').textContent = '60';
document.getElementById('fCntrStr').value = 110;
document.getElementById('fRequireBullish').checked = false;
document.getElementById('fOrderAmount').value = 500000;
document.getElementById('fMaxPositions').value = 3;
document.getElementById('fStopLoss1').value = -2;
document.getElementById('fStopLoss1Count').value = 3;
document.getElementById('fStopLoss').value = -4;
document.getElementById('fTakeProfit').value = 5;
document.getElementById('fMaxHold').value = 60;
document.getElementById('fExitBeforeClose').checked = true;
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('fStopLoss1').value = r.stopLoss1Pct ?? -2;
document.getElementById('fStopLoss1Count').value = r.stopLoss1Count ?? 3;
document.getElementById('fStopLoss').value = r.stopLossPct;
document.getElementById('fTakeProfit').value = r.takeProfitPct;
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
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),
stopLoss1Pct: parseFloat(document.getElementById('fStopLoss1').value),
stopLoss1Count: parseInt(document.getElementById('fStopLoss1Count').value) || 0,
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
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">1차손절</th>
<th class="pb-2 font-medium text-right">2차손절</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-orange-500">${p.stopLoss1 > 0 ? formatMoney(p.stopLoss1) + `<span class="text-xs text-gray-400 ml-0.5">[${p.stopLoss1Touches||0}회]</span>` : '-'}</td>
<td class="py-2 text-right text-red-500">${formatMoney(p.stopLoss)}</td>
<td class="py-2 text-center font-medium ${statusCls}">${statusTxt}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
// --- 감시 소스 ---
async function loadThemeList() {
try {
const res = await fetch('/api/themes?date=1&sort=3');
const themes = await res.json();
const sel = document.getElementById('themeSelect');
if (!sel || !themes) return;
// 가나다순 정렬 후 추가
const sorted = [...themes].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
sel.innerHTML = '<option value="">테마를 선택하세요...</option>';
sorted.forEach(t => {
const opt = document.createElement('option');
opt.value = t.code;
opt.textContent = `${t.name} (${t.fluRt >= 0 ? '+' : ''}${t.fluRt.toFixed(2)}%)`;
opt.dataset.name = t.name;
sel.appendChild(opt);
});
} catch (e) {
console.error('테마 목록 조회 실패:', e);
}
}
async function loadWatchSource() {
try {
const res = await fetch('/api/autotrade/watch-source');
watchSource = await res.json();
renderWatchSource();
} catch (e) {
console.error('감시 소스 조회 실패:', e);
}
}
async function saveWatchSource() {
const scannerChk = document.getElementById('wsScanner');
watchSource.useScanner = scannerChk ? scannerChk.checked : true;
try {
await fetch('/api/autotrade/watch-source', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(watchSource),
});
} catch (e) {
alert('감시 소스 저장 실패: ' + e.message);
}
}
function renderWatchSource() {
const scannerChk = document.getElementById('wsScanner');
if (scannerChk) scannerChk.checked = watchSource.useScanner;
const container = document.getElementById('selectedThemes');
const noMsg = document.getElementById('noThemeMsg');
if (!container) return;
const themes = watchSource.selectedThemes || [];
if (themes.length === 0) {
container.innerHTML = '<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>';
return;
}
container.innerHTML = themes.map(t => `
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
${escHtml(t.name)}
<button onclick="removeTheme('${escHtml(t.code)}')" class="hover:text-blue-900 text-blue-400 font-bold leading-none">×</button>
</span>
`).join('');
}
function addSelectedTheme() {
const sel = document.getElementById('themeSelect');
if (!sel || !sel.value) return;
const code = sel.value;
const name = sel.options[sel.selectedIndex]?.dataset.name || sel.options[sel.selectedIndex]?.text || code;
if (!watchSource.selectedThemes) watchSource.selectedThemes = [];
if (watchSource.selectedThemes.some(t => t.code === code)) {
alert('이미 추가된 테마입니다.');
return;
}
watchSource.selectedThemes.push({ code, name });
renderWatchSource();
saveWatchSource();
sel.value = '';
}
function removeTheme(code) {
if (!watchSource.selectedThemes) return;
watchSource.selectedThemes = watchSource.selectedThemes.filter(t => t.code !== code);
renderWatchSource();
saveWatchSource();
}
// --- 로그 ---
// --- WS 실시간 로그 ---
function connectTradeLogWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
tradeLogWS = new WebSocket(`${proto}//${location.host}/ws`);
tradeLogWS.onopen = () => {
updateWSStatus(true);
};
tradeLogWS.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type !== 'tradelog') return;
const log = msg.data;
localLogs.push(log);
if (localLogs.length > 300) localLogs.shift();
// 필터 조건에 맞으면 DOM에 행 추가
if (logLevelFilter !== 'action' || log.level !== 'debug') {
appendLogRow(log);
}
updateLogTime();
};
tradeLogWS.onclose = () => {
updateWSStatus(false);
// 3초 후 재연결 + 로그 재로드
setTimeout(() => {
loadLogs();
connectTradeLogWS();
}, 3000);
};
tradeLogWS.onerror = () => {
tradeLogWS.close();
};
}
function updateWSStatus(connected) {
const el = document.getElementById('wsStatus');
if (!el) return;
el.textContent = connected ? '● 실시간' : '○ 연결중...';
el.className = connected
? 'text-xs text-green-500 font-medium'
: 'text-xs text-gray-400';
}
function scrollLogsToBottom() {
const wrapper = document.getElementById('logsWrapper');
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
}
function updateLogTime() {
const el = document.getElementById('logUpdateTime');
if (el) el.textContent = new Date().toLocaleTimeString('ko-KR',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' 갱신';
}
function buildLogRow(l) {
let rowCls, levelCls, msgCls;
if (l.level === 'debug') {
rowCls = 'border-b border-gray-50 bg-gray-50';
levelCls = 'text-gray-400';
msgCls = 'text-gray-400';
} else if (l.level === 'error') {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-red-500';
msgCls = 'text-gray-700';
} else if (l.level === 'warn') {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-yellow-600';
msgCls = 'text-gray-700';
} else {
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
levelCls = 'text-gray-500';
msgCls = 'text-gray-700';
}
const time = new Date(l.at).toLocaleTimeString('ko-KR',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' });
return `<tr class="${rowCls}">
<td class="py-1.5 px-1 text-gray-400">${time}</td>
<td class="py-1.5 px-1 font-medium ${levelCls}">${l.level}</td>
<td class="py-1.5 px-1 text-gray-600">${escHtml(l.code)}</td>
<td class="py-1.5 px-1 ${msgCls}">${escHtml(l.message)}</td>
</tr>`;
}
function appendLogRow(log) {
const tbody = document.getElementById('logsList');
if (!tbody) return;
// 빈 상태 메시지 제거
if (tbody.firstElementChild?.tagName === 'TR' &&
tbody.firstElementChild.querySelector('td[colspan]')) {
tbody.innerHTML = '';
}
tbody.insertAdjacentHTML('beforeend', buildLogRow(log));
// 표시 행 수 300개 초과 시 맨 위 행 제거
while (tbody.children.length > 300) tbody.removeChild(tbody.firstChild);
// 자동스크롤
const chk = document.getElementById('autoScrollChk');
if (chk && chk.checked) scrollLogsToBottom();
}
function setLogFilter(level) {
logLevelFilter = level;
const allBtn = document.getElementById('filterAll');
const actionBtn = document.getElementById('filterAction');
if (allBtn && actionBtn) {
if (level === 'all') {
allBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
} else {
allBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
}
}
// 버퍼에서 필터 적용 후 전체 재렌더
renderLogsFromBuffer();
}
async function loadLogs() {
try {
const res = await fetch('/api/autotrade/logs');
const logs = await res.json();
// 서버는 최신순으로 반환 → 뒤집어 오래된 것부터 저장
localLogs = (logs || []).slice(0, 300).reverse();
renderLogsFromBuffer();
} catch (e) {
console.error('로그 초기 로드 실패:', e);
}
}
function renderLogsFromBuffer() {
const tbody = document.getElementById('logsList');
if (!tbody) return;
const filtered = logLevelFilter === 'action'
? localLogs.filter(l => l.level !== 'debug')
: localLogs;
// 최근 300개 표시
const items = filtered.slice(-300);
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = items.map(l => buildLogRow(l)).join('');
updateLogTime();
scrollLogsToBottom();
}
// --- 유틸 ---
function formatMoney(n) {
if (!n) return '0';
return n.toLocaleString('ko-KR');
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -1,312 +0,0 @@
/**
* 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');
});

View File

@@ -1,60 +0,0 @@
// 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();
});

View File

@@ -1,56 +0,0 @@
/**
* 주요 지수 티커 (코스피·코스닥·다우·나스닥)
* - 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);
})();

View File

@@ -1,195 +0,0 @@
/**
* 코스피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);
})();

View File

@@ -1,49 +0,0 @@
// 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();
});

View File

@@ -1,487 +0,0 @@
/**
* 주문창 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);

View File

@@ -1,145 +0,0 @@
/**
* 실시간 호가창 (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');
}

View File

@@ -1,140 +0,0 @@
/**
* 상승률 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);
})();

View File

@@ -1,68 +0,0 @@
/**
* 종목 검색 자동완성 (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');
}
})();

View File

@@ -1,457 +0,0 @@
/**
* 체결강도 상승 감지 실시간 표시
* - 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');
});
})();

View File

@@ -1,255 +0,0 @@
/**
* 테마 분석 페이지
* - 테마 목록 조회 (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();
})();

View File

@@ -1,632 +0,0 @@
/**
* 관심종목 관리 + 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();
// 테이블 생성
let tableEl = sidebarEl.querySelector('table');
if (tableEl) tableEl.remove();
if (list.length > 0) {
tableEl = document.createElement('table');
tableEl.className = 'w-full text-xs';
const tbody = document.createElement('tbody');
tbody.className = 'divide-y divide-gray-50';
list.forEach(s => tbody.appendChild(makeSidebarItem(s.code, s.name)));
tableEl.appendChild(tbody);
sidebarEl.insertBefore(tableEl, emptyEl);
}
updateEmptyStates();
}
function makeSidebarItem(code, name) {
const tr = document.createElement('tr');
tr.id = `si-${code}`;
tr.className = 'hover:bg-gray-50 group cursor-pointer';
tr.onclick = () => { window.location.href = `/stock/${code}`; };
tr.innerHTML = `
<td class="px-3 py-2">
<p class="font-medium text-gray-800 truncate">${name}</p>
<p class="text-gray-400 font-mono">${code}</p>
</td>
<td id="si-price-${code}" class="px-2 py-2 text-right font-mono text-gray-400">-</td>
<td id="si-rate-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
<td id="si-cntr-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
<td class="pr-2 py-2 text-center">
<button onclick="event.stopPropagation(); removeStock('${code}')" title="삭제"
class="text-gray-300 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">×</button>
</td>`;
return tr;
}
// ─────────────────────────────────────────────
// 패널 카드 추가 (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}`);
const rate = data.changeRate ?? 0;
const colorCls = rateClass(rate);
const bgCls = rateBadgeClass(rate);
const sign = rate > 0 ? '+' : '';
// 패널 카드 업데이트
if (priceEl) {
priceEl.textContent = fmtNum(data.currentPrice) + '원';
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
}
if (rateEl) {
rateEl.textContent = sign + rate.toFixed(2) + '%';
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
}
if (volEl) volEl.textContent = fmtNum(data.volume);
if (cntrEl && data.cntrStr !== undefined) {
const cs = data.cntrStr;
cntrEl.textContent = fmtCntr(cs);
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
}
// 사이드바 행 업데이트
const siPrice = document.getElementById(`si-price-${code}`);
const siRate = document.getElementById(`si-rate-${code}`);
const siCntr = document.getElementById(`si-cntr-${code}`);
if (siPrice) {
siPrice.textContent = fmtNum(data.currentPrice);
siPrice.className = `px-2 py-2 text-right font-mono ${colorCls}`;
}
if (siRate) {
siRate.textContent = fmtRate(rate);
siRate.className = `px-2 py-2 text-right ${colorCls}`;
}
if (siCntr && data.cntrStr !== undefined) {
const cs = data.cntrStr;
siCntr.textContent = fmtCntr(cs);
siCntr.className = `px-2 py-2 text-right ${cs >= 100 ? 'text-orange-500 font-bold' : cs > 0 ? 'text-blue-400' : 'text-gray-400'}`;
}
// 체결강도 히스토리 기록 + 미니 차트 갱신
if (data.cntrStr != null && data.cntrStr !== 0) {
recordCntr(code, data.cntrStr);
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;
if (emptyEl) 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);
})();
})();

View File

@@ -1,148 +0,0 @@
/**
* 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();

View File

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

View File

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

View File

@@ -1,287 +0,0 @@
{{ 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>
<!-- 2중 손절 설명 -->
<div class="mb-3 p-2.5 bg-orange-50 border border-orange-200 rounded-lg text-xs text-orange-700 leading-relaxed">
<span class="font-semibold">2중 손절:</span>
1차 손절가에 설정 횟수만큼 터치 시 매도,
2차 손절가 도달 시 즉시 매도.
1차 터치 횟수를 0으로 설정하면 단순 손절.
</div>
<div class="grid grid-cols-2 gap-3">
<!-- 1차 손절 -->
<div>
<label class="block text-xs text-gray-700 mb-1">1차 손절 (%)</label>
<input id="fStopLoss1" type="number" value="-2" step="0.5"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400">
</div>
<div>
<label class="block text-xs text-gray-700 mb-1">1차 손절 터치 횟수</label>
<input id="fStopLoss1Count" type="number" value="3" min="0" step="1"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400">
</div>
<!-- 2차 손절 / 익절 -->
<div>
<label class="block text-xs text-gray-700 mb-1">2차 손절 / 즉시 매도 (%)</label>
<input id="fStopLoss" type="number" value="-4" step="0.5"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400">
</div>
<div>
<label class="block text-xs text-gray-700 mb-1">익절 (%)</label>
<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 }}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,86 +0,0 @@
{{ 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 }}

27
vendor/golang.org/x/time/LICENSE generated vendored
View File

@@ -1,27 +0,0 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

22
vendor/golang.org/x/time/PATENTS generated vendored
View File

@@ -1,22 +0,0 @@
Additional IP Rights Grant (Patents)
"This implementation" means the copyrightable works distributed by
Google as part of the Go project.
Google hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section)
patent license to make, have made, use, offer to sell, sell, import,
transfer and otherwise run, modify and propagate the contents of this
implementation of Go, where such license applies only to those patent
claims, both currently owned or controlled by Google and acquired in
the future, licensable by Google that are necessarily infringed by this
implementation of Go. This grant does not include claims that would be
infringed only as a consequence of further modification of this
implementation. If you or your agent or exclusive licensee institute or
order or agree to the institution of patent litigation against any
entity (including a cross-claim or counterclaim in a lawsuit) alleging
that this implementation of Go or any code incorporated within this
implementation of Go constitutes direct or contributory patent
infringement, or inducement of patent infringement, then any patent
rights granted to you under this License for this implementation of Go
shall terminate as of the date such litigation is filed.

427
vendor/golang.org/x/time/rate/rate.go generated vendored
View File

@@ -1,427 +0,0 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package rate provides a rate limiter.
package rate
import (
"context"
"fmt"
"math"
"sync"
"time"
)
// Limit defines the maximum frequency of some events.
// Limit is represented as number of events per second.
// A zero Limit allows no events.
type Limit float64
// Inf is the infinite rate limit; it allows all events (even if burst is zero).
const Inf = Limit(math.MaxFloat64)
// Every converts a minimum time interval between events to a Limit.
func Every(interval time.Duration) Limit {
if interval <= 0 {
return Inf
}
return 1 / Limit(interval.Seconds())
}
// A Limiter controls how frequently events are allowed to happen.
// It implements a "token bucket" of size b, initially full and refilled
// at rate r tokens per second.
// Informally, in any large enough time interval, the Limiter limits the
// rate to r tokens per second, with a maximum burst size of b events.
// As a special case, if r == Inf (the infinite rate), b is ignored.
// See https://en.wikipedia.org/wiki/Token_bucket for more about token buckets.
//
// The zero value is a valid Limiter, but it will reject all events.
// Use NewLimiter to create non-zero Limiters.
//
// Limiter has three main methods, Allow, Reserve, and Wait.
// Most callers should use Wait.
//
// Each of the three methods consumes a single token.
// They differ in their behavior when no token is available.
// If no token is available, Allow returns false.
// If no token is available, Reserve returns a reservation for a future token
// and the amount of time the caller must wait before using it.
// If no token is available, Wait blocks until one can be obtained
// or its associated context.Context is canceled.
//
// The methods AllowN, ReserveN, and WaitN consume n tokens.
//
// Limiter is safe for simultaneous use by multiple goroutines.
type Limiter struct {
mu sync.Mutex
limit Limit
burst int
tokens float64
// last is the last time the limiter's tokens field was updated
last time.Time
// lastEvent is the latest time of a rate-limited event (past or future)
lastEvent time.Time
}
// Limit returns the maximum overall event rate.
func (lim *Limiter) Limit() Limit {
lim.mu.Lock()
defer lim.mu.Unlock()
return lim.limit
}
// Burst returns the maximum burst size. Burst is the maximum number of tokens
// that can be consumed in a single call to Allow, Reserve, or Wait, so higher
// Burst values allow more events to happen at once.
// A zero Burst allows no events, unless limit == Inf.
func (lim *Limiter) Burst() int {
lim.mu.Lock()
defer lim.mu.Unlock()
return lim.burst
}
// TokensAt returns the number of tokens available at time t.
func (lim *Limiter) TokensAt(t time.Time) float64 {
lim.mu.Lock()
tokens := lim.advance(t) // does not mutate lim
lim.mu.Unlock()
return tokens
}
// Tokens returns the number of tokens available now.
func (lim *Limiter) Tokens() float64 {
return lim.TokensAt(time.Now())
}
// NewLimiter returns a new Limiter that allows events up to rate r and permits
// bursts of at most b tokens.
func NewLimiter(r Limit, b int) *Limiter {
return &Limiter{
limit: r,
burst: b,
tokens: float64(b),
}
}
// Allow reports whether an event may happen now.
func (lim *Limiter) Allow() bool {
return lim.AllowN(time.Now(), 1)
}
// AllowN reports whether n events may happen at time t.
// Use this method if you intend to drop / skip events that exceed the rate limit.
// Otherwise use Reserve or Wait.
func (lim *Limiter) AllowN(t time.Time, n int) bool {
return lim.reserveN(t, n, 0).ok
}
// A Reservation holds information about events that are permitted by a Limiter to happen after a delay.
// A Reservation may be canceled, which may enable the Limiter to permit additional events.
type Reservation struct {
ok bool
lim *Limiter
tokens int
timeToAct time.Time
// This is the Limit at reservation time, it can change later.
limit Limit
}
// OK returns whether the limiter can provide the requested number of tokens
// within the maximum wait time. If OK is false, Delay returns InfDuration, and
// Cancel does nothing.
func (r *Reservation) OK() bool {
return r.ok
}
// Delay is shorthand for DelayFrom(time.Now()).
func (r *Reservation) Delay() time.Duration {
return r.DelayFrom(time.Now())
}
// InfDuration is the duration returned by Delay when a Reservation is not OK.
const InfDuration = time.Duration(math.MaxInt64)
// DelayFrom returns the duration for which the reservation holder must wait
// before taking the reserved action. Zero duration means act immediately.
// InfDuration means the limiter cannot grant the tokens requested in this
// Reservation within the maximum wait time.
func (r *Reservation) DelayFrom(t time.Time) time.Duration {
if !r.ok {
return InfDuration
}
delay := r.timeToAct.Sub(t)
if delay < 0 {
return 0
}
return delay
}
// Cancel is shorthand for CancelAt(time.Now()).
func (r *Reservation) Cancel() {
r.CancelAt(time.Now())
}
// CancelAt indicates that the reservation holder will not perform the reserved action
// and reverses the effects of this Reservation on the rate limit as much as possible,
// considering that other reservations may have already been made.
func (r *Reservation) CancelAt(t time.Time) {
if !r.ok {
return
}
r.lim.mu.Lock()
defer r.lim.mu.Unlock()
if r.lim.limit == Inf || r.tokens == 0 || r.timeToAct.Before(t) {
return
}
// calculate tokens to restore
// The duration between lim.lastEvent and r.timeToAct tells us how many tokens were reserved
// after r was obtained. These tokens should not be restored.
restoreTokens := float64(r.tokens) - r.limit.tokensFromDuration(r.lim.lastEvent.Sub(r.timeToAct))
if restoreTokens <= 0 {
return
}
// advance time to now
tokens := r.lim.advance(t)
// calculate new number of tokens
tokens += restoreTokens
if burst := float64(r.lim.burst); tokens > burst {
tokens = burst
}
// update state
r.lim.last = t
r.lim.tokens = tokens
if r.timeToAct.Equal(r.lim.lastEvent) {
prevEvent := r.timeToAct.Add(r.limit.durationFromTokens(float64(-r.tokens)))
if !prevEvent.Before(t) {
r.lim.lastEvent = prevEvent
}
}
}
// Reserve is shorthand for ReserveN(time.Now(), 1).
func (lim *Limiter) Reserve() *Reservation {
return lim.ReserveN(time.Now(), 1)
}
// ReserveN returns a Reservation that indicates how long the caller must wait before n events happen.
// The Limiter takes this Reservation into account when allowing future events.
// The returned Reservations OK() method returns false if n exceeds the Limiter's burst size.
// Usage example:
//
// r := lim.ReserveN(time.Now(), 1)
// if !r.OK() {
// // Not allowed to act! Did you remember to set lim.burst to be > 0 ?
// return
// }
// time.Sleep(r.Delay())
// Act()
//
// Use this method if you wish to wait and slow down in accordance with the rate limit without dropping events.
// If you need to respect a deadline or cancel the delay, use Wait instead.
// To drop or skip events exceeding rate limit, use Allow instead.
func (lim *Limiter) ReserveN(t time.Time, n int) *Reservation {
r := lim.reserveN(t, n, InfDuration)
return &r
}
// Wait is shorthand for WaitN(ctx, 1).
func (lim *Limiter) Wait(ctx context.Context) (err error) {
return lim.WaitN(ctx, 1)
}
// WaitN blocks until lim permits n events to happen.
// It returns an error if n exceeds the Limiter's burst size, the Context is
// canceled, or the expected wait time exceeds the Context's Deadline.
// The burst limit is ignored if the rate limit is Inf.
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) {
// The test code calls lim.wait with a fake timer generator.
// This is the real timer generator.
newTimer := func(d time.Duration) (<-chan time.Time, func() bool, func()) {
timer := time.NewTimer(d)
return timer.C, timer.Stop, func() {}
}
return lim.wait(ctx, n, time.Now(), newTimer)
}
// wait is the internal implementation of WaitN.
func (lim *Limiter) wait(ctx context.Context, n int, t time.Time, newTimer func(d time.Duration) (<-chan time.Time, func() bool, func())) error {
lim.mu.Lock()
burst := lim.burst
limit := lim.limit
lim.mu.Unlock()
if n > burst && limit != Inf {
return fmt.Errorf("rate: Wait(n=%d) exceeds limiter's burst %d", n, burst)
}
// Check if ctx is already cancelled
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Determine wait limit
waitLimit := InfDuration
if deadline, ok := ctx.Deadline(); ok {
waitLimit = deadline.Sub(t)
}
// Reserve
r := lim.reserveN(t, n, waitLimit)
if !r.ok {
return fmt.Errorf("rate: Wait(n=%d) would exceed context deadline", n)
}
// Wait if necessary
delay := r.DelayFrom(t)
if delay == 0 {
return nil
}
ch, stop, advance := newTimer(delay)
defer stop()
advance() // only has an effect when testing
select {
case <-ch:
// We can proceed.
return nil
case <-ctx.Done():
// Context was canceled before we could proceed. Cancel the
// reservation, which may permit other events to proceed sooner.
r.Cancel()
return ctx.Err()
}
}
// SetLimit is shorthand for SetLimitAt(time.Now(), newLimit).
func (lim *Limiter) SetLimit(newLimit Limit) {
lim.SetLimitAt(time.Now(), newLimit)
}
// SetLimitAt sets a new Limit for the limiter. The new Limit, and Burst, may be violated
// or underutilized by those which reserved (using Reserve or Wait) but did not yet act
// before SetLimitAt was called.
func (lim *Limiter) SetLimitAt(t time.Time, newLimit Limit) {
lim.mu.Lock()
defer lim.mu.Unlock()
tokens := lim.advance(t)
lim.last = t
lim.tokens = tokens
lim.limit = newLimit
}
// SetBurst is shorthand for SetBurstAt(time.Now(), newBurst).
func (lim *Limiter) SetBurst(newBurst int) {
lim.SetBurstAt(time.Now(), newBurst)
}
// SetBurstAt sets a new burst size for the limiter.
func (lim *Limiter) SetBurstAt(t time.Time, newBurst int) {
lim.mu.Lock()
defer lim.mu.Unlock()
tokens := lim.advance(t)
lim.last = t
lim.tokens = tokens
lim.burst = newBurst
}
// reserveN is a helper method for AllowN, ReserveN, and WaitN.
// maxFutureReserve specifies the maximum reservation wait duration allowed.
// reserveN returns Reservation, not *Reservation, to avoid allocation in AllowN and WaitN.
func (lim *Limiter) reserveN(t time.Time, n int, maxFutureReserve time.Duration) Reservation {
lim.mu.Lock()
defer lim.mu.Unlock()
if lim.limit == Inf {
return Reservation{
ok: true,
lim: lim,
tokens: n,
timeToAct: t,
}
}
tokens := lim.advance(t)
// Calculate the remaining number of tokens resulting from the request.
tokens -= float64(n)
// Calculate the wait duration
var waitDuration time.Duration
if tokens < 0 {
waitDuration = lim.limit.durationFromTokens(-tokens)
}
// Decide result
ok := n <= lim.burst && waitDuration <= maxFutureReserve
// Prepare reservation
r := Reservation{
ok: ok,
lim: lim,
limit: lim.limit,
}
if ok {
r.tokens = n
r.timeToAct = t.Add(waitDuration)
// Update state
lim.last = t
lim.tokens = tokens
lim.lastEvent = r.timeToAct
}
return r
}
// advance calculates and returns an updated number of tokens for lim
// resulting from the passage of time.
// lim is not changed.
// advance requires that lim.mu is held.
func (lim *Limiter) advance(t time.Time) (newTokens float64) {
last := lim.last
if t.Before(last) {
last = t
}
// Calculate the new number of tokens, due to time that passed.
elapsed := t.Sub(last)
delta := lim.limit.tokensFromDuration(elapsed)
tokens := lim.tokens + delta
if burst := float64(lim.burst); tokens > burst {
tokens = burst
}
return tokens
}
// durationFromTokens is a unit conversion function from the number of tokens to the duration
// of time it takes to accumulate them at a rate of limit tokens per second.
func (limit Limit) durationFromTokens(tokens float64) time.Duration {
if limit <= 0 {
return InfDuration
}
duration := (tokens / float64(limit)) * float64(time.Second)
// Cap the duration to the maximum representable int64 value, to avoid overflow.
if duration > float64(math.MaxInt64) {
return InfDuration
}
return time.Duration(duration)
}
// tokensFromDuration is a unit conversion function from a time duration to the number of tokens
// which could be accumulated during that duration at a rate of limit tokens per second.
func (limit Limit) tokensFromDuration(d time.Duration) float64 {
if limit <= 0 {
return 0
}
return d.Seconds() * float64(limit)
}

View File

@@ -1,69 +0,0 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package rate
import (
"sync"
"time"
)
// Sometimes will perform an action occasionally. The First, Every, and
// Interval fields govern the behavior of Do, which performs the action.
// A zero Sometimes value will perform an action exactly once.
//
// # Example: logging with rate limiting
//
// var sometimes = rate.Sometimes{First: 3, Interval: 10*time.Second}
// func Spammy() {
// sometimes.Do(func() { log.Info("here I am!") })
// }
type Sometimes struct {
First int // if non-zero, the first N calls to Do will run f.
Every int // if non-zero, every Nth call to Do will run f.
Interval time.Duration // if non-zero and Interval has elapsed since f's last run, Do will run f.
mu sync.Mutex
count int // number of Do calls
last time.Time // last time f was run
}
// Do runs the function f as allowed by First, Every, and Interval.
//
// The model is a union (not intersection) of filters. The first call to Do
// always runs f. Subsequent calls to Do run f if allowed by First or Every or
// Interval.
//
// A non-zero First:N causes the first N Do(f) calls to run f.
//
// A non-zero Every:M causes every Mth Do(f) call, starting with the first, to
// run f.
//
// A non-zero Interval causes Do(f) to run f if Interval has elapsed since
// Do last ran f.
//
// Specifying multiple filters produces the union of these execution streams.
// For example, specifying both First:N and Every:M causes the first N Do(f)
// calls and every Mth Do(f) call, starting with the first, to run f. See
// Examples for more.
//
// If Do is called multiple times simultaneously, the calls will block and run
// serially. Therefore, Do is intended for lightweight operations.
//
// Because a call to Do may block until f returns, if f causes Do to be called,
// it will deadlock.
func (s *Sometimes) Do(f func()) {
s.mu.Lock()
defer s.mu.Unlock()
if s.count == 0 ||
(s.First > 0 && s.count < s.First) ||
(s.Every > 0 && s.count%s.Every == 0) ||
(s.Interval > 0 && time.Since(s.last) >= s.Interval) {
f()
if s.Interval > 0 {
s.last = time.Now()
}
}
s.count++
}

15
vendor/modules.txt vendored
View File

@@ -4,10 +4,19 @@ github.com/gorilla/websocket
# github.com/joho/godotenv v1.5.1
## explicit; go 1.12
github.com/joho/godotenv
# github.com/lib/pq v1.12.3
## explicit; go 1.21
github.com/lib/pq
github.com/lib/pq/internal/pgpass
github.com/lib/pq/internal/pgservice
github.com/lib/pq/internal/pqsql
github.com/lib/pq/internal/pqtime
github.com/lib/pq/internal/pqutil
github.com/lib/pq/internal/proto
github.com/lib/pq/oid
github.com/lib/pq/pqerror
github.com/lib/pq/scram
# golang.org/x/net v0.17.0
## explicit; go 1.17
golang.org/x/net/internal/socks
golang.org/x/net/proxy
# golang.org/x/time v0.15.0
## explicit; go 1.25.0
golang.org/x/time/rate