diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 12ec212..db0550d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -40,7 +40,8 @@ "Bash(docker compose:*)", "Bash(tree:*)", "Bash(go vet:*)", - "Bash(python3:*)" + "Bash(python3:*)", + "Bash(head:*)" ] } } diff --git a/.dockerignore b/.dockerignore index b4dc0d9..f35a6c0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,3 +23,8 @@ tasks.md # Docker compose docker-compose.yml Caddyfile + +# Frontend +frontend/node_modules/ +frontend/build/ +frontend/.svelte-kit/ diff --git a/.env.example b/.env.example index 8ebeb74..4d68708 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,10 @@ CACHE_TTL_SECONDS=1 # WebSocket 설정 WS_MAX_CLIENTS=100 WS_POLL_INTERVAL_MS=1000 + +# CORS 설정 (SvelteKit 개발 서버용, 프로덕션에서는 비워둠) +CORS_ORIGIN=http://localhost:5173 + +# 관리자 계정 +ADMIN_ID=admin +ADMIN_PASSWORD= diff --git a/Dockerfile b/Dockerfile index 34ce904..6eb8715 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,15 @@ -# ── 빌드 스테이지 ────────────────────────────────────────── +# ── 프론트엔드 빌드 스테이지 ───────────────────────────────── +FROM node:22-alpine AS frontend + +WORKDIR /app + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ . +RUN npm run build + +# ── Go 빌드 스테이지 ───────────────────────────────────────── FROM golang:1.24-alpine AS builder WORKDIR /app @@ -32,6 +43,9 @@ COPY --from=builder /app/templates/ templates/ COPY --from=builder /app/static/ static/ COPY --from=builder /app/CORPCODE.xml . +# 프론트엔드 빌드 결과물 복사 +COPY --from=frontend /app/build frontend/build/ + EXPOSE 8080 # .env 파일은 컨테이너 실행 시 마운트하거나 환경변수로 주입 diff --git a/config/config.go b/config/config.go index 1df04f5..5b2049c 100644 --- a/config/config.go +++ b/config/config.go @@ -25,6 +25,7 @@ type Config struct { WSPollIntervalMS int AdminID string // 관리자 ID AdminPassword string // 관리자 비밀번호 + CORSOrigin string // CORS 허용 오리진 (예: http://localhost:5173) } var App *Config @@ -37,21 +38,22 @@ func Load() { } App = &Config{ - Env: getEnv("APP_ENV", "development"), - ServerPort: getEnv("SERVER_PORT", "8080"), - AppKey: getEnv("KIWOOM_APP_KEY", ""), - AppSecret: getEnv("KIWOOM_APP_SECRET", ""), - BaseURL: getEnv("KIWOOM_BASE_URL", "https://openapi.koreainvestment.com:9443"), + Env: getEnv("APP_ENV", "development"), + ServerPort: getEnv("SERVER_PORT", "8080"), + AppKey: getEnv("KIWOOM_APP_KEY", ""), + AppSecret: getEnv("KIWOOM_APP_SECRET", ""), + BaseURL: getEnv("KIWOOM_BASE_URL", "https://openapi.koreainvestment.com:9443"), DartAPIKey: getEnv("DART_API_KEY", ""), NaverClientID: getEnv("NAVER_CLIENT_ID", ""), NaverClientSecret: getEnv("NAVER_CLIENT_SECRET", ""), GroqAPIKey: getEnv("GROQ_API_KEY", ""), GroqModel: getEnv("GROQ_MODEL", "llama-3.3-70b-versatile"), - CacheTTLSeconds: getEnvInt("CACHE_TTL_SECONDS", 1), - WSMaxClients: getEnvInt("WS_MAX_CLIENTS", 100), - WSPollIntervalMS: getEnvInt("WS_POLL_INTERVAL_MS", 1000), - AdminID: getEnv("ADMIN_ID", "admin"), - AdminPassword: getEnv("ADMIN_PASSWORD", ""), + CacheTTLSeconds: getEnvInt("CACHE_TTL_SECONDS", 1), + WSMaxClients: getEnvInt("WS_MAX_CLIENTS", 100), + WSPollIntervalMS: getEnvInt("WS_POLL_INTERVAL_MS", 1000), + AdminID: getEnv("ADMIN_ID", "admin"), + AdminPassword: getEnv("ADMIN_PASSWORD", ""), + CORSOrigin: getEnv("CORS_ORIGIN", ""), } } diff --git a/data/watchlist.json b/data/watchlist.json new file mode 100644 index 0000000..fbf273d --- /dev/null +++ b/data/watchlist.json @@ -0,0 +1,10 @@ +[ + { + "code": "000660", + "name": "SK하이닉스" + }, + { + "code": "005930", + "name": "삼성전자" + } +] \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..296e0a2 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +build/ +.svelte-kit/ +.vscode/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3a33433 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# ── 빌드 스테이지 ────────────────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /app + +# 의존성 설치 (캐시 활용) +COPY package.json package-lock.json ./ +RUN npm ci + +# 소스 복사 및 빌드 +COPY . . +RUN npm run build + +# ── 실행 스테이지 ────────────────────────────────────────── +FROM nginx:alpine + +# nginx 설정 복사 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 빌드 결과물 복사 +COPY --from=builder /app/build /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7a119ce --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.13.2 create --template minimal --types ts --install npm frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c7c2011 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback: 파일이 없으면 index.html로 + location / { + try_files $uri $uri/ /index.html; + } + + # 정적 에셋 캐싱 (해시된 파일명) + location /_app/immutable/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API/WS는 백엔드로 프록시 (docker-compose에서 설정) + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /ws { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location /login { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + } + + location /logout { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0b6d780 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2435 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "lightweight-charts": "^5.1.0" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "autoprefixer": "^10.4.27", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightweight-charts": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz", + "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", + "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4940f3f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "autoprefixer": "^10.4.27", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "lightweight-charts": "^5.1.0" + } +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..3e9ecef --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,33 @@ +@import 'tailwindcss'; + +/* 기본 다크 테마 */ +:root { + color-scheme: dark; +} + +body { + @apply bg-gray-900 text-gray-100 antialiased; +} + +/* 스크롤바 스타일 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + @apply bg-gray-800; +} +::-webkit-scrollbar-thumb { + @apply bg-gray-600 rounded-full; +} +::-webkit-scrollbar-thumb:hover { + @apply bg-gray-500; +} + +/* 테이블 공통 */ +table { + @apply w-full border-collapse; +} +th { + @apply sticky top-0 bg-gray-800 z-10; +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..8cceb64 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts new file mode 100644 index 0000000..e05cba7 --- /dev/null +++ b/frontend/src/lib/api/account.ts @@ -0,0 +1,52 @@ +import { apiFetch } from './client' +import type { AccountBalance, OrderRequest, OrderResult, PendingOrder, OrderHistory } from './types' + +export const accountApi = { + // 계좌 잔고 + getBalance: () => + apiFetch('/api/account/balance'), + + // 미체결 주문 + getPending: () => + apiFetch('/api/account/pending'), + + // 주문 내역 + getHistory: () => + apiFetch('/api/account/history'), + + // 예수금 + getDeposit: () => + apiFetch('/api/account/deposit'), + + // 주문 가능 금액/수량 + getOrderable: (code: string, price: number) => + apiFetch(`/api/account/orderable?code=${code}&price=${price}`), + + // 매수 주문 + buy: (req: OrderRequest) => + apiFetch('/api/order/buy', { + method: 'POST', + body: JSON.stringify(req), + }), + + // 매도 주문 + sell: (req: OrderRequest) => + apiFetch('/api/order/sell', { + method: 'POST', + body: JSON.stringify(req), + }), + + // 주문 정정 + modify: (req: OrderRequest & { orderNo: string }) => + apiFetch('/api/order/modify', { + method: 'PUT', + body: JSON.stringify(req), + }), + + // 주문 취소 + cancel: (orderNo: string, code: string, qty: number) => + apiFetch<{ ok: boolean }>('/api/order', { + method: 'DELETE', + body: JSON.stringify({ orderNo, code, qty }), + }), +} diff --git a/frontend/src/lib/api/autotrade.ts b/frontend/src/lib/api/autotrade.ts new file mode 100644 index 0000000..4136dc4 --- /dev/null +++ b/frontend/src/lib/api/autotrade.ts @@ -0,0 +1,69 @@ +import { apiFetch } from './client' +import type { AutoTradeRule, AutoTradePosition, AutoTradeLog, AutoTradeStatus, AutoTradeWatchSource } from './types' + +export const autotradeApi = { + // 엔진 상태 + getStatus: () => + apiFetch('/api/autotrade/status'), + + // 규칙 목록 + getRules: () => + apiFetch('/api/autotrade/rules'), + + // 규칙 추가 + addRule: (rule: Omit) => + apiFetch('/api/autotrade/rules', { + method: 'POST', + body: JSON.stringify(rule), + }), + + // 규칙 수정 + updateRule: (id: string, rule: Partial) => + apiFetch(`/api/autotrade/rules/${id}`, { + method: 'PUT', + body: JSON.stringify(rule), + }), + + // 규칙 삭제 + deleteRule: (id: string) => + apiFetch<{ ok: boolean }>(`/api/autotrade/rules/${id}`, { method: 'DELETE' }), + + // 규칙 활성화/비활성화 + toggleRule: (id: string) => + apiFetch(`/api/autotrade/rules/${id}/toggle`, { method: 'POST' }), + + // 포지션 목록 + getPositions: () => + apiFetch('/api/autotrade/positions'), + + // 이벤트 로그 + getLogs: () => + apiFetch('/api/autotrade/logs'), + + // 감시 소스 조회 + getWatchSource: () => + apiFetch('/api/autotrade/watch-source'), + + // 감시 소스 설정 + setWatchSource: (src: AutoTradeWatchSource) => + apiFetch<{ ok: boolean }>('/api/autotrade/watch-source', { + method: 'PUT', + body: JSON.stringify(src), + }), + + // 엔진 시작 + start: () => + apiFetch<{ running: boolean }>('/api/autotrade/start', { method: 'POST' }), + + // 엔진 중지 + stop: () => + apiFetch<{ running: boolean }>('/api/autotrade/stop', { method: 'POST' }), + + // 긴급 전량 청산 + emergency: () => + apiFetch<{ ok: boolean }>('/api/autotrade/emergency', { method: 'POST' }), + + // 개별 포지션 청산 + closePosition: (code: string) => + apiFetch<{ ok: boolean }>(`/api/autotrade/positions/${code}/close`, { method: 'POST' }), +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..8d79bef --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,30 @@ +import { goto } from '$app/navigation' + +// API 공통 fetch 래퍼 — 세션 쿠키 자동 전송, 401 시 /login 리다이렉트 +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + + if (res.status === 401) { + goto('/login') + throw new Error('unauthorized') + } + + if (!res.ok) { + const text = await res.text() + let msg = text + try { + const json = JSON.parse(text) + msg = json.error ?? text + } catch {} + throw new Error(msg) + } + + // 204 No Content 처리 + if (res.status === 204) return undefined as T + + return res.json() +} diff --git a/frontend/src/lib/api/stock.ts b/frontend/src/lib/api/stock.ts new file mode 100644 index 0000000..7a3236b --- /dev/null +++ b/frontend/src/lib/api/stock.ts @@ -0,0 +1,81 @@ +import { apiFetch } from './client' +import type { + StockPrice, CandleData, StockInfo, ThemeGroup, ThemeDetail, + Kospi200Stock, IndexQuote, Signal, NewsItem, Disclosure, WatchlistItem +} from './types' + +export const stockApi = { + // 현재가 조회 + getPrice: (code: string) => + apiFetch(`/api/stock/${code}`), + + // 일봉 차트 데이터 + getChart: (code: string, type: 'D' | 'W' | 'M' | 'm' = 'D', count?: number) => { + const params = new URLSearchParams({ type }) + if (count) params.set('count', String(count)) + return apiFetch(`/api/stock/${code}/chart?${params}`) + }, + + // 종목 검색 + search: (q: string) => + apiFetch(`/api/search?q=${encodeURIComponent(q)}`), + + // 지수 (코스피/코스닥/다우/나스닥) + getIndices: () => + apiFetch('/api/indices'), + + // 스캐너 신호 + getSignals: () => + apiFetch('/api/signal'), + + // 관심종목 신호 + getWatchlistSignals: () => + apiFetch('/api/watchlist-signal'), + + // 스캐너 상태 + getScannerStatus: () => + apiFetch<{ running: boolean }>('/api/scanner/status'), + + // 스캐너 토글 + toggleScanner: () => + apiFetch<{ running: boolean }>('/api/scanner/toggle', { method: 'POST' }), + + // 뉴스 + getNews: (code?: string) => { + const params = code ? `?code=${code}` : '' + return apiFetch(`/api/news${params}`) + }, + + // 공시 + getDisclosures: (code?: string) => { + const params = code ? `?code=${code}` : '' + return apiFetch(`/api/disclosure${params}`) + }, + + // 코스피200 + getKospi200: () => + apiFetch('/api/kospi200'), + + // 테마 목록 + getThemes: () => + apiFetch('/api/themes'), + + // 테마 구성종목 + getThemeStocks: (code: string) => + apiFetch(`/api/themes/${code}`), + + // 관심종목 목록 + getWatchlist: () => + apiFetch('/api/watchlist'), + + // 관심종목 추가 + addWatchlist: (code: string, name: string) => + apiFetch<{ ok: boolean }>('/api/watchlist', { + method: 'POST', + body: JSON.stringify({ code, name }), + }), + + // 관심종목 삭제 + removeWatchlist: (code: string) => + apiFetch<{ ok: boolean }>(`/api/watchlist/${code}`, { method: 'DELETE' }), +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts new file mode 100644 index 0000000..40eb765 --- /dev/null +++ b/frontend/src/lib/api/types.ts @@ -0,0 +1,288 @@ +// Go models/ 구조체와 1:1 대응하는 TypeScript 타입 정의 + +export interface StockPrice { + code: string + name: string + currentPrice: number + changePrice: number + changeRate: number + volume: number + tradeMoney: number + high: number + low: number + open: number + tradeTime: string + tradeVolume: number + askPrice1: number + bidPrice1: number + marketStatus: string + cntrStr: number + market: string + updatedAt: string +} + +export interface CandleData { + time: number + open: number + high: number + low: number + close: number + volume: number +} + +export interface StockInfo { + code: string + name: string + market: string +} + +export interface OrderBookEntry { + price: number + volume: number +} + +export interface OrderBook { + code: string + askTime: string + asks: OrderBookEntry[] + bids: OrderBookEntry[] + totalAskVol: number + totalBidVol: number + expectedPrc: number + expectedVol: number +} + +export interface ThemeGroup { + code: string + name: string + stockCount: number + fluSig: string + fluRt: number + risingCount: number + fallCount: number + periodRt: number + mainStock: string +} + +export interface ThemeStock { + code: string + name: string + curPrc: number + fluSig: string + predPre: number + fluRt: number +} + +export interface ThemeDetail { + fluRt: number + periodRt: number + stocks: ThemeStock[] +} + +export interface Kospi200Stock { + code: string + name: string + curPrc: number + predPreSig: string + predPre: number + fluRt: number + volume: number + open: number + high: number + low: number +} + +export interface AutoTradeRule { + id: string + name: string + enabled: boolean + minRiseScore: number + minCntrStr: number + requireBullish: boolean + orderAmount: number + maxPositions: number + stopLoss1Pct: number + stopLoss1Count: number + stopLossPct: number + takeProfitPct: number + maxHoldMinutes: number + exitBeforeClose: boolean + createdAt: string +} + +export interface AutoTradePosition { + code: string + name: string + buyPrice: number + qty: number + orderNo: string + entryTime: string + ruleId: string + stopLoss1: number + stopLoss1Touches: number + stopLoss: number + takeProfit: number + status: string + exitTime?: string + exitPrice?: number + exitReason?: string +} + +export interface AutoTradeLog { + at: string + level: string + message: string + code: string +} + +export interface AutoTradeStatus { + running: boolean + positions: number + todayTrades: number + todayProfit: number +} + +export interface WatchlistItem { + code: string + name: string +} + +export interface IndexQuote { + name: string + value: number + change: number + changeRate: number +} + +// SignalStock — scanner_service.go의 SignalStock과 1:1 대응 +export interface Signal { + // StockPrice 임베드 필드 + code: string + name: string + currentPrice: number + changePrice: number + changeRate: number + volume: number + high: number + low: number + open: number + cntrStr: number + market: string + // 시그널 전용 필드 + prevCntrStr: number + risingCount: number + detectedAt: string + sentiment: string // 호재/악재/중립/정보없음 + sentimentReason: string + targetPrice: number + targetReason: string + riseScore: number + riseLabel: string // "매우 높음" | "높음" | "" + nextDayTrend: string // 상승 | 하락 | 횡보 + nextDayConf: string // 높음 | 보통 | 낮음 + nextDayReason: string + // 복합 지표 + totalAskVol: number + totalBidVol: number + askBidRatio: number + volDelta: number + volRatio: number + upperWick: number + pricePos: number + signalType: string // 강한매수 | 매수우세 | 물량소화 | 추격위험 | 약한상승 +} + +export interface NewsItem { + title: string + url: string + publishedAt: string + source: string +} + +export interface Disclosure { + rceptNo: string + corpName: string + reportNm: string + rceptDt: string + flrNm: string + url: string + tag: string +} + +export interface AccountBalance { + totalAsset: number + deposit: number + stockValue: number + profitLoss: number + profitRate: number + stocks: BalanceStock[] +} + +export interface BalanceStock { + code: string + name: string + qty: number + buyPrice: number + curPrice: number + profitLoss: number + profitRate: number + value: number +} + +// 미체결 주문 항목 +export interface PendingOrder { + ordNo: string // 주문번호 + stkCd: string // 종목코드 + stkNm: string // 종목명 + ordQty: string // 주문수량 + ordPric: string // 주문가격 + osoQty: string // 미체결수량 + ioTpNm: string // 주문구분 + trdeTp: string // 매매구분 (1:매도, 2:매수) + tm: string // 시간 + cntrPric: string // 체결가 + cntrQty: string // 체결량 +} + +// 체결내역 항목 +export interface OrderHistory { + ordNo: string + stkNm: string + ioTpNm: string + ordPric: string + ordQty: string + cntrPric: string + cntrQty: string + osoQty: string + trdeCmsn: string + trdeTax: string + ordStt: string + trdeTp: string + ordTm: string + stkCd: string +} + +export interface OrderRequest { + code: string + qty: number + price: number + tradeType?: string + exchange?: string +} + +export interface OrderResult { + orderNo: string + code: string + qty: number + price: number +} + +export interface ThemeRef { + code: string + name: string +} + +export interface AutoTradeWatchSource { + useScanner: boolean + selectedThemes: ThemeRef[] +} diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/components/CandleChart.svelte b/frontend/src/lib/components/CandleChart.svelte new file mode 100644 index 0000000..ba699c0 --- /dev/null +++ b/frontend/src/lib/components/CandleChart.svelte @@ -0,0 +1,134 @@ + + +
diff --git a/frontend/src/lib/components/CntrChart.svelte b/frontend/src/lib/components/CntrChart.svelte new file mode 100644 index 0000000..8c19619 --- /dev/null +++ b/frontend/src/lib/components/CntrChart.svelte @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte new file mode 100644 index 0000000..c122b07 --- /dev/null +++ b/frontend/src/lib/components/Modal.svelte @@ -0,0 +1,37 @@ + + +{#if open} + +
+
+
+

{title}

+ +
+
+ {@render children?.()} +
+
+
+{/if} diff --git a/frontend/src/lib/components/SortableHeader.svelte b/frontend/src/lib/components/SortableHeader.svelte new file mode 100644 index 0000000..67ddf06 --- /dev/null +++ b/frontend/src/lib/components/SortableHeader.svelte @@ -0,0 +1,25 @@ + + + dispatch('sort', col)} +> + {label} + {#if sortCol === col} + {sortDesc ? '▼' : '▲'} + {/if} + diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/stores/watchlist.ts b/frontend/src/lib/stores/watchlist.ts new file mode 100644 index 0000000..5ee93fa --- /dev/null +++ b/frontend/src/lib/stores/watchlist.ts @@ -0,0 +1,44 @@ +import { writable } from 'svelte/store' +import { stockApi } from '$lib/api/stock' +import type { WatchlistItem } from '$lib/api/types' + +// 관심종목 스토어 (서버 동기화) +function createWatchlistStore() { + const { subscribe, set, update } = writable([]) + + return { + subscribe, + + // 서버에서 목록 로딩 + async load() { + try { + const list = await stockApi.getWatchlist() + set(list ?? []) + } catch { + set([]) + } + }, + + // 관심종목 추가 + async add(code: string, name: string) { + await stockApi.addWatchlist(code, name) + update((list) => { + if (list.some((i) => i.code === code)) return list + return [...list, { code, name }] + }) + }, + + // 관심종목 삭제 + async remove(code: string) { + await stockApi.removeWatchlist(code) + update((list) => list.filter((i) => i.code !== code)) + }, + + // 포함 여부 확인 (동기) + has(list: WatchlistItem[], code: string) { + return list.some((i) => i.code === code) + }, + } +} + +export const watchlist = createWatchlistStore() diff --git a/frontend/src/lib/stores/ws.ts b/frontend/src/lib/stores/ws.ts new file mode 100644 index 0000000..cf27861 --- /dev/null +++ b/frontend/src/lib/stores/ws.ts @@ -0,0 +1,121 @@ +import { writable, derived } from 'svelte/store' +import type { StockPrice, OrderBook, AutoTradeLog } from '$lib/api/types' + +export type WsMsg = + | { type: 'price'; code: string; data: StockPrice } + | { type: 'orderbook'; code: string; data: OrderBook } + | { type: 'tradelog'; data: AutoTradeLog } + | { type: 'market'; data: unknown } + | { type: string; code?: string; data: unknown } + +// 마지막으로 수신한 WebSocket 메시지 +const { subscribe: msgSubscribe, set: setMsg } = writable(null) + +let socket: WebSocket | null = null +let reconnectTimer: ReturnType | null = null +let subscribedCodes = new Set() + +function connect() { + if (socket && (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN)) { + return + } + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + socket = new WebSocket(`${protocol}//${location.host}/ws`) + + socket.onopen = () => { + // 재연결 시 기존 구독 복원 + for (const code of subscribedCodes) { + socket!.send(JSON.stringify({ type: 'subscribe', code })) + } + } + + socket.onmessage = (e) => { + try { + setMsg(JSON.parse(e.data)) + } catch {} + } + + socket.onclose = () => { + socket = null + // 3초 후 자동 재연결 + reconnectTimer = setTimeout(connect, 3000) + } + + socket.onerror = () => { + socket?.close() + } +} + +// 종목 구독 추가 +function subscribeCode(code: string) { + subscribedCodes.add(code) + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'subscribe', code })) + } else { + connect() + } +} + +// 종목 구독 해제 +function unsubscribeCode(code: string) { + subscribedCodes.delete(code) + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'unsubscribe', code })) + } +} + +// 전체 구독 목록으로 일괄 구독 +function subscribeCodes(codes: string[]) { + for (const code of codes) { + subscribeCode(code) + } +} + +// 연결 끊기 +function disconnect() { + if (reconnectTimer) clearTimeout(reconnectTimer) + subscribedCodes.clear() + socket?.close() + socket = null +} + +export const wsStore = { + subscribe: msgSubscribe, + connect, + subscribeCode, + unsubscribeCode, + subscribeCodes, + disconnect, +} + +// 종목별 최신 시세 맵 (code → StockPrice) +export const priceMap = derived( + { subscribe: msgSubscribe }, + ($msg, set, update) => { + if ($msg?.type === 'price' && $msg.code) { + update((m) => ({ ...m, [$msg.code!]: $msg.data as StockPrice })) + } + }, + {} as Record +) + +// 종목별 최신 호가창 맵 (code → OrderBook) +export const orderBookMap = derived( + { subscribe: msgSubscribe }, + ($msg, set, update) => { + if ($msg?.type === 'orderbook' && $msg.code) { + update((m) => ({ ...m, [$msg.code!]: $msg.data as OrderBook })) + } + }, + {} as Record +) + +// 자동매매 로그 스트림 +export const tradeLogStream = derived( + { subscribe: msgSubscribe }, + ($msg) => { + if ($msg?.type === 'tradelog') return $msg.data as AutoTradeLog + return null + } +) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..71ef64b --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,54 @@ +// 가격 포맷 (천 단위 콤마) +export function formatPrice(n: number): string { + if (n === 0) return '0' + return Math.abs(n).toLocaleString('ko-KR') +} + +// 등락률 포맷 (+/-% 포함) +export function formatRate(rate: number): string { + const sign = rate >= 0 ? '+' : '' + return `${sign}${rate.toFixed(2)}%` +} + +// 등락에 따른 CSS 색상 클래스 (한국 관행: 상승=빨강, 하락=파랑) +export function priceClass(value: number): string { + if (value > 0) return 'text-red-400' + if (value < 0) return 'text-blue-400' + return 'text-gray-400' +} + +// 체결강도 CSS 클래스 (100 기준) +export function cntrClass(cntr: number): string { + if (cntr > 100) return 'text-red-400' + if (cntr < 100 && cntr > 0) return 'text-blue-400' + return 'text-gray-400' +} + +// 거래량 포맷 (만 단위 축약) +export function formatVolume(n: number): string { + if (n >= 100_000_000) return (n / 100_000_000).toFixed(1) + '억' + if (n >= 10_000) return (n / 10_000).toFixed(0) + '만' + return n.toLocaleString('ko-KR') +} + +// 날짜 포맷 (ISO → YYYY-MM-DD HH:mm) +export function formatDate(iso: string): string { + if (!iso) return '' + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +// 전일대비 부호 문자 (2=상승, 3=보합, 5=하락) +export function sigToArrow(sig: string): string { + if (sig === '2') return '▲' + if (sig === '5') return '▼' + return '-' +} + +// 전일대비 부호에 따른 클래스 +export function sigClass(sig: string): string { + if (sig === '2') return 'text-red-400' + if (sig === '5') return 'text-blue-400' + return 'text-gray-400' +} diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..3f3165a --- /dev/null +++ b/frontend/src/routes/(app)/+layout.svelte @@ -0,0 +1,64 @@ + + +
+ +
+
+
+ + 📈 StockSearch + + + + + + +
+
+
+ + +
+ {@render children()} +
+
diff --git a/frontend/src/routes/(app)/+layout.ts b/frontend/src/routes/(app)/+layout.ts new file mode 100644 index 0000000..6044c91 --- /dev/null +++ b/frontend/src/routes/(app)/+layout.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit' + +// 인증 가드: 세션 유효하지 않으면 /login으로 리다이렉트 +export async function load({ fetch }) { + try { + const res = await fetch('/api/auth/check', { credentials: 'include' }) + if (!res.ok) throw redirect(302, '/login') + } catch (e) { + // redirect 재throw + if (e && typeof e === 'object' && 'status' in e) throw e + throw redirect(302, '/login') + } +} diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..58c2bf1 --- /dev/null +++ b/frontend/src/routes/(app)/+page.svelte @@ -0,0 +1,670 @@ + + + + 주식 시세 + + + +{#if indices.length > 0} +
+ {#each indices as idx} +
+
{idx.name}
+
{idx.value.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}
+
{formatRate(idx.changeRate)}
+
+ {/each} +
+{/if} + +
+ + +
+
+

+ 관심종목 +

+ +
+ + {#if searchResults.length > 0} +
+ {#each searchResults.slice(0, 8) as result} + + {/each} +
+ {/if} +
+
+ + {#if $watchlist.length === 0} +
+ 종목을 검색해 관심종목을 추가하세요 +
+ {:else} +
+ + + + + + + + + + + + {#each (wlSorted.length > 0 ? wlSorted : $watchlist) as item (item.code)} + {@const price = $priceMap[item.code]} + goto(`/stock/${item.code}`)} + > + + + + + + + {/each} + +
+
{item.name}
+
{item.code}
+
+ {price ? formatPrice(price.currentPrice) : '-'} + + {price ? formatRate(price.changeRate) : '-'} + + {price ? price.cntrStr.toFixed(1) : '-'} + + + { e.stopPropagation(); removeFromWatchlist(item.code) }} + >× +
+
+ {/if} +
+ + +
+
+

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

+ + + + + + + + {#if updatedAt} + {updatedAt} + {/if} +
+ + {#if !scannerOn} +
+ 스캐너가 꺼져 있습니다. 버튼을 눌러 켜주세요. +
+ {:else if signals.length === 0} +
+ 08:00 이후 거래량 상위 종목에서 체결강도 100 이상 + 상승 종목을 표시합니다. +
+ {:else} +
+ {#each signals as sig (sig.code)} + {@const live = $priceMap[sig.code]} + {@const displayPrice = live ?? sig} + {@const history = getCntrHistory(sig.code)} + +
goto(`/stock/${sig.code}`)} + > + +
+ {sig.code} +
+ {#if sig.signalType} + {sig.signalType} + {/if} + {#if sig.riseLabel} + + {sig.riseLabel === '매우 높음' ? '🚀' : '📈'} {sig.riseLabel} + + {/if} + {#if sig.risingCount >= 1} + {risingLabel(sig.risingCount)} + {/if} + {#if sig.sentiment && sig.sentiment !== '정보없음'} + {sig.sentiment} + {/if} +
+
+ + +

{sig.name}

+

+ {formatPrice(displayPrice.currentPrice)}원 +

+ + +
+
+ 체결강도 + {(live?.cntrStr ?? sig.cntrStr).toFixed(2)} +
+
+ 직전 대비 + + {sig.prevCntrStr.toFixed(2)} → + +{(sig.cntrStr - sig.prevCntrStr).toFixed(2)} + +
+
+ 등락률 + + {formatRate(displayPrice.changeRate)} + +
+
+ 거래량 + {formatVolume(live?.volume ?? sig.volume)} +
+ + + {#if sig.volRatio > 0} +
+ 거래량 증가 + + {sig.volRatio.toFixed(1)}배{sig.volRatio >= 10 ? ' ⚠과열' : ''} + +
+ {/if} + {#if sig.totalAskVol > 0 && sig.totalBidVol > 0} +
+ 매도/매수 잔량 + + {sig.askBidRatio.toFixed(2)} ({askBidLabel(sig.askBidRatio)}) + +
+ {/if} + {#if sig.pricePos !== undefined} +
+ 가격 위치 + {sig.pricePos.toFixed(0)}% +
+ {/if} + + + {#if sig.targetPrice && sig.targetPrice > 0} + {@const diff = sig.targetPrice - displayPrice.currentPrice} + {@const pct = ((diff / displayPrice.currentPrice) * 100).toFixed(1)} +
+ AI 목표가 + + {formatPrice(sig.targetPrice)}원 + ({diff >= 0 ? '+' : ''}{pct}%) + +
+ {/if} + + + {#if sig.nextDayTrend} +
+ 익일 추세 +
+ + {nextDayIcon(sig.nextDayTrend)} {sig.nextDayTrend} + {#if sig.nextDayConf} + ({sig.nextDayConf}) + {/if} + + {#if sig.nextDayReason} +

{sig.nextDayReason}

+ {/if} +
+
+ {:else} +
+ 익일 추세 + 분석 중... +
+ {/if} +
+ + + {#key cntrTick} + {#if history.length >= 2} +
+ +
+ {/if} + {/key} +
+ {/each} +
+ {/if} +
+
+ + + { showGuide = false }}> +
+ + +
+

📊 상승 확률 스코어 (0~100점)

+

4가지 복합 요소를 동시에 만족해야 진짜 상승 확률이 높습니다.

+
+ + + + + + + + + + {#each [ + ['A. 체결강도 레벨', '0~30', '150+: 30점 · 130+: 22점 · 110+: 14점'], + ['B. 연속 상승 횟수', '0~25', '5회+: 25점 · 3회+: 14점 · 1회: 3점'], + ['C. 가격 위치/캔들', '0~20', '윗꼬리 10% 미만: +10점 · 60% 이상: −8점'], + ['D. 거래량 건전성', '0~15', '2~5배 증가: +15점 · 10배 이상: −5점'], + ['E. 매도잔량 소화', '0~10', '매수잔량 압도적: +10점 · 매도 우세: −3점'], + ] as [name, score, desc]} + + + + + + {/each} + +
요소배점핵심 로직
{name}{score}{desc}
+
+
+ + +
+

🏷️ 신호 유형 분류

+
+ {#each [ + ['강한매수', 'bg-red-600 text-white border-red-700', '체결강도 130+ · 3회 이상 연속 상승 · 윗꼬리 없음 · 매도잔량 소화 중'], + ['매수우세', 'bg-orange-400 text-white border-orange-500', '기본 상승 패턴. 강한매수 조건 미충족이나 전반적으로 매수세가 우위'], + ['물량소화', 'bg-yellow-100 text-yellow-700 border-yellow-300', '체결강도는 높은데 가격이 제자리 + 긴 윗꼬리 → 위에서 파는 물량을 받아내는 중'], + ['추격위험', 'bg-gray-800 text-white border-gray-700', '체결강도 170+ · 거래량 7배 이상 · 긴 윗꼬리 → 고점 물량털기 가능성 경계'], + ['약한상승', 'bg-gray-700 text-gray-300 border-gray-600', '거래량 증가 없음 · 체결강도 120 미만 → 얇은 호가에서 뜬 상승'], + ] as [label, cls, desc]} +
+ {label} + {desc} +
+ {/each} +
+
+ + +
+

🔍 지표 상세

+
+
+

체결강도

+

100 = 균형 / 100 초과 = 매수 우위 / 100 미만 = 매도 우위. 10초마다 연속 상승 종목만 표시.

+
+ 150+ 강한 매수세 + 130~150 매수 우세 + 100 미만 매도 경향 +
+
+
+

거래량 증가

+

직전 6회(1분) 평균 대비 현재 10초 구간 배수. 2~5배가 최적, 10배 이상은 고점 물량털기 가능성.

+
+
+

매도/매수 잔량

+

10단계 호가 총잔량 비율. 1 미만 = 매수 우위, 클수록 위에 팔 물량이 많음.

+
+
+

가격 위치

+

장중 저가~고가 내 현재가 위치(%). 80%+ = 고가권 강한 매수, 30% 미만 = 저가권.

+
+
+

AI 목표가 / 익일 추세

+

현재가·기술 지표·AI 감성을 종합한 추론. 참고용이며 투자 권유가 아닙니다.

+
+
+
+ +
+ 💡 진짜 상승 한 줄 기준
+ 가격이 오르면서 · 거래량이 받쳐주고 · 체결강도가 유지되고 · 위 매도물량이 실제로 소화되는 흐름 +
+ +
+ ⚠️ 모든 지표와 AI 분석은 참고용이며 투자 권유가 아닙니다. 모든 투자 손실의 책임은 투자자 본인에게 있습니다. +
+
+
diff --git a/frontend/src/routes/(app)/asset/+page.svelte b/frontend/src/routes/(app)/asset/+page.svelte new file mode 100644 index 0000000..94a1eb5 --- /dev/null +++ b/frontend/src/routes/(app)/asset/+page.svelte @@ -0,0 +1,282 @@ + + + + 자산 현황 - 주식 시세 + + +

자산 현황

+ +{#if loading} +
로딩 중...
+{:else if balance} + +
+ {#each [ + { label: '총 자산', value: balance.totalAsset, fmt: 'price' }, + { label: '예수금', value: balance.deposit, fmt: 'price' }, + { label: '주식 평가액', value: balance.stockValue, fmt: 'price' }, + { label: '평가 손익', value: balance.profitLoss, fmt: 'profit' }, + ] as card} +
+
{card.label}
+
+ {card.value >= 0 && card.fmt === 'profit' ? '+' : ''}{formatPrice(card.value)} +
+ {#if card.fmt === 'profit'} +
+ {formatRate(balance.profitRate)} +
+ {/if} +
+ {/each} +
+ + +
+ {#each [['balance', '보유종목'], ['pending', '미체결'], ['history', '주문내역']] as [tab, label]} + + {/each} +
+ + {#if activeTab === 'balance'} +
+ + + + + + + + + + + + + + {#each balance.stocks as stock (stock.code)} + goto(`/stock/${stock.code}`)} + > + + + + + + + + + {:else} + + + + {/each} + +
종목수량매수가현재가평가손익수익률평가금액
+
{stock.name}
+
{stock.code}
+
{stock.qty.toLocaleString()}{formatPrice(stock.buyPrice)}{formatPrice(stock.curPrice)} + {stock.profitLoss >= 0 ? '+' : ''}{formatPrice(stock.profitLoss)} + {formatRate(stock.profitRate)}{formatPrice(stock.value)}
보유 종목 없음
+
+ + {:else if activeTab === 'pending'} +
+ {#if pending.length === 0} +
미체결 주문 없음
+ {:else} + + + + + + + + + + + + + + + + {#each pending as order (order.ordNo)} + + + + + + + + + + + + {/each} + +
종목구분주문가주문수량미체결체결가체결량시간
+
{order.stkNm}
+
{order.stkCd}
+
+ {order.ioTpNm || tradeTypeLabel(order.trdeTp)} + + {parseInt(order.ordPric).toLocaleString()} + {order.ordQty}{order.osoQty} + {parseInt(order.cntrPric) > 0 ? parseInt(order.cntrPric).toLocaleString() : '-'} + + {parseInt(order.cntrQty) > 0 ? order.cntrQty : '-'} + {formatTm(order.tm)} + +
+ {/if} +
+ + {:else} +
+ {#if history.length === 0} +
오늘 주문 내역 없음
+ {:else} + + + + + + + + + + + + + + + + + {#each history as order (order.ordNo + order.ordTm)} + + + + + + + + + + + + + {/each} + +
종목구분주문가주문수량체결가체결량미체결상태수수료시간
+
{order.stkNm}
+
{order.stkCd}
+
+ {order.ioTpNm || tradeTypeLabel(order.trdeTp)} + + {parseInt(order.ordPric).toLocaleString()} + {order.ordQty} + {parseInt(order.cntrPric) > 0 ? parseInt(order.cntrPric).toLocaleString() : '-'} + + {parseInt(order.cntrQty) > 0 ? order.cntrQty : '-'} + + {parseInt(order.osoQty) > 0 ? order.osoQty : '0'} + + {order.ordStt || '-'} + + {parseInt(order.trdeCmsn) + parseInt(order.trdeTax) > 0 + ? (parseInt(order.trdeCmsn) + parseInt(order.trdeTax)).toLocaleString() + : '-'} + {formatTm(order.ordTm)}
+ {/if} +
+ {/if} +{:else} +
+ 계좌 정보를 불러올 수 없습니다. +
+{/if} \ No newline at end of file diff --git a/frontend/src/routes/(app)/autotrade/+page.svelte b/frontend/src/routes/(app)/autotrade/+page.svelte new file mode 100644 index 0000000..954df1a --- /dev/null +++ b/frontend/src/routes/(app)/autotrade/+page.svelte @@ -0,0 +1,570 @@ + + + + 자동매매 - 주식 시세 + + + +
+
+

자동매매

+ {#if status} + + + {status.running ? '실행 중' : '중지됨'} + + 포지션 {status.positions}개 + 오늘 {status.todayTrades}건 + {#if status.todayProfit !== 0} + + 오늘 손익 {status.todayProfit >= 0 ? '+' : ''}{formatPrice(status.todayProfit)} + + {/if} + {/if} +
+ +
+ + + +
+
+ + +
+

감시 소스

+
+ + + + +
+
+ 감시 테마 +
+ { showThemeDropdown = true }} + onblur={() => { setTimeout(() => { showThemeDropdown = false }, 200) }} + placeholder="테마 검색..." + class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> + {#if showThemeDropdown && filteredThemes.length > 0} +
+ {#each filteredThemes as theme (theme.code)} + + {/each} +
+ {/if} +
+
+ {#if watchSource.selectedThemes.length > 0} +
+ {#each watchSource.selectedThemes as theme (theme.code)} + + {theme.name} + + + {/each} +
+ {:else} +
선택된 테마 없음
+ {/if} +
+
+
+ + +
+ {#each [['rules', `규칙 (${rules.length})`], ['positions', `포지션 (${positions.length})`], ['logs', '로그']] as [tab, label]} + + {/each} +
+ + +{#if activeTab === 'rules'} +
+ +
+ + {#if rules.length === 0} +
규칙을 추가해보세요
+ {:else} +
+ {#each rules as rule (rule.id)} +
+
+
+
+ {rule.name} + + {rule.enabled ? '활성' : '비활성'} + +
+
+ 진입점수 {rule.minRiseScore}↑ + 체결강도 {rule.minCntrStr}↑ + 주문금액 {formatPrice(rule.orderAmount)} + 최대 {rule.maxPositions}종목 + 익절 +{rule.takeProfitPct}% + 손절 {rule.stopLossPct}% + {#if rule.maxHoldMinutes > 0} + 최대 {rule.maxHoldMinutes}분 + {/if} +
+
+
+ + + +
+
+
+ {/each} +
+ {/if} + + +{:else if activeTab === 'positions'} + {#if positions.length === 0} +
보유 포지션 없음
+ {:else} +
+ + + + + + + + + + + + + + + {#each positions as pos (pos.code + pos.orderNo)} + + + + + + + + + + + {/each} + +
종목수량매수가손절가익절가상태진입시각
+
{pos.name}
+
{pos.code}
+
{pos.qty}{formatPrice(pos.buyPrice)}{formatPrice(pos.stopLoss)}{formatPrice(pos.takeProfit)} + {pos.status} + {formatTime(pos.entryTime)} + {#if pos.status === 'open' || pos.status === 'pending'} + + {:else if pos.exitReason} + {pos.exitReason} + {/if} +
+
+ {/if} + + +{:else if activeTab === 'logs'} +
+ {#if logs.length === 0} +
로그 없음
+ {/if} + {#each logs as log} +
+ {formatTime(log.at)} + [{log.level}] + {#if log.code} + {log.code} + {/if} + {log.message} +
+ {/each} +
+{/if} + + + { showRuleModal = false }}> + {#if editingRule} +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ +
+ + +
+
+ {/if} +
\ No newline at end of file diff --git a/frontend/src/routes/(app)/kospi200/+page.svelte b/frontend/src/routes/(app)/kospi200/+page.svelte new file mode 100644 index 0000000..2b8c68a --- /dev/null +++ b/frontend/src/routes/(app)/kospi200/+page.svelte @@ -0,0 +1,117 @@ + + + + 코스피200 - 주식 시세 + + +
+

코스피200

+ +
+ +{#if loading} +
로딩 중...
+{:else} +
+
+ + + + + + + + + + + + + + + {#each sorted as stock (stock.code)} + goto(`/stock/${stock.code}`)} + > + + + + + + + + + + {/each} + +
+
{stock.name}
+
{stock.code}
+
+ {stock.curPrc.toLocaleString()} + + {sigToArrow(stock.predPreSig)} {stock.predPre.toLocaleString()} + + {formatRate(stock.fluRt)} + + {formatVolume(stock.volume)} + {stock.open.toLocaleString()}{stock.high.toLocaleString()}{stock.low.toLocaleString()}
+
+
+{/if} diff --git a/frontend/src/routes/(app)/stock/[code]/+page.svelte b/frontend/src/routes/(app)/stock/[code]/+page.svelte new file mode 100644 index 0000000..e89188a --- /dev/null +++ b/frontend/src/routes/(app)/stock/[code]/+page.svelte @@ -0,0 +1,256 @@ + + + + {displayPrice?.name ?? code} - 주식 시세 + + +{#if loading} +
로딩 중...
+{:else if displayPrice} + +
+
+
+ +

{displayPrice.name}

+ {code} + {displayPrice.market} + +
+ +
+ + {formatPrice(displayPrice.currentPrice)} + +
+ {displayPrice.changePrice >= 0 ? '+' : ''}{formatPrice(displayPrice.changePrice)} + ({formatRate(displayPrice.changeRate)}) +
+
+ +
+ 시가 {formatPrice(displayPrice.open)} + 고가 {formatPrice(displayPrice.high)} + 저가 {formatPrice(displayPrice.low)} + 거래량 {displayPrice.volume.toLocaleString()} + 체결강도 {displayPrice.cntrStr.toFixed(1)} +
+
+
+ +
+ +
+
+
+

캔들 차트

+
+ {#each [['D','일봉'], ['W','주봉'], ['M','월봉']] as [type, label]} + + {/each} +
+
+ +
+
+ + +
+ + {#if liveOrderBook} +
+

호가창

+
+ + {#each [...liveOrderBook.asks].reverse() as ask} +
+ {ask.volume.toLocaleString()} + {formatPrice(ask.price)} +
+ {/each} + +
+ {formatPrice(displayPrice.currentPrice)} +
+ + {#each liveOrderBook.bids as bid} +
+ {formatPrice(bid.price)} + {bid.volume.toLocaleString()} +
+ {/each} +
+
+ {/if} + + +
+

주문

+ {#if orderMsg} +
+ {orderMsg} +
+ {/if} +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+{:else} +
+
+
종목 정보를 불러올 수 없습니다
+ +
+
+{/if} diff --git a/frontend/src/routes/(app)/theme/+page.svelte b/frontend/src/routes/(app)/theme/+page.svelte new file mode 100644 index 0000000..ddb0d3e --- /dev/null +++ b/frontend/src/routes/(app)/theme/+page.svelte @@ -0,0 +1,206 @@ + + + + 테마 분석 - 주식 시세 + + +

테마 분석

+ +
+ + +
+ {#if loading} +
로딩 중...
+ {:else} +
+ +
+
+ + + + + + + + + + + + {#each sorted as theme (theme.code)} + selectTheme(theme)} + > + + + + + + + {/each} + +
+
{theme.name}
+
{theme.mainStock}
+
+ {formatRate(theme.fluRt)} + + {formatRate(theme.periodRt)} + + {theme.risingCount} + + {theme.stockCount} +
+
+ {/if} +
+ + +
+ {#if !selectedTheme} +
테마를 선택하면 구성종목이 표시됩니다
+ {:else if detailLoading} +
로딩 중...
+ {:else if detail} +
+

{selectedTheme.name}

+
+ 등락률 {formatRate(detail.fluRt)} + 기간수익 {formatRate(detail.periodRt)} + {detail.stocks.length}종목 +
+
+
+ + + + + + + + + + + {#each sortedStocks as stock (stock.code)} + goto(`/stock/${stock.code}`)} + > + + + + + + {/each} + +
+
{stock.name}
+
{stock.code}
+
+ {stock.curPrc.toLocaleString()} + + {sigToArrow(stock.fluSig)}{stock.predPre.toLocaleString()} + + {formatRate(stock.fluRt)} +
+
+ {/if} +
+ +
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..34f72f6 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..bac66f7 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,3 @@ +// SPA 모드: prerendering 비활성화 +export const prerender = false +export const ssr = false diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..3d58e1e --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,94 @@ + + + + 로그인 - 주식 시세 + + +
+
+
+

주식 시세

+

로그인이 필요합니다

+
+ +
+ {#if error} +
+ {error} +
+ {/if} + +
+ + +
+ +
+ + +
+ + +
+
+
diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..a05e8de --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,25 @@ +import adapter from '@sveltejs/adapter-static'; +import { relative, sep } from 'node:path'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + runes: ({ filename }) => { + const relativePath = relative(import.meta.dirname, filename); + const pathSegments = relativePath.toLowerCase().split(sep); + const isExternalLibrary = pathSegments.includes('node_modules'); + return isExternalLibrary ? undefined : true; + } + }, + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', // SPA fallback + precompress: false, + strict: false + }) + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2262c52 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { target: 'http://localhost:8080', changeOrigin: true }, + '/ws': { target: 'ws://localhost:8080', ws: true }, + '/login': { target: 'http://localhost:8080', changeOrigin: true }, + '/logout': { target: 'http://localhost:8080', changeOrigin: true }, + } + } +}); diff --git a/handlers/auth_handler.go b/handlers/auth_handler.go index d89bce6..d5cfd54 100644 --- a/handlers/auth_handler.go +++ b/handlers/auth_handler.go @@ -55,9 +55,9 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { return } - id := r.FormValue("id") + id := r.FormValue("id") password := r.FormValue("password") - next := r.FormValue("next") + next := r.FormValue("next") if next == "" { next = "/" } @@ -87,6 +87,18 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, next, http.StatusFound) } +// CheckSession GET /api/auth/check — 세션 유효성 확인 (200 OK / 401 Unauthorized) +func (h *AuthHandler) CheckSession(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(middleware.SessionCookieName) + if err != nil || !h.sessionSvc.Validate(cookie.Value) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + w.WriteHeader(http.StatusOK) +} + // Logout POST /logout — 세션 삭제 후 /login 리다이렉트 func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(middleware.SessionCookieName); err == nil { diff --git a/handlers/autotrade_handler.go b/handlers/autotrade_handler.go index f1bd7cc..429ff29 100644 --- a/handlers/autotrade_handler.go +++ b/handlers/autotrade_handler.go @@ -141,6 +141,20 @@ func (h *AutoTradeHandler) Emergency(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]bool{"ok": true}) } +// ClosePosition POST /api/autotrade/positions/{code}/close — 개별 포지션 청산 +func (h *AutoTradeHandler) ClosePosition(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if code == "" { + http.Error(w, "종목코드 필요", http.StatusBadRequest) + return + } + if err := h.svc.ClosePosition(code); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + jsonResponse(w, map[string]bool{"ok": true}) +} + // jsonResponse JSON 응답 헬퍼 func jsonResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") diff --git a/main.go b/main.go index ba9e4e3..acb1783 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "log" "net/http" + "os" "stocksearch/config" "stocksearch/handlers" "stocksearch/middleware" @@ -37,9 +38,9 @@ func main() { } // 서비스 추가 - sessionSvc := services.GetSessionService() - watchlistSvc := services.GetWatchlistService() - autoTradeSvc := services.GetAutoTradeService() + sessionSvc := services.GetSessionService() + watchlistSvc := services.GetWatchlistService() + autoTradeSvc := services.GetAutoTradeService() // 스캐너 구독 종목 → WebSocket 내부 구독 연결 services.GetScannerService().SetSubscribeCallback(func(codes []string) { @@ -52,11 +53,11 @@ func main() { }) // 핸들러 초기화 - pageHandler := handlers.NewPageHandler() - stockHandler := handlers.NewStockHandler(watchlistSvc) - wsHandler := handlers.NewWSHandler(hub) - authHandler := handlers.NewAuthHandler(sessionSvc) - orderHandler := handlers.NewOrderHandler() + pageHandler := handlers.NewPageHandler() + stockHandler := handlers.NewStockHandler(watchlistSvc) + wsHandler := handlers.NewWSHandler(hub) + authHandler := handlers.NewAuthHandler(sessionSvc) + orderHandler := handlers.NewOrderHandler() autoTradeHandler := handlers.NewAutoTradeHandler(autoTradeSvc) // 라우터 설정 (Go 1.22 패턴 매칭) @@ -66,6 +67,7 @@ func main() { mux.HandleFunc("GET /login", authHandler.LoginPage) mux.HandleFunc("POST /login", authHandler.Login) mux.HandleFunc("POST /logout", authHandler.Logout) + mux.HandleFunc("GET /api/auth/check", authHandler.CheckSession) // --- 페이지 라우트 --- mux.HandleFunc("GET /", pageHandler.IndexPage) @@ -105,19 +107,20 @@ func main() { mux.HandleFunc("GET /api/account/orderable", orderHandler.GetOrderable) // --- 자동매매 API 라우트 --- - mux.HandleFunc("GET /api/autotrade/status", autoTradeHandler.GetStatus) - mux.HandleFunc("GET /api/autotrade/rules", autoTradeHandler.GetRules) - mux.HandleFunc("POST /api/autotrade/rules", autoTradeHandler.AddRule) - mux.HandleFunc("PUT /api/autotrade/rules/{id}", autoTradeHandler.UpdateRule) - mux.HandleFunc("DELETE /api/autotrade/rules/{id}", autoTradeHandler.DeleteRule) + mux.HandleFunc("GET /api/autotrade/status", autoTradeHandler.GetStatus) + mux.HandleFunc("GET /api/autotrade/rules", autoTradeHandler.GetRules) + mux.HandleFunc("POST /api/autotrade/rules", autoTradeHandler.AddRule) + mux.HandleFunc("PUT /api/autotrade/rules/{id}", autoTradeHandler.UpdateRule) + mux.HandleFunc("DELETE /api/autotrade/rules/{id}", autoTradeHandler.DeleteRule) mux.HandleFunc("POST /api/autotrade/rules/{id}/toggle", autoTradeHandler.ToggleRule) - mux.HandleFunc("GET /api/autotrade/positions", autoTradeHandler.GetPositions) - mux.HandleFunc("GET /api/autotrade/logs", autoTradeHandler.GetLogs) - mux.HandleFunc("GET /api/autotrade/watch-source", autoTradeHandler.GetWatchSource) - mux.HandleFunc("PUT /api/autotrade/watch-source", autoTradeHandler.SetWatchSource) - mux.HandleFunc("POST /api/autotrade/start", autoTradeHandler.Start) - mux.HandleFunc("POST /api/autotrade/stop", autoTradeHandler.Stop) - mux.HandleFunc("POST /api/autotrade/emergency", autoTradeHandler.Emergency) + mux.HandleFunc("GET /api/autotrade/positions", autoTradeHandler.GetPositions) + mux.HandleFunc("GET /api/autotrade/logs", autoTradeHandler.GetLogs) + mux.HandleFunc("GET /api/autotrade/watch-source", autoTradeHandler.GetWatchSource) + mux.HandleFunc("PUT /api/autotrade/watch-source", autoTradeHandler.SetWatchSource) + mux.HandleFunc("POST /api/autotrade/start", autoTradeHandler.Start) + mux.HandleFunc("POST /api/autotrade/stop", autoTradeHandler.Stop) + mux.HandleFunc("POST /api/autotrade/emergency", autoTradeHandler.Emergency) + mux.HandleFunc("POST /api/autotrade/positions/{code}/close", autoTradeHandler.ClosePosition) // --- WebSocket 라우트 --- mux.HandleFunc("GET /ws", wsHandler.ServeWS) @@ -125,8 +128,22 @@ func main() { // --- 정적 파일 --- mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - // 미들웨어 체인 적용 (Auth → Logger → Recovery 순) - handler := middleware.Chain(mux, middleware.Recovery, middleware.Logger, middleware.Auth(sessionSvc)) + // --- SvelteKit 빌드 정적 서빙 (SPA fallback 포함) --- + if _, err := os.Stat("frontend/build"); err == nil { + spa := http.FileServer(http.Dir("frontend/build")) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := "frontend/build" + r.URL.Path + if _, err := os.Stat(path); os.IsNotExist(err) { + // SPA fallback: 파일 없으면 index.html 서빙 + http.ServeFile(w, r, "frontend/build/index.html") + return + } + spa.ServeHTTP(w, r) + }) + } + + // 미들웨어 체인 적용 (CORS → Auth → Logger → Recovery 순) + handler := middleware.Chain(mux, middleware.Recovery, middleware.Logger, middleware.Auth(sessionSvc), middleware.CORS) addr := "0.0.0.0:" + config.App.ServerPort log.Printf("서버 시작: http://%s", addr) diff --git a/middleware/auth.go b/middleware/auth.go index d03c427..b735c93 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -41,6 +41,7 @@ var publicPaths = []string{ "/api/kospi200", "/api/news", "/api/disclosure", + "/api/auth/check", "/ws", } diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000..07f4d07 --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "net/http" + "stocksearch/config" +) + +// CORS SvelteKit 개발 서버(localhost:5173)에서의 크로스오리진 요청 허용 미들웨어 +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + allowed := config.App.CORSOrigin + + if allowed != "" && origin == allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + } + + // Preflight 요청 즉시 응답 + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/models/autotrade.go b/models/autotrade.go index d8e4440..6b42b90 100644 --- a/models/autotrade.go +++ b/models/autotrade.go @@ -20,7 +20,12 @@ type AutoTradeRule struct { MaxPositions int `json:"maxPositions"` // 동시 최대 보유 종목 수 (기본 3) // 청산 조건 - StopLossPct float64 `json:"stopLossPct"` // 손절 % (예: -3.0) + // 2중 손절 구조: + // 1차 - StopLoss1Pct 에 StopLoss1Count 회 터치 시 매도 + // 2차 - StopLossPct 에 닿으면 즉시 매도 (StopLoss1Count==0 이면 단일 손절) + StopLoss1Pct float64 `json:"stopLoss1Pct"` // 1차 손절 % (예: -2.0, 0=비활성) + StopLoss1Count int `json:"stopLoss1Count"` // 1차 손절 터치 횟수 (예: 3) + StopLossPct float64 `json:"stopLossPct"` // 2차 손절 % (예: -4.0, 즉시 매도) TakeProfitPct float64 `json:"takeProfitPct"` // 익절 % (예: 5.0) MaxHoldMinutes int `json:"maxHoldMinutes"` // 최대 보유 시간(분, 0=무제한) ExitBeforeClose bool `json:"exitBeforeClose"` // 장 마감 전 청산(15:20 기준) @@ -30,15 +35,17 @@ type AutoTradeRule struct { // AutoTradePosition 자동매매 포지션 type AutoTradePosition struct { - Code string `json:"code"` - Name string `json:"name"` - BuyPrice int64 `json:"buyPrice"` // 매수 체결가 - Qty int64 `json:"qty"` // 수량 - OrderNo string `json:"orderNo"` // 매수 주문번호 - EntryTime time.Time `json:"entryTime"` // 진입 시각 - RuleID string `json:"ruleId"` // 규칙 ID - StopLoss int64 `json:"stopLoss"` // 절대 손절가 - TakeProfit int64 `json:"takeProfit"` // 절대 익절가 + Code string `json:"code"` + Name string `json:"name"` + BuyPrice int64 `json:"buyPrice"` // 매수 체결가 + Qty int64 `json:"qty"` // 수량 + OrderNo string `json:"orderNo"` // 매수 주문번호 + EntryTime time.Time `json:"entryTime"` // 진입 시각 + RuleID string `json:"ruleId"` // 규칙 ID + StopLoss1 int64 `json:"stopLoss1"` // 절대 1차 손절가 (0=비활성) + StopLoss1Touches int `json:"stopLoss1Touches"` // 1차 손절가 터치 누적 횟수 + StopLoss int64 `json:"stopLoss"` // 절대 2차 손절가 (즉시 매도) + TakeProfit int64 `json:"takeProfit"` // 절대 익절가 // 상태: "pending"=체결 대기 | "open"=보유중 | "closed"=청산완료 Status string `json:"status"` @@ -51,9 +58,9 @@ type AutoTradePosition struct { // AutoTradeLog 자동매매 이벤트 로그 type AutoTradeLog struct { At time.Time `json:"at"` - Level string `json:"level"` // "info"|"warn"|"error" + Level string `json:"level"` // "info"|"warn"|"error" Message string `json:"message"` - Code string `json:"code"` // 관련 종목코드 (없으면 "") + Code string `json:"code"` // 관련 종목코드 (없으면 "") } // ThemeRef 감시 소스로 선택된 테마 참조 diff --git a/services/autotrade_service.go b/services/autotrade_service.go index 933a8cc..7cc82cf 100644 --- a/services/autotrade_service.go +++ b/services/autotrade_service.go @@ -380,11 +380,11 @@ func (s *AutoTradeService) exitLoop() { // checkEntries 진입 조건 체크 및 매수 주문 func (s *AutoTradeService) checkEntries() { - signals := s.getWatchSignals() - + // 규칙 및 현재 포지션 수 선조회 s.mu.RLock() rules := make([]models.AutoTradeRule, len(s.rules)) copy(rules, s.rules) + posCount := s.countActivePositions() s.mu.RUnlock() // 활성 규칙 수 계산 @@ -395,6 +395,21 @@ func (s *AutoTradeService) checkEntries() { } } + // 모든 활성 규칙이 최대 포지션에 도달했으면 신호 조회 생략 → 보유 종목 감시만 진행 + hasRoom := false + for _, r := range rules { + if r.Enabled && posCount < r.MaxPositions { + hasRoom = true + break + } + } + if !hasRoom && activeRules > 0 { + s.addLog("debug", "", fmt.Sprintf("진입 스캔 생략: 최대 포지션 도달 (%d개 보유 중, 청산 감시만 진행)", posCount)) + return + } + + signals := s.getWatchSignals() + s.addLog("debug", "", fmt.Sprintf("진입 스캔: 신호 %d개, 활성규칙 %d개", len(signals), activeRules)) if len(signals) == 0 { @@ -497,13 +512,13 @@ func (s *AutoTradeService) checkEntries() { } // 포지션 등록 (현재가 기준 예상 손절/익절가 미리 계산 → UI에 0원 방지) - estStop := int64(float64(sig.CurrentPrice) * (1 + rule.StopLossPct/100)) - estProfit := int64(float64(sig.CurrentPrice) * (1 + rule.TakeProfitPct/100)) + estStop1, estStop, estProfit := calcStopTargets(sig.CurrentPrice, &rule) pos := &models.AutoTradePosition{ Code: code, Name: sig.Name, Qty: qty, BuyPrice: sig.CurrentPrice, + StopLoss1: estStop1, StopLoss: estStop, TakeProfit: estProfit, OrderNo: result.OrderNo, @@ -561,21 +576,13 @@ func (s *AutoTradeService) checkExits() { // 포지션 모니터링 debug 로그 if posCopy.BuyPrice > 0 { pl := (float64(curPrice) - float64(posCopy.BuyPrice)) / float64(posCopy.BuyPrice) * 100 - s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (손절=%s 익절=%s)", - posCopy.Name, formatComma(curPrice), pl, formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit))) + s.addLog("debug", code, fmt.Sprintf("모니터링 [%s] 현재가=%s원 손익=%+.2f%% (1차손절=%s[%d/%d회] 2차손절=%s 익절=%s)", + posCopy.Name, formatComma(curPrice), pl, + formatComma(posCopy.StopLoss1), posCopy.StopLoss1Touches, s.stopLoss1Limit(posCopy.RuleID), + formatComma(posCopy.StopLoss), formatComma(posCopy.TakeProfit))) } - var reason string - switch { - case curPrice <= posCopy.StopLoss: - reason = "손절" - case curPrice >= posCopy.TakeProfit: - reason = "익절" - case exitBeforeCloseRule(s.rules, &posCopy) && kstNow.After(closeTime): - reason = "장마감" - case maxHoldExpired(s.rules, &posCopy, now): - reason = "시간초과" - } + reason := s.evalExitReason(code, &posCopy, curPrice, kstNow, closeTime, now) if reason != "" { if err := s.executeSell(&posCopy, reason); err != nil { @@ -629,23 +636,27 @@ func (s *AutoTradeService) checkPending() { } rule := s.findRule(pos.RuleID) - var stopLoss, takeProfit int64 + var stopLoss1, stopLoss, takeProfit int64 if rule != nil && buyPrice > 0 { - stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100)) - takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100)) + stopLoss1, stopLoss, takeProfit = calcStopTargets(buyPrice, rule) } s.mu.Lock() if p, ok := s.positions[pos.Code]; ok && p.Status == "pending" { p.BuyPrice = buyPrice + p.StopLoss1 = stopLoss1 p.StopLoss = stopLoss p.TakeProfit = takeProfit p.Status = "open" } s.mu.Unlock() - s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (손절: %d, 익절: %d)", - pos.Name, pos.Qty, buyPrice, stopLoss, takeProfit)) + sl1Count := 0 + if rule != nil { + sl1Count = rule.StopLoss1Count + } + s.addLog("info", pos.Code, fmt.Sprintf("매수 체결 확인: %s %d주 @ %d원 (1차손절: %d[%d회], 2차손절: %d, 익절: %d)", + pos.Name, pos.Qty, buyPrice, stopLoss1, sl1Count, stopLoss, takeProfit)) } } @@ -706,6 +717,20 @@ func (s *AutoTradeService) executeSell(pos *models.AutoTradePosition, reason str return nil } +// ClosePosition 개별 포지션 수동 청산 +func (s *AutoTradeService) ClosePosition(code string) error { + s.mu.RLock() + pos, ok := s.positions[code] + s.mu.RUnlock() + if !ok { + return fmt.Errorf("포지션을 찾을 수 없습니다: %s", code) + } + if pos.Status != "open" && pos.Status != "pending" { + return fmt.Errorf("이미 청산된 포지션입니다: %s", code) + } + return s.executeSell(pos, "수동청산") +} + // countActivePositions pending+open 포지션 수 func (s *AutoTradeService) countActivePositions() int { count := 0 @@ -799,3 +824,80 @@ func maxHoldExpired(rules []models.AutoTradeRule, p *models.AutoTradePosition, n } return false } + +// calcStopTargets 매수가 기준으로 1차 손절가 / 2차 손절가 / 익절가 계산 +func calcStopTargets(buyPrice int64, rule *models.AutoTradeRule) (stopLoss1, stopLoss, takeProfit int64) { + if rule == nil || buyPrice <= 0 { + return 0, 0, 0 + } + if rule.StopLoss1Count > 0 && rule.StopLoss1Pct != 0 { + stopLoss1 = int64(float64(buyPrice) * (1 + rule.StopLoss1Pct/100)) + } + stopLoss = int64(float64(buyPrice) * (1 + rule.StopLossPct/100)) + takeProfit = int64(float64(buyPrice) * (1 + rule.TakeProfitPct/100)) + return +} + +// stopLoss1Limit 규칙의 1차 손절 터치 임계 반환 (규칙 없으면 0) +func (s *AutoTradeService) stopLoss1Limit(ruleID string) int { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.rules { + if r.ID == ruleID { + return r.StopLoss1Count + } + } + return 0 +} + +// evalExitReason 청산 조건 평가 — 2중 손절 포함 +// 2차 손절(즉시) → 1차 손절(터치 카운트) → 익절 → 장마감 → 시간초과 순으로 체크 +func (s *AutoTradeService) evalExitReason(code string, pos *models.AutoTradePosition, curPrice int64, + kstNow time.Time, closeTime time.Time, now time.Time) string { + + // 2차 손절: 즉시 매도 + if curPrice <= pos.StopLoss { + return "2차손절" + } + + // 1차 손절: X회 터치 시 매도 + if pos.StopLoss1 > 0 && curPrice <= pos.StopLoss1 { + rule := s.findRule(pos.RuleID) + limit := 0 + if rule != nil { + limit = rule.StopLoss1Count + } + + s.mu.Lock() + var touches int + if p, ok := s.positions[code]; ok && p.Status == "open" { + p.StopLoss1Touches++ + touches = p.StopLoss1Touches + } + s.mu.Unlock() + + if limit > 0 && touches >= limit { + return "1차손절" + } + s.addLog("warn", code, fmt.Sprintf("1차손절 터치 %d/%d회 (현재가: %s원, 1차손절가: %s원)", + touches, limit, formatComma(curPrice), formatComma(pos.StopLoss1))) + return "" + } + + // 익절 + if curPrice >= pos.TakeProfit { + return "익절" + } + + // 장마감 전 청산 + s.mu.RLock() + rules := s.rules + s.mu.RUnlock() + if exitBeforeCloseRule(rules, pos) && kstNow.After(closeTime) { + return "장마감" + } + if maxHoldExpired(rules, pos, now) { + return "시간초과" + } + return "" +} diff --git a/static/js/autotrade.js b/static/js/autotrade.js index cada25a..2c10c9b 100644 --- a/static/js/autotrade.js +++ b/static/js/autotrade.js @@ -129,7 +129,7 @@ function renderRules(rules) {

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

-

청산: 손절${r.stopLossPct}% / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}

+

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

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

@@ -173,7 +173,9 @@ function showAddRuleModal() { document.getElementById('fRequireBullish').checked = false; document.getElementById('fOrderAmount').value = 500000; document.getElementById('fMaxPositions').value = 3; - document.getElementById('fStopLoss').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; @@ -190,6 +192,8 @@ function showEditRuleModal(r) { 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; @@ -211,6 +215,8 @@ async function submitRule() { 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), @@ -268,7 +274,8 @@ function renderPositions(positions) { 종목 매수가 수량 - 손절가 + 1차손절 + 2차손절 상태 @@ -284,7 +291,8 @@ function renderPositions(positions) { ${formatMoney(p.buyPrice)} ${p.qty} - ${formatMoney(p.stopLoss)} + ${p.stopLoss1 > 0 ? formatMoney(p.stopLoss1) + `[${p.stopLoss1Touches||0}회]` : '-'} + ${formatMoney(p.stopLoss)} ${statusTxt} `; diff --git a/static/js/watchlist.js b/static/js/watchlist.js index 47da819..ef4542c 100644 --- a/static/js/watchlist.js +++ b/static/js/watchlist.js @@ -351,27 +351,39 @@ // ───────────────────────────────────────────── function renderSidebar() { const list = loadList(); - Array.from(sidebarEl.children).forEach(el => { - if (el.id !== 'watchlistEmpty') el.remove(); - }); - list.forEach(s => sidebarEl.appendChild(makeSidebarItem(s.code, s.name))); + // 테이블 생성 + 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 li = document.createElement('li'); - li.id = `si-${code}`; - li.className = 'flex items-center justify-between px-3 py-2.5 hover:bg-gray-50 group'; - li.innerHTML = ` - -

${name}

-

${code}

-
- `; - return li; + const tr = document.createElement('tr'); + tr.id = `si-${code}`; + tr.className = 'hover:bg-gray-50 group cursor-pointer'; + tr.onclick = () => { window.location.href = `/stock/${code}`; }; + tr.innerHTML = ` + +

${name}

+

${code}

+ + - + - + - + + + `; + return tr; } // ───────────────────────────────────────────── @@ -437,17 +449,21 @@ const rateEl = document.getElementById(`wc-rate-${code}`); const volEl = document.getElementById(`wc-vol-${code}`); const cntrEl = document.getElementById(`wc-cntr-${code}`); - if (!priceEl) return; const rate = data.changeRate ?? 0; const colorCls = rateClass(rate); const bgCls = rateBadgeClass(rate); const sign = rate > 0 ? '+' : ''; - priceEl.textContent = fmtNum(data.currentPrice) + '원'; - priceEl.className = `text-xl font-bold mb-2 ${colorCls}`; - rateEl.textContent = sign + rate.toFixed(2) + '%'; - rateEl.className = `text-xs px-2 py-0.5 rounded-full font-semibold ${bgCls}`; + // 패널 카드 업데이트 + if (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; @@ -455,6 +471,24 @@ 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); @@ -536,7 +570,7 @@ // ───────────────────────────────────────────── function updateEmptyStates() { const hasItems = cachedList.length > 0; - emptyEl.classList.toggle('hidden', hasItems); + if (emptyEl) emptyEl.classList.toggle('hidden', hasItems); panelEmpty?.classList.toggle('hidden', hasItems); } diff --git a/templates/pages/autotrade.html b/templates/pages/autotrade.html index 6c144ad..e97f9c6 100644 --- a/templates/pages/autotrade.html +++ b/templates/pages/autotrade.html @@ -220,11 +220,32 @@

청산 조건

+ + +
+ 2중 손절: + 1차 손절가에 설정 횟수만큼 터치 시 매도, + 2차 손절가 도달 시 즉시 매도. + 1차 터치 횟수를 0으로 설정하면 단순 손절. +
+
+
- - + + +
+
+ + +
+ +
+ +
diff --git a/templates/pages/index.html b/templates/pages/index.html index 4683a7f..37a31a8 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -22,11 +22,22 @@
-
    -
  • + + + + + + + + + + +
    종목현재가등락강도
    +
    +
    관심종목을 추가해주세요 -
  • -
+
+
{{ end }}