자산 현황 및 자동매매 페이지 제거:
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 11m20s
Some checks failed
Build Push and Restart Compose / deploy (push) Failing after 11m20s
- `/templates/pages/asset.html`, `/templates/pages/autotrade.html` HTML 템플릿 삭제. - `/static/js/asset.js`, `/static/js/autotrade.js` 클라이언트 스크립트 제거. - 관련 함수 및 초기화 로직 삭제 (자산 조회 및 자동매매 기능 비활성화).
This commit is contained in:
@@ -19,3 +19,6 @@ CORS_ORIGIN=http://localhost:5173
|
|||||||
# 관리자 계정
|
# 관리자 계정
|
||||||
ADMIN_ID=admin
|
ADMIN_ID=admin
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# PostgreSQL (미설정 시 메모리 모드)
|
||||||
|
DATABASE_URL=postgres://postgres:password@localhost:5432/stocksearch?sslmode=disable
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ WORKDIR /app
|
|||||||
# 바이너리 복사
|
# 바이너리 복사
|
||||||
COPY --from=builder /app/stocksearch .
|
COPY --from=builder /app/stocksearch .
|
||||||
|
|
||||||
# 런타임에 필요한 정적 파일 복사
|
# 런타임에 필요한 파일 복사
|
||||||
COPY --from=builder /app/templates/ templates/
|
|
||||||
COPY --from=builder /app/static/ static/
|
|
||||||
COPY --from=builder /app/CORPCODE.xml .
|
COPY --from=builder /app/CORPCODE.xml .
|
||||||
|
|
||||||
# 프론트엔드 빌드 결과물 복사
|
# 프론트엔드 빌드 결과물 복사
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Config struct {
|
|||||||
AdminID string // 관리자 ID
|
AdminID string // 관리자 ID
|
||||||
AdminPassword string // 관리자 비밀번호
|
AdminPassword string // 관리자 비밀번호
|
||||||
CORSOrigin string // CORS 허용 오리진 (예: http://localhost:5173)
|
CORSOrigin string // CORS 허용 오리진 (예: http://localhost:5173)
|
||||||
|
DatabaseURL string // PostgreSQL 연결 URL
|
||||||
}
|
}
|
||||||
|
|
||||||
var App *Config
|
var App *Config
|
||||||
@@ -54,6 +55,7 @@ func Load() {
|
|||||||
AdminID: getEnv("ADMIN_ID", "admin"),
|
AdminID: getEnv("ADMIN_ID", "admin"),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||||
CORSOrigin: getEnv("CORS_ORIGIN", ""),
|
CORSOrigin: getEnv("CORS_ORIGIN", ""),
|
||||||
|
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const autotradeApi = {
|
|||||||
getPositions: () =>
|
getPositions: () =>
|
||||||
apiFetch<AutoTradePosition[]>('/api/autotrade/positions'),
|
apiFetch<AutoTradePosition[]>('/api/autotrade/positions'),
|
||||||
|
|
||||||
|
// 거래 내역 (종료된 포지션)
|
||||||
|
getTrades: () =>
|
||||||
|
apiFetch<AutoTradePosition[]>('/api/autotrade/trades'),
|
||||||
|
|
||||||
// 이벤트 로그
|
// 이벤트 로그
|
||||||
getLogs: () =>
|
getLogs: () =>
|
||||||
apiFetch<AutoTradeLog[]>('/api/autotrade/logs'),
|
apiFetch<AutoTradeLog[]>('/api/autotrade/logs'),
|
||||||
|
|||||||
@@ -137,9 +137,9 @@ export interface AutoTradeLog {
|
|||||||
|
|
||||||
export interface AutoTradeStatus {
|
export interface AutoTradeStatus {
|
||||||
running: boolean
|
running: boolean
|
||||||
positions: number
|
activePositions: number
|
||||||
todayTrades: number
|
tradeCount: number
|
||||||
todayProfit: number
|
totalPL: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WatchlistItem {
|
export interface WatchlistItem {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
let rules = $state<AutoTradeRule[]>([])
|
let rules = $state<AutoTradeRule[]>([])
|
||||||
let positions = $state<AutoTradePosition[]>([])
|
let positions = $state<AutoTradePosition[]>([])
|
||||||
let logs = $state<AutoTradeLog[]>([])
|
let logs = $state<AutoTradeLog[]>([])
|
||||||
let activeTab = $state<'rules' | 'positions' | 'logs'>('rules')
|
let activeTab = $state<'rules' | 'positions' | 'trades' | 'logs'>('rules')
|
||||||
|
let trades = $state<AutoTradePosition[]>([])
|
||||||
let showRuleModal = $state(false)
|
let showRuleModal = $state(false)
|
||||||
let editingRule = $state<Partial<AutoTradeRule> | null>(null)
|
let editingRule = $state<Partial<AutoTradeRule> | null>(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
@@ -55,10 +56,11 @@
|
|||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
try {
|
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.getStatus(),
|
||||||
autotradeApi.getRules(),
|
autotradeApi.getRules(),
|
||||||
autotradeApi.getPositions(),
|
autotradeApi.getPositions(),
|
||||||
|
autotradeApi.getTrades(),
|
||||||
autotradeApi.getLogs(),
|
autotradeApi.getLogs(),
|
||||||
autotradeApi.getWatchSource(),
|
autotradeApi.getWatchSource(),
|
||||||
stockApi.getThemes(),
|
stockApi.getThemes(),
|
||||||
@@ -66,6 +68,7 @@
|
|||||||
status = s
|
status = s
|
||||||
rules = r
|
rules = r
|
||||||
positions = p
|
positions = p
|
||||||
|
trades = t ?? []
|
||||||
logs = l
|
logs = l
|
||||||
watchSource = ws
|
watchSource = ws
|
||||||
allThemes = themes
|
allThemes = themes
|
||||||
@@ -239,14 +242,12 @@
|
|||||||
<span class="w-2 h-2 rounded-full {status.running ? 'bg-green-400 animate-pulse' : 'bg-gray-600'}"></span>
|
<span class="w-2 h-2 rounded-full {status.running ? 'bg-green-400 animate-pulse' : 'bg-gray-600'}"></span>
|
||||||
{status.running ? '실행 중' : '중지됨'}
|
{status.running ? '실행 중' : '중지됨'}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-400">포지션 {status.positions}개</span>
|
<span class="text-sm text-gray-400">포지션 {status.activePositions}개</span>
|
||||||
<span class="text-sm text-gray-400">오늘 {status.todayTrades}건</span>
|
<span class="text-sm text-gray-400">오늘 {status.tradeCount}건</span>
|
||||||
{#if status.todayProfit !== 0}
|
<span class="text-sm {status.totalPL !== 0 ? priceClass(status.totalPL) : 'text-gray-400'}">
|
||||||
<span class="text-sm {priceClass(status.todayProfit)}">
|
오늘 손익 {status.totalPL >= 0 ? '+' : ''}{formatPrice(status.totalPL)}
|
||||||
오늘 손익 {status.todayProfit >= 0 ? '+' : ''}{formatPrice(status.todayProfit)}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -339,7 +340,7 @@
|
|||||||
|
|
||||||
<!-- 탭 -->
|
<!-- 탭 -->
|
||||||
<div class="flex gap-1 mb-4 border-b border-gray-700 pb-1">
|
<div class="flex gap-1 mb-4 border-b border-gray-700 pb-1">
|
||||||
{#each [['rules', `규칙 (${rules.length})`], ['positions', `포지션 (${positions.length})`], ['logs', '로그']] as [tab, label]}
|
{#each [['rules', `규칙 (${rules.length})`], ['positions', `포지션 (${positions.length})`], ['trades', `거래 (${trades.length})`], ['logs', '로그']] as [tab, label]}
|
||||||
<button
|
<button
|
||||||
onclick={() => { activeTab = tab as typeof activeTab }}
|
onclick={() => { activeTab = tab as typeof activeTab }}
|
||||||
class="px-4 py-2 text-sm transition-colors {activeTab === tab ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-400 hover:text-white'}"
|
class="px-4 py-2 text-sm transition-colors {activeTab === tab ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-400 hover:text-white'}"
|
||||||
@@ -457,6 +458,62 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- 거래 탭 -->
|
||||||
|
{:else if activeTab === 'trades'}
|
||||||
|
{#if trades.length === 0}
|
||||||
|
<div class="bg-gray-800 rounded-xl p-8 text-center text-gray-500">거래 내역 없음</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-400">종목</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-medium text-gray-400">구분</th>
|
||||||
|
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">수량</th>
|
||||||
|
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">매수가</th>
|
||||||
|
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">매도가</th>
|
||||||
|
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">손익</th>
|
||||||
|
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">수익률</th>
|
||||||
|
<th class="px-3 py-3 text-center text-xs font-medium text-gray-400">사유</th>
|
||||||
|
<th class="px-3 py-3 text-right text-xs font-medium text-gray-400">청산시각</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
{#each trades as trade (trade.orderNo + (trade.exitTime ?? ''))}
|
||||||
|
{@const pl = ((trade.exitPrice ?? 0) - trade.buyPrice) * trade.qty}
|
||||||
|
{@const plRate = trade.buyPrice > 0 ? ((trade.exitPrice ?? 0) - trade.buyPrice) / trade.buyPrice * 100 : 0}
|
||||||
|
<tr class="hover:bg-gray-700">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="text-sm font-medium text-white">{trade.name}</div>
|
||||||
|
<div class="text-xs text-gray-500">{trade.code}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded bg-blue-900/50 text-blue-300">매도</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm text-gray-300">{trade.qty}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono text-gray-300">{formatPrice(trade.buyPrice)}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(pl)}">{formatPrice(trade.exitPrice ?? 0)}</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm font-mono {priceClass(pl)}">
|
||||||
|
{pl >= 0 ? '+' : ''}{formatPrice(pl)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-sm {priceClass(plRate)}">
|
||||||
|
{plRate >= 0 ? '+' : ''}{plRate.toFixed(2)}%
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-center">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded {
|
||||||
|
trade.exitReason === '익절' ? 'bg-red-900/50 text-red-300' :
|
||||||
|
trade.exitReason === '손절' ? 'bg-blue-900/50 text-blue-300' :
|
||||||
|
'bg-gray-700 text-gray-400'
|
||||||
|
}">{trade.exitReason ?? '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-right text-xs text-gray-400">{formatTime(trade.exitTime ?? '')}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- 로그 탭 -->
|
<!-- 로그 탭 -->
|
||||||
{:else if activeTab === 'logs'}
|
{:else if activeTab === 'logs'}
|
||||||
<div class="bg-gray-800 rounded-xl p-4 font-mono text-xs space-y-1 max-h-[60vh] overflow-auto">
|
<div class="bg-gray-800 rounded-xl p-4 font-mono text-xs space-y-1 max-h-[60vh] overflow-auto">
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -3,8 +3,9 @@ module stocksearch
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.1 // indirect
|
github.com/gorilla/websocket v1.5.1
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/net v0.17.0 // indirect
|
github.com/lib/pq v1.12.3
|
||||||
golang.org/x/time v0.15.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/net v0.17.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
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=
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"stocksearch/config"
|
"stocksearch/config"
|
||||||
"stocksearch/middleware"
|
"stocksearch/middleware"
|
||||||
@@ -12,40 +10,11 @@ import (
|
|||||||
// AuthHandler 로그인/로그아웃 핸들러
|
// AuthHandler 로그인/로그아웃 핸들러
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
sessionSvc *services.SessionService
|
sessionSvc *services.SessionService
|
||||||
loginTmpl *template.Template
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler 인증 핸들러 초기화
|
// NewAuthHandler 인증 핸들러 초기화
|
||||||
func NewAuthHandler(sessionSvc *services.SessionService) *AuthHandler {
|
func NewAuthHandler(sessionSvc *services.SessionService) *AuthHandler {
|
||||||
tmpl, err := template.ParseFiles("templates/pages/login.html")
|
return &AuthHandler{sessionSvc: sessionSvc}
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("로그인 템플릿 파싱 실패: %v", err)
|
|
||||||
}
|
|
||||||
return &AuthHandler{
|
|
||||||
sessionSvc: sessionSvc,
|
|
||||||
loginTmpl: tmpl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginPage GET /login — 로그인 폼 렌더링
|
|
||||||
func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// 이미 로그인된 경우 메인으로 리다이렉트
|
|
||||||
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
|
||||||
if h.sessionSvc.Validate(cookie.Value) {
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// next 파라미터가 없으면 로그인 후 메인 페이지로
|
|
||||||
next := r.URL.Query().Get("next")
|
|
||||||
if next == "" {
|
|
||||||
next = "/"
|
|
||||||
}
|
|
||||||
data := map[string]string{
|
|
||||||
"Next": next,
|
|
||||||
"Error": "",
|
|
||||||
}
|
|
||||||
h.renderLogin(w, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login POST /login — ID/PW 검증 후 세션 발급
|
// Login POST /login — ID/PW 검증 후 세션 발급
|
||||||
@@ -64,12 +33,9 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ID/PW 검증
|
// ID/PW 검증
|
||||||
if id != config.App.AdminID || password != config.App.AdminPassword {
|
if id != config.App.AdminID || password != config.App.AdminPassword {
|
||||||
data := map[string]string{
|
w.Header().Set("Content-Type", "application/json")
|
||||||
"Next": next,
|
|
||||||
"Error": "아이디 또는 비밀번호가 올바르지 않습니다.",
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
h.renderLogin(w, data)
|
_, _ = w.Write([]byte(`{"error":"아이디 또는 비밀번호가 올바르지 않습니다."}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,13 +65,12 @@ func (h *AuthHandler) CheckSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout POST /logout — 세션 삭제 후 /login 리다이렉트
|
// Logout POST /logout — 세션 삭제
|
||||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil {
|
||||||
h.sessionSvc.Delete(cookie.Value)
|
h.sessionSvc.Delete(cookie.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 쿠키 만료 처리
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: middleware.SessionCookieName,
|
Name: middleware.SessionCookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
@@ -114,14 +79,5 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ func (h *AutoTradeHandler) GetPositions(w http.ResponseWriter, r *http.Request)
|
|||||||
jsonResponse(w, h.svc.GetPositions())
|
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 제외)
|
// GetLogs GET /api/autotrade/logs — 최근 로그 (?level=action 이면 debug 제외)
|
||||||
func (h *AutoTradeHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
|
func (h *AutoTradeHandler) GetLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
logs := h.svc.GetLogs()
|
logs := h.svc.GetLogs()
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"stocksearch/middleware"
|
|
||||||
"stocksearch/services"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PageHandler HTML 페이지 렌더링 핸들러
|
|
||||||
type PageHandler struct {
|
|
||||||
// 페이지별 독립 템플릿 세트 (content 블록 충돌 방지)
|
|
||||||
pageTemplates map[string]*template.Template
|
|
||||||
stockService *services.StockService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPageHandler 페이지별 템플릿을 분리 파싱하여 핸들러 초기화
|
|
||||||
func NewPageHandler() *PageHandler {
|
|
||||||
base := "templates/layout/base.html"
|
|
||||||
pages := []string{"index.html", "stock_detail.html", "theme.html", "kospi200.html", "asset.html", "autotrade.html"}
|
|
||||||
|
|
||||||
fns := templateFuncs()
|
|
||||||
tmplMap := make(map[string]*template.Template, len(pages))
|
|
||||||
for _, page := range pages {
|
|
||||||
tmplMap[page] = template.Must(
|
|
||||||
template.New("").Funcs(fns).ParseFiles(base, "templates/pages/"+page),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return &PageHandler{
|
|
||||||
pageTemplates: tmplMap,
|
|
||||||
stockService: services.GetStockService(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IndexPage GET / - 메인 페이지
|
|
||||||
func (h *PageHandler) IndexPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := withLoggedIn(r, map[string]interface{}{
|
|
||||||
"Title": "주식 시세",
|
|
||||||
"ActiveMenu": "시세",
|
|
||||||
})
|
|
||||||
h.render(w, "index.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThemePage GET /theme - 테마 분석 페이지
|
|
||||||
func (h *PageHandler) ThemePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := withLoggedIn(r, map[string]interface{}{
|
|
||||||
"Title": "테마 분석 - 주식 시세",
|
|
||||||
"ActiveMenu": "테마",
|
|
||||||
})
|
|
||||||
h.render(w, "theme.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kospi200Page GET /kospi200 - 코스피200 종목 페이지
|
|
||||||
func (h *PageHandler) Kospi200Page(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := withLoggedIn(r, map[string]interface{}{
|
|
||||||
"Title": "코스피200 - 주식 시세",
|
|
||||||
"ActiveMenu": "코스피200",
|
|
||||||
})
|
|
||||||
h.render(w, "kospi200.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetPage GET /asset - 자산 현황 페이지
|
|
||||||
func (h *PageHandler) AssetPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := withLoggedIn(r, map[string]interface{}{
|
|
||||||
"Title": "자산 현황 - 주식 시세",
|
|
||||||
"ActiveMenu": "자산",
|
|
||||||
})
|
|
||||||
h.render(w, "asset.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoTradePage GET /autotrade - 자동매매 페이지
|
|
||||||
func (h *PageHandler) AutoTradePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := withLoggedIn(r, map[string]interface{}{
|
|
||||||
"Title": "자동매매 - 주식 시세",
|
|
||||||
"ActiveMenu": "자동매매",
|
|
||||||
})
|
|
||||||
h.render(w, "autotrade.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StockDetailPage GET /stock/{code} - 종목 상세 페이지
|
|
||||||
func (h *PageHandler) StockDetailPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
code := r.PathValue("code")
|
|
||||||
|
|
||||||
price, err := h.stockService.GetCurrentPrice(code)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("현재가 조회 실패 [%s]: %v", code, err)
|
|
||||||
http.Error(w, "종목 정보를 불러올 수 없습니다.", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := withLoggedIn(r, map[string]interface{}{
|
|
||||||
"Title": price.Name + " - 주식 시세",
|
|
||||||
"Stock": price,
|
|
||||||
"ActiveMenu": "",
|
|
||||||
})
|
|
||||||
|
|
||||||
h.render(w, "stock_detail.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// withLoggedIn 템플릿 데이터에 LoggedIn 필드 추가
|
|
||||||
func withLoggedIn(r *http.Request, data map[string]interface{}) map[string]interface{} {
|
|
||||||
data["LoggedIn"] = middleware.IsLoggedIn(r)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// render 템플릿 렌더링 헬퍼
|
|
||||||
func (h *PageHandler) render(w http.ResponseWriter, name string, data interface{}) {
|
|
||||||
tmpl, ok := h.pageTemplates[name]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "페이지를 찾을 수 없습니다.", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
|
||||||
log.Printf("템플릿 렌더링 실패 [%s]: %v", name, err)
|
|
||||||
http.Error(w, "페이지를 표시할 수 없습니다.", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// templateFuncs 템플릿에서 사용할 커스텀 함수
|
|
||||||
func templateFuncs() template.FuncMap {
|
|
||||||
return template.FuncMap{
|
|
||||||
// 가격 포맷 (천 단위 콤마)
|
|
||||||
"formatPrice": func(n int64) string {
|
|
||||||
return formatNumber(n)
|
|
||||||
},
|
|
||||||
// 등락률 부호 포함 문자열
|
|
||||||
"formatRate": func(f float64) string {
|
|
||||||
if f >= 0 {
|
|
||||||
return "+" + formatFloat(f) + "%"
|
|
||||||
}
|
|
||||||
return formatFloat(f) + "%"
|
|
||||||
},
|
|
||||||
// 등락에 따른 CSS 색상 클래스
|
|
||||||
"priceClass": func(f float64) string {
|
|
||||||
if f > 0 {
|
|
||||||
return "text-red-500" // 상승: 빨강 (한국 관행)
|
|
||||||
} else if f < 0 {
|
|
||||||
return "text-blue-500" // 하락: 파랑 (한국 관행)
|
|
||||||
}
|
|
||||||
return "text-gray-500"
|
|
||||||
},
|
|
||||||
// 체결강도 CSS 클래스 (100 기준)
|
|
||||||
"cntrClass": func(f float64) string {
|
|
||||||
if f > 100 {
|
|
||||||
return "text-red-500"
|
|
||||||
} else if f < 100 && f > 0 {
|
|
||||||
return "text-blue-500"
|
|
||||||
}
|
|
||||||
return "text-gray-400"
|
|
||||||
},
|
|
||||||
// 체결강도 포맷
|
|
||||||
"formatCntr": func(f float64) string {
|
|
||||||
return formatFloat(f)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatNumber(n int64) string {
|
|
||||||
s := ""
|
|
||||||
negative := n < 0
|
|
||||||
if negative {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
for i, c := range reverse(itoa(n)) {
|
|
||||||
if i > 0 && i%3 == 0 {
|
|
||||||
s = "," + s
|
|
||||||
}
|
|
||||||
s = string(c) + s
|
|
||||||
}
|
|
||||||
if negative {
|
|
||||||
s = "-" + s
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatFloat(f float64) string {
|
|
||||||
if f < 0 {
|
|
||||||
f = -f
|
|
||||||
}
|
|
||||||
// 소수점 2자리
|
|
||||||
result := ""
|
|
||||||
intPart := int64(f)
|
|
||||||
fracPart := int64((f - float64(intPart)) * 100)
|
|
||||||
result = itoa(intPart)
|
|
||||||
if fracPart < 10 {
|
|
||||||
result += ".0" + itoa(fracPart)
|
|
||||||
} else {
|
|
||||||
result += "." + itoa(fracPart)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int64) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
s := ""
|
|
||||||
for n > 0 {
|
|
||||||
s = string(rune('0'+n%10)) + s
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func reverse(s string) string {
|
|
||||||
r := []rune(s)
|
|
||||||
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
r[i], r[j] = r[j], r[i]
|
|
||||||
}
|
|
||||||
return string(r)
|
|
||||||
}
|
|
||||||
18
main.go
18
main.go
@@ -16,6 +16,9 @@ func main() {
|
|||||||
// 환경변수 로딩
|
// 환경변수 로딩
|
||||||
config.Load()
|
config.Load()
|
||||||
|
|
||||||
|
// PostgreSQL 초기화 (DATABASE_URL 미설정 시 메모리 모드)
|
||||||
|
services.InitDB()
|
||||||
|
|
||||||
// 키움증권 토큰 발급 (서버 시작 시 즉시 실행)
|
// 키움증권 토큰 발급 (서버 시작 시 즉시 실행)
|
||||||
tokenSvc := services.GetTokenService()
|
tokenSvc := services.GetTokenService()
|
||||||
if err := tokenSvc.Start(); err != nil {
|
if err := tokenSvc.Start(); err != nil {
|
||||||
@@ -53,7 +56,6 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 핸들러 초기화
|
// 핸들러 초기화
|
||||||
pageHandler := handlers.NewPageHandler()
|
|
||||||
stockHandler := handlers.NewStockHandler(watchlistSvc)
|
stockHandler := handlers.NewStockHandler(watchlistSvc)
|
||||||
wsHandler := handlers.NewWSHandler(hub)
|
wsHandler := handlers.NewWSHandler(hub)
|
||||||
authHandler := handlers.NewAuthHandler(sessionSvc)
|
authHandler := handlers.NewAuthHandler(sessionSvc)
|
||||||
@@ -64,19 +66,10 @@ func main() {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// --- 인증 라우트 ---
|
// --- 인증 라우트 ---
|
||||||
mux.HandleFunc("GET /login", authHandler.LoginPage)
|
|
||||||
mux.HandleFunc("POST /login", authHandler.Login)
|
mux.HandleFunc("POST /login", authHandler.Login)
|
||||||
mux.HandleFunc("POST /logout", authHandler.Logout)
|
mux.HandleFunc("POST /logout", authHandler.Logout)
|
||||||
mux.HandleFunc("GET /api/auth/check", authHandler.CheckSession)
|
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 라우트 ---
|
// --- REST API 라우트 ---
|
||||||
mux.HandleFunc("GET /api/stock/{code}", stockHandler.GetCurrentPrice)
|
mux.HandleFunc("GET /api/stock/{code}", stockHandler.GetCurrentPrice)
|
||||||
mux.HandleFunc("GET /api/stock/{code}/chart", stockHandler.GetDailyChart)
|
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("DELETE /api/autotrade/rules/{id}", autoTradeHandler.DeleteRule)
|
||||||
mux.HandleFunc("POST /api/autotrade/rules/{id}/toggle", autoTradeHandler.ToggleRule)
|
mux.HandleFunc("POST /api/autotrade/rules/{id}/toggle", autoTradeHandler.ToggleRule)
|
||||||
mux.HandleFunc("GET /api/autotrade/positions", autoTradeHandler.GetPositions)
|
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/logs", autoTradeHandler.GetLogs)
|
||||||
mux.HandleFunc("GET /api/autotrade/watch-source", autoTradeHandler.GetWatchSource)
|
mux.HandleFunc("GET /api/autotrade/watch-source", autoTradeHandler.GetWatchSource)
|
||||||
mux.HandleFunc("PUT /api/autotrade/watch-source", autoTradeHandler.SetWatchSource)
|
mux.HandleFunc("PUT /api/autotrade/watch-source", autoTradeHandler.SetWatchSource)
|
||||||
@@ -125,16 +119,12 @@ func main() {
|
|||||||
// --- WebSocket 라우트 ---
|
// --- WebSocket 라우트 ---
|
||||||
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
|
mux.HandleFunc("GET /ws", wsHandler.ServeWS)
|
||||||
|
|
||||||
// --- 정적 파일 ---
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
|
||||||
|
|
||||||
// --- SvelteKit 빌드 정적 서빙 (SPA fallback 포함) ---
|
// --- SvelteKit 빌드 정적 서빙 (SPA fallback 포함) ---
|
||||||
if _, err := os.Stat("frontend/build"); err == nil {
|
if _, err := os.Stat("frontend/build"); err == nil {
|
||||||
spa := http.FileServer(http.Dir("frontend/build"))
|
spa := http.FileServer(http.Dir("frontend/build"))
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := "frontend/build" + r.URL.Path
|
path := "frontend/build" + r.URL.Path
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
// SPA fallback: 파일 없으면 index.html 서빙
|
|
||||||
http.ServeFile(w, r, "frontend/build/index.html")
|
http.ServeFile(w, r, "frontend/build/index.html")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ func IsLoggedIn(r *http.Request) bool {
|
|||||||
// publicPaths 로그인 없이 접근 가능한 경로 (전체 일치 또는 prefix)
|
// publicPaths 로그인 없이 접근 가능한 경로 (전체 일치 또는 prefix)
|
||||||
var publicPaths = []string{
|
var publicPaths = []string{
|
||||||
"/login",
|
"/login",
|
||||||
"/static/",
|
|
||||||
// 공개 페이지
|
// 공개 페이지
|
||||||
"/",
|
"/",
|
||||||
"/theme",
|
"/theme",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type AutoTradeRule struct {
|
|||||||
|
|
||||||
// AutoTradePosition 자동매매 포지션
|
// AutoTradePosition 자동매매 포지션
|
||||||
type AutoTradePosition struct {
|
type AutoTradePosition struct {
|
||||||
|
DBID int `json:"-"` // DB 시퀀스 ID (API 노출 안함)
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BuyPrice int64 `json:"buyPrice"` // 매수 체결가
|
BuyPrice int64 `json:"buyPrice"` // 매수 체결가
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ func GetAutoTradeService() *AutoTradeService {
|
|||||||
SelectedThemes: []models.ThemeRef{},
|
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
|
return autoTradeService
|
||||||
}
|
}
|
||||||
@@ -93,6 +110,7 @@ func (s *AutoTradeService) SetWatchSource(ws models.AutoTradeWatchSource) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.watchSource = ws
|
s.watchSource = ws
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
dbUpsertWatchSource(ws)
|
||||||
|
|
||||||
sources := "없음"
|
sources := "없음"
|
||||||
if ws.UseScanner && len(ws.SelectedThemes) > 0 {
|
if ws.UseScanner && len(ws.SelectedThemes) > 0 {
|
||||||
@@ -245,6 +263,7 @@ func (s *AutoTradeService) AddRule(rule models.AutoTradeRule) models.AutoTradeRu
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.rules = append(s.rules, rule)
|
s.rules = append(s.rules, rule)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
dbInsertRule(rule)
|
||||||
s.addLog("info", "", fmt.Sprintf("규칙 추가: %s", rule.Name))
|
s.addLog("info", "", fmt.Sprintf("규칙 추가: %s", rule.Name))
|
||||||
return rule
|
return rule
|
||||||
}
|
}
|
||||||
@@ -258,6 +277,7 @@ func (s *AutoTradeService) UpdateRule(id string, updated models.AutoTradeRule) b
|
|||||||
updated.ID = id
|
updated.ID = id
|
||||||
updated.CreatedAt = r.CreatedAt
|
updated.CreatedAt = r.CreatedAt
|
||||||
s.rules[i] = updated
|
s.rules[i] = updated
|
||||||
|
dbUpdateRule(updated)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,6 +291,7 @@ func (s *AutoTradeService) DeleteRule(id string) bool {
|
|||||||
for i, r := range s.rules {
|
for i, r := range s.rules {
|
||||||
if r.ID == id {
|
if r.ID == id {
|
||||||
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
s.rules = append(s.rules[:i], s.rules[i+1:]...)
|
||||||
|
dbDeleteRule(id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +305,7 @@ func (s *AutoTradeService) ToggleRule(id string) (bool, bool) {
|
|||||||
for i, r := range s.rules {
|
for i, r := range s.rules {
|
||||||
if r.ID == id {
|
if r.ID == id {
|
||||||
s.rules[i].Enabled = !r.Enabled
|
s.rules[i].Enabled = !r.Enabled
|
||||||
|
dbUpdateRule(s.rules[i])
|
||||||
return true, s.rules[i].Enabled
|
return true, s.rules[i].Enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,6 +333,24 @@ func (s *AutoTradeService) GetPositions() []*models.AutoTradePosition {
|
|||||||
return result
|
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 최근 로그 반환
|
// GetLogs 최근 로그 반환
|
||||||
func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
|
func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
@@ -322,6 +362,11 @@ func (s *AutoTradeService) GetLogs() []models.AutoTradeLog {
|
|||||||
|
|
||||||
// GetStats 오늘 통계 반환 (매매 횟수, 손익)
|
// GetStats 오늘 통계 반환 (매매 횟수, 손익)
|
||||||
func (s *AutoTradeService) GetStats() (tradeCount int, totalPL int64) {
|
func (s *AutoTradeService) GetStats() (tradeCount int, totalPL int64) {
|
||||||
|
// DB가 있으면 DB에서 조회 (종료된 포지션 포함)
|
||||||
|
if db != nil {
|
||||||
|
return dbGetTodayStats()
|
||||||
|
}
|
||||||
|
// DB 없으면 메모리에서 계산
|
||||||
today := time.Now().Truncate(24 * time.Hour)
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -532,6 +577,7 @@ func (s *AutoTradeService) checkEntries() {
|
|||||||
s.cooldown[code] = time.Now()
|
s.cooldown[code] = time.Now()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
dbInsertPosition(pos)
|
||||||
s.addLog("info", code, fmt.Sprintf("매수 주문 접수: %s %d주 (주문번호: %s, RiseScore: %d)", sig.Name, qty, result.OrderNo, sig.RiseScore))
|
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.StopLoss = stopLoss
|
||||||
p.TakeProfit = takeProfit
|
p.TakeProfit = takeProfit
|
||||||
p.Status = "open"
|
p.Status = "open"
|
||||||
|
dbUpdatePosition(p)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -709,6 +756,7 @@ func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason str
|
|||||||
p.ExitTime = time.Now()
|
p.ExitTime = time.Now()
|
||||||
p.ExitPrice = exitPrice
|
p.ExitPrice = exitPrice
|
||||||
p.ExitReason = reason
|
p.ExitReason = reason
|
||||||
|
dbUpdatePosition(p)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -771,6 +819,11 @@ func (s *AutoTradeService) addLog(level, code, message string) {
|
|||||||
broadcaster := s.logBroadcaster
|
broadcaster := s.logBroadcaster
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// debug 로그는 DB에 저장하지 않음 (빈도 높음)
|
||||||
|
if level != "debug" {
|
||||||
|
dbInsertLog(entry)
|
||||||
|
}
|
||||||
|
|
||||||
// WS 브로드캐스트 (락 밖에서 호출해 데드락 방지)
|
// WS 브로드캐스트 (락 밖에서 호출해 데드락 방지)
|
||||||
if broadcaster != nil {
|
if broadcaster != nil {
|
||||||
broadcaster(entry)
|
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" {
|
if p, ok := s.positions[code]; ok && p.Status == "open" {
|
||||||
p.StopLoss1Touches++
|
p.StopLoss1Touches++
|
||||||
touches = p.StopLoss1Touches
|
touches = p.StopLoss1Touches
|
||||||
|
dbUpdatePosition(p)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -14,8 +13,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// cntrStrCacheEntry getCntrStr 캐시 항목
|
// cntrStrCacheEntry getCntrStr 캐시 항목
|
||||||
@@ -24,14 +21,30 @@ type cntrStrCacheEntry struct {
|
|||||||
expiresAt time.Time
|
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 클라이언트
|
// KiwoomClient 키움증권 REST API HTTP 클라이언트
|
||||||
|
// 모든 API 호출은 단일 워커 큐를 통해 순차 처리 (429 방지)
|
||||||
|
// 1초 윈도우 내 처리 시간 합산 → 남은 시간 대기 후 다음 배치
|
||||||
type KiwoomClient struct {
|
type KiwoomClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
tokenService *TokenService
|
tokenService *TokenService
|
||||||
limiter *rate.Limiter
|
queue chan apiJob // API 요청 큐
|
||||||
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
|
cntrStrCache sync.Map // stockCode → cntrStrCacheEntry (5초 TTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiQueueSize = 256 // 큐 버퍼 크기
|
||||||
|
|
||||||
var kiwoomClient *KiwoomClient
|
var kiwoomClient *KiwoomClient
|
||||||
|
|
||||||
// GetKiwoomClient 키움 클라이언트 싱글턴 반환
|
// GetKiwoomClient 키움 클라이언트 싱글턴 반환
|
||||||
@@ -40,14 +53,39 @@ func GetKiwoomClient() *KiwoomClient {
|
|||||||
kiwoomClient = &KiwoomClient{
|
kiwoomClient = &KiwoomClient{
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
tokenService: GetTokenService(),
|
tokenService: GetTokenService(),
|
||||||
// 초당 1건, 버스트 1 → 완전 직렬화 (키움 API 실질 한도 ~1req/s per API ID)
|
queue: make(chan apiJob, apiQueueSize),
|
||||||
limiter: rate.NewLimiter(rate.Limit(1), 1),
|
|
||||||
}
|
}
|
||||||
|
go kiwoomClient.worker()
|
||||||
}
|
}
|
||||||
return kiwoomClient
|
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) {
|
func (k *KiwoomClient) post(apiID string, path string, body map[string]string) ([]byte, error) {
|
||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
backoff := 1 * time.Second
|
backoff := 1 * time.Second
|
||||||
@@ -55,10 +93,6 @@ func (k *KiwoomClient) post(apiID string, path string, body map[string]string) (
|
|||||||
data, _ := json.Marshal(body)
|
data, _ := json.Marshal(body)
|
||||||
|
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
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))
|
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("요청 생성 실패: %w", err)
|
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("cont-yn", "N")
|
||||||
req.Header.Set("next-key", "")
|
req.Header.Set("next-key", "")
|
||||||
|
|
||||||
resp, err := k.httpClient.Do(req)
|
resp, err := k.enqueue(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("API 요청 실패: %w", err)
|
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) 함께 반환
|
// postPaged 연속조회 지원 POST 요청 - 응답 헤더(cont-yn, next-key) 함께 반환
|
||||||
func (k *KiwoomClient) postPaged(apiID, path string, body map[string]string, contYn, nextKey string) ([]byte, string, string, error) {
|
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)
|
data, _ := json.Marshal(body)
|
||||||
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
req, err := http.NewRequest("POST", config.App.BaseURL+path, bytes.NewReader(data))
|
||||||
if err != nil {
|
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("cont-yn", contYn)
|
||||||
req.Header.Set("next-key", nextKey)
|
req.Header.Set("next-key", nextKey)
|
||||||
|
|
||||||
resp, err := k.httpClient.Do(req)
|
resp, err := k.enqueue(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", fmt.Errorf("API 요청 실패: %w", err)
|
return nil, "", "", fmt.Errorf("API 요청 실패: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", fmt.Errorf("응답 읽기 실패: %w", err)
|
return nil, "", "", fmt.Errorf("응답 읽기 실패: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"stocksearch/config"
|
||||||
"stocksearch/models"
|
"stocksearch/models"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,10 +15,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
kiwoomWSURL = "wss://api.kiwoom.com:10000/api/dostk/websocket"
|
|
||||||
writeTimeout = 10 * time.Second // 쓰기 타임아웃
|
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 클라이언트
|
// KiwoomWSClient 키움증권 실시간 WebSocket 클라이언트
|
||||||
type KiwoomWSClient struct {
|
type KiwoomWSClient struct {
|
||||||
tokenService *TokenService
|
tokenService *TokenService
|
||||||
@@ -90,7 +99,7 @@ func (k *KiwoomWSClient) Connect() error {
|
|||||||
// dial WSS 연결 수립 후 로그인 패킷 전송
|
// dial WSS 연결 수립 후 로그인 패킷 전송
|
||||||
func (k *KiwoomWSClient) dial() (*websocket.Conn, error) {
|
func (k *KiwoomWSClient) dial() (*websocket.Conn, error) {
|
||||||
// HTTP 헤더 없이 연결 (키움 WS는 헤더 인증 불필요)
|
// HTTP 헤더 없이 연결 (키움 WS는 헤더 인증 불필요)
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(kiwoomWSURL, nil)
|
conn, _, err := websocket.DefaultDialer.Dial(kiwoomWSURL(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,25 +406,17 @@ func (s *ScannerService) scan() {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// ── 호가잔량 병렬 조회 (체결강도 상승 종목에 한해) ────────────────
|
// ── 호가잔량 순차 조회 (체결강도 상승 종목에 한해) ────────────────
|
||||||
if len(signals) > 0 {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := range signals {
|
for i := range signals {
|
||||||
wg.Add(1)
|
ask, bid, _, err := s.kiwoom.getOrderBook(signals[i].Code)
|
||||||
go func(idx int) {
|
|
||||||
defer wg.Done()
|
|
||||||
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
signals[idx].TotalAskVol = ask
|
signals[i].TotalAskVol = ask
|
||||||
signals[idx].TotalBidVol = bid
|
signals[i].TotalBidVol = bid
|
||||||
if bid > 0 {
|
if bid > 0 {
|
||||||
signals[idx].AskBidRatio = float64(ask) / float64(bid)
|
signals[i].AskBidRatio = float64(ask) / float64(bid)
|
||||||
}
|
}
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 최종 스코어 및 신호 유형 계산 (호가잔량 포함) ────────────────
|
// ── 최종 스코어 및 신호 유형 계산 (호가잔량 포함) ────────────────
|
||||||
@@ -642,25 +634,17 @@ func (s *ScannerService) AnalyzeWatchlist(codes []string) []SignalStock {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: 호가잔량 병렬 조회
|
// Phase 3: 호가잔량 순차 조회
|
||||||
if len(signals) > 0 {
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := range signals {
|
for i := range signals {
|
||||||
wg.Add(1)
|
ask, bid, _, err := s.kiwoom.getOrderBook(signals[i].Code)
|
||||||
go func(idx int) {
|
|
||||||
defer wg.Done()
|
|
||||||
ask, bid, _, err := s.kiwoom.getOrderBook(signals[idx].Code)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
signals[idx].TotalAskVol = ask
|
signals[i].TotalAskVol = ask
|
||||||
signals[idx].TotalBidVol = bid
|
signals[i].TotalBidVol = bid
|
||||||
if bid > 0 {
|
if bid > 0 {
|
||||||
signals[idx].AskBidRatio = float64(ask) / float64(bid)
|
signals[i].AskBidRatio = float64(ask) / float64(bid)
|
||||||
}
|
}
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 4: 스코어 및 신호 유형 계산
|
// Phase 4: 스코어 및 신호 유형 계산
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
/**
|
|
||||||
* 자산 현황 페이지 로직
|
|
||||||
* - 요약 카드, 예수금, 보유 종목, 미체결/체결내역
|
|
||||||
*/
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 초기화
|
|
||||||
// -----------------------------------
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadSummary();
|
|
||||||
loadCash();
|
|
||||||
loadPending();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 요약 카드 + 보유 종목 (GET /api/account/balance)
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadSummary() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/balance');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
renderSummaryError(data.error || '잔고 조회 실패');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSummaryCards(data);
|
|
||||||
renderHoldings(data.stocks);
|
|
||||||
} catch (e) {
|
|
||||||
renderSummaryError('네트워크 오류: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummaryCards(data) {
|
|
||||||
const plRate = parseFloat(data.totPrftRt || '0');
|
|
||||||
const plAmt = parseInt(data.totEvltPl || '0');
|
|
||||||
const plClass = plRate >= 0 ? 'text-red-500' : 'text-blue-500';
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
label: '추정예탁자산',
|
|
||||||
value: parseInt(data.prsmDpstAsetAmt || '0').toLocaleString('ko-KR') + '원',
|
|
||||||
cls: 'text-gray-800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '총평가금액',
|
|
||||||
value: parseInt(data.totEvltAmt || '0').toLocaleString('ko-KR') + '원',
|
|
||||||
cls: 'text-gray-800',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '총평가손익',
|
|
||||||
value: (plAmt >= 0 ? '+' : '') + plAmt.toLocaleString('ko-KR') + '원',
|
|
||||||
cls: plClass,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '수익률',
|
|
||||||
value: (plRate >= 0 ? '+' : '') + plRate.toFixed(2) + '%',
|
|
||||||
cls: plClass,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
document.getElementById('summaryCards').innerHTML = cards.map(c => `
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">${c.label}</p>
|
|
||||||
<p class="text-lg font-bold ${c.cls}">${c.value}</p>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummaryError(msg) {
|
|
||||||
document.getElementById('summaryCards').innerHTML = `
|
|
||||||
<div class="col-span-4 text-sm text-red-400 text-center py-4">${msg}</div>
|
|
||||||
`;
|
|
||||||
document.getElementById('holdingsTable').innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHoldings(stocks) {
|
|
||||||
const tbody = document.getElementById('holdingsTable');
|
|
||||||
|
|
||||||
if (!stocks || stocks.length === 0) {
|
|
||||||
tbody.innerHTML = '<div class="px-5 py-10 text-center text-sm text-gray-400">보유 종목이 없습니다.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = stocks.map(s => {
|
|
||||||
const prft = parseFloat(s.prftRt || '0');
|
|
||||||
const evlt = parseInt(s.evltvPrft || '0');
|
|
||||||
const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500';
|
|
||||||
const sign = prft >= 0 ? '+' : '';
|
|
||||||
return `
|
|
||||||
<div class="grid grid-cols-[1fr_80px_90px_90px_100px_80px] text-sm px-5 py-3 border-b border-gray-50 hover:bg-gray-50 gap-2 items-center">
|
|
||||||
<a href="/stock/${s.stkCd}" class="font-medium text-gray-800 hover:text-blue-600 truncate">${s.stkNm}</a>
|
|
||||||
<span class="text-right text-gray-600">${parseInt(s.rmndQty || '0').toLocaleString('ko-KR')}주</span>
|
|
||||||
<span class="text-right text-gray-600">${parseInt(s.purPric || '0').toLocaleString('ko-KR')}</span>
|
|
||||||
<span class="text-right text-gray-600">${parseInt(s.curPrc || '0').toLocaleString('ko-KR')}</span>
|
|
||||||
<span class="text-right ${cls}">${(evlt >= 0 ? '+' : '') + evlt.toLocaleString('ko-KR')}원</span>
|
|
||||||
<span class="text-right ${cls}">${sign}${prft.toFixed(2)}%</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 예수금 카드 (GET /api/account/deposit — kt00001)
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadCash() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/deposit');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
document.getElementById('cashEntr').textContent = '조회 실패';
|
|
||||||
document.getElementById('cashOrdAlowa').textContent = '조회 실패';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// entr: 예수금, d2Entra: D+2 추정예수금, ordAlowAmt: 주문가능금액
|
|
||||||
document.getElementById('cashEntr').textContent =
|
|
||||||
data.d2Entra ? parseInt(data.d2Entra).toLocaleString('ko-KR') + '원' : '-';
|
|
||||||
document.getElementById('cashOrdAlowa').textContent =
|
|
||||||
data.ordAlowAmt ? parseInt(data.ordAlowAmt).toLocaleString('ko-KR') + '원' : '-';
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('cashEntr').textContent = '오류';
|
|
||||||
document.getElementById('cashOrdAlowa').textContent = '오류';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 탭 전환
|
|
||||||
// -----------------------------------
|
|
||||||
function showAssetTab(tab) {
|
|
||||||
['pending', 'history'].forEach(t => {
|
|
||||||
const btn = document.getElementById('asset' + capitalize(t) + 'Tab');
|
|
||||||
const panel = document.getElementById('asset' + capitalize(t) + 'Panel');
|
|
||||||
const active = t === tab;
|
|
||||||
if (btn) {
|
|
||||||
btn.classList.toggle('border-b-2', active);
|
|
||||||
btn.classList.toggle('border-blue-500', active);
|
|
||||||
btn.classList.toggle('text-blue-600', active);
|
|
||||||
btn.classList.toggle('text-gray-500', !active);
|
|
||||||
}
|
|
||||||
if (panel) panel.classList.toggle('hidden', !active);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tab === 'pending') loadPending();
|
|
||||||
if (tab === 'history') loadHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(s) {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 미체결 목록 (GET /api/account/pending)
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadPending() {
|
|
||||||
const panel = document.getElementById('assetPendingPanel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/pending');
|
|
||||||
const list = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">${list.error || '조회 실패'}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6">미체결 주문이 없습니다.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-xs text-gray-500 bg-gray-50 border-b border-gray-100">
|
|
||||||
<th class="px-3 py-2 text-left font-medium">종목명</th>
|
|
||||||
<th class="px-3 py-2 text-center font-medium">구분</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium">주문가</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium">미체결/주문</th>
|
|
||||||
<th class="px-3 py-2 text-center font-medium">취소</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${list.map(o => {
|
|
||||||
const isBuy = o.trdeTp === '2';
|
|
||||||
const cls = isBuy ? 'text-red-500' : 'text-blue-500';
|
|
||||||
const label = isBuy ? '매수' : '매도';
|
|
||||||
return `
|
|
||||||
<tr class="border-b border-gray-50 hover:bg-gray-50">
|
|
||||||
<td class="px-3 py-2.5 font-medium text-gray-800">${o.stkNm}</td>
|
|
||||||
<td class="px-3 py-2.5 text-center ${cls}">${label}</td>
|
|
||||||
<td class="px-3 py-2.5 text-right">${parseInt(o.ordPric || '0').toLocaleString('ko-KR')}원</td>
|
|
||||||
<td class="px-3 py-2.5 text-right">${parseInt(o.osoQty || '0').toLocaleString('ko-KR')} / ${parseInt(o.ordQty || '0').toLocaleString('ko-KR')}주</td>
|
|
||||||
<td class="px-3 py-2.5 text-center">
|
|
||||||
<button onclick="assetCancelOrder('${o.ordNo}','${o.stkCd}')"
|
|
||||||
class="px-2.5 py-1 text-xs rounded border border-gray-300 text-gray-600 hover:bg-gray-100">취소</button>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
} catch (e) {
|
|
||||||
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">오류: ${e.message}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 미체결 취소
|
|
||||||
// -----------------------------------
|
|
||||||
async function assetCancelOrder(ordNo, stkCd) {
|
|
||||||
if (!confirm('전량 취소하시겠습니까?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/order', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ origOrdNo: ordNo, code: stkCd, qty: '0', exchange: 'KRX' }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || data.error) {
|
|
||||||
showAssetToast(data.error || '취소 실패', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showAssetToast('취소 주문 접수 완료', 'success');
|
|
||||||
loadPending();
|
|
||||||
} catch (e) {
|
|
||||||
showAssetToast('네트워크 오류', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 체결내역 (GET /api/account/history)
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadHistory() {
|
|
||||||
const panel = document.getElementById('assetHistoryPanel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/history');
|
|
||||||
const list = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">${list.error || '조회 실패'}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
panel.innerHTML = '<div class="text-sm text-gray-400 text-center py-6">체결 내역이 없습니다.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.innerHTML = `
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-xs text-gray-500 bg-gray-50 border-b border-gray-100">
|
|
||||||
<th class="px-3 py-2 text-left font-medium">종목명</th>
|
|
||||||
<th class="px-3 py-2 text-center font-medium">구분</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium">체결가</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium">체결수량</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium">수수료+세금</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${list.map(o => {
|
|
||||||
const isBuy = o.trdeTp === '2';
|
|
||||||
const cls = isBuy ? 'text-red-500' : 'text-blue-500';
|
|
||||||
const label = isBuy ? '매수' : '매도';
|
|
||||||
const fee = (parseInt(o.trdeCmsn || '0') + parseInt(o.trdeTax || '0')).toLocaleString('ko-KR');
|
|
||||||
return `
|
|
||||||
<tr class="border-b border-gray-50 hover:bg-gray-50">
|
|
||||||
<td class="px-3 py-2.5 font-medium text-gray-800">${o.stkNm}</td>
|
|
||||||
<td class="px-3 py-2.5 text-center ${cls}">${label}</td>
|
|
||||||
<td class="px-3 py-2.5 text-right">${parseInt(o.cntrPric || '0').toLocaleString('ko-KR')}원</td>
|
|
||||||
<td class="px-3 py-2.5 text-right">${parseInt(o.cntrQty || '0').toLocaleString('ko-KR')}주</td>
|
|
||||||
<td class="px-3 py-2.5 text-right text-gray-500">${fee}원</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
} catch (e) {
|
|
||||||
panel.innerHTML = `<div class="text-sm text-red-400 text-center py-6">오류: ${e.message}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 토스트 알림
|
|
||||||
// -----------------------------------
|
|
||||||
function showAssetToast(msg, type) {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-lg text-sm font-medium shadow-lg transition-opacity
|
|
||||||
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`;
|
|
||||||
toast.textContent = msg;
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.opacity = '0';
|
|
||||||
setTimeout(() => toast.remove(), 400);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
// 자동매매 페이지 클라이언트 스크립트
|
|
||||||
|
|
||||||
let logLevelFilter = 'all'; // 'all' | 'action'
|
|
||||||
let watchSource = { useScanner: true, selectedThemes: [] };
|
|
||||||
let localLogs = []; // 클라이언트 로그 버퍼 (오래된 것 → 최신 순)
|
|
||||||
let tradeLogWS = null;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadStatus();
|
|
||||||
loadPositions();
|
|
||||||
loadLogs(); // 기존 로그 초기 로드 (HTTP)
|
|
||||||
loadRules();
|
|
||||||
loadThemeList();
|
|
||||||
loadWatchSource();
|
|
||||||
connectTradeLogWS(); // WS 실시간 연결
|
|
||||||
|
|
||||||
// 자동스크롤 체크박스: ON 전환 시 즉시 최신으로 이동
|
|
||||||
const chk = document.getElementById('autoScrollChk');
|
|
||||||
if (chk) {
|
|
||||||
chk.addEventListener('change', () => {
|
|
||||||
if (chk.checked) scrollLogsToBottom();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상태: 3초 주기, 포지션: 5초 주기 (로그는 WS로 대체)
|
|
||||||
setInterval(loadStatus, 3000);
|
|
||||||
setInterval(loadPositions, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 엔진 상태 ---
|
|
||||||
|
|
||||||
async function loadStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/autotrade/status');
|
|
||||||
const data = await res.json();
|
|
||||||
updateStatusUI(data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('상태 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatusUI(data) {
|
|
||||||
const dot = document.getElementById('statusDot');
|
|
||||||
const text = document.getElementById('statusText');
|
|
||||||
|
|
||||||
if (data.running) {
|
|
||||||
dot.className = 'w-3 h-3 rounded-full bg-green-500';
|
|
||||||
text.textContent = '● 실행중';
|
|
||||||
text.className = 'text-sm font-medium text-green-600';
|
|
||||||
} else {
|
|
||||||
dot.className = 'w-3 h-3 rounded-full bg-gray-300';
|
|
||||||
text.textContent = '○ 중지';
|
|
||||||
text.className = 'text-sm font-medium text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('statActivePos').textContent = data.activePositions ?? 0;
|
|
||||||
document.getElementById('statTradeCount').textContent = data.tradeCount ?? 0;
|
|
||||||
|
|
||||||
const pl = data.totalPL ?? 0;
|
|
||||||
const plEl = document.getElementById('statTotalPL');
|
|
||||||
plEl.textContent = formatMoney(pl) + '원';
|
|
||||||
plEl.className = 'text-2xl font-bold ' + (pl > 0 ? 'text-red-500' : pl < 0 ? 'text-blue-500' : 'text-gray-800');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 엔진 제어 ---
|
|
||||||
|
|
||||||
async function startEngine() {
|
|
||||||
if (!confirm('자동매매 엔진을 시작하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
await fetch('/api/autotrade/start', { method: 'POST' });
|
|
||||||
await loadStatus();
|
|
||||||
} catch (e) {
|
|
||||||
alert('엔진 시작 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopEngine() {
|
|
||||||
if (!confirm('자동매매 엔진을 중지하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
await fetch('/api/autotrade/stop', { method: 'POST' });
|
|
||||||
await loadStatus();
|
|
||||||
} catch (e) {
|
|
||||||
alert('엔진 중지 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emergencyStop() {
|
|
||||||
if (!confirm('⚠ 긴급청산: 모든 포지션을 즉시 시장가 매도합니다.\n계속하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
await fetch('/api/autotrade/emergency', { method: 'POST' });
|
|
||||||
await loadStatus();
|
|
||||||
await loadPositions();
|
|
||||||
} catch (e) {
|
|
||||||
alert('긴급청산 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 규칙 ---
|
|
||||||
|
|
||||||
async function loadRules() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/autotrade/rules');
|
|
||||||
const rules = await res.json();
|
|
||||||
renderRules(rules);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('규칙 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRules(rules) {
|
|
||||||
const el = document.getElementById('rulesList');
|
|
||||||
if (!rules || rules.length === 0) {
|
|
||||||
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">규칙이 없습니다.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = rules.map(r => `
|
|
||||||
<div class="border border-gray-100 rounded-lg p-3 space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-semibold text-gray-800">${escHtml(r.name)}</span>
|
|
||||||
<label class="inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleRule('${r.id}')"
|
|
||||||
class="sr-only peer">
|
|
||||||
<div class="w-9 h-5 bg-gray-200 peer-checked:bg-blue-600 rounded-full peer
|
|
||||||
peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors relative
|
|
||||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
|
||||||
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
|
|
||||||
peer-checked:after:translate-x-4"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 space-y-0.5">
|
|
||||||
<p>진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}</p>
|
|
||||||
<p>청산: ${r.stopLoss1Count > 0 ? `1차손절${r.stopLoss1Pct}%[${r.stopLoss1Count}회] / 2차손절${r.stopLossPct}%` : `손절${r.stopLossPct}%`} / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}</p>
|
|
||||||
<p>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="showEditRuleModal(${JSON.stringify(r).replace(/"/g, '"')})"
|
|
||||||
class="px-2 py-1 text-xs text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition-colors">수정</button>
|
|
||||||
<button onclick="deleteRule('${r.id}')"
|
|
||||||
class="px-2 py-1 text-xs text-red-500 border border-red-200 rounded hover:bg-red-50 transition-colors">삭제</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRule(id) {
|
|
||||||
if (!confirm('규칙을 삭제하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
await fetch('/api/autotrade/rules/' + id, { method: 'DELETE' });
|
|
||||||
await loadRules();
|
|
||||||
} catch (e) {
|
|
||||||
alert('삭제 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleRule(id) {
|
|
||||||
try {
|
|
||||||
await fetch('/api/autotrade/rules/' + id + '/toggle', { method: 'POST' });
|
|
||||||
await loadRules();
|
|
||||||
} catch (e) {
|
|
||||||
alert('토글 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 모달 ---
|
|
||||||
|
|
||||||
function showAddRuleModal() {
|
|
||||||
document.getElementById('modalTitle').textContent = '규칙 추가';
|
|
||||||
document.getElementById('ruleId').value = '';
|
|
||||||
document.getElementById('fName').value = '';
|
|
||||||
document.getElementById('fRiseScore').value = 60;
|
|
||||||
document.getElementById('riseScoreVal').textContent = '60';
|
|
||||||
document.getElementById('fCntrStr').value = 110;
|
|
||||||
document.getElementById('fRequireBullish').checked = false;
|
|
||||||
document.getElementById('fOrderAmount').value = 500000;
|
|
||||||
document.getElementById('fMaxPositions').value = 3;
|
|
||||||
document.getElementById('fStopLoss1').value = -2;
|
|
||||||
document.getElementById('fStopLoss1Count').value = 3;
|
|
||||||
document.getElementById('fStopLoss').value = -4;
|
|
||||||
document.getElementById('fTakeProfit').value = 5;
|
|
||||||
document.getElementById('fMaxHold').value = 60;
|
|
||||||
document.getElementById('fExitBeforeClose').checked = true;
|
|
||||||
document.getElementById('ruleModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEditRuleModal(r) {
|
|
||||||
document.getElementById('modalTitle').textContent = '규칙 수정';
|
|
||||||
document.getElementById('ruleId').value = r.id;
|
|
||||||
document.getElementById('fName').value = r.name;
|
|
||||||
document.getElementById('fRiseScore').value = r.minRiseScore;
|
|
||||||
document.getElementById('riseScoreVal').textContent = r.minRiseScore;
|
|
||||||
document.getElementById('fCntrStr').value = r.minCntrStr;
|
|
||||||
document.getElementById('fRequireBullish').checked = r.requireBullish;
|
|
||||||
document.getElementById('fOrderAmount').value = r.orderAmount;
|
|
||||||
document.getElementById('fMaxPositions').value = r.maxPositions;
|
|
||||||
document.getElementById('fStopLoss1').value = r.stopLoss1Pct ?? -2;
|
|
||||||
document.getElementById('fStopLoss1Count').value = r.stopLoss1Count ?? 3;
|
|
||||||
document.getElementById('fStopLoss').value = r.stopLossPct;
|
|
||||||
document.getElementById('fTakeProfit').value = r.takeProfitPct;
|
|
||||||
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
|
|
||||||
document.getElementById('fExitBeforeClose').checked = r.exitBeforeClose;
|
|
||||||
document.getElementById('ruleModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideModal() {
|
|
||||||
document.getElementById('ruleModal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitRule() {
|
|
||||||
const id = document.getElementById('ruleId').value;
|
|
||||||
const rule = {
|
|
||||||
name: document.getElementById('fName').value.trim(),
|
|
||||||
enabled: true,
|
|
||||||
minRiseScore: parseInt(document.getElementById('fRiseScore').value),
|
|
||||||
minCntrStr: parseFloat(document.getElementById('fCntrStr').value),
|
|
||||||
requireBullish: document.getElementById('fRequireBullish').checked,
|
|
||||||
orderAmount: parseInt(document.getElementById('fOrderAmount').value),
|
|
||||||
maxPositions: parseInt(document.getElementById('fMaxPositions').value),
|
|
||||||
stopLoss1Pct: parseFloat(document.getElementById('fStopLoss1').value),
|
|
||||||
stopLoss1Count: parseInt(document.getElementById('fStopLoss1Count').value) || 0,
|
|
||||||
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
|
|
||||||
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
|
|
||||||
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
|
|
||||||
exitBeforeClose: document.getElementById('fExitBeforeClose').checked,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!rule.name) { alert('규칙명을 입력해주세요.'); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (id) {
|
|
||||||
await fetch('/api/autotrade/rules/' + id, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(rule),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fetch('/api/autotrade/rules', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(rule),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
hideModal();
|
|
||||||
await loadRules();
|
|
||||||
} catch (e) {
|
|
||||||
alert('저장 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 포지션 ---
|
|
||||||
|
|
||||||
async function loadPositions() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/autotrade/positions');
|
|
||||||
const positions = await res.json();
|
|
||||||
renderPositions(positions);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('포지션 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPositions(positions) {
|
|
||||||
const el = document.getElementById('positionsList');
|
|
||||||
const active = (positions || []).filter(p => p.status === 'open' || p.status === 'pending');
|
|
||||||
|
|
||||||
if (active.length === 0) {
|
|
||||||
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">보유 포지션 없음</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<table class="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-left text-gray-400 border-b border-gray-100">
|
|
||||||
<th class="pb-2 font-medium">종목</th>
|
|
||||||
<th class="pb-2 font-medium text-right">매수가</th>
|
|
||||||
<th class="pb-2 font-medium text-right">수량</th>
|
|
||||||
<th class="pb-2 font-medium text-right">1차손절</th>
|
|
||||||
<th class="pb-2 font-medium text-right">2차손절</th>
|
|
||||||
<th class="pb-2 font-medium text-center">상태</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${active.map(p => {
|
|
||||||
const statusCls = p.status === 'open' ? 'text-green-600' : 'text-yellow-600';
|
|
||||||
const statusTxt = p.status === 'open' ? '보유중' : '체결대기';
|
|
||||||
return `
|
|
||||||
<tr class="border-b border-gray-50">
|
|
||||||
<td class="py-2">
|
|
||||||
<div class="font-medium text-gray-800">${escHtml(p.name)}</div>
|
|
||||||
<div class="text-gray-400">${p.code}</div>
|
|
||||||
</td>
|
|
||||||
<td class="py-2 text-right text-gray-700">${formatMoney(p.buyPrice)}</td>
|
|
||||||
<td class="py-2 text-right text-gray-700">${p.qty}</td>
|
|
||||||
<td class="py-2 text-right text-orange-500">${p.stopLoss1 > 0 ? formatMoney(p.stopLoss1) + `<span class="text-xs text-gray-400 ml-0.5">[${p.stopLoss1Touches||0}회]</span>` : '-'}</td>
|
|
||||||
<td class="py-2 text-right text-red-500">${formatMoney(p.stopLoss)}</td>
|
|
||||||
<td class="py-2 text-center font-medium ${statusCls}">${statusTxt}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 감시 소스 ---
|
|
||||||
|
|
||||||
async function loadThemeList() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/themes?date=1&sort=3');
|
|
||||||
const themes = await res.json();
|
|
||||||
const sel = document.getElementById('themeSelect');
|
|
||||||
if (!sel || !themes) return;
|
|
||||||
// 가나다순 정렬 후 추가
|
|
||||||
const sorted = [...themes].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
|
||||||
sel.innerHTML = '<option value="">테마를 선택하세요...</option>';
|
|
||||||
sorted.forEach(t => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = t.code;
|
|
||||||
opt.textContent = `${t.name} (${t.fluRt >= 0 ? '+' : ''}${t.fluRt.toFixed(2)}%)`;
|
|
||||||
opt.dataset.name = t.name;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('테마 목록 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWatchSource() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/autotrade/watch-source');
|
|
||||||
watchSource = await res.json();
|
|
||||||
renderWatchSource();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('감시 소스 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveWatchSource() {
|
|
||||||
const scannerChk = document.getElementById('wsScanner');
|
|
||||||
watchSource.useScanner = scannerChk ? scannerChk.checked : true;
|
|
||||||
try {
|
|
||||||
await fetch('/api/autotrade/watch-source', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(watchSource),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
alert('감시 소스 저장 실패: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWatchSource() {
|
|
||||||
const scannerChk = document.getElementById('wsScanner');
|
|
||||||
if (scannerChk) scannerChk.checked = watchSource.useScanner;
|
|
||||||
|
|
||||||
const container = document.getElementById('selectedThemes');
|
|
||||||
const noMsg = document.getElementById('noThemeMsg');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const themes = watchSource.selectedThemes || [];
|
|
||||||
if (themes.length === 0) {
|
|
||||||
container.innerHTML = '<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = themes.map(t => `
|
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
|
||||||
${escHtml(t.name)}
|
|
||||||
<button onclick="removeTheme('${escHtml(t.code)}')" class="hover:text-blue-900 text-blue-400 font-bold leading-none">×</button>
|
|
||||||
</span>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSelectedTheme() {
|
|
||||||
const sel = document.getElementById('themeSelect');
|
|
||||||
if (!sel || !sel.value) return;
|
|
||||||
const code = sel.value;
|
|
||||||
const name = sel.options[sel.selectedIndex]?.dataset.name || sel.options[sel.selectedIndex]?.text || code;
|
|
||||||
|
|
||||||
if (!watchSource.selectedThemes) watchSource.selectedThemes = [];
|
|
||||||
if (watchSource.selectedThemes.some(t => t.code === code)) {
|
|
||||||
alert('이미 추가된 테마입니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
watchSource.selectedThemes.push({ code, name });
|
|
||||||
renderWatchSource();
|
|
||||||
saveWatchSource();
|
|
||||||
sel.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTheme(code) {
|
|
||||||
if (!watchSource.selectedThemes) return;
|
|
||||||
watchSource.selectedThemes = watchSource.selectedThemes.filter(t => t.code !== code);
|
|
||||||
renderWatchSource();
|
|
||||||
saveWatchSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 로그 ---
|
|
||||||
|
|
||||||
// --- WS 실시간 로그 ---
|
|
||||||
|
|
||||||
function connectTradeLogWS() {
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
tradeLogWS = new WebSocket(`${proto}//${location.host}/ws`);
|
|
||||||
|
|
||||||
tradeLogWS.onopen = () => {
|
|
||||||
updateWSStatus(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
tradeLogWS.onmessage = (e) => {
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(e.data); } catch { return; }
|
|
||||||
if (msg.type !== 'tradelog') return;
|
|
||||||
const log = msg.data;
|
|
||||||
localLogs.push(log);
|
|
||||||
if (localLogs.length > 300) localLogs.shift();
|
|
||||||
|
|
||||||
// 필터 조건에 맞으면 DOM에 행 추가
|
|
||||||
if (logLevelFilter !== 'action' || log.level !== 'debug') {
|
|
||||||
appendLogRow(log);
|
|
||||||
}
|
|
||||||
updateLogTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
tradeLogWS.onclose = () => {
|
|
||||||
updateWSStatus(false);
|
|
||||||
// 3초 후 재연결 + 로그 재로드
|
|
||||||
setTimeout(() => {
|
|
||||||
loadLogs();
|
|
||||||
connectTradeLogWS();
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
tradeLogWS.onerror = () => {
|
|
||||||
tradeLogWS.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateWSStatus(connected) {
|
|
||||||
const el = document.getElementById('wsStatus');
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = connected ? '● 실시간' : '○ 연결중...';
|
|
||||||
el.className = connected
|
|
||||||
? 'text-xs text-green-500 font-medium'
|
|
||||||
: 'text-xs text-gray-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollLogsToBottom() {
|
|
||||||
const wrapper = document.getElementById('logsWrapper');
|
|
||||||
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLogTime() {
|
|
||||||
const el = document.getElementById('logUpdateTime');
|
|
||||||
if (el) el.textContent = new Date().toLocaleTimeString('ko-KR',
|
|
||||||
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' 갱신';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLogRow(l) {
|
|
||||||
let rowCls, levelCls, msgCls;
|
|
||||||
if (l.level === 'debug') {
|
|
||||||
rowCls = 'border-b border-gray-50 bg-gray-50';
|
|
||||||
levelCls = 'text-gray-400';
|
|
||||||
msgCls = 'text-gray-400';
|
|
||||||
} else if (l.level === 'error') {
|
|
||||||
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
|
|
||||||
levelCls = 'text-red-500';
|
|
||||||
msgCls = 'text-gray-700';
|
|
||||||
} else if (l.level === 'warn') {
|
|
||||||
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
|
|
||||||
levelCls = 'text-yellow-600';
|
|
||||||
msgCls = 'text-gray-700';
|
|
||||||
} else {
|
|
||||||
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
|
|
||||||
levelCls = 'text-gray-500';
|
|
||||||
msgCls = 'text-gray-700';
|
|
||||||
}
|
|
||||||
const time = new Date(l.at).toLocaleTimeString('ko-KR',
|
|
||||||
{ hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
return `<tr class="${rowCls}">
|
|
||||||
<td class="py-1.5 px-1 text-gray-400">${time}</td>
|
|
||||||
<td class="py-1.5 px-1 font-medium ${levelCls}">${l.level}</td>
|
|
||||||
<td class="py-1.5 px-1 text-gray-600">${escHtml(l.code)}</td>
|
|
||||||
<td class="py-1.5 px-1 ${msgCls}">${escHtml(l.message)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendLogRow(log) {
|
|
||||||
const tbody = document.getElementById('logsList');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
// 빈 상태 메시지 제거
|
|
||||||
if (tbody.firstElementChild?.tagName === 'TR' &&
|
|
||||||
tbody.firstElementChild.querySelector('td[colspan]')) {
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.insertAdjacentHTML('beforeend', buildLogRow(log));
|
|
||||||
|
|
||||||
// 표시 행 수 300개 초과 시 맨 위 행 제거
|
|
||||||
while (tbody.children.length > 300) tbody.removeChild(tbody.firstChild);
|
|
||||||
|
|
||||||
// 자동스크롤
|
|
||||||
const chk = document.getElementById('autoScrollChk');
|
|
||||||
if (chk && chk.checked) scrollLogsToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLogFilter(level) {
|
|
||||||
logLevelFilter = level;
|
|
||||||
|
|
||||||
const allBtn = document.getElementById('filterAll');
|
|
||||||
const actionBtn = document.getElementById('filterAction');
|
|
||||||
if (allBtn && actionBtn) {
|
|
||||||
if (level === 'all') {
|
|
||||||
allBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
|
|
||||||
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
|
|
||||||
} else {
|
|
||||||
allBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
|
|
||||||
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 버퍼에서 필터 적용 후 전체 재렌더
|
|
||||||
renderLogsFromBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLogs() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/autotrade/logs');
|
|
||||||
const logs = await res.json();
|
|
||||||
// 서버는 최신순으로 반환 → 뒤집어 오래된 것부터 저장
|
|
||||||
localLogs = (logs || []).slice(0, 300).reverse();
|
|
||||||
renderLogsFromBuffer();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('로그 초기 로드 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLogsFromBuffer() {
|
|
||||||
const tbody = document.getElementById('logsList');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
const filtered = logLevelFilter === 'action'
|
|
||||||
? localLogs.filter(l => l.level !== 'debug')
|
|
||||||
: localLogs;
|
|
||||||
|
|
||||||
// 최근 300개 표시
|
|
||||||
const items = filtered.slice(-300);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = items.map(l => buildLogRow(l)).join('');
|
|
||||||
updateLogTime();
|
|
||||||
scrollLogsToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 유틸 ---
|
|
||||||
|
|
||||||
function formatMoney(n) {
|
|
||||||
if (!n) return '0';
|
|
||||||
return n.toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
// YYYYMMDD → YYYY.MM.DD
|
|
||||||
function formatDate(d) {
|
|
||||||
return d ? `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}` : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 태그별 색상 매핑
|
|
||||||
const TAG_COLORS = {
|
|
||||||
'실적': 'bg-blue-100 text-blue-700',
|
|
||||||
'유증': 'bg-red-100 text-red-700',
|
|
||||||
'무증': 'bg-orange-100 text-orange-700',
|
|
||||||
'수주': 'bg-green-100 text-green-700',
|
|
||||||
'소송': 'bg-red-100 text-red-700',
|
|
||||||
'M&A': 'bg-purple-100 text-purple-700',
|
|
||||||
'지분': 'bg-indigo-100 text-indigo-700',
|
|
||||||
'자사주':'bg-teal-100 text-teal-700',
|
|
||||||
'경영': 'bg-gray-100 text-gray-600',
|
|
||||||
'CB/BW': 'bg-yellow-100 text-yellow-700',
|
|
||||||
'공시': 'bg-gray-100 text-gray-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
function tagBadge(tag) {
|
|
||||||
const cls = TAG_COLORS[tag] || TAG_COLORS['공시'];
|
|
||||||
return `<span class="text-xs font-medium px-2 py-0.5 rounded-full ${cls} shrink-0">${tag}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDisclosures() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/disclosure?code=${STOCK_CODE}`);
|
|
||||||
if (!resp.ok) throw new Error();
|
|
||||||
const list = await resp.json();
|
|
||||||
|
|
||||||
document.getElementById('disclosureLoading').classList.add('hidden');
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
document.getElementById('disclosureEmpty').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ul = document.getElementById('disclosureList');
|
|
||||||
list.forEach(item => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'py-3';
|
|
||||||
li.innerHTML = `<a href="${item.url}" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-2 hover:bg-gray-50 px-1 rounded transition-colors">
|
|
||||||
${tagBadge(item.tag)}
|
|
||||||
<span class="text-sm text-gray-800 flex-1 min-w-0 truncate">${item.reportNm}</span>
|
|
||||||
<span class="text-xs text-gray-400 shrink-0">${formatDate(item.rceptDt)}</span>
|
|
||||||
</a>`;
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
ul.classList.remove('hidden');
|
|
||||||
} catch {
|
|
||||||
document.getElementById('disclosureLoading').classList.add('hidden');
|
|
||||||
document.getElementById('disclosureError').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (typeof STOCK_CODE !== 'undefined') loadDisclosures();
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* 주요 지수 티커 (코스피·코스닥·다우·나스닥)
|
|
||||||
* - 10초 주기 폴링
|
|
||||||
* - 내비게이션 바 하단 어두운 띠에 표시
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
const ticker = document.getElementById('indexTicker');
|
|
||||||
const INTERVAL = 10 * 1000;
|
|
||||||
|
|
||||||
function colorClass(rate) {
|
|
||||||
if (rate > 0) return 'text-red-400';
|
|
||||||
if (rate < 0) return 'text-blue-400';
|
|
||||||
return 'text-gray-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrow(rate) {
|
|
||||||
if (rate > 0) return '▲';
|
|
||||||
if (rate < 0) return '▼';
|
|
||||||
return '–';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtPrice(name, price) {
|
|
||||||
if (!price) return '-';
|
|
||||||
// 코스피·코스닥은 소수점 2자리, 해외는 정수 + 소수점 2자리
|
|
||||||
return price.toLocaleString('ko-KR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(quotes) {
|
|
||||||
if (!quotes || quotes.length === 0) return;
|
|
||||||
ticker.innerHTML = quotes.map(q => {
|
|
||||||
const cls = colorClass(q.changeRate);
|
|
||||||
const arr = arrow(q.changeRate);
|
|
||||||
const rate = q.changeRate != null ? (q.changeRate >= 0 ? '+' : '') + q.changeRate.toFixed(2) + '%' : '-';
|
|
||||||
return `
|
|
||||||
<span class="shrink-0 flex items-center gap-1.5">
|
|
||||||
<span class="text-gray-400 font-medium">${q.name}</span>
|
|
||||||
<span class="font-mono font-semibold">${fmtPrice(q.name, q.price)}</span>
|
|
||||||
<span class="${cls} font-mono">${arr} ${rate}</span>
|
|
||||||
</span>`;
|
|
||||||
}).join('<span class="text-gray-600 shrink-0">|</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetch_() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/indices');
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
render(data);
|
|
||||||
} catch (e) {
|
|
||||||
// 조용히 실패 (티커 미표시)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch_();
|
|
||||||
setInterval(fetch_, INTERVAL);
|
|
||||||
})();
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* 코스피200 종목 목록 페이지
|
|
||||||
* - /api/kospi200 폴링 (1분 캐시 기반)
|
|
||||||
* - 정렬: 등락률 / 거래량 / 현재가
|
|
||||||
* - 필터: 전체 / 상승 / 하락
|
|
||||||
* - 종목명 검색
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
let allStocks = [];
|
|
||||||
let currentSort = 'fluRt'; // fluRt | volume | curPrc
|
|
||||||
let currentDir = 'all'; // all | up | down
|
|
||||||
let sortDesc = true; // 기본 내림차순
|
|
||||||
|
|
||||||
const listEl = document.getElementById('k200List');
|
|
||||||
const countEl = document.getElementById('k200Count');
|
|
||||||
const searchEl = document.getElementById('k200Search');
|
|
||||||
const updatedEl = document.getElementById('lastUpdated');
|
|
||||||
|
|
||||||
// ── 포맷 유틸 ────────────────────────────────────────────────
|
|
||||||
function fmtNum(n) {
|
|
||||||
if (n == null || n === 0) return '-';
|
|
||||||
return Math.abs(n).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
function fmtRate(f) {
|
|
||||||
if (f == null) return '-';
|
|
||||||
const sign = f > 0 ? '+' : '';
|
|
||||||
return sign + f.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
function fmtDiff(n) {
|
|
||||||
if (n == null || n === 0) return '0';
|
|
||||||
const sign = n > 0 ? '+' : '';
|
|
||||||
return sign + Math.abs(n).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
function rateClass(f) {
|
|
||||||
if (f > 0) return 'text-red-500';
|
|
||||||
if (f < 0) return 'text-blue-500';
|
|
||||||
return 'text-gray-500';
|
|
||||||
}
|
|
||||||
function rateBg(f) {
|
|
||||||
if (f > 0) return 'bg-red-50 text-red-500';
|
|
||||||
if (f < 0) return 'bg-blue-50 text-blue-500';
|
|
||||||
return 'bg-gray-100 text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 행 렌더 ──────────────────────────────────────────────────
|
|
||||||
function makeRow(s, rank) {
|
|
||||||
const cls = rateClass(s.fluRt);
|
|
||||||
const bgCls = rateBg(s.fluRt);
|
|
||||||
return `
|
|
||||||
<a href="/stock/${s.code}"
|
|
||||||
class="grid grid-cols-[2.5rem_1fr_1fr_90px_90px_100px_90px_90px_90px] gap-2
|
|
||||||
px-4 py-2.5 hover:bg-gray-50 transition-colors text-sm items-center">
|
|
||||||
<span class="text-xs text-gray-400 text-center">${rank}</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="font-medium text-gray-800 truncate">${s.name}</p>
|
|
||||||
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right font-semibold ${cls}">${fmtNum(s.curPrc)}원</div>
|
|
||||||
<div class="text-right text-xs ${cls}">${fmtDiff(s.predPre)}</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded font-semibold ${bgCls}">${fmtRate(s.fluRt)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-right text-xs text-gray-500">${fmtNum(s.volume)}</div>
|
|
||||||
<div class="text-right text-xs text-gray-400">${fmtNum(s.open)}</div>
|
|
||||||
<div class="text-right text-xs text-red-400">${fmtNum(s.high)}</div>
|
|
||||||
<div class="text-right text-xs text-blue-400">${fmtNum(s.low)}</div>
|
|
||||||
</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 필터 + 정렬 + 렌더 ───────────────────────────────────────
|
|
||||||
function renderList() {
|
|
||||||
const q = searchEl.value.trim();
|
|
||||||
|
|
||||||
let filtered = allStocks;
|
|
||||||
|
|
||||||
// 상승/하락 필터
|
|
||||||
if (currentDir === 'up') filtered = filtered.filter(s => s.fluRt > 0);
|
|
||||||
if (currentDir === 'down') filtered = filtered.filter(s => s.fluRt < 0);
|
|
||||||
|
|
||||||
// 종목명 검색
|
|
||||||
if (q) filtered = filtered.filter(s => s.name.includes(q) || s.code.includes(q));
|
|
||||||
|
|
||||||
// 정렬
|
|
||||||
filtered = [...filtered].sort((a, b) => {
|
|
||||||
const diff = b[currentSort] - a[currentSort];
|
|
||||||
return sortDesc ? diff : -diff;
|
|
||||||
});
|
|
||||||
|
|
||||||
countEl.textContent = `${filtered.length}개 종목`;
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">조건에 맞는 종목이 없습니다.</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = filtered.map((s, i) => makeRow(s, i + 1)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 데이터 로드 ───────────────────────────────────────────────
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/kospi200');
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
allStocks = await resp.json();
|
|
||||||
updatedEl.textContent = new Date().toTimeString().slice(0, 8) + ' 기준';
|
|
||||||
renderList();
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
|
|
||||||
console.error('코스피200 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
|
|
||||||
function updateColHeaders() {
|
|
||||||
document.querySelectorAll('.col-sort').forEach(el => {
|
|
||||||
const arrow = el.querySelector('.sort-arrow');
|
|
||||||
if (!arrow) return;
|
|
||||||
if (el.dataset.col === currentSort) {
|
|
||||||
arrow.textContent = sortDesc ? '▼' : '▲';
|
|
||||||
arrow.className = 'sort-arrow text-blue-400';
|
|
||||||
el.classList.add('text-blue-500');
|
|
||||||
el.classList.remove('text-gray-500');
|
|
||||||
} else {
|
|
||||||
arrow.textContent = '';
|
|
||||||
arrow.className = 'sort-arrow';
|
|
||||||
el.classList.remove('text-blue-500');
|
|
||||||
el.classList.add('text-gray-500');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 정렬 탭 이벤트 ───────────────────────────────────────────
|
|
||||||
document.querySelectorAll('.sort-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
if (currentSort === btn.dataset.sort) {
|
|
||||||
sortDesc = !sortDesc;
|
|
||||||
} else {
|
|
||||||
currentSort = btn.dataset.sort;
|
|
||||||
sortDesc = true;
|
|
||||||
}
|
|
||||||
document.querySelectorAll('.sort-tab').forEach(b => {
|
|
||||||
const active = b.dataset.sort === currentSort;
|
|
||||||
b.className = active
|
|
||||||
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
||||||
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
||||||
});
|
|
||||||
updateColHeaders();
|
|
||||||
renderList();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
|
|
||||||
document.querySelectorAll('.col-sort').forEach(el => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
const col = el.dataset.col;
|
|
||||||
if (currentSort === col) {
|
|
||||||
sortDesc = !sortDesc;
|
|
||||||
} else {
|
|
||||||
currentSort = col;
|
|
||||||
sortDesc = true;
|
|
||||||
}
|
|
||||||
// 상단 정렬 탭 동기화
|
|
||||||
document.querySelectorAll('.sort-tab').forEach(b => {
|
|
||||||
const active = b.dataset.sort === currentSort;
|
|
||||||
b.className = active
|
|
||||||
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
||||||
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
||||||
});
|
|
||||||
updateColHeaders();
|
|
||||||
renderList();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 방향 필터 탭 이벤트 ──────────────────────────────────────
|
|
||||||
document.querySelectorAll('.dir-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
currentDir = btn.dataset.dir;
|
|
||||||
document.querySelectorAll('.dir-tab').forEach(b => {
|
|
||||||
const active = b.dataset.dir === currentDir;
|
|
||||||
b.className = active
|
|
||||||
? 'dir-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
||||||
: 'dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
||||||
});
|
|
||||||
renderList();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 검색 이벤트 ──────────────────────────────────────────────
|
|
||||||
searchEl.addEventListener('input', renderList);
|
|
||||||
|
|
||||||
// ── 초기 로드 + 1분 자동 갱신 ────────────────────────────────
|
|
||||||
updateColHeaders();
|
|
||||||
loadData();
|
|
||||||
setInterval(loadData, 60 * 1000);
|
|
||||||
})();
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// RFC1123Z → "MM/DD HH:MM" 형식으로 변환
|
|
||||||
function formatNewsDate(s) {
|
|
||||||
if (!s) return '';
|
|
||||||
const d = new Date(s);
|
|
||||||
if (isNaN(d)) return s;
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const dd = String(d.getDate()).padStart(2, '0');
|
|
||||||
const hh = String(d.getHours()).padStart(2, '0');
|
|
||||||
const min = String(d.getMinutes()).padStart(2, '0');
|
|
||||||
return `${mm}/${dd} ${hh}:${min}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadNews() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/news?name=${encodeURIComponent(STOCK_NAME)}`);
|
|
||||||
if (!resp.ok) throw new Error();
|
|
||||||
const list = await resp.json();
|
|
||||||
|
|
||||||
document.getElementById('newsLoading').classList.add('hidden');
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
document.getElementById('newsEmpty').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ul = document.getElementById('newsList');
|
|
||||||
list.forEach(item => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'py-3';
|
|
||||||
li.innerHTML = `<a href="${item.url}" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="flex items-start gap-3 hover:bg-gray-50 px-1 rounded transition-colors">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm text-gray-800 truncate">${item.title}</p>
|
|
||||||
<p class="text-xs text-gray-400 mt-0.5">${item.source}</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400 shrink-0 mt-0.5">${formatNewsDate(item.publishedAt)}</span>
|
|
||||||
</a>`;
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
ul.classList.remove('hidden');
|
|
||||||
} catch {
|
|
||||||
document.getElementById('newsLoading').classList.add('hidden');
|
|
||||||
document.getElementById('newsError').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (typeof STOCK_NAME !== 'undefined') loadNews();
|
|
||||||
});
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
/**
|
|
||||||
* 주문창 UI 전체 로직
|
|
||||||
* - 매수/매도 탭, 주문유형, 단가/수량 입력, 주문 제출
|
|
||||||
* - 미체결/체결/잔고 탭
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 현재 활성 탭: 'buy' | 'sell'
|
|
||||||
let orderSide = 'buy';
|
|
||||||
// 주문가능수량 (퍼센트 버튼용)
|
|
||||||
let orderableQty = 0;
|
|
||||||
// 현재 종목 코드 (전역에서 STOCK_CODE 사용)
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 호가 단위(틱) 계산
|
|
||||||
// -----------------------------------
|
|
||||||
function getTickSize(price) {
|
|
||||||
if (price < 2000) return 1;
|
|
||||||
if (price < 5000) return 5;
|
|
||||||
if (price < 20000) return 10;
|
|
||||||
if (price < 50000) return 50;
|
|
||||||
if (price < 200000) return 100;
|
|
||||||
if (price < 500000) return 500;
|
|
||||||
return 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 초기화
|
|
||||||
// -----------------------------------
|
|
||||||
function initOrder() {
|
|
||||||
updateOrderSide('buy');
|
|
||||||
loadOrderable();
|
|
||||||
loadPendingTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 매수/매도 탭 전환
|
|
||||||
// -----------------------------------
|
|
||||||
function updateOrderSide(side) {
|
|
||||||
orderSide = side;
|
|
||||||
|
|
||||||
const buyTab = document.getElementById('orderBuyTab');
|
|
||||||
const sellTab = document.getElementById('orderSellTab');
|
|
||||||
const submitBtn = document.getElementById('orderSubmitBtn');
|
|
||||||
|
|
||||||
if (side === 'buy') {
|
|
||||||
buyTab.classList.add('bg-red-500', 'text-white');
|
|
||||||
buyTab.classList.remove('bg-gray-100', 'text-gray-600');
|
|
||||||
sellTab.classList.add('bg-gray-100', 'text-gray-600');
|
|
||||||
sellTab.classList.remove('bg-blue-500', 'text-white');
|
|
||||||
submitBtn.textContent = '매수';
|
|
||||||
submitBtn.className = 'w-full py-2.5 text-sm font-bold rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors';
|
|
||||||
} else {
|
|
||||||
sellTab.classList.add('bg-blue-500', 'text-white');
|
|
||||||
sellTab.classList.remove('bg-gray-100', 'text-gray-600');
|
|
||||||
buyTab.classList.add('bg-gray-100', 'text-gray-600');
|
|
||||||
buyTab.classList.remove('bg-red-500', 'text-white');
|
|
||||||
submitBtn.textContent = '매도';
|
|
||||||
submitBtn.className = 'w-full py-2.5 text-sm font-bold rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
resetOrderForm();
|
|
||||||
loadOrderable();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetOrderForm() {
|
|
||||||
const priceEl = document.getElementById('orderPrice');
|
|
||||||
const qtyEl = document.getElementById('orderQty');
|
|
||||||
if (priceEl) priceEl.value = '';
|
|
||||||
if (qtyEl) qtyEl.value = '';
|
|
||||||
updateOrderTotal();
|
|
||||||
hideOrderConfirm();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 주문유형 변경 처리
|
|
||||||
// -----------------------------------
|
|
||||||
function onTradeTypeChange() {
|
|
||||||
const tp = document.getElementById('orderTradeType').value;
|
|
||||||
const priceEl = document.getElementById('orderPrice');
|
|
||||||
const priceUpBtn = document.getElementById('priceUpBtn');
|
|
||||||
const priceDownBtn = document.getElementById('priceDownBtn');
|
|
||||||
|
|
||||||
// 시장가(3)일 때 단가 입력 비활성화
|
|
||||||
const isMarket = (tp === '3');
|
|
||||||
priceEl.disabled = isMarket;
|
|
||||||
priceUpBtn.disabled = isMarket;
|
|
||||||
priceDownBtn.disabled = isMarket;
|
|
||||||
if (isMarket) {
|
|
||||||
priceEl.value = '';
|
|
||||||
priceEl.placeholder = '시장가';
|
|
||||||
updateOrderTotal();
|
|
||||||
} else {
|
|
||||||
priceEl.placeholder = '단가 입력';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 단가 ▲▼ 버튼
|
|
||||||
// -----------------------------------
|
|
||||||
function adjustPrice(direction) {
|
|
||||||
const el = document.getElementById('orderPrice');
|
|
||||||
// 입력란이 비어 있으면 현재가를 기준으로 시작
|
|
||||||
let price = parseInt(el.value, 10);
|
|
||||||
if (!price || isNaN(price)) {
|
|
||||||
const rawEl = document.getElementById('currentPrice');
|
|
||||||
price = rawEl ? parseInt(rawEl.dataset.raw || '0', 10) : 0;
|
|
||||||
}
|
|
||||||
const tick = getTickSize(price);
|
|
||||||
price += direction * tick;
|
|
||||||
if (price < 1) price = 1;
|
|
||||||
el.value = price;
|
|
||||||
updateOrderTotal();
|
|
||||||
loadOrderable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 호가창 클릭 → 단가 자동 입력
|
|
||||||
// -----------------------------------
|
|
||||||
window.setOrderPrice = function(price) {
|
|
||||||
const priceEl = document.getElementById('orderPrice');
|
|
||||||
if (!priceEl || priceEl.disabled) return;
|
|
||||||
priceEl.value = price;
|
|
||||||
updateOrderTotal();
|
|
||||||
loadOrderable();
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 총 주문금액 실시간 표시
|
|
||||||
// -----------------------------------
|
|
||||||
function updateOrderTotal() {
|
|
||||||
const price = parseInt(document.getElementById('orderPrice').value.replace(/,/g, ''), 10) || 0;
|
|
||||||
const qty = parseInt(document.getElementById('orderQty').value.replace(/,/g, ''), 10) || 0;
|
|
||||||
const total = price * qty;
|
|
||||||
const el = document.getElementById('orderTotal');
|
|
||||||
if (el) el.textContent = total > 0 ? total.toLocaleString('ko-KR') + '원' : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 수량 퍼센트 버튼
|
|
||||||
// -----------------------------------
|
|
||||||
function setQtyPercent(pct) {
|
|
||||||
if (orderableQty <= 0) return;
|
|
||||||
const qty = Math.floor(orderableQty * pct / 100);
|
|
||||||
const el = document.getElementById('orderQty');
|
|
||||||
if (el) el.value = qty > 0 ? qty : 0;
|
|
||||||
updateOrderTotal();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 주문가능금액/수량 조회
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadOrderable() {
|
|
||||||
const code = typeof STOCK_CODE !== 'undefined' ? STOCK_CODE : '';
|
|
||||||
const price = document.getElementById('orderPrice')?.value || '0';
|
|
||||||
const side = orderSide === 'buy' ? 'buy' : 'sell';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/account/orderable?code=${code}&price=${price}&side=${side}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const qtyEl = document.getElementById('orderableQty');
|
|
||||||
const amtEl = document.getElementById('orderableAmt');
|
|
||||||
const entrEl = document.getElementById('orderableEntr');
|
|
||||||
|
|
||||||
if (data.ordAlowq) {
|
|
||||||
orderableQty = parseInt(data.ordAlowq.replace(/,/g, ''), 10) || 0;
|
|
||||||
if (qtyEl) qtyEl.textContent = orderableQty.toLocaleString('ko-KR') + '주';
|
|
||||||
} else {
|
|
||||||
orderableQty = 0;
|
|
||||||
if (qtyEl) qtyEl.textContent = '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (amtEl) amtEl.textContent = data.ordAlowa ? parseInt(data.ordAlowa.replace(/,/g, ''), 10).toLocaleString('ko-KR') + '원' : '-';
|
|
||||||
if (entrEl) entrEl.textContent = data.entr ? parseInt(data.entr.replace(/,/g, ''), 10).toLocaleString('ko-KR') + '원' : '-';
|
|
||||||
} catch (e) {
|
|
||||||
// 조회 실패 시 조용히 처리
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 주문 확인 메시지 표시/숨김
|
|
||||||
// -----------------------------------
|
|
||||||
function showOrderConfirm() {
|
|
||||||
const price = parseInt(document.getElementById('orderPrice').value.replace(/,/g, ''), 10) || 0;
|
|
||||||
const qty = parseInt(document.getElementById('orderQty').value.replace(/,/g, ''), 10) || 0;
|
|
||||||
const tp = document.getElementById('orderTradeType').value;
|
|
||||||
const name = typeof STOCK_NAME !== 'undefined' ? STOCK_NAME : '';
|
|
||||||
|
|
||||||
if (qty <= 0) {
|
|
||||||
showOrderToast('수량을 입력해주세요.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tp !== '3' && price <= 0) {
|
|
||||||
showOrderToast('단가를 입력해주세요.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sideText = orderSide === 'buy' ? '매수' : '매도';
|
|
||||||
const priceText = tp === '3' ? '시장가' : price.toLocaleString('ko-KR') + '원';
|
|
||||||
const msg = `${name} ${qty.toLocaleString('ko-KR')}주 ${priceText} ${sideText} 하시겠습니까?`;
|
|
||||||
|
|
||||||
const confirmEl = document.getElementById('orderConfirmMsg');
|
|
||||||
const confirmBox = document.getElementById('orderConfirmBox');
|
|
||||||
if (confirmEl) confirmEl.textContent = msg;
|
|
||||||
if (confirmBox) confirmBox.classList.remove('hidden');
|
|
||||||
document.getElementById('orderSubmitBtn').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideOrderConfirm() {
|
|
||||||
const confirmBox = document.getElementById('orderConfirmBox');
|
|
||||||
const submitBtn = document.getElementById('orderSubmitBtn');
|
|
||||||
if (confirmBox) confirmBox.classList.add('hidden');
|
|
||||||
if (submitBtn) submitBtn.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 주문 제출
|
|
||||||
// -----------------------------------
|
|
||||||
async function submitOrder() {
|
|
||||||
const price = document.getElementById('orderPrice').value || '';
|
|
||||||
const qty = document.getElementById('orderQty').value || '';
|
|
||||||
const tradeTP = document.getElementById('orderTradeType').value;
|
|
||||||
const exchange = document.querySelector('input[name="orderExchange"]:checked')?.value || 'KRX';
|
|
||||||
const code = typeof STOCK_CODE !== 'undefined' ? STOCK_CODE : '';
|
|
||||||
|
|
||||||
hideOrderConfirm();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
exchange: exchange,
|
|
||||||
code: code,
|
|
||||||
qty: qty.replace(/,/g, ''),
|
|
||||||
price: tradeTP === '3' ? '' : price.replace(/,/g, ''),
|
|
||||||
tradeTP: tradeTP,
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = orderSide === 'buy' ? '/api/order/buy' : '/api/order/sell';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok || data.error) {
|
|
||||||
showOrderToast(data.error || '주문 실패', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sideText = orderSide === 'buy' ? '매수' : '매도';
|
|
||||||
showOrderToast(`${sideText} 주문 접수 완료 (주문번호: ${data.orderNo})`, 'success');
|
|
||||||
resetOrderForm();
|
|
||||||
loadOrderable();
|
|
||||||
// 미체결 탭 갱신
|
|
||||||
loadPendingTab();
|
|
||||||
if (document.getElementById('pendingTab')?.classList.contains('active-tab')) {
|
|
||||||
showAccountTab('pending');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showOrderToast('네트워크 오류: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 토스트 알림
|
|
||||||
// -----------------------------------
|
|
||||||
function showOrderToast(msg, type) {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-lg text-sm font-medium shadow-lg transition-opacity
|
|
||||||
${type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'}`;
|
|
||||||
toast.textContent = msg;
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.opacity = '0';
|
|
||||||
setTimeout(() => toast.remove(), 400);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 계좌 탭 전환
|
|
||||||
// -----------------------------------
|
|
||||||
function showAccountTab(tab) {
|
|
||||||
['pending', 'history', 'balance'].forEach(t => {
|
|
||||||
const btn = document.getElementById(t + 'Tab');
|
|
||||||
const panel = document.getElementById(t + 'Panel');
|
|
||||||
if (btn) {
|
|
||||||
if (t === tab) {
|
|
||||||
btn.classList.add('border-b-2', 'border-blue-500', 'text-blue-600', 'active-tab');
|
|
||||||
btn.classList.remove('text-gray-500');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('border-b-2', 'border-blue-500', 'text-blue-600', 'active-tab');
|
|
||||||
btn.classList.add('text-gray-500');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (panel) panel.classList.toggle('hidden', t !== tab);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tab === 'pending') loadPendingTab();
|
|
||||||
if (tab === 'history') loadHistoryTab();
|
|
||||||
if (tab === 'balance') loadBalanceTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 미체결 탭
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadPendingTab() {
|
|
||||||
const panel = document.getElementById('pendingPanel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/pending');
|
|
||||||
const list = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${list.error || '조회 실패'}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">미체결 내역이 없습니다.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.innerHTML = list.map(o => {
|
|
||||||
const isBuy = o.trdeTp === '2';
|
|
||||||
const sideClass = isBuy ? 'text-red-500' : 'text-blue-500';
|
|
||||||
const sideText = isBuy ? '매수' : '매도';
|
|
||||||
return `
|
|
||||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 text-xs gap-2">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="font-semibold text-gray-800 truncate">${o.stkNm}</p>
|
|
||||||
<p class="${sideClass}">${sideText} · ${parseInt(o.ordPric||0).toLocaleString('ko-KR')}원</p>
|
|
||||||
<p class="text-gray-400">미체결 ${parseInt(o.osoQty||0).toLocaleString('ko-KR')}/${parseInt(o.ordQty||0).toLocaleString('ko-KR')}주</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 shrink-0">
|
|
||||||
<button onclick="cancelOrder('${o.ordNo}','${o.stkCd}')"
|
|
||||||
class="px-2 py-1 text-xs rounded border border-gray-300 text-gray-600 hover:bg-gray-100">취소</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
|
||||||
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 미체결 취소
|
|
||||||
// -----------------------------------
|
|
||||||
async function cancelOrder(ordNo, stkCd) {
|
|
||||||
if (!confirm('전량 취소하시겠습니까?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/order', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ origOrdNo: ordNo, code: stkCd, qty: '0', exchange: 'KRX' }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok || data.error) {
|
|
||||||
showOrderToast(data.error || '취소 실패', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showOrderToast('취소 주문 접수 완료', 'success');
|
|
||||||
loadPendingTab();
|
|
||||||
} catch (e) {
|
|
||||||
showOrderToast('네트워크 오류', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 체결내역 탭
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadHistoryTab() {
|
|
||||||
const panel = document.getElementById('historyPanel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/history');
|
|
||||||
const list = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${list.error || '조회 실패'}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!list || list.length === 0) {
|
|
||||||
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">체결 내역이 없습니다.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.innerHTML = list.map(o => {
|
|
||||||
const isBuy = o.trdeTp === '2';
|
|
||||||
const sideClass = isBuy ? 'text-red-500' : 'text-blue-500';
|
|
||||||
const sideText = isBuy ? '매수' : '매도';
|
|
||||||
const fee = (parseInt(o.trdeCmsn||0) + parseInt(o.trdeTax||0)).toLocaleString('ko-KR');
|
|
||||||
return `
|
|
||||||
<div class="py-2 border-b border-gray-100 text-xs">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="font-semibold text-gray-800">${o.stkNm}</span>
|
|
||||||
<span class="${sideClass}">${sideText}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-gray-500 mt-0.5">
|
|
||||||
<span>체결가 ${parseInt(o.cntrPric||0).toLocaleString('ko-KR')}원 × ${parseInt(o.cntrQty||0).toLocaleString('ko-KR')}주</span>
|
|
||||||
<span>수수료+세금 ${fee}원</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (e) {
|
|
||||||
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// 잔고 탭
|
|
||||||
// -----------------------------------
|
|
||||||
async function loadBalanceTab() {
|
|
||||||
const panel = document.getElementById('balancePanel');
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
panel.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/account/balance');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">${data.error || '조회 실패'}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plClass = parseFloat(data.totPrftRt || '0') >= 0 ? 'text-red-500' : 'text-blue-500';
|
|
||||||
let html = `
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-xs mb-3 pb-2 border-b border-gray-100">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">추정예탁자산</p>
|
|
||||||
<p class="font-semibold">${parseInt(data.prsmDpstAsetAmt||0).toLocaleString('ko-KR')}원</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">총평가손익</p>
|
|
||||||
<p class="font-semibold ${plClass}">${parseInt(data.totEvltPl||0).toLocaleString('ko-KR')}원</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">총평가금액</p>
|
|
||||||
<p class="font-semibold">${parseInt(data.totEvltAmt||0).toLocaleString('ko-KR')}원</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">수익률</p>
|
|
||||||
<p class="font-semibold ${plClass}">${parseFloat(data.totPrftRt||0).toFixed(2)}%</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
if (!data.stocks || data.stocks.length === 0) {
|
|
||||||
html += '<p class="text-xs text-gray-400 text-center py-2">보유 종목이 없습니다.</p>';
|
|
||||||
} else {
|
|
||||||
html += data.stocks.map(s => {
|
|
||||||
const prft = parseFloat(s.prftRt || '0');
|
|
||||||
const cls = prft >= 0 ? 'text-red-500' : 'text-blue-500';
|
|
||||||
return `
|
|
||||||
<div class="py-2 border-b border-gray-100 text-xs">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="font-semibold text-gray-800">${s.stkNm}</span>
|
|
||||||
<span class="${cls}">${prft >= 0 ? '+' : ''}${prft.toFixed(2)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-gray-500 mt-0.5">
|
|
||||||
<span>${parseInt(s.rmndQty||0).toLocaleString('ko-KR')}주 / 평단 ${parseInt(s.purPric||0).toLocaleString('ko-KR')}원</span>
|
|
||||||
<span class="${cls}">${parseInt(s.evltvPrft||0).toLocaleString('ko-KR')}원</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.innerHTML = html;
|
|
||||||
} catch (e) {
|
|
||||||
panel.innerHTML = `<p class="text-xs text-red-400 text-center py-4">오류: ${e.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------
|
|
||||||
// DOM 준비 후 초기화
|
|
||||||
// -----------------------------------
|
|
||||||
document.addEventListener('DOMContentLoaded', initOrder);
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
/**
|
|
||||||
* 실시간 호가창 (OrderBook) 렌더러
|
|
||||||
* 0D: 주식호가잔량, 0w: 프로그램매매
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 호가창 초기화
|
|
||||||
function initOrderBook() {
|
|
||||||
renderOrderBook(null);
|
|
||||||
renderProgram(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 호가창 렌더링
|
|
||||||
// asks[0] = 매도1호가(최우선, 가장 낮은 매도가), asks[9] = 매도10호가
|
|
||||||
// bids[0] = 매수1호가(최우선, 가장 높은 매수가), bids[9] = 매수10호가
|
|
||||||
function renderOrderBook(ob) {
|
|
||||||
const tbody = document.getElementById('orderbookBody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
if (!ob) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="3" class="text-center py-4 text-xs text-gray-400">호가 데이터 수신 대기 중...</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최대 잔량 (진행바 비율 계산용)
|
|
||||||
const maxVol = Math.max(
|
|
||||||
...ob.asks.map(a => a.volume),
|
|
||||||
...ob.bids.map(b => b.volume),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
// 매도호가: 10호가부터 1호가 순으로 위에서 아래 (asks[9]→asks[0])
|
|
||||||
for (let i = 9; i >= 0; i--) {
|
|
||||||
const ask = ob.asks[i] || { price: 0, volume: 0 };
|
|
||||||
const pct = Math.round((ask.volume / maxVol) * 100);
|
|
||||||
html += `
|
|
||||||
<tr class="ask-row border-b border-gray-50 hover:bg-red-50 transition-colors cursor-pointer" data-price="${ask.price}">
|
|
||||||
<td class="py-1.5 px-2 text-right">
|
|
||||||
<div class="relative h-6 flex items-center justify-end">
|
|
||||||
<div class="absolute right-0 top-0 h-full bg-red-100 rounded-l" style="width:${pct}%"></div>
|
|
||||||
<span class="relative text-xs text-gray-600 font-mono z-10">${ask.volume > 0 ? ask.volume.toLocaleString('ko-KR') : ''}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="py-1.5 px-2 text-center">
|
|
||||||
<span class="text-sm font-bold text-red-500">${ask.price > 0 ? ask.price.toLocaleString('ko-KR') : '-'}</span>
|
|
||||||
</td>
|
|
||||||
<td class="py-1.5 px-2"></td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 예상체결 행 (스프레드 사이)
|
|
||||||
if (ob.expectedPrc > 0) {
|
|
||||||
html += `
|
|
||||||
<tr class="bg-yellow-50 border-y-2 border-yellow-300">
|
|
||||||
<td class="py-1.5 px-2 text-right text-xs text-yellow-700">예상</td>
|
|
||||||
<td class="py-1.5 px-2 text-center text-sm font-bold text-yellow-700">${ob.expectedPrc.toLocaleString('ko-KR')}</td>
|
|
||||||
<td class="py-1.5 px-2 text-left text-xs text-yellow-700">${ob.expectedVol > 0 ? ob.expectedVol.toLocaleString('ko-KR') : ''}</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 매수호가: 1호가부터 10호가 순으로 위에서 아래 (bids[0]→bids[9])
|
|
||||||
for (let i = 0; i < ob.bids.length; i++) {
|
|
||||||
const bid = ob.bids[i] || { price: 0, volume: 0 };
|
|
||||||
const pct = Math.round((bid.volume / maxVol) * 100);
|
|
||||||
html += `
|
|
||||||
<tr class="bid-row border-b border-gray-50 hover:bg-blue-50 transition-colors cursor-pointer" data-price="${bid.price}">
|
|
||||||
<td class="py-1.5 px-2"></td>
|
|
||||||
<td class="py-1.5 px-2 text-center">
|
|
||||||
<span class="text-sm font-bold text-blue-500">${bid.price > 0 ? bid.price.toLocaleString('ko-KR') : '-'}</span>
|
|
||||||
</td>
|
|
||||||
<td class="py-1.5 px-2 text-left">
|
|
||||||
<div class="relative h-6 flex items-center">
|
|
||||||
<div class="absolute left-0 top-0 h-full bg-blue-100 rounded-r" style="width:${pct}%"></div>
|
|
||||||
<span class="relative text-xs text-gray-600 font-mono z-10">${bid.volume > 0 ? bid.volume.toLocaleString('ko-KR') : ''}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = html;
|
|
||||||
|
|
||||||
// 호가행 클릭 → 주문창 단가 자동 입력
|
|
||||||
tbody.querySelectorAll('tr[data-price]').forEach(row => {
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
const price = parseInt(row.dataset.price, 10);
|
|
||||||
if (price > 0 && window.setOrderPrice) window.setOrderPrice(price);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 총잔량 업데이트
|
|
||||||
const totalAsk = document.getElementById('totalAskVol');
|
|
||||||
const totalBid = document.getElementById('totalBidVol');
|
|
||||||
if (totalAsk) totalAsk.textContent = ob.totalAskVol.toLocaleString('ko-KR');
|
|
||||||
if (totalBid) totalBid.textContent = ob.totalBidVol.toLocaleString('ko-KR');
|
|
||||||
|
|
||||||
// 호가 시간 업데이트
|
|
||||||
const askTime = document.getElementById('askTime');
|
|
||||||
if (askTime && ob.askTime && ob.askTime.length >= 6) {
|
|
||||||
const t = ob.askTime;
|
|
||||||
askTime.textContent = `${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로그램 매매 렌더링
|
|
||||||
function renderProgram(pg) {
|
|
||||||
const container = document.getElementById('programTrading');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (!pg) {
|
|
||||||
container.innerHTML = `<span class="text-xs text-gray-400">프로그램 매매 데이터 수신 대기 중...</span>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const netClass = pg.netBuyVolume >= 0 ? 'text-red-500' : 'text-blue-500';
|
|
||||||
const netSign = pg.netBuyVolume >= 0 ? '+' : '';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="grid grid-cols-3 gap-3 text-xs">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-gray-400 mb-0.5">매도</p>
|
|
||||||
<p class="font-semibold text-blue-500">${(pg.sellVolume||0).toLocaleString('ko-KR')}</p>
|
|
||||||
<p class="text-gray-500">${formatMoney(pg.sellAmount)}원</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center border-x border-gray-100">
|
|
||||||
<p class="text-gray-400 mb-0.5">순매수</p>
|
|
||||||
<p class="font-semibold ${netClass}">${netSign}${(pg.netBuyVolume||0).toLocaleString('ko-KR')}</p>
|
|
||||||
<p class="${netClass}">${netSign}${formatMoney(pg.netBuyAmount)}원</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-gray-400 mb-0.5">매수</p>
|
|
||||||
<p class="font-semibold text-red-500">${(pg.buyVolume||0).toLocaleString('ko-KR')}</p>
|
|
||||||
<p class="text-gray-500">${formatMoney(pg.buyAmount)}원</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 금액을 억/만 단위로 포맷
|
|
||||||
function formatMoney(n) {
|
|
||||||
if (!n) return '0';
|
|
||||||
const abs = Math.abs(n);
|
|
||||||
if (abs >= 100000000) return (n / 100000000).toFixed(1) + '억';
|
|
||||||
if (abs >= 10000) return Math.round(n / 10000) + '만';
|
|
||||||
return n.toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* 상승률 TOP 10 실시간 갱신
|
|
||||||
* - 페이지 로드 시 즉시 조회 + WS 개별 구독
|
|
||||||
* - 30초마다 랭킹 순위 재조회 (종목 변동 반영)
|
|
||||||
* - WS로 현재가/등락률/거래량/체결강도 1초 단위 갱신
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
const gridEl = document.getElementById('rankingGrid');
|
|
||||||
const updatedEl = document.getElementById('rankingUpdatedAt');
|
|
||||||
const INTERVAL = 30 * 1000; // 30초 (순위 재조회 주기)
|
|
||||||
|
|
||||||
// 현재 구독 중인 종목 코드 목록 (재조회 시 해제용)
|
|
||||||
let currentCodes = [];
|
|
||||||
|
|
||||||
// --- 숫자 포맷 ---
|
|
||||||
function fmtNum(n) {
|
|
||||||
if (n == null) return '-';
|
|
||||||
return Math.abs(n).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
function fmtRate(f) {
|
|
||||||
if (f == null) return '-';
|
|
||||||
const sign = f >= 0 ? '+' : '';
|
|
||||||
return sign + f.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
function fmtCntr(f) {
|
|
||||||
if (!f) return '-';
|
|
||||||
return f.toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 등락률에 따른 CSS 클래스 ---
|
|
||||||
function rateClass(f) {
|
|
||||||
if (f > 0) return 'text-red-500';
|
|
||||||
if (f < 0) return 'text-blue-500';
|
|
||||||
return 'text-gray-500';
|
|
||||||
}
|
|
||||||
function rateBadgeClass(f) {
|
|
||||||
if (f > 0) return 'bg-red-50 text-red-500';
|
|
||||||
if (f < 0) return 'bg-blue-50 text-blue-500';
|
|
||||||
return 'bg-gray-100 text-gray-500';
|
|
||||||
}
|
|
||||||
function cntrClass(f) {
|
|
||||||
if (f > 100) return 'text-red-500';
|
|
||||||
if (f > 0 && f < 100) return 'text-blue-500';
|
|
||||||
return 'text-gray-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 카드 HTML 생성 (실시간 업데이트용 ID 포함) ---
|
|
||||||
function makeCard(s) {
|
|
||||||
const colorCls = rateClass(s.changeRate);
|
|
||||||
return `
|
|
||||||
<a href="/stock/${s.code}" id="rk-${s.code}"
|
|
||||||
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100">
|
|
||||||
<div class="flex justify-between items-start mb-2">
|
|
||||||
<span class="text-xs text-gray-400 font-mono">${s.code}</span>
|
|
||||||
<span id="rk-rate-${s.code}" class="text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
|
|
||||||
${fmtRate(s.changeRate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${s.name}</p>
|
|
||||||
<p id="rk-price-${s.code}" class="text-lg font-bold ${colorCls}">${fmtNum(s.currentPrice)}원</p>
|
|
||||||
<div class="flex justify-between text-xs mt-1">
|
|
||||||
<span class="text-gray-400">거래량 <span id="rk-vol-${s.code}">${fmtNum(s.volume)}</span></span>
|
|
||||||
<span id="rk-cntr-${s.code}" class="${cntrClass(s.cntrStr)}">체결강도 ${fmtCntr(s.cntrStr)}</span>
|
|
||||||
</div>
|
|
||||||
</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- WS 실시간 카드 업데이트 ---
|
|
||||||
function updateCard(code, data) {
|
|
||||||
const priceEl = document.getElementById(`rk-price-${code}`);
|
|
||||||
const rateEl = document.getElementById(`rk-rate-${code}`);
|
|
||||||
const volEl = document.getElementById(`rk-vol-${code}`);
|
|
||||||
const cntrEl = document.getElementById(`rk-cntr-${code}`);
|
|
||||||
if (!priceEl) return;
|
|
||||||
|
|
||||||
const rate = data.changeRate ?? 0;
|
|
||||||
const colorCls = rateClass(rate);
|
|
||||||
const sign = rate >= 0 ? '+' : '';
|
|
||||||
|
|
||||||
priceEl.textContent = fmtNum(data.currentPrice) + '원';
|
|
||||||
priceEl.className = `text-lg font-bold ${colorCls}`;
|
|
||||||
rateEl.textContent = sign + rate.toFixed(2) + '%';
|
|
||||||
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
|
|
||||||
volEl.textContent = fmtNum(data.volume);
|
|
||||||
if (cntrEl && data.cntrStr !== undefined) {
|
|
||||||
cntrEl.textContent = '체결강도 ' + fmtCntr(data.cntrStr);
|
|
||||||
cntrEl.className = cntrClass(data.cntrStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 이전 구독 해제 후 새 종목 구독 ---
|
|
||||||
function resubscribe(newCodes) {
|
|
||||||
// 빠진 종목 구독 해제
|
|
||||||
currentCodes.forEach(code => {
|
|
||||||
if (!newCodes.includes(code)) {
|
|
||||||
stockWS.unsubscribe(code);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 새로 추가된 종목 구독
|
|
||||||
newCodes.forEach(code => {
|
|
||||||
if (!currentCodes.includes(code)) {
|
|
||||||
stockWS.subscribe(code);
|
|
||||||
stockWS.onPrice(code, data => updateCard(code, data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
currentCodes = newCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 랭킹 조회 및 렌더링 ---
|
|
||||||
async function fetchAndRender() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/ranking?market=J&dir=up');
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
const stocks = await resp.json();
|
|
||||||
|
|
||||||
if (!Array.isArray(stocks) || stocks.length === 0) {
|
|
||||||
gridEl.innerHTML = '<p class="col-span-5 text-gray-400 text-center py-8">데이터가 없습니다.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
gridEl.innerHTML = stocks.map(makeCard).join('');
|
|
||||||
|
|
||||||
// WS 구독 갱신
|
|
||||||
resubscribe(stocks.map(s => s.code));
|
|
||||||
|
|
||||||
// 순위 갱신 시각 표시
|
|
||||||
const now = new Date();
|
|
||||||
const hh = String(now.getHours()).padStart(2, '0');
|
|
||||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
||||||
updatedEl.textContent = `${hh}:${mm}:${ss} 기준`;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('랭킹 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 초기 로드 + 30초마다 순위 재조회
|
|
||||||
fetchAndRender();
|
|
||||||
setInterval(fetchAndRender, INTERVAL);
|
|
||||||
})();
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* 종목 검색 자동완성 (Debounce 300ms)
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
const input = document.getElementById('searchInput');
|
|
||||||
const dropdown = document.getElementById('searchDropdown');
|
|
||||||
if (!input || !dropdown) return;
|
|
||||||
|
|
||||||
let debounceTimer = null;
|
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
const q = input.value.trim();
|
|
||||||
|
|
||||||
if (q.length < 1) {
|
|
||||||
dropdown.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debounceTimer = setTimeout(() => fetchSuggestions(q), 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 외부 클릭 시 드롭다운 닫기
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!input.contains(e.target) && !dropdown.contains(e.target)) {
|
|
||||||
dropdown.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 엔터 시 검색 결과 페이지 이동
|
|
||||||
input.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const q = input.value.trim();
|
|
||||||
if (q) location.href = `/search?q=${encodeURIComponent(q)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchSuggestions(q) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const results = await resp.json();
|
|
||||||
renderDropdown(results);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('검색 요청 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDropdown(results) {
|
|
||||||
if (!results || results.length === 0) {
|
|
||||||
dropdown.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdown.innerHTML = results.slice(0, 8).map(item => `
|
|
||||||
<a href="/stock/${item.code}"
|
|
||||||
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-50 last:border-0">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-800 text-sm">${item.name}</span>
|
|
||||||
<span class="text-xs text-gray-400 ml-2">${item.code}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full">${item.market}</span>
|
|
||||||
</a>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
dropdown.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
/**
|
|
||||||
* 체결강도 상승 감지 실시간 표시
|
|
||||||
* - 10초마다 /api/signal 폴링
|
|
||||||
* - 감지된 종목 WS 구독으로 실시간 가격/체결강도 갱신
|
|
||||||
* - 각 카드에 체결강도 히스토리 미니 라인차트 표시
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
const gridEl = document.getElementById('signalGrid');
|
|
||||||
const emptyEl = document.getElementById('signalEmpty');
|
|
||||||
const updatedEl = document.getElementById('signalUpdatedAt');
|
|
||||||
const INTERVAL = 10 * 1000;
|
|
||||||
const MAX_HISTORY = 60; // 최대 60개 포인트 유지
|
|
||||||
|
|
||||||
let currentCodes = [];
|
|
||||||
|
|
||||||
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
|
|
||||||
const cntrHistory = new Map(); // code → number[]
|
|
||||||
|
|
||||||
function fmtNum(n) {
|
|
||||||
if (n == null) return '-';
|
|
||||||
return Math.abs(n).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
function fmtRate(f) {
|
|
||||||
if (f == null) return '-';
|
|
||||||
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
function fmtCntr(f) {
|
|
||||||
return f ? f.toFixed(2) : '-';
|
|
||||||
}
|
|
||||||
function rateClass(f) {
|
|
||||||
if (f > 0) return 'text-red-500';
|
|
||||||
if (f < 0) return 'text-blue-500';
|
|
||||||
return 'text-gray-500';
|
|
||||||
}
|
|
||||||
function rateBadgeClass(f) {
|
|
||||||
if (f > 0) return 'bg-red-50 text-red-500';
|
|
||||||
if (f < 0) return 'bg-blue-50 text-blue-500';
|
|
||||||
return 'bg-gray-100 text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 체결강도 히스토리에 값 추가
|
|
||||||
function recordCntr(code, value) {
|
|
||||||
if (value == null || value === 0) return;
|
|
||||||
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
|
|
||||||
const arr = cntrHistory.get(code);
|
|
||||||
arr.push(value);
|
|
||||||
if (arr.length > MAX_HISTORY) arr.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// canvas에 체결강도 라인차트 그리기
|
|
||||||
function drawChart(code) {
|
|
||||||
const canvas = document.getElementById(`sg-chart-${code}`);
|
|
||||||
if (!canvas) return;
|
|
||||||
const data = cntrHistory.get(code) || [];
|
|
||||||
if (data.length < 2) return;
|
|
||||||
|
|
||||||
// canvas 픽셀 크기를 실제 표시 크기에 맞춤 (선명도 유지)
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const w = Math.floor(rect.width * dpr);
|
|
||||||
const h = Math.floor(rect.height * dpr);
|
|
||||||
if (w === 0 || h === 0) return;
|
|
||||||
if (canvas.width !== w || canvas.height !== h) {
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const pad = 3 * dpr; // 상하 여백
|
|
||||||
const drawH = h - pad * 2;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
const min = Math.min(...data);
|
|
||||||
const max = Math.max(...data);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const step = w / (data.length - 1);
|
|
||||||
|
|
||||||
const getY = val => h - pad - ((val - min) / range) * drawH;
|
|
||||||
|
|
||||||
// 그라디언트 필
|
|
||||||
const lastX = (data.length - 1) * step;
|
|
||||||
const grad = ctx.createLinearGradient(0, pad, 0, h);
|
|
||||||
grad.addColorStop(0, 'rgba(249,115,22,0.35)');
|
|
||||||
grad.addColorStop(1, 'rgba(249,115,22,0)');
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
data.forEach((val, i) => {
|
|
||||||
const x = i * step;
|
|
||||||
const y = getY(val);
|
|
||||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
||||||
});
|
|
||||||
ctx.lineTo(lastX, h);
|
|
||||||
ctx.lineTo(0, h);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// 라인
|
|
||||||
ctx.beginPath();
|
|
||||||
data.forEach((val, i) => {
|
|
||||||
const x = i * step;
|
|
||||||
const y = getY(val);
|
|
||||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
||||||
});
|
|
||||||
ctx.strokeStyle = '#f97316';
|
|
||||||
ctx.lineWidth = 1.5 * dpr;
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// 마지막 포인트 강조
|
|
||||||
const lastVal = data[data.length - 1];
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = '#f97316';
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 신호 유형 뱃지 HTML 생성
|
|
||||||
function signalTypeBadge(s) {
|
|
||||||
if (!s.signalType) return '';
|
|
||||||
const map = {
|
|
||||||
'강한매수': 'bg-red-600 text-white border-red-700',
|
|
||||||
'매수우세': 'bg-orange-400 text-white border-orange-500',
|
|
||||||
'물량소화': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
|
||||||
'추격위험': 'bg-gray-800 text-white border-gray-900',
|
|
||||||
'약한상승': 'bg-gray-100 text-gray-500 border-gray-300',
|
|
||||||
};
|
|
||||||
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
|
|
||||||
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1시간 이내 상승 확률 뱃지 HTML 생성
|
|
||||||
function riseProbBadge(s) {
|
|
||||||
if (!s.riseLabel) return '';
|
|
||||||
const isVeryHigh = s.riseLabel === '매우 높음';
|
|
||||||
const cls = isVeryHigh
|
|
||||||
? 'bg-emerald-500 text-white border border-emerald-600'
|
|
||||||
: 'bg-teal-100 text-teal-700 border border-teal-200';
|
|
||||||
const icon = isVeryHigh ? '🚀' : '📈';
|
|
||||||
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 호재/악재/중립 뱃지 HTML 생성 ("정보없음"은 빈 문자열 반환)
|
|
||||||
function sentimentBadge(s) {
|
|
||||||
const map = {
|
|
||||||
'호재': 'bg-green-100 text-green-700 border border-green-200',
|
|
||||||
'악재': 'bg-red-100 text-red-600 border border-red-200',
|
|
||||||
'중립': 'bg-gray-100 text-gray-500',
|
|
||||||
};
|
|
||||||
const cls = map[s.sentiment];
|
|
||||||
if (!cls) return '';
|
|
||||||
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
|
|
||||||
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연속 상승 횟수에 따른 뱃지 텍스트
|
|
||||||
function risingBadge(n) {
|
|
||||||
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
|
|
||||||
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
|
|
||||||
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 복합 지표 섹션 HTML 생성 (거래량 배수 · 매도잔량비 · 가격 위치)
|
|
||||||
function complexIndicators(s) {
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
// 거래량 증가율
|
|
||||||
if (s.volRatio > 0) {
|
|
||||||
let volCls = 'text-gray-500';
|
|
||||||
let volLabel = `${s.volRatio.toFixed(1)}배`;
|
|
||||||
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
|
|
||||||
else if (s.volRatio >= 5) volCls = 'text-orange-400';
|
|
||||||
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
|
|
||||||
else if (s.volRatio >= 1) volCls = 'text-green-500';
|
|
||||||
rows.push(`<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">거래량 증가</span>
|
|
||||||
<span class="${volCls}">${volLabel}</span>
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 매도/매수 잔량비
|
|
||||||
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
|
|
||||||
const ratio = s.askBidRatio.toFixed(2);
|
|
||||||
let ratioCls = 'text-gray-500';
|
|
||||||
let ratioLabel = `${ratio} (`;
|
|
||||||
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
|
|
||||||
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
|
|
||||||
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
|
|
||||||
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
|
|
||||||
rows.push(`<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">매도/매수 잔량</span>
|
|
||||||
<span class="${ratioCls} text-xs">${ratioLabel}</span>
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 가격 위치 (장중 저가~고가 내 %)
|
|
||||||
if (s.pricePos !== undefined) {
|
|
||||||
const pos = s.pricePos.toFixed(0);
|
|
||||||
let posCls = 'text-gray-500';
|
|
||||||
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
|
|
||||||
else if (s.pricePos >= 60) posCls = 'text-orange-400';
|
|
||||||
else if (s.pricePos <= 30) posCls = 'text-blue-400';
|
|
||||||
rows.push(`<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">가격 위치</span>
|
|
||||||
<span class="${posCls}">${pos}%</span>
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length === 0) return '';
|
|
||||||
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 익일 추세 예상 리포팅 HTML 생성
|
|
||||||
function nextDayBadge(s) {
|
|
||||||
const trendMap = {
|
|
||||||
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
|
|
||||||
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
|
|
||||||
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// LLM 결과가 없으면 "분석 중..." 표시
|
|
||||||
if (!s.nextDayTrend) {
|
|
||||||
return `<div class="mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-400">익일 추세</span>
|
|
||||||
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
|
|
||||||
const confBadge = s.nextDayConf
|
|
||||||
? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>`
|
|
||||||
: '';
|
|
||||||
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
|
|
||||||
return `<div class="mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-400">익일 추세</span>
|
|
||||||
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
|
|
||||||
${style.icon} ${s.nextDayTrend}${confBadge}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI 목표가 뱃지 HTML 생성
|
|
||||||
function targetPriceBadge(s) {
|
|
||||||
if (!s.targetPrice || s.targetPrice === 0) return '';
|
|
||||||
const diff = s.targetPrice - s.currentPrice;
|
|
||||||
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
|
|
||||||
const sign = diff >= 0 ? '+' : '';
|
|
||||||
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
|
|
||||||
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
|
|
||||||
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
|
|
||||||
<span class="text-xs text-gray-400">AI 목표가</span>
|
|
||||||
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
|
|
||||||
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
|
|
||||||
</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시그널 종목 카드 HTML 생성
|
|
||||||
function makeCard(s) {
|
|
||||||
const diff = s.cntrStr - s.prevCntrStr;
|
|
||||||
const rising = s.risingCount || 1;
|
|
||||||
return `
|
|
||||||
<a href="/stock/${s.code}" id="sg-${s.code}"
|
|
||||||
class="block bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-5 border border-orange-100">
|
|
||||||
<div class="flex justify-between items-start mb-3">
|
|
||||||
<span class="text-sm text-gray-400 font-mono">${s.code}</span>
|
|
||||||
<div class="flex items-center gap-1.5 flex-wrap justify-end">
|
|
||||||
${signalTypeBadge(s)}
|
|
||||||
${riseProbBadge(s)}
|
|
||||||
${risingBadge(rising)}
|
|
||||||
${sentimentBadge(s)}
|
|
||||||
<span id="sg-rate-${s.code}" class="text-sm px-2.5 py-0.5 rounded-full font-semibold ${rateBadgeClass(s.changeRate)}">
|
|
||||||
${fmtRate(s.changeRate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-bold text-gray-800 text-base truncate mb-1">${s.name}</p>
|
|
||||||
<p id="sg-price-${s.code}" class="text-2xl font-bold ${rateClass(s.changeRate)} mb-3">${fmtNum(s.currentPrice)}원</p>
|
|
||||||
<div class="pt-3 border-t border-gray-50 text-sm space-y-1.5">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">체결강도</span>
|
|
||||||
<span id="sg-cntr-${s.code}" class="font-bold text-orange-500">${fmtCntr(s.cntrStr)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">직전 대비</span>
|
|
||||||
<span class="text-gray-500">${fmtCntr(s.prevCntrStr)} → <span class="text-green-500 font-semibold">+${diff.toFixed(2)}</span></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">거래량</span>
|
|
||||||
<span id="sg-vol-${s.code}" class="text-gray-600">${fmtNum(s.volume)}</span>
|
|
||||||
</div>
|
|
||||||
${complexIndicators(s)}
|
|
||||||
${targetPriceBadge(s)}
|
|
||||||
${nextDayBadge(s)}
|
|
||||||
</div>
|
|
||||||
<!-- 체결강도 미니 라인차트 -->
|
|
||||||
<canvas id="sg-chart-${s.code}"
|
|
||||||
style="width:100%;height:48px;display:block;margin-top:12px;"
|
|
||||||
class="rounded-sm"></canvas>
|
|
||||||
</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카드 삽입 후 canvas 초기화 및 초기값 기록
|
|
||||||
function initChart(code, cntrStr) {
|
|
||||||
recordCntr(code, cntrStr);
|
|
||||||
drawChart(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket 실시간 가격 수신 시 카드 업데이트
|
|
||||||
function updateCard(code, data) {
|
|
||||||
const priceEl = document.getElementById(`sg-price-${code}`);
|
|
||||||
const rateEl = document.getElementById(`sg-rate-${code}`);
|
|
||||||
const cntrEl = document.getElementById(`sg-cntr-${code}`);
|
|
||||||
const volEl = document.getElementById(`sg-vol-${code}`);
|
|
||||||
if (!priceEl) return;
|
|
||||||
|
|
||||||
const rate = data.changeRate ?? 0;
|
|
||||||
priceEl.textContent = fmtNum(data.currentPrice) + '원';
|
|
||||||
priceEl.className = `text-lg font-bold ${rateClass(rate)}`;
|
|
||||||
rateEl.textContent = fmtRate(rate);
|
|
||||||
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${rateBadgeClass(rate)}`;
|
|
||||||
if (cntrEl && data.cntrStr !== undefined) {
|
|
||||||
cntrEl.textContent = fmtCntr(data.cntrStr);
|
|
||||||
}
|
|
||||||
if (volEl) volEl.textContent = fmtNum(data.volume);
|
|
||||||
|
|
||||||
// 체결강도 히스토리 기록 후 차트 갱신
|
|
||||||
if (data.cntrStr != null && data.cntrStr !== 0) {
|
|
||||||
recordCntr(code, data.cntrStr);
|
|
||||||
drawChart(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이전 구독 종목 해제 후 신규 종목 구독
|
|
||||||
function resubscribe(newCodes) {
|
|
||||||
currentCodes.forEach(code => {
|
|
||||||
if (!newCodes.includes(code)) stockWS.unsubscribe(code);
|
|
||||||
});
|
|
||||||
newCodes.forEach(code => {
|
|
||||||
if (!currentCodes.includes(code)) {
|
|
||||||
stockWS.subscribe(code);
|
|
||||||
stockWS.onPrice(code, data => updateCard(code, data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
currentCodes = newCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/signal 조회 후 그리드 렌더링
|
|
||||||
async function fetchAndRender() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/signal');
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
const signals = await resp.json();
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
updatedEl.textContent = now.toTimeString().slice(0, 8) + ' 기준';
|
|
||||||
|
|
||||||
if (!Array.isArray(signals) || signals.length === 0) {
|
|
||||||
gridEl.innerHTML = '';
|
|
||||||
if (emptyEl) emptyEl.classList.remove('hidden');
|
|
||||||
resubscribe([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emptyEl) emptyEl.classList.add('hidden');
|
|
||||||
gridEl.innerHTML = signals.map(makeCard).join('');
|
|
||||||
|
|
||||||
// 카드 삽입 후 각 종목 초기 체결강도 기록 및 차트 초기화
|
|
||||||
signals.forEach(s => initChart(s.code, s.cntrStr));
|
|
||||||
|
|
||||||
resubscribe(signals.map(s => s.code));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('시그널 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pollTimer = null;
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
if (pollTimer) return;
|
|
||||||
fetchAndRender();
|
|
||||||
pollTimer = setInterval(fetchAndRender, INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollTimer) {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
pollTimer = null;
|
|
||||||
}
|
|
||||||
// 그리드 비우기
|
|
||||||
gridEl.innerHTML = '';
|
|
||||||
if (emptyEl) {
|
|
||||||
emptyEl.textContent = '스캐너가 꺼져 있습니다. 버튼을 눌러 켜주세요.';
|
|
||||||
emptyEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
resubscribe([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 토글 버튼 상태 적용
|
|
||||||
const toggleBtn = document.getElementById('scannerToggleBtn');
|
|
||||||
function applyState(enabled) {
|
|
||||||
if (!toggleBtn) return;
|
|
||||||
if (enabled) {
|
|
||||||
toggleBtn.textContent = '● ON';
|
|
||||||
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-green-100 text-green-700 border-green-300';
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
toggleBtn.textContent = '○ OFF';
|
|
||||||
toggleBtn.className = 'text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-gray-100 text-gray-400 border-gray-300';
|
|
||||||
stopPolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 페이지 로드 시 백엔드 상태 조회
|
|
||||||
fetch('/api/scanner/status')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => applyState(d.enabled))
|
|
||||||
.catch(() => applyState(true)); // 실패 시 켜짐으로 fallback
|
|
||||||
|
|
||||||
// 토글 버튼 클릭
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/scanner/toggle', { method: 'POST' });
|
|
||||||
const data = await resp.json();
|
|
||||||
applyState(data.enabled);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('스캐너 토글 실패:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 시그널 판단 기준 모달 열기/닫기
|
|
||||||
(function () {
|
|
||||||
const btn = document.getElementById('signalGuideBtn');
|
|
||||||
const modal = document.getElementById('signalGuideModal');
|
|
||||||
const close = document.getElementById('signalGuideClose');
|
|
||||||
if (!btn || !modal) return;
|
|
||||||
|
|
||||||
btn.addEventListener('click', () => modal.classList.remove('hidden'));
|
|
||||||
close.addEventListener('click', () => modal.classList.add('hidden'));
|
|
||||||
// 모달 바깥 클릭 시 닫기
|
|
||||||
modal.addEventListener('click', e => {
|
|
||||||
if (e.target === modal) modal.classList.add('hidden');
|
|
||||||
});
|
|
||||||
// ESC 키로 닫기
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Escape') modal.classList.add('hidden');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
/**
|
|
||||||
* 테마 분석 페이지
|
|
||||||
* - 테마 목록 조회 (ka90001)
|
|
||||||
* - 테마 구성종목 조회 (ka90002)
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
let currentDate = '1';
|
|
||||||
let currentSort = '3'; // 3=등락률순, 1=기간수익률순
|
|
||||||
let selectedCode = null;
|
|
||||||
let selectedName = null;
|
|
||||||
let allThemes = [];
|
|
||||||
let clientSortCol = null; // 헤더 클릭 정렬 컬럼 (null=서버 순서 유지)
|
|
||||||
let clientSortDesc = true;
|
|
||||||
|
|
||||||
const listEl = document.getElementById('themeList');
|
|
||||||
const countEl = document.getElementById('themeCount');
|
|
||||||
const searchEl = document.getElementById('themeSearch');
|
|
||||||
const emptyEl = document.getElementById('themeDetailEmpty');
|
|
||||||
const contentEl = document.getElementById('themeDetailContent');
|
|
||||||
const loadingEl = document.getElementById('themeDetailLoading');
|
|
||||||
const nameEl = document.getElementById('detailThemeName');
|
|
||||||
const fluRtEl = document.getElementById('detailFluRt');
|
|
||||||
const periodRtEl = document.getElementById('detailPeriodRt');
|
|
||||||
const stockListEl = document.getElementById('detailStockList');
|
|
||||||
|
|
||||||
// ── 포맷 유틸 ────────────────────────────────────────────────
|
|
||||||
function fmtRate(f) {
|
|
||||||
if (f == null) return '-';
|
|
||||||
const sign = f >= 0 ? '+' : '';
|
|
||||||
return sign + f.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
function fmtNum(n) {
|
|
||||||
if (n == null) return '-';
|
|
||||||
return Math.abs(n).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
function rateClass(f) {
|
|
||||||
if (f > 0) return 'text-red-500';
|
|
||||||
if (f < 0) return 'text-blue-500';
|
|
||||||
return 'text-gray-500';
|
|
||||||
}
|
|
||||||
function rateBg(f) {
|
|
||||||
if (f > 0) return 'bg-red-50 text-red-500';
|
|
||||||
if (f < 0) return 'bg-blue-50 text-blue-500';
|
|
||||||
return 'bg-gray-100 text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 테마 목록 행 렌더 ────────────────────────────────────────
|
|
||||||
function makeRow(t, rank) {
|
|
||||||
const fluCls = rateClass(t.fluRt);
|
|
||||||
const periodCls = t.periodRt >= 0 ? 'text-purple-600' : 'text-blue-500';
|
|
||||||
const isSelected = t.code === selectedCode;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div data-code="${t.code}" data-name="${t.name}"
|
|
||||||
class="theme-row grid grid-cols-[2fr_1fr_80px_80px_80px_80px] gap-0
|
|
||||||
px-4 py-3 cursor-pointer transition-colors text-sm
|
|
||||||
${isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'}">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-xs text-gray-400 w-5 shrink-0">${rank}</span>
|
|
||||||
<span class="font-medium text-gray-800 truncate">${t.name}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 truncate self-center">${t.mainStock || '-'}</div>
|
|
||||||
<div class="text-right self-center font-semibold ${fluCls}">${fmtRate(t.fluRt)}</div>
|
|
||||||
<div class="text-right self-center font-semibold ${periodCls}">${fmtRate(t.periodRt)}</div>
|
|
||||||
<div class="text-center self-center text-gray-500">${t.stockCount}</div>
|
|
||||||
<div class="text-center self-center text-xs">
|
|
||||||
<span class="text-red-400">${t.risingCount}▲</span>
|
|
||||||
<span class="text-gray-300 mx-0.5">/</span>
|
|
||||||
<span class="text-blue-400">${t.fallCount}▼</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
|
|
||||||
function updateThemeColHeaders() {
|
|
||||||
document.querySelectorAll('.theme-col-sort').forEach(el => {
|
|
||||||
const arrow = el.querySelector('.sort-arrow');
|
|
||||||
if (!arrow) return;
|
|
||||||
if (el.dataset.col === clientSortCol) {
|
|
||||||
arrow.textContent = clientSortDesc ? '▼' : '▲';
|
|
||||||
arrow.className = 'sort-arrow text-blue-400';
|
|
||||||
el.classList.add('text-blue-500');
|
|
||||||
el.classList.remove('text-gray-500');
|
|
||||||
} else {
|
|
||||||
arrow.textContent = '';
|
|
||||||
arrow.className = 'sort-arrow';
|
|
||||||
el.classList.remove('text-blue-500');
|
|
||||||
el.classList.add('text-gray-500');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 클라이언트 정렬 적용 ─────────────────────────────────────
|
|
||||||
function applyClientSort(themes) {
|
|
||||||
if (!clientSortCol) return themes;
|
|
||||||
return [...themes].sort((a, b) => {
|
|
||||||
const av = a[clientSortCol], bv = b[clientSortCol];
|
|
||||||
if (typeof av === 'string') {
|
|
||||||
const cmp = av.localeCompare(bv, 'ko');
|
|
||||||
return clientSortDesc ? cmp : -cmp;
|
|
||||||
}
|
|
||||||
const diff = bv - av;
|
|
||||||
return clientSortDesc ? diff : -diff;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 테마 목록 렌더 ───────────────────────────────────────────
|
|
||||||
function renderList(themes) {
|
|
||||||
const q = searchEl.value.trim();
|
|
||||||
let filtered = q
|
|
||||||
? themes.filter(t => t.name.includes(q))
|
|
||||||
: themes;
|
|
||||||
|
|
||||||
filtered = applyClientSort(filtered);
|
|
||||||
|
|
||||||
countEl.textContent = `총 ${filtered.length}개 테마`;
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">검색 결과가 없습니다.</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = filtered.map((t, i) => makeRow(t, i + 1)).join('');
|
|
||||||
|
|
||||||
// 행 클릭 이벤트
|
|
||||||
listEl.querySelectorAll('.theme-row').forEach(row => {
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
const code = row.dataset.code;
|
|
||||||
const name = row.dataset.name;
|
|
||||||
if (code === selectedCode) return;
|
|
||||||
selectedCode = code;
|
|
||||||
selectedName = name;
|
|
||||||
// 선택 상태 표시 갱신
|
|
||||||
listEl.querySelectorAll('.theme-row').forEach(r => {
|
|
||||||
r.classList.toggle('bg-blue-50', r.dataset.code === code);
|
|
||||||
r.classList.toggle('hover:bg-gray-50', r.dataset.code !== code);
|
|
||||||
});
|
|
||||||
loadDetail(code, name);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 테마 목록 조회 ───────────────────────────────────────────
|
|
||||||
async function loadThemes() {
|
|
||||||
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>`;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/themes?date=${currentDate}&sort=${currentSort}`);
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
allThemes = await resp.json();
|
|
||||||
renderList(allThemes);
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
|
|
||||||
console.error('테마 목록 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 구성종목 조회 ─────────────────────────────────────────────
|
|
||||||
async function loadDetail(code, name) {
|
|
||||||
emptyEl.classList.add('hidden');
|
|
||||||
contentEl.classList.add('hidden');
|
|
||||||
loadingEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/themes/${code}?date=${currentDate}`);
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
nameEl.textContent = name;
|
|
||||||
fluRtEl.textContent = fmtRate(data.fluRt);
|
|
||||||
fluRtEl.className = 'font-semibold ' + rateClass(data.fluRt);
|
|
||||||
periodRtEl.textContent = fmtRate(data.periodRt);
|
|
||||||
|
|
||||||
const stocks = data.stocks || [];
|
|
||||||
if (stocks.length === 0) {
|
|
||||||
stockListEl.innerHTML = `<div class="px-4 py-8 text-center text-xs text-gray-400">구성종목이 없습니다.</div>`;
|
|
||||||
} else {
|
|
||||||
stockListEl.innerHTML = stocks.map(s => {
|
|
||||||
const cls = rateClass(s.fluRt);
|
|
||||||
const bgCls = rateBg(s.fluRt);
|
|
||||||
const sign = s.predPre >= 0 ? '+' : '';
|
|
||||||
return `
|
|
||||||
<a href="/stock/${s.code}"
|
|
||||||
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 transition-colors">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-medium text-gray-800 truncate">${s.name}</p>
|
|
||||||
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right shrink-0 ml-3">
|
|
||||||
<p class="text-sm font-semibold ${cls}">${fmtNum(s.curPrc)}원</p>
|
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded ${bgCls}">${sign}${fmtRate(s.fluRt)}</span>
|
|
||||||
</div>
|
|
||||||
</a>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingEl.classList.add('hidden');
|
|
||||||
contentEl.classList.remove('hidden');
|
|
||||||
} catch (e) {
|
|
||||||
loadingEl.classList.add('hidden');
|
|
||||||
emptyEl.classList.remove('hidden');
|
|
||||||
emptyEl.innerHTML = '<p class="text-red-400">구성종목을 불러오지 못했습니다.</p>';
|
|
||||||
console.error('테마 구성종목 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 날짜 탭 이벤트 ───────────────────────────────────────────
|
|
||||||
document.querySelectorAll('.date-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
currentDate = btn.dataset.date;
|
|
||||||
document.querySelectorAll('.date-tab').forEach(b => {
|
|
||||||
b.className = b.dataset.date === currentDate
|
|
||||||
? 'date-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
||||||
: 'date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
||||||
});
|
|
||||||
loadThemes();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 정렬 탭 이벤트 (서버 사이드) ─────────────────────────────
|
|
||||||
document.querySelectorAll('.sort-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
currentSort = btn.dataset.sort;
|
|
||||||
// 헤더 클라이언트 정렬 초기화 (서버 순서 우선)
|
|
||||||
clientSortCol = null;
|
|
||||||
document.querySelectorAll('.sort-tab').forEach(b => {
|
|
||||||
b.className = b.dataset.sort === currentSort
|
|
||||||
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
||||||
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
||||||
});
|
|
||||||
updateThemeColHeaders();
|
|
||||||
loadThemes();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
|
|
||||||
document.querySelectorAll('.theme-col-sort').forEach(el => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
const col = el.dataset.col;
|
|
||||||
if (clientSortCol === col) {
|
|
||||||
clientSortDesc = !clientSortDesc;
|
|
||||||
} else {
|
|
||||||
clientSortCol = col;
|
|
||||||
clientSortDesc = true;
|
|
||||||
}
|
|
||||||
updateThemeColHeaders();
|
|
||||||
renderList(allThemes);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 검색 필터 이벤트 ─────────────────────────────────────────
|
|
||||||
searchEl.addEventListener('input', () => renderList(allThemes));
|
|
||||||
|
|
||||||
// ── 초기 로드 ────────────────────────────────────────────────
|
|
||||||
loadThemes();
|
|
||||||
})();
|
|
||||||
@@ -1,632 +0,0 @@
|
|||||||
/**
|
|
||||||
* 관심종목 관리 + WebSocket 실시간 시세 + 10초 폴링 분석
|
|
||||||
* - 서버 API (/api/watchlist)에 종목 저장
|
|
||||||
* - WS 구독으로 1초마다 현재가/등락률/체결강도 + 미니 차트 갱신
|
|
||||||
* - 10초마다 /api/watchlist-signal 폴링으로 복합 분석 뱃지 갱신
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
const MAX_HISTORY = 60; // 체결강도 히스토리 최대 포인트
|
|
||||||
const SIGNAL_INTERVAL = 10 * 1000; // 10초 폴링
|
|
||||||
|
|
||||||
// 종목별 체결강도 히스토리 (카드 재렌더링 시에도 유지)
|
|
||||||
const cntrHistory = new Map(); // code → number[]
|
|
||||||
|
|
||||||
// --- 서버 API 헬퍼 ---
|
|
||||||
// 메모리 캐시 (서버 동기화용)
|
|
||||||
let cachedList = [];
|
|
||||||
|
|
||||||
async function loadFromServer() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/watchlist');
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
const list = await resp.json();
|
|
||||||
cachedList = Array.isArray(list) ? list : [];
|
|
||||||
return cachedList;
|
|
||||||
} catch {
|
|
||||||
return cachedList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadList() {
|
|
||||||
return cachedList;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addToServer(code, name) {
|
|
||||||
const resp = await fetch('/api/watchlist', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ code, name }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(err.error || '추가 실패');
|
|
||||||
}
|
|
||||||
cachedList.push({ code, name });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFromServer(code) {
|
|
||||||
await fetch(`/api/watchlist/${code}`, { method: 'DELETE' });
|
|
||||||
cachedList = cachedList.filter(s => s.code !== code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DOM 요소 ---
|
|
||||||
const input = document.getElementById('watchlistInput');
|
|
||||||
const addBtn = document.getElementById('watchlistAddBtn');
|
|
||||||
const msgEl = document.getElementById('watchlistMsg');
|
|
||||||
const sidebarEl = document.getElementById('watchlistSidebar');
|
|
||||||
const emptyEl = document.getElementById('watchlistEmpty');
|
|
||||||
const panelEl = document.getElementById('watchlistPanel');
|
|
||||||
const panelEmpty = document.getElementById('watchlistPanelEmpty');
|
|
||||||
const wsStatusEl = document.getElementById('wsStatus');
|
|
||||||
|
|
||||||
// --- WS 상태 표시 ---
|
|
||||||
function setWsStatus(text, color) {
|
|
||||||
if (!wsStatusEl) return;
|
|
||||||
wsStatusEl.textContent = text;
|
|
||||||
wsStatusEl.className = `text-xs font-normal ml-1 ${color}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 포맷 유틸
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function fmtNum(n) {
|
|
||||||
if (n == null) return '-';
|
|
||||||
return Math.abs(n).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
function fmtRate(f) {
|
|
||||||
if (f == null) return '-';
|
|
||||||
return (f >= 0 ? '+' : '') + f.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
function fmtCntr(f) {
|
|
||||||
return f ? f.toFixed(2) : '-';
|
|
||||||
}
|
|
||||||
function rateClass(f) {
|
|
||||||
if (f > 0) return 'text-red-500';
|
|
||||||
if (f < 0) return 'text-blue-500';
|
|
||||||
return 'text-gray-500';
|
|
||||||
}
|
|
||||||
function rateBadgeClass(f) {
|
|
||||||
if (f > 0) return 'bg-red-50 text-red-500';
|
|
||||||
if (f < 0) return 'bg-blue-50 text-blue-500';
|
|
||||||
return 'bg-gray-100 text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 뱃지 HTML 생성 함수 (signal.js와 동일)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function signalTypeBadge(s) {
|
|
||||||
if (!s.signalType) return '';
|
|
||||||
const map = {
|
|
||||||
'강한매수': 'bg-red-600 text-white border-red-700',
|
|
||||||
'매수우세': 'bg-orange-400 text-white border-orange-500',
|
|
||||||
'물량소화': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
|
||||||
'추격위험': 'bg-gray-800 text-white border-gray-900',
|
|
||||||
'약한상승': 'bg-gray-100 text-gray-500 border-gray-300',
|
|
||||||
};
|
|
||||||
const cls = map[s.signalType] || 'bg-gray-100 text-gray-500';
|
|
||||||
return `<span class="border ${cls} text-xs px-1.5 py-0.5 rounded font-bold">${s.signalType}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function riseProbBadge(s) {
|
|
||||||
if (!s.riseLabel) return '';
|
|
||||||
const isVeryHigh = s.riseLabel === '매우 높음';
|
|
||||||
const cls = isVeryHigh
|
|
||||||
? 'bg-emerald-500 text-white border border-emerald-600'
|
|
||||||
: 'bg-teal-100 text-teal-700 border border-teal-200';
|
|
||||||
const icon = isVeryHigh ? '🚀' : '📈';
|
|
||||||
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-bold" title="상승확률점수: ${s.riseScore}점">${icon} ${s.riseLabel}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function risingBadge(n) {
|
|
||||||
if (!n) return '';
|
|
||||||
if (n >= 4) return `<span class="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded font-bold">🔥${n}연속</span>`;
|
|
||||||
if (n >= 2) return `<span class="bg-orange-400 text-white text-xs px-1.5 py-0.5 rounded font-bold">▲${n}연속</span>`;
|
|
||||||
return `<span class="bg-yellow-100 text-yellow-700 text-xs px-1.5 py-0.5 rounded font-semibold">↑상승</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sentimentBadge(s) {
|
|
||||||
const map = {
|
|
||||||
'호재': 'bg-green-100 text-green-700 border border-green-200',
|
|
||||||
'악재': 'bg-red-100 text-red-600 border border-red-200',
|
|
||||||
'중립': 'bg-gray-100 text-gray-500',
|
|
||||||
};
|
|
||||||
const cls = map[s.sentiment];
|
|
||||||
if (!cls) return '';
|
|
||||||
const title = s.sentimentReason ? ` title="${s.sentimentReason}"` : '';
|
|
||||||
return `<span class="${cls} text-xs px-1.5 py-0.5 rounded font-semibold"${title}>${s.sentiment}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function complexIndicators(s) {
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
if (s.volRatio > 0) {
|
|
||||||
let volCls = 'text-gray-500';
|
|
||||||
let volLabel = `${s.volRatio.toFixed(1)}배`;
|
|
||||||
if (s.volRatio >= 10) { volCls = 'text-gray-400 line-through'; volLabel += ' ⚠과열'; }
|
|
||||||
else if (s.volRatio >= 5) volCls = 'text-orange-400';
|
|
||||||
else if (s.volRatio >= 2) volCls = 'text-green-600 font-semibold';
|
|
||||||
else if (s.volRatio >= 1) volCls = 'text-green-500';
|
|
||||||
rows.push(`<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">거래량 증가</span>
|
|
||||||
<span class="${volCls}">${volLabel}</span>
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.totalAskVol > 0 && s.totalBidVol > 0) {
|
|
||||||
const ratio = s.askBidRatio.toFixed(2);
|
|
||||||
let ratioCls = 'text-gray-500';
|
|
||||||
let ratioLabel = `${ratio} (`;
|
|
||||||
if (s.askBidRatio <= 0.7) { ratioCls = 'text-green-600 font-semibold'; ratioLabel += '매수 강세)'; }
|
|
||||||
else if (s.askBidRatio <= 1.0) { ratioCls = 'text-green-500'; ratioLabel += '매수 우세)'; }
|
|
||||||
else if (s.askBidRatio <= 1.5) { ratioCls = 'text-gray-500'; ratioLabel += '균형)'; }
|
|
||||||
else { ratioCls = 'text-blue-500'; ratioLabel += '매도 우세)'; }
|
|
||||||
rows.push(`<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">매도/매수 잔량</span>
|
|
||||||
<span class="${ratioCls} text-xs">${ratioLabel}</span>
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.pricePos !== undefined) {
|
|
||||||
const pos = s.pricePos.toFixed(0);
|
|
||||||
let posCls = 'text-gray-500';
|
|
||||||
if (s.pricePos >= 80) posCls = 'text-red-500 font-semibold';
|
|
||||||
else if (s.pricePos >= 60) posCls = 'text-orange-400';
|
|
||||||
else if (s.pricePos <= 30) posCls = 'text-blue-400';
|
|
||||||
rows.push(`<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">가격 위치</span>
|
|
||||||
<span class="${posCls}">${pos}%</span>
|
|
||||||
</div>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length === 0) return '';
|
|
||||||
return `<div class="mt-2 pt-2 border-t border-gray-100 space-y-1 text-sm">${rows.join('')}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetPriceBadge(s) {
|
|
||||||
if (!s.targetPrice || s.targetPrice === 0) return '';
|
|
||||||
const diff = s.targetPrice - s.currentPrice;
|
|
||||||
const pct = ((diff / s.currentPrice) * 100).toFixed(1);
|
|
||||||
const sign = diff >= 0 ? '+' : '';
|
|
||||||
const cls = diff >= 0 ? 'bg-purple-50 text-purple-600 border border-purple-200' : 'bg-gray-100 text-gray-500';
|
|
||||||
const title = s.targetReason ? ` title="${s.targetReason}"` : '';
|
|
||||||
return `<div class="flex justify-between items-center mt-2 pt-2 border-t border-purple-50">
|
|
||||||
<span class="text-xs text-gray-400">AI 목표가</span>
|
|
||||||
<span class="${cls} text-xs px-2 py-0.5 rounded font-semibold"${title}>
|
|
||||||
${fmtNum(s.targetPrice)}원 <span class="opacity-70">(${sign}${pct}%)</span>
|
|
||||||
</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextDayBadge(s) {
|
|
||||||
const trendMap = {
|
|
||||||
'상승': { bg: 'bg-red-50 border-red-200', icon: '▲', cls: 'text-red-500' },
|
|
||||||
'하락': { bg: 'bg-blue-50 border-blue-200', icon: '▼', cls: 'text-blue-500' },
|
|
||||||
'횡보': { bg: 'bg-gray-50 border-gray-200', icon: '─', cls: 'text-gray-500' },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!s.nextDayTrend) {
|
|
||||||
return `<div class="mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-400">익일 추세</span>
|
|
||||||
<span class="text-xs text-gray-400 animate-pulse">분석 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = trendMap[s.nextDayTrend] || trendMap['횡보'];
|
|
||||||
const confBadge = s.nextDayConf ? `<span class="text-xs text-gray-400 ml-1">(${s.nextDayConf})</span>` : '';
|
|
||||||
const reasonTip = s.nextDayReason ? ` title="${s.nextDayReason}"` : '';
|
|
||||||
return `<div class="mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-400">익일 추세</span>
|
|
||||||
<span class="${style.bg} ${style.cls} border text-xs px-2 py-0.5 rounded font-bold"${reasonTip}>
|
|
||||||
${style.icon} ${s.nextDayTrend}${confBadge}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
${s.nextDayReason ? `<p class="text-xs text-gray-400 mt-1 truncate" title="${s.nextDayReason}">${s.nextDayReason}</p>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 체결강도 히스토리 + 미니 차트
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function recordCntr(code, value) {
|
|
||||||
if (value == null || value === 0) return;
|
|
||||||
if (!cntrHistory.has(code)) cntrHistory.set(code, []);
|
|
||||||
const arr = cntrHistory.get(code);
|
|
||||||
arr.push(value);
|
|
||||||
if (arr.length > MAX_HISTORY) arr.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawChart(code) {
|
|
||||||
const canvas = document.getElementById(`wc-chart-${code}`);
|
|
||||||
if (!canvas) return;
|
|
||||||
const data = cntrHistory.get(code) || [];
|
|
||||||
if (data.length < 2) return;
|
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const w = Math.floor(rect.width * dpr);
|
|
||||||
const h = Math.floor(rect.height * dpr);
|
|
||||||
if (w === 0 || h === 0) return;
|
|
||||||
if (canvas.width !== w || canvas.height !== h) {
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const pad = 3 * dpr;
|
|
||||||
const drawH = h - pad * 2;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
const min = Math.min(...data);
|
|
||||||
const max = Math.max(...data);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const step = w / (data.length - 1);
|
|
||||||
const getY = val => h - pad - ((val - min) / range) * drawH;
|
|
||||||
|
|
||||||
// 그라디언트 필
|
|
||||||
const lastX = (data.length - 1) * step;
|
|
||||||
const grad = ctx.createLinearGradient(0, pad, 0, h);
|
|
||||||
grad.addColorStop(0, 'rgba(249,115,22,0.35)');
|
|
||||||
grad.addColorStop(1, 'rgba(249,115,22,0)');
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
data.forEach((val, i) => {
|
|
||||||
const x = i * step;
|
|
||||||
const y = getY(val);
|
|
||||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
||||||
});
|
|
||||||
ctx.lineTo(lastX, h);
|
|
||||||
ctx.lineTo(0, h);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// 라인
|
|
||||||
ctx.beginPath();
|
|
||||||
data.forEach((val, i) => {
|
|
||||||
const x = i * step;
|
|
||||||
const y = getY(val);
|
|
||||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
||||||
});
|
|
||||||
ctx.strokeStyle = '#f97316';
|
|
||||||
ctx.lineWidth = 1.5 * dpr;
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// 마지막 포인트 강조
|
|
||||||
const lastVal = data[data.length - 1];
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(lastX, getY(lastVal), 2.5 * dpr, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = '#f97316';
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 관심종목 추가 / 삭제
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
async function addStock(code) {
|
|
||||||
code = code.trim().toUpperCase();
|
|
||||||
if (!/^\d{6}$/.test(code)) {
|
|
||||||
showMsg('6자리 숫자 종목코드를 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loadList().find(s => s.code === code)) {
|
|
||||||
showMsg('이미 추가된 종목입니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showMsg('조회 중...', false);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/stock/${code}`);
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.name) throw new Error('종목 없음');
|
|
||||||
|
|
||||||
await addToServer(code, data.name);
|
|
||||||
hideMsg();
|
|
||||||
input.value = '';
|
|
||||||
renderSidebar();
|
|
||||||
addPanelCard(code, data.name);
|
|
||||||
updateCard(code, data);
|
|
||||||
subscribeCode(code);
|
|
||||||
} catch (e) {
|
|
||||||
showMsg(e.message || '종목을 찾을 수 없습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeStock(code) {
|
|
||||||
await removeFromServer(code);
|
|
||||||
stockWS.unsubscribe(code);
|
|
||||||
document.getElementById(`si-${code}`)?.remove();
|
|
||||||
document.getElementById(`wc-${code}`)?.remove();
|
|
||||||
cntrHistory.delete(code);
|
|
||||||
updateEmptyStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 사이드바 렌더링
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function renderSidebar() {
|
|
||||||
const list = loadList();
|
|
||||||
// 테이블 생성
|
|
||||||
let tableEl = sidebarEl.querySelector('table');
|
|
||||||
if (tableEl) tableEl.remove();
|
|
||||||
if (list.length > 0) {
|
|
||||||
tableEl = document.createElement('table');
|
|
||||||
tableEl.className = 'w-full text-xs';
|
|
||||||
const tbody = document.createElement('tbody');
|
|
||||||
tbody.className = 'divide-y divide-gray-50';
|
|
||||||
list.forEach(s => tbody.appendChild(makeSidebarItem(s.code, s.name)));
|
|
||||||
tableEl.appendChild(tbody);
|
|
||||||
sidebarEl.insertBefore(tableEl, emptyEl);
|
|
||||||
}
|
|
||||||
updateEmptyStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSidebarItem(code, name) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.id = `si-${code}`;
|
|
||||||
tr.className = 'hover:bg-gray-50 group cursor-pointer';
|
|
||||||
tr.onclick = () => { window.location.href = `/stock/${code}`; };
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td class="px-3 py-2">
|
|
||||||
<p class="font-medium text-gray-800 truncate">${name}</p>
|
|
||||||
<p class="text-gray-400 font-mono">${code}</p>
|
|
||||||
</td>
|
|
||||||
<td id="si-price-${code}" class="px-2 py-2 text-right font-mono text-gray-400">-</td>
|
|
||||||
<td id="si-rate-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
|
|
||||||
<td id="si-cntr-${code}" class="px-2 py-2 text-right text-gray-400">-</td>
|
|
||||||
<td class="pr-2 py-2 text-center">
|
|
||||||
<button onclick="event.stopPropagation(); removeStock('${code}')" title="삭제"
|
|
||||||
class="text-gray-300 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity text-base leading-none">×</button>
|
|
||||||
</td>`;
|
|
||||||
return tr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 패널 카드 추가 (signal.js makeCard 구조와 동일)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function addPanelCard(code, name) {
|
|
||||||
if (document.getElementById(`wc-${code}`)) return;
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.id = `wc-${code}`;
|
|
||||||
card.className = 'bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow p-4 border border-gray-100 relative';
|
|
||||||
card.innerHTML = `
|
|
||||||
<!-- 헤더: 종목코드 + 분석 뱃지 + 등락률 -->
|
|
||||||
<div class="flex justify-between items-start mb-2">
|
|
||||||
<a href="/stock/${code}" class="text-xs text-gray-400 font-mono hover:text-blue-500">${code}</a>
|
|
||||||
<div id="wc-badges-${code}" class="flex items-center gap-1 flex-wrap justify-end">
|
|
||||||
<span id="wc-rate-${code}" class="text-xs px-2 py-0.5 rounded-full font-semibold bg-gray-100 text-gray-500">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 종목명 + 현재가 -->
|
|
||||||
<a href="/stock/${code}" class="block">
|
|
||||||
<p class="font-semibold text-gray-800 text-sm truncate mb-1">${name}</p>
|
|
||||||
<p id="wc-price-${code}" class="text-xl font-bold text-gray-900 mb-2">-</p>
|
|
||||||
</a>
|
|
||||||
<!-- info 섹션 -->
|
|
||||||
<div class="pt-2 border-t border-gray-50 text-sm space-y-1">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">체결강도</span>
|
|
||||||
<span id="wc-cntr-${code}" class="font-bold text-orange-500">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">직전 대비</span>
|
|
||||||
<span id="wc-prev-${code}" class="text-gray-500">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">거래량</span>
|
|
||||||
<span id="wc-vol-${code}" class="text-gray-600">-</span>
|
|
||||||
</div>
|
|
||||||
<!-- 복합 지표 (10초 폴링 갱신) -->
|
|
||||||
<div id="wc-complex-${code}"></div>
|
|
||||||
<!-- AI 목표가 (10초 폴링 갱신) -->
|
|
||||||
<div id="wc-target-${code}"></div>
|
|
||||||
<!-- 익일 추세 (10초 폴링 갱신) -->
|
|
||||||
<div id="wc-nextday-${code}"></div>
|
|
||||||
</div>
|
|
||||||
<!-- 체결강도 미니 라인차트 -->
|
|
||||||
<canvas id="wc-chart-${code}"
|
|
||||||
style="width:100%;height:48px;display:block;margin-top:10px;"
|
|
||||||
class="rounded-sm"></canvas>
|
|
||||||
<!-- 삭제 버튼 -->
|
|
||||||
<button onclick="removeStock('${code}')" title="삭제"
|
|
||||||
class="absolute top-3 right-3 text-gray-200 hover:text-red-400 text-lg leading-none opacity-0 hover:opacity-100 transition-opacity" style="font-size:18px;">×</button>`;
|
|
||||||
|
|
||||||
panelEl.appendChild(card);
|
|
||||||
updateEmptyStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// WS 실시간 카드 업데이트 (1초)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function updateCard(code, data) {
|
|
||||||
const priceEl = document.getElementById(`wc-price-${code}`);
|
|
||||||
const rateEl = document.getElementById(`wc-rate-${code}`);
|
|
||||||
const volEl = document.getElementById(`wc-vol-${code}`);
|
|
||||||
const cntrEl = document.getElementById(`wc-cntr-${code}`);
|
|
||||||
|
|
||||||
const rate = data.changeRate ?? 0;
|
|
||||||
const colorCls = rateClass(rate);
|
|
||||||
const bgCls = rateBadgeClass(rate);
|
|
||||||
const sign = rate > 0 ? '+' : '';
|
|
||||||
|
|
||||||
// 패널 카드 업데이트
|
|
||||||
if (priceEl) {
|
|
||||||
priceEl.textContent = fmtNum(data.currentPrice) + '원';
|
|
||||||
priceEl.className = `text-xl font-bold mb-2 ${colorCls}`;
|
|
||||||
}
|
|
||||||
if (rateEl) {
|
|
||||||
rateEl.textContent = sign + rate.toFixed(2) + '%';
|
|
||||||
rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`;
|
|
||||||
}
|
|
||||||
if (volEl) volEl.textContent = fmtNum(data.volume);
|
|
||||||
if (cntrEl && data.cntrStr !== undefined) {
|
|
||||||
const cs = data.cntrStr;
|
|
||||||
cntrEl.textContent = fmtCntr(cs);
|
|
||||||
cntrEl.className = `font-bold ${cs >= 100 ? 'text-orange-500' : 'text-blue-400'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사이드바 행 업데이트
|
|
||||||
const siPrice = document.getElementById(`si-price-${code}`);
|
|
||||||
const siRate = document.getElementById(`si-rate-${code}`);
|
|
||||||
const siCntr = document.getElementById(`si-cntr-${code}`);
|
|
||||||
if (siPrice) {
|
|
||||||
siPrice.textContent = fmtNum(data.currentPrice);
|
|
||||||
siPrice.className = `px-2 py-2 text-right font-mono ${colorCls}`;
|
|
||||||
}
|
|
||||||
if (siRate) {
|
|
||||||
siRate.textContent = fmtRate(rate);
|
|
||||||
siRate.className = `px-2 py-2 text-right ${colorCls}`;
|
|
||||||
}
|
|
||||||
if (siCntr && data.cntrStr !== undefined) {
|
|
||||||
const cs = data.cntrStr;
|
|
||||||
siCntr.textContent = fmtCntr(cs);
|
|
||||||
siCntr.className = `px-2 py-2 text-right ${cs >= 100 ? 'text-orange-500 font-bold' : cs > 0 ? 'text-blue-400' : 'text-gray-400'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 체결강도 히스토리 기록 + 미니 차트 갱신
|
|
||||||
if (data.cntrStr != null && data.cntrStr !== 0) {
|
|
||||||
recordCntr(code, data.cntrStr);
|
|
||||||
drawChart(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 10초 폴링 분석 결과 DOM 갱신
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function updateAnalysis(code, sig) {
|
|
||||||
// 뱃지 영역 갱신 (등락률 뱃지는 유지하면서 분석 뱃지 앞에 삽입)
|
|
||||||
const badgesEl = document.getElementById(`wc-badges-${code}`);
|
|
||||||
const rateEl = document.getElementById(`wc-rate-${code}`);
|
|
||||||
if (badgesEl && rateEl) {
|
|
||||||
// 기존 분석 뱃지 제거
|
|
||||||
badgesEl.querySelectorAll('.analysis-badge').forEach(el => el.remove());
|
|
||||||
// 분석 뱃지 생성 후 등락률 앞에 삽입
|
|
||||||
const frag = document.createRange().createContextualFragment(
|
|
||||||
[
|
|
||||||
signalTypeBadge(sig),
|
|
||||||
riseProbBadge(sig),
|
|
||||||
risingBadge(sig.risingCount),
|
|
||||||
sentimentBadge(sig),
|
|
||||||
].filter(Boolean).join('')
|
|
||||||
);
|
|
||||||
// analysis-badge 클래스 추가 (제거 시 사용)
|
|
||||||
frag.querySelectorAll('span').forEach(el => el.classList.add('analysis-badge'));
|
|
||||||
badgesEl.insertBefore(frag, rateEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 직전 대비 갱신
|
|
||||||
const prevEl = document.getElementById(`wc-prev-${code}`);
|
|
||||||
if (prevEl && sig.prevCntrStr != null && sig.cntrStr != null) {
|
|
||||||
const diff = sig.cntrStr - sig.prevCntrStr;
|
|
||||||
prevEl.innerHTML = `${fmtCntr(sig.prevCntrStr)} → <span class="${diff >= 0 ? 'text-green-500' : 'text-blue-400'} font-semibold">${diff >= 0 ? '+' : ''}${diff.toFixed(2)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 복합 지표 갱신
|
|
||||||
const complexEl = document.getElementById(`wc-complex-${code}`);
|
|
||||||
if (complexEl) complexEl.innerHTML = complexIndicators(sig);
|
|
||||||
|
|
||||||
// AI 목표가 갱신
|
|
||||||
const targetEl = document.getElementById(`wc-target-${code}`);
|
|
||||||
if (targetEl) targetEl.innerHTML = targetPriceBadge(sig);
|
|
||||||
|
|
||||||
// 익일 추세 갱신
|
|
||||||
const nextEl = document.getElementById(`wc-nextday-${code}`);
|
|
||||||
if (nextEl) nextEl.innerHTML = nextDayBadge(sig);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// WS 구독
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function subscribeCode(code) {
|
|
||||||
stockWS.subscribe(code);
|
|
||||||
stockWS.onPrice(code, (data) => updateCard(code, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 10초 폴링: /api/watchlist-signal
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
async function fetchWatchlistSignal() {
|
|
||||||
const codes = cachedList.map(s => s.code).join(',');
|
|
||||||
if (!codes) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/watchlist-signal?codes=${codes}`);
|
|
||||||
if (!resp.ok) throw new Error('조회 실패');
|
|
||||||
const signals = await resp.json();
|
|
||||||
if (!Array.isArray(signals)) return;
|
|
||||||
signals.forEach(sig => updateAnalysis(sig.code, sig));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('관심종목 분석 조회 실패:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 빈 상태 처리
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function updateEmptyStates() {
|
|
||||||
const hasItems = cachedList.length > 0;
|
|
||||||
if (emptyEl) emptyEl.classList.toggle('hidden', hasItems);
|
|
||||||
panelEmpty?.classList.toggle('hidden', hasItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 메시지
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function showMsg(text, isError = true) {
|
|
||||||
msgEl.textContent = text;
|
|
||||||
msgEl.className = `text-xs mt-1 ${isError ? 'text-red-500' : 'text-gray-400'}`;
|
|
||||||
msgEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
function hideMsg() { msgEl.classList.add('hidden'); }
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// WS 상태 모니터링
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
function monitorWS() {
|
|
||||||
setInterval(() => {
|
|
||||||
if (stockWS.ws?.readyState === WebSocket.OPEN) {
|
|
||||||
setWsStatus('● 실시간', 'text-green-500 font-normal ml-1');
|
|
||||||
} else {
|
|
||||||
setWsStatus('○ 연결 중...', 'text-gray-400 font-normal ml-1');
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 이벤트 바인딩
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
addBtn.addEventListener('click', () => addStock(input.value));
|
|
||||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') addStock(input.value); });
|
|
||||||
|
|
||||||
// removeStock을 전역으로 노출 (onclick 속성에서 호출)
|
|
||||||
window.removeStock = removeStock;
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 초기화
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
monitorWS();
|
|
||||||
|
|
||||||
// 서버에서 관심종목 로드 후 카드 생성 + WS 구독
|
|
||||||
(async function init() {
|
|
||||||
await loadFromServer();
|
|
||||||
renderSidebar();
|
|
||||||
cachedList.forEach(s => {
|
|
||||||
addPanelCard(s.code, s.name);
|
|
||||||
subscribeCode(s.code);
|
|
||||||
fetch(`/api/stock/${s.code}`)
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => { if (data) updateCard(s.code, data); })
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
updateEmptyStates();
|
|
||||||
|
|
||||||
// 10초 폴링 시작 (즉시 1회 + 10초 주기)
|
|
||||||
fetchWatchlistSignal();
|
|
||||||
setInterval(fetchWatchlistSignal, SIGNAL_INTERVAL);
|
|
||||||
})();
|
|
||||||
})();
|
|
||||||
@@ -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();
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ .Title }}</title>
|
|
||||||
<!-- Tailwind CSS CDN -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<!-- TradingView Lightweight Charts v4 (v5는 API 호환성 문제로 고정) -->
|
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.1/dist/lightweight-charts.standalone.production.js"></script>
|
|
||||||
<link rel="stylesheet" href="/static/css/custom.css">
|
|
||||||
<style>
|
|
||||||
/* 상승/하락 색상 (한국 주식 관행) */
|
|
||||||
.price-up { color: #ef4444; } /* 빨강 */
|
|
||||||
.price-down { color: #3b82f6; } /* 파랑 */
|
|
||||||
.price-flat { color: #6b7280; } /* 회색 */
|
|
||||||
|
|
||||||
/* 사이드바 토글 애니메이션 */
|
|
||||||
#sidebarWrapper {
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: width 0.25s ease, opacity 0.25s ease, margin 0.25s ease;
|
|
||||||
width: 240px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
#sidebarWrapper.sidebar-hidden {
|
|
||||||
width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
margin-right: 0 !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 min-h-screen">
|
|
||||||
|
|
||||||
<!-- 네비게이션 -->
|
|
||||||
<nav class="bg-white shadow-sm sticky top-0 z-50">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between h-14">
|
|
||||||
<!-- 사이드바 토글 버튼 -->
|
|
||||||
<button id="sidebarToggle" title="사이드바 열기/닫기"
|
|
||||||
class="mr-2 p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 transition-colors shrink-0">
|
|
||||||
<!-- 사이드바 열림: 왼쪽 화살표(닫기) -->
|
|
||||||
<svg id="sidebarIconOpen" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
<!-- 사이드바 닫힘: 오른쪽 화살표(열기) -->
|
|
||||||
<svg id="sidebarIconClose" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- 로고 -->
|
|
||||||
<a href="/" class="text-lg font-bold text-gray-800 hover:text-blue-600 shrink-0 mr-2">
|
|
||||||
📈 주식 시세
|
|
||||||
</a>
|
|
||||||
<!-- 네비 메뉴 -->
|
|
||||||
<nav class="flex items-center gap-0.5 shrink-0">
|
|
||||||
<a href="/" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
|
|
||||||
{{ if eq .ActiveMenu "시세" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">시세</a>
|
|
||||||
<a href="/theme" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
|
|
||||||
{{ if eq .ActiveMenu "테마" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">테마</a>
|
|
||||||
<a href="/kospi200" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
|
|
||||||
{{ if eq .ActiveMenu "코스피200" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">코스피200</a>
|
|
||||||
{{ if .LoggedIn }}
|
|
||||||
<a href="/asset" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
|
|
||||||
{{ if eq .ActiveMenu "자산" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">자산</a>
|
|
||||||
<a href="/autotrade" class="text-sm px-3 py-1.5 rounded-lg font-medium transition-colors
|
|
||||||
{{ if eq .ActiveMenu "자동매매" }}bg-blue-50 text-blue-600{{ else }}text-gray-500 hover:bg-gray-100 hover:text-gray-800{{ end }}">자동매매</a>
|
|
||||||
{{ end }}
|
|
||||||
</nav>
|
|
||||||
<!-- 검색창 -->
|
|
||||||
<div class="flex-1 max-w-md mx-4 relative">
|
|
||||||
<input
|
|
||||||
id="searchInput"
|
|
||||||
type="text"
|
|
||||||
placeholder="종목명 또는 코드 검색"
|
|
||||||
autocomplete="off"
|
|
||||||
class="w-full px-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
||||||
>
|
|
||||||
<!-- 자동완성 드롭다운 -->
|
|
||||||
<div id="searchDropdown" class="hidden absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-60 overflow-y-auto"></div>
|
|
||||||
</div>
|
|
||||||
<!-- 로그인/로그아웃 버튼 -->
|
|
||||||
{{ if .LoggedIn }}
|
|
||||||
<form method="POST" action="/logout" class="shrink-0">
|
|
||||||
<button type="submit"
|
|
||||||
class="text-xs px-3 py-1.5 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-800 font-medium transition-colors">
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{{ else }}
|
|
||||||
<a href="/login" class="shrink-0 text-xs px-3 py-1.5 rounded-lg text-blue-600 hover:bg-blue-50 font-medium transition-colors">
|
|
||||||
로그인
|
|
||||||
</a>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- 지수 티커 바 -->
|
|
||||||
<div class="bg-gray-800 text-white text-xs">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-8 flex items-center gap-6 overflow-x-auto" id="indexTicker">
|
|
||||||
<span class="text-gray-400 shrink-0">로딩 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 메인 콘텐츠 -->
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex gap-6 items-start">
|
|
||||||
<div id="sidebarWrapper">
|
|
||||||
{{ block "sidebar" . }}{{ end }}
|
|
||||||
</div>
|
|
||||||
<main class="flex-1 min-w-0">
|
|
||||||
{{ block "content" . }}{{ end }}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 공통 JavaScript -->
|
|
||||||
<script src="/static/js/websocket.js"></script>
|
|
||||||
<script src="/static/js/search.js"></script>
|
|
||||||
<script src="/static/js/indices.js"></script>
|
|
||||||
{{ block "scripts" . }}{{ end }}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const wrapper = document.getElementById('sidebarWrapper');
|
|
||||||
const toggle = document.getElementById('sidebarToggle');
|
|
||||||
const iconOpen = document.getElementById('sidebarIconOpen');
|
|
||||||
const iconClose = document.getElementById('sidebarIconClose');
|
|
||||||
if (!wrapper || !toggle) return;
|
|
||||||
|
|
||||||
// 사이드바가 없는 페이지(sidebar 블록이 비어 있음)는 버튼 숨김
|
|
||||||
if (!wrapper.children.length) {
|
|
||||||
toggle.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'sidebar_visible';
|
|
||||||
|
|
||||||
function applyState(visible) {
|
|
||||||
if (visible) {
|
|
||||||
wrapper.classList.remove('sidebar-hidden');
|
|
||||||
iconOpen.classList.remove('hidden');
|
|
||||||
iconClose.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
wrapper.classList.add('sidebar-hidden');
|
|
||||||
iconOpen.classList.add('hidden');
|
|
||||||
iconClose.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
localStorage.setItem(STORAGE_KEY, visible ? '1' : '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 저장된 상태 복원 (기본값: 표시)
|
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
|
||||||
applyState(saved !== '0');
|
|
||||||
|
|
||||||
toggle.addEventListener('click', () => {
|
|
||||||
const isHidden = wrapper.classList.contains('sidebar-hidden');
|
|
||||||
applyState(isHidden); // 현재 숨겨져 있으면 보이게
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
{{ template "base.html" . }}
|
|
||||||
|
|
||||||
{{ define "sidebar" }}{{ end }}
|
|
||||||
|
|
||||||
{{ define "content" }}
|
|
||||||
<div class="space-y-5">
|
|
||||||
|
|
||||||
<!-- 페이지 제목 -->
|
|
||||||
<h1 class="text-xl font-bold text-gray-800">자산 현황</h1>
|
|
||||||
|
|
||||||
<!-- 요약 카드 4개 -->
|
|
||||||
<div id="summaryCards" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
|
|
||||||
<p class="text-lg font-bold text-gray-300">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
|
|
||||||
<p class="text-lg font-bold text-gray-300">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
|
|
||||||
<p class="text-lg font-bold text-gray-300">-</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4 animate-pulse">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">로딩 중...</p>
|
|
||||||
<p class="text-lg font-bold text-gray-300">-</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 예수금 카드 -->
|
|
||||||
<div id="cashCard" class="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">예수금</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400 mb-1">D+2 추정예수금</p>
|
|
||||||
<p id="cashEntr" class="text-base font-bold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400 mb-1">주문가능현금</p>
|
|
||||||
<p id="cashOrdAlowa" class="text-base font-bold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 보유 종목 테이블 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-5 py-3 border-b border-gray-100">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-700">보유 종목</h2>
|
|
||||||
</div>
|
|
||||||
<!-- 테이블 헤더 -->
|
|
||||||
<div class="grid grid-cols-[1fr_80px_90px_90px_100px_80px] text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100 px-5 py-2.5 gap-2">
|
|
||||||
<span>종목명</span>
|
|
||||||
<span class="text-right">보유수량</span>
|
|
||||||
<span class="text-right">평균단가</span>
|
|
||||||
<span class="text-right">현재가</span>
|
|
||||||
<span class="text-right">평가손익</span>
|
|
||||||
<span class="text-right">수익률</span>
|
|
||||||
</div>
|
|
||||||
<!-- 종목 목록 -->
|
|
||||||
<div id="holdingsTable">
|
|
||||||
<div class="px-5 py-10 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 미체결/체결내역 탭 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
||||||
<!-- 탭 버튼 -->
|
|
||||||
<div class="flex border-b border-gray-100">
|
|
||||||
<button id="assetPendingTab"
|
|
||||||
onclick="showAssetTab('pending')"
|
|
||||||
class="px-5 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
|
|
||||||
미체결
|
|
||||||
</button>
|
|
||||||
<button id="assetHistoryTab"
|
|
||||||
onclick="showAssetTab('history')"
|
|
||||||
class="px-5 py-3 text-sm font-medium text-gray-500 hover:text-gray-700">
|
|
||||||
체결내역
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 미체결 패널 -->
|
|
||||||
<div id="assetPendingPanel" class="p-5">
|
|
||||||
<div class="text-sm text-gray-400 text-center py-6 animate-pulse">조회 중...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 체결내역 패널 -->
|
|
||||||
<div id="assetHistoryPanel" class="p-5 hidden">
|
|
||||||
<div class="text-sm text-gray-400 text-center py-6">탭을 클릭하면 조회됩니다.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "scripts" }}
|
|
||||||
<script src="/static/js/asset.js"></script>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
{{ template "base.html" . }}
|
|
||||||
|
|
||||||
{{ define "sidebar" }}{{ end }}
|
|
||||||
|
|
||||||
{{ define "content" }}
|
|
||||||
<div class="space-y-5">
|
|
||||||
|
|
||||||
<!-- 페이지 제목 + 상태바 -->
|
|
||||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800">자동매매</h1>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span id="statusDot" class="w-3 h-3 rounded-full bg-gray-300"></span>
|
|
||||||
<span id="statusText" class="text-sm font-medium text-gray-600">중지</span>
|
|
||||||
</div>
|
|
||||||
<button onclick="startEngine()" id="btnStart"
|
|
||||||
class="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
||||||
시작
|
|
||||||
</button>
|
|
||||||
<button onclick="stopEngine()" id="btnStop"
|
|
||||||
class="px-4 py-2 text-sm font-medium bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
|
||||||
중지
|
|
||||||
</button>
|
|
||||||
<button onclick="emergencyStop()"
|
|
||||||
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
|
||||||
⚠ 긴급청산
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 요약 카드 3개 -->
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<p class="text-xs text-gray-500 mb-1">진행중 포지션</p>
|
|
||||||
<p id="statActivePos" class="text-2xl font-bold text-gray-800">0</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<p class="text-xs text-gray-500 mb-1">오늘 매매 횟수</p>
|
|
||||||
<p id="statTradeCount" class="text-2xl font-bold text-gray-800">0</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<p class="text-xs text-gray-500 mb-1">오늘 손익</p>
|
|
||||||
<p id="statTotalPL" class="text-2xl font-bold text-gray-800">0원</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 감시 소스 설정 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800">감시 소스 설정</h2>
|
|
||||||
<span class="text-xs text-gray-400">매수 신호를 가져올 종목 범위를 선택하세요</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
|
||||||
<!-- 체결강도 자동감지 토글 -->
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50 min-w-[200px]">
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-800">체결강도 자동감지</p>
|
|
||||||
<p class="text-xs text-gray-400">거래량 상위 20종목 실시간 분석</p>
|
|
||||||
</div>
|
|
||||||
<label class="inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" id="wsScanner" checked onchange="saveWatchSource()"
|
|
||||||
class="sr-only peer">
|
|
||||||
<div class="w-11 h-6 bg-gray-200 peer-checked:bg-blue-600 rounded-full peer
|
|
||||||
peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors relative
|
|
||||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
|
||||||
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
|
|
||||||
peer-checked:after:translate-x-5"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 테마 선택 -->
|
|
||||||
<div class="flex-1 p-3 rounded-lg border border-gray-100 bg-gray-50">
|
|
||||||
<p class="text-sm font-medium text-gray-800 mb-2">테마 감시</p>
|
|
||||||
<!-- 테마 드롭다운 + 추가 버튼 -->
|
|
||||||
<div class="flex gap-2 mb-2">
|
|
||||||
<select id="themeSelect"
|
|
||||||
class="flex-1 px-3 py-1.5 text-xs border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
<option value="">테마를 선택하세요...</option>
|
|
||||||
</select>
|
|
||||||
<button onclick="addSelectedTheme()"
|
|
||||||
class="px-3 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors whitespace-nowrap">
|
|
||||||
+ 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- 선택된 테마 태그 목록 -->
|
|
||||||
<div id="selectedThemes" class="flex flex-wrap gap-1.5 min-h-[28px]">
|
|
||||||
<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 규칙 + 포지션 -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
|
|
||||||
<!-- 매매 규칙 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800">매매 규칙</h2>
|
|
||||||
<button onclick="showAddRuleModal()"
|
|
||||||
class="px-3 py-1.5 text-xs font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
||||||
+ 규칙 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="rulesList" class="space-y-3">
|
|
||||||
<p class="text-xs text-gray-400 text-center py-4">규칙이 없습니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 현재 포지션 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800">현재 포지션</h2>
|
|
||||||
<span class="text-xs text-gray-400">5초 자동 갱신</span>
|
|
||||||
</div>
|
|
||||||
<div id="positionsList">
|
|
||||||
<p class="text-xs text-gray-400 text-center py-4">보유 포지션 없음</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 매매 로그 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
|
||||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800">매매 로그</h2>
|
|
||||||
<span id="wsStatus" class="text-xs text-gray-400">○ 연결중...</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 text-xs">
|
|
||||||
<button onclick="setLogFilter('all')" id="filterAll"
|
|
||||||
class="px-3 py-1 rounded-full bg-gray-800 text-white font-medium">전체</button>
|
|
||||||
<button onclick="setLogFilter('action')" id="filterAction"
|
|
||||||
class="px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200">매매만</button>
|
|
||||||
</div>
|
|
||||||
<label class="flex items-center gap-1 text-xs text-gray-400 cursor-pointer">
|
|
||||||
<input type="checkbox" id="autoScrollChk" checked class="w-3 h-3 accent-blue-500">
|
|
||||||
자동스크롤
|
|
||||||
</label>
|
|
||||||
<span id="logUpdateTime" class="text-xs text-gray-400"></span>
|
|
||||||
</div>
|
|
||||||
<div id="logsWrapper" class="overflow-y-auto rounded border border-gray-100" style="max-height:480px;">
|
|
||||||
<table class="w-full text-xs">
|
|
||||||
<thead class="sticky top-0 bg-white">
|
|
||||||
<tr class="text-left text-gray-400 border-b border-gray-100">
|
|
||||||
<th class="pb-2 pt-2 px-1 font-medium w-36">시각</th>
|
|
||||||
<th class="pb-2 pt-2 px-1 font-medium w-16">레벨</th>
|
|
||||||
<th class="pb-2 pt-2 px-1 font-medium w-20">종목</th>
|
|
||||||
<th class="pb-2 pt-2 px-1 font-medium">내용</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="logsList">
|
|
||||||
<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 규칙 추가/수정 모달 -->
|
|
||||||
<div id="ruleModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
|
||||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center justify-between mb-5">
|
|
||||||
<h3 id="modalTitle" class="text-base font-semibold text-gray-800">규칙 추가</h3>
|
|
||||||
<button onclick="hideModal()" class="text-gray-400 hover:text-gray-600 text-lg">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<input type="hidden" id="ruleId">
|
|
||||||
|
|
||||||
<!-- 규칙명 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">규칙명</label>
|
|
||||||
<input id="fName" type="text" placeholder="예: 강한매수 전략"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 진입 조건 -->
|
|
||||||
<div class="border-t pt-4">
|
|
||||||
<p class="text-xs font-semibold text-gray-600 mb-3">진입 조건</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="flex items-center justify-between text-xs text-gray-700 mb-1">
|
|
||||||
<span>최소 상승점수 (RiseScore)</span>
|
|
||||||
<span id="riseScoreVal" class="font-semibold text-blue-600">60</span>
|
|
||||||
</label>
|
|
||||||
<input id="fRiseScore" type="range" min="0" max="100" value="60"
|
|
||||||
oninput="document.getElementById('riseScoreVal').textContent=this.value"
|
|
||||||
class="w-full accent-blue-600">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">최소 체결강도</label>
|
|
||||||
<input id="fCntrStr" type="number" value="110" step="1" min="0"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input id="fRequireBullish" type="checkbox" class="w-4 h-4 accent-blue-600">
|
|
||||||
<label class="text-xs text-gray-700">AI 호재 신호 필요</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 주문 설정 -->
|
|
||||||
<div class="border-t pt-4">
|
|
||||||
<p class="text-xs font-semibold text-gray-600 mb-3">주문 설정</p>
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">1회 주문금액 (원)</label>
|
|
||||||
<input id="fOrderAmount" type="number" value="500000" step="10000" min="10000"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">최대 보유 종목 수</label>
|
|
||||||
<input id="fMaxPositions" type="number" value="3" min="1" max="20"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 청산 조건 -->
|
|
||||||
<div class="border-t pt-4">
|
|
||||||
<p class="text-xs font-semibold text-gray-600 mb-3">청산 조건</p>
|
|
||||||
|
|
||||||
<!-- 2중 손절 설명 -->
|
|
||||||
<div class="mb-3 p-2.5 bg-orange-50 border border-orange-200 rounded-lg text-xs text-orange-700 leading-relaxed">
|
|
||||||
<span class="font-semibold">2중 손절:</span>
|
|
||||||
1차 손절가에 설정 횟수만큼 터치 시 매도,
|
|
||||||
2차 손절가 도달 시 즉시 매도.
|
|
||||||
1차 터치 횟수를 0으로 설정하면 단순 손절.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<!-- 1차 손절 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">1차 손절 (%)</label>
|
|
||||||
<input id="fStopLoss1" type="number" value="-2" step="0.5"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">1차 손절 터치 횟수</label>
|
|
||||||
<input id="fStopLoss1Count" type="number" value="3" min="0" step="1"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400">
|
|
||||||
</div>
|
|
||||||
<!-- 2차 손절 / 익절 -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">2차 손절 / 즉시 매도 (%)</label>
|
|
||||||
<input id="fStopLoss" type="number" value="-4" step="0.5"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">익절 (%)</label>
|
|
||||||
<input id="fTakeProfit" type="number" value="5" step="0.5"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-700 mb-1">최대 보유 시간 (분, 0=무제한)</label>
|
|
||||||
<input id="fMaxHold" type="number" value="60" min="0"
|
|
||||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end pb-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input id="fExitBeforeClose" type="checkbox" checked class="w-4 h-4 accent-blue-600">
|
|
||||||
<label class="text-xs text-gray-700">장마감 전 청산 (15:20)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
|
||||||
<button type="button" onclick="submitRule()"
|
|
||||||
class="flex-1 py-2.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
||||||
저장
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="hideModal()"
|
|
||||||
class="flex-1 py-2.5 text-sm font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "scripts" }}
|
|
||||||
<script src="/static/js/autotrade.js"></script>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
{{ template "base.html" . }}
|
|
||||||
|
|
||||||
{{ define "sidebar" }}
|
|
||||||
<aside class="w-60 shrink-0 sticky top-20 self-start">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-4 py-3 bg-gray-50 border-b border-gray-100">
|
|
||||||
<h2 class="font-bold text-gray-800 text-sm">관심종목</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 종목 추가 입력 -->
|
|
||||||
<div class="p-3 border-b border-gray-100">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input id="watchlistInput" type="text" placeholder="종목코드 (예: 005930)"
|
|
||||||
maxlength="6"
|
|
||||||
class="flex-1 text-xs px-2 py-1.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-400 font-mono">
|
|
||||||
<button id="watchlistAddBtn"
|
|
||||||
class="text-xs px-2.5 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium shrink-0">
|
|
||||||
추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p id="watchlistMsg" class="text-xs text-red-500 mt-1 hidden"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 관심종목 목록 -->
|
|
||||||
<table class="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-100 text-gray-400">
|
|
||||||
<th class="px-3 py-2 text-left font-medium">종목</th>
|
|
||||||
<th class="px-2 py-2 text-right font-medium">현재가</th>
|
|
||||||
<th class="px-2 py-2 text-right font-medium">등락</th>
|
|
||||||
<th class="px-2 py-2 text-right font-medium">강도</th>
|
|
||||||
<th class="w-6"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
<div id="watchlistSidebar" class="max-h-[60vh] overflow-y-auto">
|
|
||||||
<div class="px-4 py-8 text-center text-xs text-gray-400" id="watchlistEmpty">
|
|
||||||
관심종목을 추가해주세요
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "content" }}
|
|
||||||
<div class="space-y-8">
|
|
||||||
|
|
||||||
<!-- 체결강도 상승 감지 -->
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<span class="text-orange-500">⚡</span> 체결강도 상승 감지
|
|
||||||
<span class="text-xs font-normal text-gray-400 ml-1">(거래량 상위 20 | 10초 갱신)</span>
|
|
||||||
<button id="signalGuideBtn"
|
|
||||||
class="w-5 h-5 rounded-full bg-gray-200 text-gray-500 text-xs font-bold hover:bg-orange-100 hover:text-orange-600 transition-colors flex items-center justify-center"
|
|
||||||
title="판단 기준 보기">?</button>
|
|
||||||
<button id="scannerToggleBtn"
|
|
||||||
class="text-xs px-2.5 py-1 rounded-full font-semibold border transition-colors bg-green-100 text-green-700 border-green-300"
|
|
||||||
title="스캐너 ON/OFF">● ON</button>
|
|
||||||
<span id="signalUpdatedAt" class="text-xs font-normal text-gray-400 ml-auto"></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- 시그널 판단 기준 모달 -->
|
|
||||||
<div id="signalGuideModal"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 hidden"
|
|
||||||
role="dialog" aria-modal="true">
|
|
||||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="flex items-center justify-between px-6 pt-5 pb-3 border-b border-gray-100 sticky top-0 bg-white">
|
|
||||||
<h3 class="text-base font-bold text-gray-800">⚡ 시그널 판단 기준</h3>
|
|
||||||
<button id="signalGuideClose" class="text-gray-400 hover:text-gray-600 text-xl font-bold leading-none">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="px-6 py-5 space-y-6 text-sm text-gray-700">
|
|
||||||
|
|
||||||
<!-- 스코어 설명 -->
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-gray-800 mb-2">📊 상승 확률 스코어 (0~100점)</p>
|
|
||||||
<p class="text-xs text-gray-400 mb-3">4가지 복합 요소를 동시에 만족해야 진짜 상승 확률이 높습니다.</p>
|
|
||||||
<table class="w-full text-xs border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-50 text-gray-500">
|
|
||||||
<th class="text-left px-3 py-2 border border-gray-100 font-semibold">요소</th>
|
|
||||||
<th class="text-center px-3 py-2 border border-gray-100 font-semibold w-16">배점</th>
|
|
||||||
<th class="text-left px-3 py-2 border border-gray-100 font-semibold">핵심 로직</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 font-medium">A. 체결강도 레벨</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~30</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-gray-500">150+: 30점 · 130+: 22점 · 110+: 14점</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-gray-50">
|
|
||||||
<td class="px-3 py-2 border border-gray-100 font-medium">B. 연속 상승 횟수</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~25</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-gray-500">5회+: 25점 · 3회+: 14점 · 1회: 3점</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 font-medium">C. 가격 위치/캔들</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~20</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-gray-500">윗꼬리 10% 미만: +10점 · 60% 이상: −8점</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-gray-50">
|
|
||||||
<td class="px-3 py-2 border border-gray-100 font-medium">D. 거래량 건전성</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~15</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-gray-500">2~5배 증가: +15점 · 10배 이상: −5점</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 font-medium">E. 매도잔량 소화</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-center text-gray-500">0~10</td>
|
|
||||||
<td class="px-3 py-2 border border-gray-100 text-gray-500">매수잔량 압도적: +10점 · 매도 우세: −3점</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 신호 유형 설명 -->
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-gray-800 mb-2">🏷️ 신호 유형 분류</p>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-red-50">
|
|
||||||
<span class="bg-red-600 text-white text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">강한매수</span>
|
|
||||||
<span class="text-gray-600">체결강도 130+ · 3회 이상 연속 상승 · 윗꼬리 없음 · 매도잔량 소화 중 → 실매수 강하고 던지는 물량도 받아내는 패턴</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-orange-50">
|
|
||||||
<span class="bg-orange-400 text-white text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">매수우세</span>
|
|
||||||
<span class="text-gray-600">기본 상승 패턴. 강한매수 조건 미충족이나 전반적으로 매수세가 우위</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-yellow-50">
|
|
||||||
<span class="bg-yellow-100 text-yellow-700 border border-yellow-300 text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">물량소화</span>
|
|
||||||
<span class="text-gray-600">체결강도는 높은데 가격이 제자리 + 긴 윗꼬리 → 위에서 파는 물량을 받아내기만 하는 중. 돌파 여부 확인 필요</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-gray-100">
|
|
||||||
<span class="bg-gray-800 text-white text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">추격위험</span>
|
|
||||||
<span class="text-gray-600">체결강도 170+ · 거래량 7배 이상 · 긴 윗꼬리 동시 출현 → 단타 추격매수 몰림 후 고점 물량털기 가능성 경계</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-gray-50">
|
|
||||||
<span class="bg-gray-100 text-gray-500 border border-gray-300 text-xs px-2 py-0.5 rounded font-bold shrink-0 mt-0.5">약한상승</span>
|
|
||||||
<span class="text-gray-600">거래량 증가 없음 · 체결강도 120 미만 → 얇은 호가에서 뜬 상승, 소량 매도에도 쉽게 꺾일 수 있음</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 지표별 상세 설명 -->
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-gray-800 mb-3">🔍 카드 지표 상세 설명</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
|
|
||||||
<!-- 체결강도 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">체결강도</span>
|
|
||||||
<span class="text-xs text-gray-400">· 매수/매도 힘의 비율</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
|
|
||||||
<p>10초마다 직전 체결강도와 비교해 <span class="font-semibold text-orange-500">연속으로 상승하는 종목</span>만 표시합니다.</p>
|
|
||||||
<p class="text-gray-400">100 = 매수·매도 균형 / 100 초과 = 매수 우위 / 100 미만 = 매도 우위</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<span class="bg-red-50 text-red-500 px-2 py-0.5 rounded">150+ 강한 매수세</span>
|
|
||||||
<span class="bg-orange-50 text-orange-500 px-2 py-0.5 rounded">130~150 매수 우세</span>
|
|
||||||
<span class="bg-yellow-50 text-yellow-600 px-2 py-0.5 rounded">100~130 매수 경향</span>
|
|
||||||
<span class="bg-blue-50 text-blue-400 px-2 py-0.5 rounded">100 미만 매도 경향</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 연속 상승 뱃지 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">연속 상승 뱃지</span>
|
|
||||||
<span class="text-xs text-gray-400">· 10초 단위 체결강도 상승 횟수</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1.5">
|
|
||||||
<p>직전 측정값 대비 체결강도가 <span class="font-semibold">끊기지 않고 몇 번 연속으로 올랐는지</span>를 나타냅니다.</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-red-500 text-white px-2 py-0.5 rounded font-bold">🔥N연속</span>
|
|
||||||
<span class="text-gray-500 self-center">4회 이상 — 강한 지속 매수세</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-orange-400 text-white px-2 py-0.5 rounded font-bold">▲N연속</span>
|
|
||||||
<span class="text-gray-500 self-center">2~3회 — 매수세 형성 중</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded font-bold">↑상승</span>
|
|
||||||
<span class="text-gray-500 self-center">1회 — 초기 매수 감지</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 거래량 증가율 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">거래량 증가</span>
|
|
||||||
<span class="text-xs text-gray-400">· 직전 평균 대비 현재 구간 배수</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
|
|
||||||
<p>직전 6회(1분) 구간 평균 거래량 대비 <span class="font-semibold">현재 10초 구간에서 얼마나 폭발적으로 거래됐는지</span>를 나타냅니다.</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<span class="bg-green-50 text-green-600 font-semibold px-2 py-0.5 rounded">2~5배 최적</span>
|
|
||||||
<span class="bg-orange-50 text-orange-400 px-2 py-0.5 rounded">5~10배 과열 초입</span>
|
|
||||||
<span class="bg-gray-100 text-gray-400 px-2 py-0.5 rounded line-through">10배+ ⚠과열</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-400 mt-1">※ 10배 이상은 고점 물량털기 가능성이 높아 스코어가 감점됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 매도/매수 잔량비 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">매도/매수 잔량</span>
|
|
||||||
<span class="text-xs text-gray-400">· 호가창 전체 잔량 비율</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
|
|
||||||
<p>10단계 매도호가 총잔량 ÷ 10단계 매수호가 총잔량입니다. <span class="font-semibold">1보다 작을수록 매수 우위</span>, 클수록 위에 팔 물량이 많다는 의미입니다.</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<span class="bg-green-50 text-green-600 font-semibold px-2 py-0.5 rounded">0.7 미만 — 매수 강세</span>
|
|
||||||
<span class="bg-green-50 text-green-500 px-2 py-0.5 rounded">0.7~1.0 — 매수 우세</span>
|
|
||||||
<span class="bg-gray-50 text-gray-500 px-2 py-0.5 rounded">1.0~1.5 — 균형</span>
|
|
||||||
<span class="bg-blue-50 text-blue-500 px-2 py-0.5 rounded">1.5+ — 매도 우세</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 가격 위치 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">가격 위치</span>
|
|
||||||
<span class="text-xs text-gray-400">· 장중 저가~고가 내 현재가 위치 %</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
|
|
||||||
<p>오늘 장중 저가를 0%, 고가를 100%로 봤을 때 <span class="font-semibold">현재가가 어디에 있는지</span>를 나타냅니다.</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<span class="bg-red-50 text-red-500 font-semibold px-2 py-0.5 rounded">80%+ 고가권 · 강한 매수</span>
|
|
||||||
<span class="bg-orange-50 text-orange-400 px-2 py-0.5 rounded">60~80% 상단부</span>
|
|
||||||
<span class="bg-gray-50 text-gray-500 px-2 py-0.5 rounded">30~60% 중간대</span>
|
|
||||||
<span class="bg-blue-50 text-blue-400 px-2 py-0.5 rounded">30% 미만 저가권</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-400 mt-1">※ 체결강도가 강한데 가격 위치가 낮으면 저점 반등 초기 신호일 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 감성 분석 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">AI 감성 분석</span>
|
|
||||||
<span class="text-xs text-gray-400">· 최신 뉴스 기반 호재/악재 판단</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1.5">
|
|
||||||
<p>상승 확률 50점 이상 · 2회 이상 연속 상승 · 체결강도 100 이상 종목에 한해 AI가 최신 뉴스를 분석합니다.</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-green-100 text-green-700 border border-green-200 px-2 py-0.5 rounded font-semibold">호재</span>
|
|
||||||
<span class="text-gray-500 self-center">긍정적 뉴스·공시 감지</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-red-100 text-red-600 border border-red-200 px-2 py-0.5 rounded font-semibold">악재</span>
|
|
||||||
<span class="text-gray-500 self-center">부정적 뉴스·공시 감지</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-gray-100 text-gray-500 px-2 py-0.5 rounded font-semibold">중립</span>
|
|
||||||
<span class="text-gray-500 self-center">특이 뉴스 없음</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-400">※ 뱃지에 마우스를 올리면 AI가 판단한 근거 한 줄이 표시됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 목표가 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">AI 목표가</span>
|
|
||||||
<span class="text-xs text-gray-400">· 단기 기술적 목표가 추론</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1">
|
|
||||||
<p>현재가·고가·저가·체결강도·연속상승 횟수·AI 감성을 종합해 <span class="font-semibold text-purple-600">단기 목표가</span>를 추론합니다. 투자 조언이 아닌 참고용 수치입니다.</p>
|
|
||||||
<p class="text-gray-400">※ 뱃지에 마우스를 올리면 목표가 추론 근거가 표시됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 익일 추세 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">익일 추세</span>
|
|
||||||
<span class="text-xs text-gray-400">· 다음 거래일 방향성 예측</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600 space-y-1.5">
|
|
||||||
<p>당일 기술 지표와 AI 감성 분석을 결합해 다음 거래일 방향성을 예측합니다. 신뢰도(높음/보통/낮음)를 함께 표시합니다.</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span class="bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded font-bold">▲ 상승</span>
|
|
||||||
<span class="bg-blue-50 text-blue-500 border border-blue-200 px-2 py-0.5 rounded font-bold">▼ 하락</span>
|
|
||||||
<span class="bg-gray-50 text-gray-500 border border-gray-200 px-2 py-0.5 rounded font-bold">─ 횡보</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-400">※ AI 예측은 참고용이며, 실제 시장 상황에 따라 달라질 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 체결강도 미니차트 -->
|
|
||||||
<div class="rounded-lg border border-gray-100 overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-gray-50 flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-800 text-xs">체결강도 차트</span>
|
|
||||||
<span class="text-xs text-gray-400">· 카드 하단 주황색 라인</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2.5 text-xs text-gray-600">
|
|
||||||
<p>시그널 감지 이후 <span class="font-semibold text-orange-500">10초마다 수집된 체결강도 흐름</span>을 라인차트로 표시합니다. 우상향이면 매수세가 지속되는 중입니다. 최대 60포인트(10분) 유지됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 한 줄 기준 -->
|
|
||||||
<div class="bg-orange-50 rounded-xl p-4 border border-orange-100">
|
|
||||||
<p class="font-semibold text-orange-700 mb-1">💡 진짜 상승 한 줄 기준</p>
|
|
||||||
<p class="text-orange-600">가격이 오르면서 · 거래량이 받쳐주고 · 체결강도가 유지되고 · 위 매도물량이 실제로 소화되는 흐름</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 면책 안내 -->
|
|
||||||
<div class="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
|
||||||
<p class="font-semibold text-gray-600 mb-1">⚠️ 투자 유의사항</p>
|
|
||||||
<p class="text-gray-500 text-xs leading-relaxed">이 서비스의 모든 지표와 AI 분석은 <span class="font-semibold">참고용 정보</span>이며 투자 권유나 매매 신호가 아닙니다. 투자 결정은 본인의 판단과 책임 하에 이루어져야 하며, 모든 투자 손실에 대한 책임은 투자자 본인에게 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="signalGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<p class="col-span-3 text-gray-400 text-center py-6 text-sm" id="signalEmpty">
|
|
||||||
08:00 이후 거래량 상위 종목에서 체결강도 100 이상 + 상승 종목을 표시합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 관심종목 실시간 패널 -->
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<span class="text-yellow-500">★</span> 관심종목 실시간
|
|
||||||
<span class="text-xs font-normal text-gray-400 ml-1" id="wsStatus">연결 중...</span>
|
|
||||||
</h2>
|
|
||||||
<div id="watchlistPanel" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
<div id="watchlistPanelEmpty" class="col-span-3 text-center py-12 text-gray-400 text-sm bg-white rounded-xl border border-dashed border-gray-200">
|
|
||||||
좌측 메뉴에서 관심종목을 추가하면 실시간 시세가 표시됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "scripts" }}
|
|
||||||
<script src="/static/js/signal.js"></script>
|
|
||||||
<script src="/static/js/watchlist.js"></script>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
{{ template "base.html" . }}
|
|
||||||
|
|
||||||
{{ define "sidebar" }}{{ end }}
|
|
||||||
|
|
||||||
{{ define "content" }}
|
|
||||||
<div class="space-y-4">
|
|
||||||
|
|
||||||
<!-- 헤더 + 필터 바 -->
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
|
||||||
<span class="text-blue-600">🔵</span> 코스피200
|
|
||||||
<span id="lastUpdated" class="text-xs font-normal text-gray-400"></span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<!-- 정렬 -->
|
|
||||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
|
|
||||||
<button data-sort="fluRt" class="sort-tab px-3 py-1.5 bg-blue-500 text-white">등락률</button>
|
|
||||||
<button data-sort="volume" class="sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">거래량</button>
|
|
||||||
<button data-sort="curPrc" class="sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">현재가</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 상승/하락/전체 필터 -->
|
|
||||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
|
|
||||||
<button data-dir="all" class="dir-tab px-3 py-1.5 bg-blue-500 text-white">전체</button>
|
|
||||||
<button data-dir="up" class="dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">상승</button>
|
|
||||||
<button data-dir="down" class="dir-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">하락</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 종목 검색 -->
|
|
||||||
<input id="k200Search" type="text" placeholder="종목명 검색..."
|
|
||||||
class="text-xs px-3 py-1.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-400 w-36">
|
|
||||||
|
|
||||||
<span id="k200Count" class="text-xs text-gray-400 ml-auto"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 종목 테이블 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
||||||
<!-- 테이블 헤더 -->
|
|
||||||
<div class="grid grid-cols-[2.5rem_1fr_1fr_90px_90px_100px_90px_90px_90px] text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100 px-4 py-2.5 gap-2">
|
|
||||||
<span class="text-center">#</span>
|
|
||||||
<span>종목명</span>
|
|
||||||
<span data-col="curPrc" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">현재가 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="predPre" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">전일대비 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="fluRt" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">등락률 <span class="sort-arrow text-blue-400">▼</span></span>
|
|
||||||
<span data-col="volume" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">거래량 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="open" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">시가 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="high" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">고가 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="low" class="col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">저가 <span class="sort-arrow"></span></span>
|
|
||||||
</div>
|
|
||||||
<!-- 종목 목록 -->
|
|
||||||
<div id="k200List" class="divide-y divide-gray-50">
|
|
||||||
<div class="px-4 py-12 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "scripts" }}
|
|
||||||
<script src="/static/js/kospi200.js"></script>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{{define "login.html"}}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>로그인 - 주식 시세</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 min-h-screen flex items-center justify-center">
|
|
||||||
<div class="bg-white rounded-2xl shadow-lg w-full max-w-sm p-8">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-800">📈 주식 시세</h1>
|
|
||||||
<p class="text-sm text-gray-400 mt-1">로그인이 필요한 서비스입니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ if .Error }}
|
|
||||||
<div class="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p class="text-sm text-red-600">{{ .Error }}</p>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<form method="POST" action="/login" class="space-y-4">
|
|
||||||
<input type="hidden" name="next" value="{{ .Next }}">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">아이디</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="id"
|
|
||||||
placeholder="아이디를 입력하세요"
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="비밀번호를 입력하세요"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg transition-colors mt-2">
|
|
||||||
로그인
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
{{ template "base.html" . }}
|
|
||||||
|
|
||||||
{{ define "content" }}
|
|
||||||
<div class="space-y-4">
|
|
||||||
|
|
||||||
<!-- 현재가 헤더 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
|
||||||
<!-- 종목명 + 장운영 상태 -->
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<span class="text-xs text-gray-400 font-mono">{{ .Stock.Code }}</span>
|
|
||||||
<h1 class="text-xl font-bold text-gray-900">{{ .Stock.Name }}</h1>
|
|
||||||
<span id="marketStatusBadge" class="px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">장 중</span>
|
|
||||||
<button id="watchlistToggleBtn" onclick="toggleWatchlist()"
|
|
||||||
class="ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
|
||||||
<!-- 현재가 & 등락 -->
|
|
||||||
<div>
|
|
||||||
<p id="currentPrice" class="text-4xl font-bold text-gray-900" data-raw="{{ .Stock.CurrentPrice }}">
|
|
||||||
{{ formatPrice .Stock.CurrentPrice }}원
|
|
||||||
</p>
|
|
||||||
<p id="changeInfo" class="text-lg mt-1 {{ priceClass .Stock.ChangeRate }}">
|
|
||||||
{{ if gt .Stock.ChangePrice 0 }}+{{ end }}{{ formatPrice .Stock.ChangePrice }}원
|
|
||||||
({{ formatRate .Stock.ChangeRate }})
|
|
||||||
</p>
|
|
||||||
<p id="updatedAt" class="text-xs text-gray-400 mt-1">장 중</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최우선 호가 -->
|
|
||||||
<div class="flex gap-4 text-sm">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">최우선매도</p>
|
|
||||||
<p id="askPrice1" class="font-semibold text-red-500">-원</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-xs text-gray-400 mb-1">최우선매수</p>
|
|
||||||
<p id="bidPrice1" class="font-semibold text-blue-500">-원</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 요약 그리드 -->
|
|
||||||
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3 mt-5 pt-4 border-t border-gray-100 text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">시가</p>
|
|
||||||
<p id="openPrice" class="font-semibold text-gray-800">{{ formatPrice .Stock.Open }}원</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">고가</p>
|
|
||||||
<p id="highPrice" class="font-semibold text-red-500">{{ formatPrice .Stock.High }}원</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">저가</p>
|
|
||||||
<p id="lowPrice" class="font-semibold text-blue-500">{{ formatPrice .Stock.Low }}원</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">거래량</p>
|
|
||||||
<p id="volume" class="font-semibold text-gray-800">{{ formatPrice .Stock.Volume }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">거래대금</p>
|
|
||||||
<p id="tradeMoney" class="font-semibold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">체결량</p>
|
|
||||||
<p id="tradeVolume" class="font-semibold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2행: 기준가/상하한가/체결시각 -->
|
|
||||||
<div class="grid grid-cols-3 sm:grid-cols-4 gap-3 mt-3 pt-3 border-t border-gray-50 text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">기준가</p>
|
|
||||||
<p id="basePrice" class="font-semibold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">상한가</p>
|
|
||||||
<p id="upperLimit" class="font-semibold text-red-500">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">하한가</p>
|
|
||||||
<p id="lowerLimit" class="font-semibold text-blue-500">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">체결시각</p>
|
|
||||||
<p id="tradeTime" class="font-semibold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 차트 + 호가창 (좌우 분할) -->
|
|
||||||
<div class="flex flex-col xl:flex-row gap-4">
|
|
||||||
|
|
||||||
<!-- 차트 섹션 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100 flex-1 min-w-0">
|
|
||||||
<!-- 차트 탭 -->
|
|
||||||
<div class="flex gap-2 mb-4">
|
|
||||||
<button onclick="switchChart('daily')" id="tab-daily"
|
|
||||||
class="px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200">
|
|
||||||
일봉
|
|
||||||
</button>
|
|
||||||
<button onclick="switchChart('minute1')" id="tab-minute1"
|
|
||||||
class="px-4 py-1.5 text-sm rounded-full bg-blue-500 text-white font-medium">
|
|
||||||
1분
|
|
||||||
</button>
|
|
||||||
<button onclick="switchChart('minute5')" id="tab-minute5"
|
|
||||||
class="px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200">
|
|
||||||
5분
|
|
||||||
</button>
|
|
||||||
<button onclick="switchChart('tick')" id="tab-tick"
|
|
||||||
class="px-4 py-1.5 text-sm rounded-full bg-gray-100 text-gray-600 font-medium hover:bg-gray-200">
|
|
||||||
틱
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- 이동평균선 범례 -->
|
|
||||||
<div class="flex gap-3 mb-2 text-xs">
|
|
||||||
<span class="flex items-center gap-1"><span class="inline-block w-4 bg-amber-400" style="height:1px"></span>MA20</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="inline-block w-4 bg-emerald-500" style="height:1px"></span>MA60</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="inline-block w-4 bg-violet-500" style="height:1px"></span>MA120</span>
|
|
||||||
</div>
|
|
||||||
<!-- 캔들 차트 컨테이너 (기본 표시) -->
|
|
||||||
<div id="chartContainer" class="w-full" style="height: 380px;"></div>
|
|
||||||
<!-- 틱 차트 컨테이너 (기본 숨김) -->
|
|
||||||
<div id="tickChartContainer" class="w-full" style="height: 380px; display: none;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 우측 컬럼: 로그인 시 호가창+주문+계좌, 비로그인 시 넓은 호가창만 -->
|
|
||||||
<div class="{{ if .LoggedIn }}xl:w-80{{ else }}xl:w-96{{ end }} shrink-0 flex flex-col gap-3">
|
|
||||||
|
|
||||||
<!-- 호가창 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800">호가창</h2>
|
|
||||||
<span id="askTime" class="text-xs text-gray-400">-</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 총잔량 헤더 -->
|
|
||||||
<div class="grid grid-cols-3 text-xs text-gray-400 mb-1 px-2">
|
|
||||||
<span class="text-right"><span id="totalAskVol" class="text-red-400 font-semibold">-</span></span>
|
|
||||||
<span class="text-center">호가</span>
|
|
||||||
<span class="text-left"><span id="totalBidVol" class="text-blue-400 font-semibold">-</span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-xs text-gray-400 border-b border-gray-200">
|
|
||||||
<th class="py-1.5 px-2 text-right font-normal">매도잔량</th>
|
|
||||||
<th class="py-1.5 px-2 text-center font-normal">호가</th>
|
|
||||||
<th class="py-1.5 px-2 text-left font-normal">매수잔량</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="orderbookBody">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="text-center py-6 text-xs text-gray-400">호가 데이터 수신 대기 중...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ if .LoggedIn }}
|
|
||||||
<!-- 주문창 패널 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
|
|
||||||
<!-- 매수/매도 탭 -->
|
|
||||||
<div class="flex gap-1 mb-3">
|
|
||||||
<button id="orderBuyTab" onclick="updateOrderSide('buy')"
|
|
||||||
class="flex-1 py-1.5 text-sm font-semibold rounded-lg bg-red-500 text-white transition-colors">
|
|
||||||
매수
|
|
||||||
</button>
|
|
||||||
<button id="orderSellTab" onclick="updateOrderSide('sell')"
|
|
||||||
class="flex-1 py-1.5 text-sm font-semibold rounded-lg bg-gray-100 text-gray-600 transition-colors">
|
|
||||||
매도
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 거래소 구분 -->
|
|
||||||
<div class="flex gap-3 mb-3 text-xs">
|
|
||||||
<label class="flex items-center gap-1 cursor-pointer">
|
|
||||||
<input type="radio" name="orderExchange" value="KRX" checked class="accent-blue-500">
|
|
||||||
<span>KRX</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1 cursor-pointer">
|
|
||||||
<input type="radio" name="orderExchange" value="NXT" class="accent-blue-500">
|
|
||||||
<span>NXT</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 주문유형 -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="text-xs text-gray-400 block mb-1">주문유형</label>
|
|
||||||
<select id="orderTradeType" onchange="onTradeTypeChange()"
|
|
||||||
class="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-blue-400">
|
|
||||||
<option value="0">지정가</option>
|
|
||||||
<option value="3">시장가</option>
|
|
||||||
<option value="6">최유리지정가</option>
|
|
||||||
<option value="7">최우선지정가</option>
|
|
||||||
<option value="5">조건부지정가</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 단가 입력 -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="text-xs text-gray-400 block mb-1">단가</label>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<input id="orderPrice" type="number" min="0" placeholder="단가 입력"
|
|
||||||
oninput="updateOrderTotal(); loadOrderable()"
|
|
||||||
class="flex-1 text-xs border border-gray-200 rounded-lg px-2 py-1.5 text-right focus:outline-none focus:border-blue-400">
|
|
||||||
<button id="priceUpBtn" onclick="adjustPrice(1)"
|
|
||||||
class="px-2 py-1 text-xs rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">▲</button>
|
|
||||||
<button id="priceDownBtn" onclick="adjustPrice(-1)"
|
|
||||||
class="px-2 py-1 text-xs rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">▼</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 수량 입력 -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="text-xs text-gray-400 block mb-1">수량</label>
|
|
||||||
<input id="orderQty" type="number" min="0" placeholder="수량 입력"
|
|
||||||
oninput="updateOrderTotal()"
|
|
||||||
class="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 text-right focus:outline-none focus:border-blue-400">
|
|
||||||
<!-- 퍼센트 버튼 -->
|
|
||||||
<div class="flex gap-1 mt-1">
|
|
||||||
<button onclick="setQtyPercent(10)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">10%</button>
|
|
||||||
<button onclick="setQtyPercent(25)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">25%</button>
|
|
||||||
<button onclick="setQtyPercent(50)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">50%</button>
|
|
||||||
<button onclick="setQtyPercent(100)" class="flex-1 text-xs py-1 rounded border border-gray-200 hover:bg-gray-50">100%</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 총금액 + 주문가능 정보 -->
|
|
||||||
<div class="mb-3 text-xs space-y-1">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">총 주문금액</span>
|
|
||||||
<span id="orderTotal" class="font-semibold text-gray-800">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">주문가능현금</span>
|
|
||||||
<span id="orderableAmt" class="text-gray-600">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">예수금</span>
|
|
||||||
<span id="orderableEntr" class="text-gray-600">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-400">주문가능수량</span>
|
|
||||||
<span id="orderableQty" class="text-gray-600">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 확인 메시지 -->
|
|
||||||
<div id="orderConfirmBox" class="hidden mb-3 p-2 bg-gray-50 rounded-lg border border-gray-200 text-xs text-center">
|
|
||||||
<p id="orderConfirmMsg" class="text-gray-700 mb-2"></p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="hideOrderConfirm()"
|
|
||||||
class="flex-1 py-1.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 text-xs">취소</button>
|
|
||||||
<button onclick="submitOrder()"
|
|
||||||
class="flex-1 py-1.5 rounded bg-blue-500 text-white hover:bg-blue-600 text-xs font-semibold">확인</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 주문 버튼 -->
|
|
||||||
<button id="orderSubmitBtn" onclick="showOrderConfirm()"
|
|
||||||
class="w-full py-2.5 text-sm font-bold rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors">
|
|
||||||
매수
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 계좌 정보 탭 (미체결/체결/잔고) -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100">
|
|
||||||
<!-- 탭 헤더 -->
|
|
||||||
<div class="flex border-b border-gray-100 text-xs font-medium">
|
|
||||||
<button id="pendingTab" onclick="showAccountTab('pending')"
|
|
||||||
class="flex-1 py-2.5 text-center border-b-2 border-blue-500 text-blue-600 active-tab transition-colors">
|
|
||||||
미체결
|
|
||||||
</button>
|
|
||||||
<button id="historyTab" onclick="showAccountTab('history')"
|
|
||||||
class="flex-1 py-2.5 text-center text-gray-500 hover:text-gray-700 transition-colors">
|
|
||||||
체결
|
|
||||||
</button>
|
|
||||||
<button id="balanceTab" onclick="showAccountTab('balance')"
|
|
||||||
class="flex-1 py-2.5 text-center text-gray-500 hover:text-gray-700 transition-colors">
|
|
||||||
잔고
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- 탭 패널 -->
|
|
||||||
<div class="p-3 max-h-64 overflow-y-auto">
|
|
||||||
<div id="pendingPanel">
|
|
||||||
<p class="text-xs text-gray-400 text-center py-4">조회 중...</p>
|
|
||||||
</div>
|
|
||||||
<div id="historyPanel" class="hidden"></div>
|
|
||||||
<div id="balancePanel" class="hidden"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
<!-- 비로그인: 로그인 유도 배너 -->
|
|
||||||
<div class="bg-gray-50 rounded-xl border border-gray-200 p-5 text-center">
|
|
||||||
<p class="text-sm text-gray-500 mb-3">주문 기능은 로그인 후 이용 가능합니다.</p>
|
|
||||||
<a href="/login?next={{ .Stock.Code }}"
|
|
||||||
class="inline-block px-5 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
|
||||||
로그인
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
</div><!-- /우측 컬럼 -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 프로그램 매매 + 체결강도 -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<!-- 프로그램 매매 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800 mb-3">프로그램 매매</h2>
|
|
||||||
<div id="programTrading">
|
|
||||||
<span class="text-xs text-gray-400">프로그램 매매 데이터 수신 대기 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 체결강도 & 예상체결 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-4 border border-gray-100">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800 mb-3">체결 정보</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">체결강도</p>
|
|
||||||
<p id="cntrStr" class="font-semibold text-gray-800">{{ printf "%.1f" .Stock.CntrStr }}%</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-400">시가총액</p>
|
|
||||||
<p id="marketCap" class="font-semibold text-gray-800">-</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최근 공시 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
|
||||||
<h2 class="text-base font-semibold text-gray-800 mb-4">최근 공시</h2>
|
|
||||||
<div id="disclosureLoading" class="text-center py-6 text-sm text-gray-400">공시 정보를 불러오는 중...</div>
|
|
||||||
<ul id="disclosureList" class="hidden divide-y divide-gray-100"></ul>
|
|
||||||
<div id="disclosureError" class="hidden text-center py-6 text-sm text-gray-400">공시 정보를 불러올 수 없습니다.</div>
|
|
||||||
<div id="disclosureEmpty" class="hidden text-center py-6 text-sm text-gray-400">최근 공시 내역이 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 관련 뉴스 -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
|
|
||||||
<h2 class="text-base font-semibold text-gray-800 mb-4">관련 뉴스</h2>
|
|
||||||
<div id="newsLoading" class="text-center py-6 text-sm text-gray-400">뉴스를 불러오는 중...</div>
|
|
||||||
<ul id="newsList" class="hidden divide-y divide-gray-100"></ul>
|
|
||||||
<div id="newsError" class="hidden text-center py-6 text-sm text-gray-400">뉴스를 불러올 수 없습니다.</div>
|
|
||||||
<div id="newsEmpty" class="hidden text-center py-6 text-sm text-gray-400">관련 뉴스가 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 종목 코드를 JavaScript에 전달 -->
|
|
||||||
<script>
|
|
||||||
const STOCK_CODE = "{{ .Stock.Code }}";
|
|
||||||
const STOCK_NAME = "{{ .Stock.Name }}";
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'watchlist_v1';
|
|
||||||
|
|
||||||
function loadWatchlist() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
|
|
||||||
catch { return []; }
|
|
||||||
}
|
|
||||||
function saveWatchlist(list) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
|
||||||
}
|
|
||||||
function isWatched() {
|
|
||||||
return loadWatchlist().some(s => s.code === STOCK_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWatchlistBtn() {
|
|
||||||
const btn = document.getElementById('watchlistToggleBtn');
|
|
||||||
if (!btn) return;
|
|
||||||
if (isWatched()) {
|
|
||||||
btn.innerHTML = '★ 관심종목 해제';
|
|
||||||
btn.className = 'ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors bg-yellow-50 text-yellow-600 border-yellow-300 hover:bg-yellow-100';
|
|
||||||
} else {
|
|
||||||
btn.innerHTML = '☆ 관심종목 추가';
|
|
||||||
btn.className = 'ml-auto flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors bg-white text-gray-500 border-gray-300 hover:bg-gray-50';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWatchlist() {
|
|
||||||
const list = loadWatchlist();
|
|
||||||
const idx = list.findIndex(s => s.code === STOCK_CODE);
|
|
||||||
if (idx >= 0) {
|
|
||||||
list.splice(idx, 1);
|
|
||||||
} else {
|
|
||||||
list.push({ code: STOCK_CODE, name: STOCK_NAME });
|
|
||||||
}
|
|
||||||
saveWatchlist(list);
|
|
||||||
renderWatchlistBtn();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderWatchlistBtn();
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "scripts" }}
|
|
||||||
<script src="/static/js/orderbook.js"></script>
|
|
||||||
<script src="/static/js/chart.js"></script>
|
|
||||||
<script src="/static/js/disclosure.js"></script>
|
|
||||||
<script src="/static/js/news.js"></script>
|
|
||||||
{{ if .LoggedIn }}<script src="/static/js/order.js"></script>{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
{{ template "base.html" . }}
|
|
||||||
|
|
||||||
{{ define "sidebar" }}{{ end }}
|
|
||||||
|
|
||||||
{{ define "content" }}
|
|
||||||
<div class="space-y-4">
|
|
||||||
|
|
||||||
<!-- 헤더 + 필터 바 -->
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
|
||||||
<span class="text-purple-500">📊</span> 테마 분석
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<!-- 날짜 구간 탭 -->
|
|
||||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
|
|
||||||
<button data-date="1" class="date-tab px-3 py-1.5 bg-blue-500 text-white">1일</button>
|
|
||||||
<button data-date="5" class="date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">5일</button>
|
|
||||||
<button data-date="20" class="date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">20일</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 정렬 탭 -->
|
|
||||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden text-xs font-medium">
|
|
||||||
<button data-sort="3" class="sort-tab px-3 py-1.5 bg-blue-500 text-white">등락률순</button>
|
|
||||||
<button data-sort="1" class="sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200">기간수익률순</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 테마 검색 -->
|
|
||||||
<input id="themeSearch" type="text" placeholder="테마명 검색..."
|
|
||||||
class="text-xs px-3 py-1.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-400 w-36">
|
|
||||||
|
|
||||||
<span id="themeCount" class="text-xs text-gray-400 ml-auto"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2열 레이아웃: 테마 목록 + 구성종목 패널 -->
|
|
||||||
<div class="flex gap-5 items-start">
|
|
||||||
|
|
||||||
<!-- 좌: 테마 목록 -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
||||||
<!-- 테이블 헤더 -->
|
|
||||||
<div class="grid grid-cols-[2fr_1fr_80px_80px_80px_80px] gap-0 text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100 px-4 py-2.5">
|
|
||||||
<span data-col="name" class="theme-col-sort cursor-pointer select-none hover:text-blue-500 transition-colors">테마명 <span class="sort-arrow"></span></span>
|
|
||||||
<span>주력종목</span>
|
|
||||||
<span data-col="fluRt" class="theme-col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">등락률 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="periodRt" class="theme-col-sort text-right cursor-pointer select-none hover:text-blue-500 transition-colors">기간수익률 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="stockCount" class="theme-col-sort text-center cursor-pointer select-none hover:text-blue-500 transition-colors">종목수 <span class="sort-arrow"></span></span>
|
|
||||||
<span data-col="risingCount" class="theme-col-sort text-center cursor-pointer select-none hover:text-blue-500 transition-colors">상승/하락 <span class="sort-arrow"></span></span>
|
|
||||||
</div>
|
|
||||||
<!-- 테마 목록 -->
|
|
||||||
<div id="themeList" class="divide-y divide-gray-50">
|
|
||||||
<div class="px-4 py-10 text-center text-sm text-gray-400">데이터를 불러오는 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 우: 구성종목 패널 (sticky) -->
|
|
||||||
<div id="themeDetailPanel"
|
|
||||||
class="w-80 shrink-0 sticky top-20 self-start bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
||||||
<!-- 초기 안내 -->
|
|
||||||
<div id="themeDetailEmpty" class="px-6 py-12 text-center text-sm text-gray-400">
|
|
||||||
<p class="text-2xl mb-2">👈</p>
|
|
||||||
<p>테마를 클릭하면<br>구성종목을 확인할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
<!-- 구성종목 내용 (로드 후 표시) -->
|
|
||||||
<div id="themeDetailContent" class="hidden">
|
|
||||||
<div class="px-4 py-3 border-b border-gray-100 bg-gray-50">
|
|
||||||
<p id="detailThemeName" class="font-bold text-gray-800 text-sm truncate"></p>
|
|
||||||
<div class="flex items-center gap-3 mt-1 text-xs">
|
|
||||||
<span>등락률 <span id="detailFluRt" class="font-semibold"></span></span>
|
|
||||||
<span>기간수익률 <span id="detailPeriodRt" class="font-semibold text-purple-600"></span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="detailStockList" class="divide-y divide-gray-50 max-h-[70vh] overflow-y-auto"></div>
|
|
||||||
</div>
|
|
||||||
<div id="themeDetailLoading" class="hidden px-6 py-10 text-center text-sm text-gray-400">
|
|
||||||
<div class="animate-pulse">조회 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "scripts" }}
|
|
||||||
<script src="/static/js/theme.js"></script>
|
|
||||||
{{ end }}
|
|
||||||
27
vendor/golang.org/x/time/LICENSE
generated
vendored
27
vendor/golang.org/x/time/LICENSE
generated
vendored
@@ -1,27 +0,0 @@
|
|||||||
Copyright 2009 The Go Authors.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are
|
|
||||||
met:
|
|
||||||
|
|
||||||
* Redistributions of source code must retain the above copyright
|
|
||||||
notice, this list of conditions and the following disclaimer.
|
|
||||||
* Redistributions in binary form must reproduce the above
|
|
||||||
copyright notice, this list of conditions and the following disclaimer
|
|
||||||
in the documentation and/or other materials provided with the
|
|
||||||
distribution.
|
|
||||||
* Neither the name of Google LLC nor the names of its
|
|
||||||
contributors may be used to endorse or promote products derived from
|
|
||||||
this software without specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
||||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
||||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
||||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
||||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
||||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
||||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
||||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
||||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
22
vendor/golang.org/x/time/PATENTS
generated
vendored
22
vendor/golang.org/x/time/PATENTS
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
Additional IP Rights Grant (Patents)
|
|
||||||
|
|
||||||
"This implementation" means the copyrightable works distributed by
|
|
||||||
Google as part of the Go project.
|
|
||||||
|
|
||||||
Google hereby grants to You a perpetual, worldwide, non-exclusive,
|
|
||||||
no-charge, royalty-free, irrevocable (except as stated in this section)
|
|
||||||
patent license to make, have made, use, offer to sell, sell, import,
|
|
||||||
transfer and otherwise run, modify and propagate the contents of this
|
|
||||||
implementation of Go, where such license applies only to those patent
|
|
||||||
claims, both currently owned or controlled by Google and acquired in
|
|
||||||
the future, licensable by Google that are necessarily infringed by this
|
|
||||||
implementation of Go. This grant does not include claims that would be
|
|
||||||
infringed only as a consequence of further modification of this
|
|
||||||
implementation. If you or your agent or exclusive licensee institute or
|
|
||||||
order or agree to the institution of patent litigation against any
|
|
||||||
entity (including a cross-claim or counterclaim in a lawsuit) alleging
|
|
||||||
that this implementation of Go or any code incorporated within this
|
|
||||||
implementation of Go constitutes direct or contributory patent
|
|
||||||
infringement, or inducement of patent infringement, then any patent
|
|
||||||
rights granted to you under this License for this implementation of Go
|
|
||||||
shall terminate as of the date such litigation is filed.
|
|
||||||
427
vendor/golang.org/x/time/rate/rate.go
generated
vendored
427
vendor/golang.org/x/time/rate/rate.go
generated
vendored
@@ -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)
|
|
||||||
}
|
|
||||||
69
vendor/golang.org/x/time/rate/sometimes.go
generated
vendored
69
vendor/golang.org/x/time/rate/sometimes.go
generated
vendored
@@ -1,69 +0,0 @@
|
|||||||
// Copyright 2022 The Go Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package rate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sometimes will perform an action occasionally. The First, Every, and
|
|
||||||
// Interval fields govern the behavior of Do, which performs the action.
|
|
||||||
// A zero Sometimes value will perform an action exactly once.
|
|
||||||
//
|
|
||||||
// # Example: logging with rate limiting
|
|
||||||
//
|
|
||||||
// var sometimes = rate.Sometimes{First: 3, Interval: 10*time.Second}
|
|
||||||
// func Spammy() {
|
|
||||||
// sometimes.Do(func() { log.Info("here I am!") })
|
|
||||||
// }
|
|
||||||
type Sometimes struct {
|
|
||||||
First int // if non-zero, the first N calls to Do will run f.
|
|
||||||
Every int // if non-zero, every Nth call to Do will run f.
|
|
||||||
Interval time.Duration // if non-zero and Interval has elapsed since f's last run, Do will run f.
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
count int // number of Do calls
|
|
||||||
last time.Time // last time f was run
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do runs the function f as allowed by First, Every, and Interval.
|
|
||||||
//
|
|
||||||
// The model is a union (not intersection) of filters. The first call to Do
|
|
||||||
// always runs f. Subsequent calls to Do run f if allowed by First or Every or
|
|
||||||
// Interval.
|
|
||||||
//
|
|
||||||
// A non-zero First:N causes the first N Do(f) calls to run f.
|
|
||||||
//
|
|
||||||
// A non-zero Every:M causes every Mth Do(f) call, starting with the first, to
|
|
||||||
// run f.
|
|
||||||
//
|
|
||||||
// A non-zero Interval causes Do(f) to run f if Interval has elapsed since
|
|
||||||
// Do last ran f.
|
|
||||||
//
|
|
||||||
// Specifying multiple filters produces the union of these execution streams.
|
|
||||||
// For example, specifying both First:N and Every:M causes the first N Do(f)
|
|
||||||
// calls and every Mth Do(f) call, starting with the first, to run f. See
|
|
||||||
// Examples for more.
|
|
||||||
//
|
|
||||||
// If Do is called multiple times simultaneously, the calls will block and run
|
|
||||||
// serially. Therefore, Do is intended for lightweight operations.
|
|
||||||
//
|
|
||||||
// Because a call to Do may block until f returns, if f causes Do to be called,
|
|
||||||
// it will deadlock.
|
|
||||||
func (s *Sometimes) Do(f func()) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.count == 0 ||
|
|
||||||
(s.First > 0 && s.count < s.First) ||
|
|
||||||
(s.Every > 0 && s.count%s.Every == 0) ||
|
|
||||||
(s.Interval > 0 && time.Since(s.last) >= s.Interval) {
|
|
||||||
f()
|
|
||||||
if s.Interval > 0 {
|
|
||||||
s.last = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.count++
|
|
||||||
}
|
|
||||||
15
vendor/modules.txt
vendored
15
vendor/modules.txt
vendored
@@ -4,10 +4,19 @@ github.com/gorilla/websocket
|
|||||||
# github.com/joho/godotenv v1.5.1
|
# github.com/joho/godotenv v1.5.1
|
||||||
## explicit; go 1.12
|
## explicit; go 1.12
|
||||||
github.com/joho/godotenv
|
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
|
# golang.org/x/net v0.17.0
|
||||||
## explicit; go 1.17
|
## explicit; go 1.17
|
||||||
golang.org/x/net/internal/socks
|
golang.org/x/net/internal/socks
|
||||||
golang.org/x/net/proxy
|
golang.org/x/net/proxy
|
||||||
# golang.org/x/time v0.15.0
|
|
||||||
## explicit; go 1.25.0
|
|
||||||
golang.org/x/time/rate
|
|
||||||
|
|||||||
Reference in New Issue
Block a user