diff --git a/.env.example b/.env.example index 4d68708..6c15384 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 6eb8715..ad2fdc8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . # 프론트엔드 빌드 결과물 복사 diff --git a/config/config.go b/config/config.go index 5b2049c..c30f0a0 100644 --- a/config/config.go +++ b/config/config.go @@ -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", ""), } } diff --git a/frontend/src/lib/api/autotrade.ts b/frontend/src/lib/api/autotrade.ts index 4136dc4..206a7b9 100644 --- a/frontend/src/lib/api/autotrade.ts +++ b/frontend/src/lib/api/autotrade.ts @@ -36,6 +36,10 @@ export const autotradeApi = { getPositions: () => apiFetch('/api/autotrade/positions'), + // 거래 내역 (종료된 포지션) + getTrades: () => + apiFetch('/api/autotrade/trades'), + // 이벤트 로그 getLogs: () => apiFetch('/api/autotrade/logs'), diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 40eb765..6948542 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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 { diff --git a/frontend/src/routes/(app)/autotrade/+page.svelte b/frontend/src/routes/(app)/autotrade/+page.svelte index 954df1a..2270aad 100644 --- a/frontend/src/routes/(app)/autotrade/+page.svelte +++ b/frontend/src/routes/(app)/autotrade/+page.svelte @@ -14,7 +14,8 @@ let rules = $state([]) let positions = $state([]) let logs = $state([]) - let activeTab = $state<'rules' | 'positions' | 'logs'>('rules') + let activeTab = $state<'rules' | 'positions' | 'trades' | 'logs'>('rules') + let trades = $state([]) let showRuleModal = $state(false) let editingRule = $state | 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 @@ {status.running ? '실행 중' : '중지됨'} - 포지션 {status.positions}개 - 오늘 {status.todayTrades}건 - {#if status.todayProfit !== 0} - - 오늘 손익 {status.todayProfit >= 0 ? '+' : ''}{formatPrice(status.todayProfit)} - - {/if} + 포지션 {status.activePositions}개 + 오늘 {status.tradeCount}건 + + 오늘 손익 {status.totalPL >= 0 ? '+' : ''}{formatPrice(status.totalPL)} + {/if} @@ -339,7 +340,7 @@
- {#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]}
{/if} + +{:else if activeTab === 'trades'} + {#if trades.length === 0} +
거래 내역 없음
+ {:else} +
+ + + + + + + + + + + + + + + + {#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} + + + + + + + + + + + + {/each} + +
종목구분수량매수가매도가손익수익률사유청산시각
+
{trade.name}
+
{trade.code}
+
+ 매도 + {trade.qty}{formatPrice(trade.buyPrice)}{formatPrice(trade.exitPrice ?? 0)} + {pl >= 0 ? '+' : ''}{formatPrice(pl)} + + {plRate >= 0 ? '+' : ''}{plRate.toFixed(2)}% + + {trade.exitReason ?? '-'} + {formatTime(trade.exitTime ?? '')}
+
+ {/if} + {:else if activeTab === 'logs'}
diff --git a/go.mod b/go.mod index 5404274..e105d0c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9f6ea67..e37a08c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handlers/auth_handler.go b/handlers/auth_handler.go index d5cfd54..eddd09e 100644 --- a/handlers/auth_handler.go +++ b/handlers/auth_handler.go @@ -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) } diff --git a/handlers/autotrade_handler.go b/handlers/autotrade_handler.go index 429ff29..45d68d9 100644 --- a/handlers/autotrade_handler.go +++ b/handlers/autotrade_handler.go @@ -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() diff --git a/handlers/page_handler.go b/handlers/page_handler.go deleted file mode 100644 index 382267b..0000000 --- a/handlers/page_handler.go +++ /dev/null @@ -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) -} diff --git a/main.go b/main.go index acb1783..bf9d387 100644 --- a/main.go +++ b/main.go @@ -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 } diff --git a/middleware/auth.go b/middleware/auth.go index 22802ee..5c5d3b7 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -23,7 +23,6 @@ func IsLoggedIn(r *http.Request) bool { // publicPaths 로그인 없이 접근 가능한 경로 (전체 일치 또는 prefix) var publicPaths = []string{ "/login", - "/static/", // 공개 페이지 "/", "/theme", diff --git a/models/autotrade.go b/models/autotrade.go index 6b42b90..5d2ada9 100644 --- a/models/autotrade.go +++ b/models/autotrade.go @@ -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"` // 매수 체결가 diff --git a/services/autotrade_service.go b/services/autotrade_service.go index 7cc82cf..1c0d7e2 100644 --- a/services/autotrade_service.go +++ b/services/autotrade_service.go @@ -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() diff --git a/services/kiwoom_service.go b/services/kiwoom_service.go index 3116940..f63dab6 100644 --- a/services/kiwoom_service.go +++ b/services/kiwoom_service.go @@ -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 diff --git a/services/kiwoom_ws_service.go b/services/kiwoom_ws_service.go index 4e5b492..44997a6 100644 --- a/services/kiwoom_ws_service.go +++ b/services/kiwoom_ws_service.go @@ -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 } diff --git a/services/scanner_service.go b/services/scanner_service.go index 3d07974..e8dd64e 100644 --- a/services/scanner_service.go +++ b/services/scanner_service.go @@ -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: 스코어 및 신호 유형 계산 diff --git a/static/css/custom.css b/static/css/custom.css deleted file mode 100644 index 239ab66..0000000 --- a/static/css/custom.css +++ /dev/null @@ -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; } -} diff --git a/static/js/asset.js b/static/js/asset.js deleted file mode 100644 index fd74016..0000000 --- a/static/js/asset.js +++ /dev/null @@ -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 => ` -
-

${c.label}

-

${c.value}

-
- `).join(''); -} - -function renderSummaryError(msg) { - document.getElementById('summaryCards').innerHTML = ` -
${msg}
- `; - document.getElementById('holdingsTable').innerHTML = ''; -} - -function renderHoldings(stocks) { - const tbody = document.getElementById('holdingsTable'); - - if (!stocks || stocks.length === 0) { - tbody.innerHTML = '
보유 종목이 없습니다.
'; - 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 ` -
- ${s.stkNm} - ${parseInt(s.rmndQty || '0').toLocaleString('ko-KR')}주 - ${parseInt(s.purPric || '0').toLocaleString('ko-KR')} - ${parseInt(s.curPrc || '0').toLocaleString('ko-KR')} - ${(evlt >= 0 ? '+' : '') + evlt.toLocaleString('ko-KR')}원 - ${sign}${prft.toFixed(2)}% -
`; - }).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 = '
조회 중...
'; - - try { - const res = await fetch('/api/account/pending'); - const list = await res.json(); - - if (!res.ok) { - panel.innerHTML = `
${list.error || '조회 실패'}
`; - return; - } - - if (!list || list.length === 0) { - panel.innerHTML = '
미체결 주문이 없습니다.
'; - return; - } - - panel.innerHTML = ` -
- - - - - - - - - - - - ${list.map(o => { - const isBuy = o.trdeTp === '2'; - const cls = isBuy ? 'text-red-500' : 'text-blue-500'; - const label = isBuy ? '매수' : '매도'; - return ` - - - - - - - `; - }).join('')} - -
종목명구분주문가미체결/주문취소
${o.stkNm}${label}${parseInt(o.ordPric || '0').toLocaleString('ko-KR')}원${parseInt(o.osoQty || '0').toLocaleString('ko-KR')} / ${parseInt(o.ordQty || '0').toLocaleString('ko-KR')}주 - -
-
`; - } catch (e) { - panel.innerHTML = `
오류: ${e.message}
`; - } -} - -// ----------------------------------- -// 미체결 취소 -// ----------------------------------- -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 = '
조회 중...
'; - - try { - const res = await fetch('/api/account/history'); - const list = await res.json(); - - if (!res.ok) { - panel.innerHTML = `
${list.error || '조회 실패'}
`; - return; - } - - if (!list || list.length === 0) { - panel.innerHTML = '
체결 내역이 없습니다.
'; - return; - } - - panel.innerHTML = ` -
- - - - - - - - - - - - ${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 ` - - - - - - - `; - }).join('')} - -
종목명구분체결가체결수량수수료+세금
${o.stkNm}${label}${parseInt(o.cntrPric || '0').toLocaleString('ko-KR')}원${parseInt(o.cntrQty || '0').toLocaleString('ko-KR')}주${fee}원
-
`; - } catch (e) { - panel.innerHTML = `
오류: ${e.message}
`; - } -} - -// ----------------------------------- -// 토스트 알림 -// ----------------------------------- -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); -} diff --git a/static/js/autotrade.js b/static/js/autotrade.js deleted file mode 100644 index 2c10c9b..0000000 --- a/static/js/autotrade.js +++ /dev/null @@ -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 = '

규칙이 없습니다.

'; - return; - } - el.innerHTML = rules.map(r => ` -
-
- ${escHtml(r.name)} - -
-
-

진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}

-

청산: ${r.stopLoss1Count > 0 ? `1차손절${r.stopLoss1Pct}%[${r.stopLoss1Count}회] / 2차손절${r.stopLossPct}%` : `손절${r.stopLossPct}%`} / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}

-

주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목

-
-
- - -
-
- `).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 = '

보유 포지션 없음

'; - return; - } - - el.innerHTML = ` - - - - - - - - - - - - - ${active.map(p => { - const statusCls = p.status === 'open' ? 'text-green-600' : 'text-yellow-600'; - const statusTxt = p.status === 'open' ? '보유중' : '체결대기'; - return ` - - - - - - - - - `; - }).join('')} - -
종목매수가수량1차손절2차손절상태
-
${escHtml(p.name)}
-
${p.code}
-
${formatMoney(p.buyPrice)}${p.qty}${p.stopLoss1 > 0 ? formatMoney(p.stopLoss1) + `[${p.stopLoss1Touches||0}회]` : '-'}${formatMoney(p.stopLoss)}${statusTxt}
- `; -} - -// --- 감시 소스 --- - -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 = ''; - 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 = '선택된 테마 없음'; - return; - } - - container.innerHTML = themes.map(t => ` - - ${escHtml(t.name)} - - - `).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 ` - ${time} - ${l.level} - ${escHtml(l.code)} - ${escHtml(l.message)} - `; -} - -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 = '로그가 없습니다.'; - return; - } - - tbody.innerHTML = items.map(l => buildLogRow(l)).join(''); - updateLogTime(); - scrollLogsToBottom(); -} - -// --- 유틸 --- - -function formatMoney(n) { - if (!n) return '0'; - return n.toLocaleString('ko-KR'); -} - -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/static/js/chart.js b/static/js/chart.js deleted file mode 100644 index cb94f81..0000000 --- a/static/js/chart.js +++ /dev/null @@ -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'); -}); diff --git a/static/js/disclosure.js b/static/js/disclosure.js deleted file mode 100644 index 4fc637b..0000000 --- a/static/js/disclosure.js +++ /dev/null @@ -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 `${tag}`; -} - -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 = ` - ${tagBadge(item.tag)} - ${item.reportNm} - ${formatDate(item.rceptDt)} - `; - 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(); -}); \ No newline at end of file diff --git a/static/js/indices.js b/static/js/indices.js deleted file mode 100644 index c6f005f..0000000 --- a/static/js/indices.js +++ /dev/null @@ -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 ` - - ${q.name} - ${fmtPrice(q.name, q.price)} - ${arr} ${rate} - `; - }).join('|'); - } - - 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); -})(); \ No newline at end of file diff --git a/static/js/kospi200.js b/static/js/kospi200.js deleted file mode 100644 index 9e585fa..0000000 --- a/static/js/kospi200.js +++ /dev/null @@ -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 ` - - ${rank} -
-

${s.name}

-

${s.code}

-
-
${fmtNum(s.curPrc)}원
-
${fmtDiff(s.predPre)}
-
- ${fmtRate(s.fluRt)} -
-
${fmtNum(s.volume)}
-
${fmtNum(s.open)}
-
${fmtNum(s.high)}
-
${fmtNum(s.low)}
-
`; - } - - // ── 필터 + 정렬 + 렌더 ─────────────────────────────────────── - 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 = `
조건에 맞는 종목이 없습니다.
`; - 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 = `
데이터를 불러오지 못했습니다.
`; - 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); -})(); diff --git a/static/js/news.js b/static/js/news.js deleted file mode 100644 index a5b067d..0000000 --- a/static/js/news.js +++ /dev/null @@ -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 = ` -
-

${item.title}

-

${item.source}

-
- ${formatNewsDate(item.publishedAt)} -
`; - 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(); -}); \ No newline at end of file diff --git a/static/js/order.js b/static/js/order.js deleted file mode 100644 index 4571f69..0000000 --- a/static/js/order.js +++ /dev/null @@ -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 = '

조회 중...

'; - - try { - const res = await fetch('/api/account/pending'); - const list = await res.json(); - - if (!res.ok) { - panel.innerHTML = `

${list.error || '조회 실패'}

`; - return; - } - - if (!list || list.length === 0) { - panel.innerHTML = '

미체결 내역이 없습니다.

'; - return; - } - - panel.innerHTML = list.map(o => { - const isBuy = o.trdeTp === '2'; - const sideClass = isBuy ? 'text-red-500' : 'text-blue-500'; - const sideText = isBuy ? '매수' : '매도'; - return ` -
-
-

${o.stkNm}

-

${sideText} · ${parseInt(o.ordPric||0).toLocaleString('ko-KR')}원

-

미체결 ${parseInt(o.osoQty||0).toLocaleString('ko-KR')}/${parseInt(o.ordQty||0).toLocaleString('ko-KR')}주

-
-
- -
-
`; - }).join(''); - } catch (e) { - panel.innerHTML = `

오류: ${e.message}

`; - } -} - -// ----------------------------------- -// 미체결 취소 -// ----------------------------------- -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 = '

조회 중...

'; - - try { - const res = await fetch('/api/account/history'); - const list = await res.json(); - - if (!res.ok) { - panel.innerHTML = `

${list.error || '조회 실패'}

`; - return; - } - - if (!list || list.length === 0) { - panel.innerHTML = '

체결 내역이 없습니다.

'; - 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 ` -
-
- ${o.stkNm} - ${sideText} -
-
- 체결가 ${parseInt(o.cntrPric||0).toLocaleString('ko-KR')}원 × ${parseInt(o.cntrQty||0).toLocaleString('ko-KR')}주 - 수수료+세금 ${fee}원 -
-
`; - }).join(''); - } catch (e) { - panel.innerHTML = `

오류: ${e.message}

`; - } -} - -// ----------------------------------- -// 잔고 탭 -// ----------------------------------- -async function loadBalanceTab() { - const panel = document.getElementById('balancePanel'); - if (!panel) return; - - panel.innerHTML = '

조회 중...

'; - - try { - const res = await fetch('/api/account/balance'); - const data = await res.json(); - - if (!res.ok) { - panel.innerHTML = `

${data.error || '조회 실패'}

`; - return; - } - - const plClass = parseFloat(data.totPrftRt || '0') >= 0 ? 'text-red-500' : 'text-blue-500'; - let html = ` -
-
-

추정예탁자산

-

${parseInt(data.prsmDpstAsetAmt||0).toLocaleString('ko-KR')}원

-
-
-

총평가손익

-

${parseInt(data.totEvltPl||0).toLocaleString('ko-KR')}원

-
-
-

총평가금액

-

${parseInt(data.totEvltAmt||0).toLocaleString('ko-KR')}원

-
-
-

수익률

-

${parseFloat(data.totPrftRt||0).toFixed(2)}%

-
-
`; - - if (!data.stocks || data.stocks.length === 0) { - html += '

보유 종목이 없습니다.

'; - } else { - html += data.stocks.map(s => { - const prft = parseFloat(s.prftRt || '0'); - const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500'; - return ` -
-
- ${s.stkNm} - ${prft >= 0 ? '+' : ''}${prft.toFixed(2)}% -
-
- ${parseInt(s.rmndQty||0).toLocaleString('ko-KR')}주 / 평단 ${parseInt(s.purPric||0).toLocaleString('ko-KR')}원 - ${parseInt(s.evltvPrft||0).toLocaleString('ko-KR')}원 -
-
`; - }).join(''); - } - - panel.innerHTML = html; - } catch (e) { - panel.innerHTML = `

오류: ${e.message}

`; - } -} - -// ----------------------------------- -// DOM 준비 후 초기화 -// ----------------------------------- -document.addEventListener('DOMContentLoaded', initOrder); diff --git a/static/js/orderbook.js b/static/js/orderbook.js deleted file mode 100644 index 817ba13..0000000 --- a/static/js/orderbook.js +++ /dev/null @@ -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 = `호가 데이터 수신 대기 중...`; - 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 += ` - - -
-
- ${ask.volume > 0 ? ask.volume.toLocaleString('ko-KR') : ''} -
- - - ${ask.price > 0 ? ask.price.toLocaleString('ko-KR') : '-'} - - - `; - } - - // 예상체결 행 (스프레드 사이) - if (ob.expectedPrc > 0) { - html += ` - - 예상 - ${ob.expectedPrc.toLocaleString('ko-KR')} - ${ob.expectedVol > 0 ? ob.expectedVol.toLocaleString('ko-KR') : ''} - `; - } - - // 매수호가: 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 += ` - - - - ${bid.price > 0 ? bid.price.toLocaleString('ko-KR') : '-'} - - -
-
- ${bid.volume > 0 ? bid.volume.toLocaleString('ko-KR') : ''} -
- - `; - } - - 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 = `프로그램 매매 데이터 수신 대기 중...`; - return; - } - - const netClass = pg.netBuyVolume >= 0 ? 'text-red-500' : 'text-blue-500'; - const netSign = pg.netBuyVolume >= 0 ? '+' : ''; - - container.innerHTML = ` -
-
-

매도

-

${(pg.sellVolume||0).toLocaleString('ko-KR')}

-

${formatMoney(pg.sellAmount)}원

-
-
-

순매수

-

${netSign}${(pg.netBuyVolume||0).toLocaleString('ko-KR')}

-

${netSign}${formatMoney(pg.netBuyAmount)}원

-
-
-

매수

-

${(pg.buyVolume||0).toLocaleString('ko-KR')}

-

${formatMoney(pg.buyAmount)}원

-
-
`; -} - -// 금액을 억/만 단위로 포맷 -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'); -} diff --git a/static/js/ranking.js b/static/js/ranking.js deleted file mode 100644 index 2701352..0000000 --- a/static/js/ranking.js +++ /dev/null @@ -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 ` - -
- ${s.code} - - ${fmtRate(s.changeRate)} - -
-

${s.name}

-

${fmtNum(s.currentPrice)}원

-
- 거래량 ${fmtNum(s.volume)} - 체결강도 ${fmtCntr(s.cntrStr)} -
-
`; - } - - // --- 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 = '

데이터가 없습니다.

'; - 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); -})(); \ No newline at end of file diff --git a/static/js/search.js b/static/js/search.js deleted file mode 100644 index 384f1bc..0000000 --- a/static/js/search.js +++ /dev/null @@ -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 => ` - -
- ${item.name} - ${item.code} -
- ${item.market} -
- `).join(''); - - dropdown.classList.remove('hidden'); - } -})(); diff --git a/static/js/signal.js b/static/js/signal.js deleted file mode 100644 index ab07ac2..0000000 --- a/static/js/signal.js +++ /dev/null @@ -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 `${s.signalType}`; - } - - // 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 `${icon} ${s.riseLabel}`; - } - - // 호재/악재/중립 뱃지 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 `${s.sentiment}`; - } - - // 연속 상승 횟수에 따른 뱃지 텍스트 - function risingBadge(n) { - if (n >= 4) return `🔥${n}연속`; - if (n >= 2) return `▲${n}연속`; - return `↑상승`; - } - - // 복합 지표 섹션 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(`
- 거래량 증가 - ${volLabel} -
`); - } - - // 매도/매수 잔량비 - 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(`
- 매도/매수 잔량 - ${ratioLabel} -
`); - } - - // 가격 위치 (장중 저가~고가 내 %) - 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(`
- 가격 위치 - ${pos}% -
`); - } - - if (rows.length === 0) return ''; - return `
${rows.join('')}
`; - } - - // 익일 추세 예상 리포팅 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 `
-
- 익일 추세 - 분석 중... -
-
`; - } - - const style = trendMap[s.nextDayTrend] || trendMap['횡보']; - const confBadge = s.nextDayConf - ? `(${s.nextDayConf})` - : ''; - const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : ''; - return `
-
- 익일 추세 - - ${style.icon} ${s.nextDayTrend}${confBadge} - -
- ${s.nextDayReason ? `

${s.nextDayReason}

` : ''} -
`; - } - - // 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 `
- AI 목표가 - - ${fmtNum(s.targetPrice)}원 (${sign}${pct}%) - -
`; - } - - // 시그널 종목 카드 HTML 생성 - function makeCard(s) { - const diff = s.cntrStr - s.prevCntrStr; - const rising = s.risingCount || 1; - return ` - -
- ${s.code} -
- ${signalTypeBadge(s)} - ${riseProbBadge(s)} - ${risingBadge(rising)} - ${sentimentBadge(s)} - - ${fmtRate(s.changeRate)} - -
-
-

${s.name}

-

${fmtNum(s.currentPrice)}원

-
-
- 체결강도 - ${fmtCntr(s.cntrStr)} -
-
- 직전 대비 - ${fmtCntr(s.prevCntrStr)} → +${diff.toFixed(2)} -
-
- 거래량 - ${fmtNum(s.volume)} -
- ${complexIndicators(s)} - ${targetPriceBadge(s)} - ${nextDayBadge(s)} -
- - -
`; - } - - // 카드 삽입 후 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'); - }); -})(); \ No newline at end of file diff --git a/static/js/theme.js b/static/js/theme.js deleted file mode 100644 index de5cbc7..0000000 --- a/static/js/theme.js +++ /dev/null @@ -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 ` -
-
- ${rank} - ${t.name} -
-
${t.mainStock || '-'}
-
${fmtRate(t.fluRt)}
-
${fmtRate(t.periodRt)}
-
${t.stockCount}
-
- ${t.risingCount}▲ - / - ${t.fallCount}▼ -
-
`; - } - - // ── 헤더 정렬 화살표 갱신 ──────────────────────────────────── - 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 = `
검색 결과가 없습니다.
`; - 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 = `
데이터를 불러오는 중...
`; - 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 = `
데이터를 불러오지 못했습니다.
`; - 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 = `
구성종목이 없습니다.
`; - } else { - stockListEl.innerHTML = stocks.map(s => { - const cls = rateClass(s.fluRt); - const bgCls = rateBg(s.fluRt); - const sign = s.predPre >= 0 ? '+' : ''; - return ` - -
-

${s.name}

-

${s.code}

-
-
-

${fmtNum(s.curPrc)}원

- ${sign}${fmtRate(s.fluRt)} -
-
`; - }).join(''); - } - - loadingEl.classList.add('hidden'); - contentEl.classList.remove('hidden'); - } catch (e) { - loadingEl.classList.add('hidden'); - emptyEl.classList.remove('hidden'); - emptyEl.innerHTML = '

구성종목을 불러오지 못했습니다.

'; - 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(); -})(); diff --git a/static/js/watchlist.js b/static/js/watchlist.js deleted file mode 100644 index ef4542c..0000000 --- a/static/js/watchlist.js +++ /dev/null @@ -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 `${s.signalType}`; - } - - 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 `${icon} ${s.riseLabel}`; - } - - function risingBadge(n) { - if (!n) return ''; - if (n >= 4) return `🔥${n}연속`; - if (n >= 2) return `▲${n}연속`; - return `↑상승`; - } - - 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 `${s.sentiment}`; - } - - 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(`
- 거래량 증가 - ${volLabel} -
`); - } - - 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(`
- 매도/매수 잔량 - ${ratioLabel} -
`); - } - - 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(`
- 가격 위치 - ${pos}% -
`); - } - - if (rows.length === 0) return ''; - return `
${rows.join('')}
`; - } - - 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 `
- AI 목표가 - - ${fmtNum(s.targetPrice)}원 (${sign}${pct}%) - -
`; - } - - 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 `
-
- 익일 추세 - 분석 중... -
-
`; - } - - const style = trendMap[s.nextDayTrend] || trendMap['횡보']; - const confBadge = s.nextDayConf ? `(${s.nextDayConf})` : ''; - const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : ''; - return `
-
- 익일 추세 - - ${style.icon} ${s.nextDayTrend}${confBadge} - -
- ${s.nextDayReason ? `

${s.nextDayReason}

` : ''} -
`; - } - - // ───────────────────────────────────────────── - // 체결강도 히스토리 + 미니 차트 - // ───────────────────────────────────────────── - 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 = ` - -

${name}

-

${code}

- - - - - - - - - - `; - 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 = ` - -
- ${code} -
- - -
-
- - -

${name}

-

-

-
- -
-
- 체결강도 - - -
-
- 직전 대비 - - -
-
- 거래량 - - -
- -
- -
- -
-
- - - - `; - - 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)} → ${diff >= 0 ? '+' : ''}${diff.toFixed(2)}`; - } - - // 복합 지표 갱신 - 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); - })(); -})(); diff --git a/static/js/websocket.js b/static/js/websocket.js deleted file mode 100644 index 2048013..0000000 --- a/static/js/websocket.js +++ /dev/null @@ -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(); diff --git a/templates/layout/base.html b/templates/layout/base.html deleted file mode 100644 index 21bb62b..0000000 --- a/templates/layout/base.html +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - {{ .Title }} - - - - - - - - - - - - - -
-
- 로딩 중... -
-
- - -
-
- {{ block "sidebar" . }}{{ end }} -
-
- {{ block "content" . }}{{ end }} -
-
- - - - - -{{ block "scripts" . }}{{ end }} - - - - - diff --git a/templates/pages/asset.html b/templates/pages/asset.html deleted file mode 100644 index 0b243ec..0000000 --- a/templates/pages/asset.html +++ /dev/null @@ -1,98 +0,0 @@ -{{ template "base.html" . }} - -{{ define "sidebar" }}{{ end }} - -{{ define "content" }} -
- - -

자산 현황

- - -
-
-

로딩 중...

-

-

-
-
-

로딩 중...

-

-

-
-
-

로딩 중...

-

-

-
-
-

로딩 중...

-

-

-
-
- - -
-

예수금

-
-
-

D+2 추정예수금

-

-

-
-
-

주문가능현금

-

-

-
-
-
- - -
-
-

보유 종목

-
- -
- 종목명 - 보유수량 - 평균단가 - 현재가 - 평가손익 - 수익률 -
- -
-
데이터를 불러오는 중...
-
-
- - -
- -
- - -
- - -
-
조회 중...
-
- - - -
- -
-{{ end }} - -{{ define "scripts" }} - -{{ end }} diff --git a/templates/pages/autotrade.html b/templates/pages/autotrade.html deleted file mode 100644 index e97f9c6..0000000 --- a/templates/pages/autotrade.html +++ /dev/null @@ -1,287 +0,0 @@ -{{ template "base.html" . }} - -{{ define "sidebar" }}{{ end }} - -{{ define "content" }} -
- - -
-

자동매매

-
-
- - 중지 -
- - - -
-
- - -
-
-

진행중 포지션

-

0

-
-
-

오늘 매매 횟수

-

0

-
-
-

오늘 손익

-

0원

-
-
- - -
-
-

감시 소스 설정

- 매수 신호를 가져올 종목 범위를 선택하세요 -
-
- -
-
-

체결강도 자동감지

-

거래량 상위 20종목 실시간 분석

-
- -
- - -
-

테마 감시

- -
- - -
- -
- 선택된 테마 없음 -
-
-
-
- - -
- - -
-
-

매매 규칙

- -
-
-

규칙이 없습니다.

-
-
- - -
-
-

현재 포지션

- 5초 자동 갱신 -
-
-

보유 포지션 없음

-
-
-
- - -
-
-
-

매매 로그

- ○ 연결중... -
-
- - -
- - -
-
- - - - - - - - - - - - -
시각레벨종목내용
로그가 없습니다.
-
-
-
- - - -{{ end }} - -{{ define "scripts" }} - -{{ end }} diff --git a/templates/pages/index.html b/templates/pages/index.html deleted file mode 100644 index 37a31a8..0000000 --- a/templates/pages/index.html +++ /dev/null @@ -1,349 +0,0 @@ -{{ template "base.html" . }} - -{{ define "sidebar" }} - -{{ end }} - -{{ define "content" }} -
- - -
-

- 체결강도 상승 감지 - (거래량 상위 20 | 10초 갱신) - - - -

- - - -
-

- 08:00 이후 거래량 상위 종목에서 체결강도 100 이상 + 상승 종목을 표시합니다. -

-
-
- - -
-

- 관심종목 실시간 - 연결 중... -

-
-
- 좌측 메뉴에서 관심종목을 추가하면 실시간 시세가 표시됩니다. -
-
-
- -
- -{{ end }} - -{{ define "scripts" }} - - -{{ end }} diff --git a/templates/pages/kospi200.html b/templates/pages/kospi200.html deleted file mode 100644 index 0d9b4c2..0000000 --- a/templates/pages/kospi200.html +++ /dev/null @@ -1,61 +0,0 @@ -{{ template "base.html" . }} - -{{ define "sidebar" }}{{ end }} - -{{ define "content" }} -
- - -
-

- 🔵 코스피200 - -

- - -
- - - -
- - -
- - - -
- - - - - -
- - -
- -
- # - 종목명 - 현재가 - 전일대비 - 등락률 - 거래량 - 시가 - 고가 - 저가 -
- -
-
데이터를 불러오는 중...
-
-
- -
-{{ end }} - -{{ define "scripts" }} - -{{ end }} diff --git a/templates/pages/login.html b/templates/pages/login.html deleted file mode 100644 index 8cc64c7..0000000 --- a/templates/pages/login.html +++ /dev/null @@ -1,59 +0,0 @@ -{{define "login.html"}} - - - - - - 로그인 - 주식 시세 - - - -
-
-

📈 주식 시세

-

로그인이 필요한 서비스입니다.

-
- - {{ if .Error }} -
-

{{ .Error }}

-
- {{ end }} - -
- - -
- - -
- -
- - -
- - -
-
- - -{{end}} diff --git a/templates/pages/stock_detail.html b/templates/pages/stock_detail.html deleted file mode 100644 index f24c05b..0000000 --- a/templates/pages/stock_detail.html +++ /dev/null @@ -1,407 +0,0 @@ -{{ template "base.html" . }} - -{{ define "content" }} -
- - -
- -
- {{ .Stock.Code }} -

{{ .Stock.Name }}

- 장 중 - -
- -
- -
-

- {{ formatPrice .Stock.CurrentPrice }}원 -

-

- {{ if gt .Stock.ChangePrice 0 }}+{{ end }}{{ formatPrice .Stock.ChangePrice }}원 - ({{ formatRate .Stock.ChangeRate }}) -

-

장 중

-
- - -
-
-

최우선매도

-

-원

-
-
-

최우선매수

-

-원

-
-
-
- - -
-
-

시가

-

{{ formatPrice .Stock.Open }}원

-
-
-

고가

-

{{ formatPrice .Stock.High }}원

-
-
-

저가

-

{{ formatPrice .Stock.Low }}원

-
-
-

거래량

-

{{ formatPrice .Stock.Volume }}

-
-
-

거래대금

-

-

-
-
-

체결량

-

-

-
-
- - -
-
-

기준가

-

-

-
-
-

상한가

-

-

-
-
-

하한가

-

-

-
-
-

체결시각

-

-

-
-
-
- - -
- - -
- -
- - - - -
- -
- MA20 - MA60 - MA120 -
- -
- - -
- - -
- - -
-
-

호가창

- - -
- - -
- - - 호가 - - -
- - - - - - - - - - - - - - -
매도잔량호가매수잔량
호가 데이터 수신 대기 중...
-
- - {{ if .LoggedIn }} - -
- -
- - -
- - -
- - -
- - -
- - -
- - -
- -
- - - -
-
- - -
- - - -
- - - - -
-
- - -
-
- 총 주문금액 - - -
-
- 주문가능현금 - - -
-
- 예수금 - - -
-
- 주문가능수량 - - -
-
- - - - - - -
- - -
- -
- - - -
- -
-
-

조회 중...

-
- - -
-
- {{ else }} - -
-

주문 기능은 로그인 후 이용 가능합니다.

- - 로그인 - -
- {{ end }} - -
-
- - -
- -
-

프로그램 매매

-
- 프로그램 매매 데이터 수신 대기 중... -
-
- - -
-

체결 정보

-
-
-

체결강도

-

{{ printf "%.1f" .Stock.CntrStr }}%

-
-
-

시가총액

-

-

-
-
-
-
- - -
-

최근 공시

-
공시 정보를 불러오는 중...
- - - -
- - -
-

관련 뉴스

-
뉴스를 불러오는 중...
- - - -
- -
- - - -{{ end }} - -{{ define "scripts" }} - - - - -{{ if .LoggedIn }}{{ end }} -{{ end }} diff --git a/templates/pages/theme.html b/templates/pages/theme.html deleted file mode 100644 index 9046991..0000000 --- a/templates/pages/theme.html +++ /dev/null @@ -1,86 +0,0 @@ -{{ template "base.html" . }} - -{{ define "sidebar" }}{{ end }} - -{{ define "content" }} -
- - -
-

- 📊 테마 분석 -

- - -
- - - -
- - -
- - -
- - - - - -
- - -
- - -
-
- -
- 테마명 - 주력종목 - 등락률 - 기간수익률 - 종목수 - 상승/하락 -
- -
-
데이터를 불러오는 중...
-
-
-
- - -
- -
-

👈

-

테마를 클릭하면
구성종목을 확인할 수 있습니다.

-
- - - -
- -
-
-{{ end }} - -{{ define "scripts" }} - -{{ end }} diff --git a/vendor/golang.org/x/time/LICENSE b/vendor/golang.org/x/time/LICENSE deleted file mode 100644 index 2a7cf70..0000000 --- a/vendor/golang.org/x/time/LICENSE +++ /dev/null @@ -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. diff --git a/vendor/golang.org/x/time/PATENTS b/vendor/golang.org/x/time/PATENTS deleted file mode 100644 index 7330990..0000000 --- a/vendor/golang.org/x/time/PATENTS +++ /dev/null @@ -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. diff --git a/vendor/golang.org/x/time/rate/rate.go b/vendor/golang.org/x/time/rate/rate.go deleted file mode 100644 index 563270c..0000000 --- a/vendor/golang.org/x/time/rate/rate.go +++ /dev/null @@ -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 Reservation’s 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) -} diff --git a/vendor/golang.org/x/time/rate/sometimes.go b/vendor/golang.org/x/time/rate/sometimes.go deleted file mode 100644 index 9b83932..0000000 --- a/vendor/golang.org/x/time/rate/sometimes.go +++ /dev/null @@ -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++ -} diff --git a/vendor/modules.txt b/vendor/modules.txt index 1e4799e..34e7677 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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