feat: full-stack frontend with gateway API integration
- Dashboard with real-time service health, screening results, stream stats - Stock list page with search, market filter, pagination via KIS API - Stock detail page with prices, valuation, AI analysis, catalysts - Screening page with trigger + results display - Pipeline monitoring with service status and stream info - Typed API client (lib/api.ts) for all gateway endpoints - Reusable components (Sidebar, StatCard) - Dockerfile with build-time NEXT_PUBLIC_API_URL injection - Docker-compatible .dockerignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:9080/api/v1
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
507
package-lock.json
generated
507
package-lock.json
generated
@ -8,9 +8,12 @@
|
|||||||
"name": "stock-frontend",
|
"name": "stock-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ioredis": "^5.9.3",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -971,6 +974,12 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@ -1226,6 +1235,42 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@ -1233,6 +1278,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -1524,6 +1581,69 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -1559,7 +1679,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@ -1575,6 +1695,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.56.0",
|
"version": "8.56.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
|
||||||
@ -2605,6 +2731,24 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -2658,9 +2802,130 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@ -2726,7 +2991,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -2740,6 +3004,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@ -2783,6 +3053,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -3026,6 +3305,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@ -3479,6 +3768,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -3935,6 +4230,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@ -3977,6 +4282,39 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.5.0",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@ -4838,6 +5176,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@ -4868,6 +5218,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.575.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||||
|
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -4939,7 +5298,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -5457,9 +5815,97 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@ -5504,6 +5950,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@ -5873,6 +6325,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@ -6093,6 +6551,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@ -6430,6 +6894,37 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@ -9,9 +9,12 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ioredis": "^5.9.3",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@ -1,26 +1,33 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #0f172a;
|
||||||
--foreground: #171717;
|
--foreground: #e2e8f0;
|
||||||
|
--card: #1e293b;
|
||||||
|
--card-border: #334155;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-green: #22c55e;
|
||||||
|
--accent-red: #ef4444;
|
||||||
|
--accent-yellow: #eab308;
|
||||||
|
--muted: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-border: var(--card-border);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-green: var(--accent-green);
|
||||||
|
--color-accent-red: var(--accent-red);
|
||||||
|
--color-accent-yellow: var(--accent-yellow);
|
||||||
|
--color-muted: var(--muted);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Stock Analysis",
|
||||||
description: "Generated by create next app",
|
description: "AI-powered Korean stock analysis platform",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -23,11 +24,14 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
249
src/app/page.tsx
249
src/app/page.tsx
@ -1,65 +1,200 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BarChart3, TrendingUp, Zap, Activity } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StatCard } from "@/components/stat-card";
|
||||||
|
import {
|
||||||
|
fetchAllHealth,
|
||||||
|
fetchScreeningLatest,
|
||||||
|
fetchAllStreams,
|
||||||
|
type ScreeningResultItem,
|
||||||
|
type HealthResponse,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
function recBadge(rec: string) {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
STRONG_BUY: "bg-green-500/20 text-green-400",
|
||||||
|
BUY: "bg-green-500/10 text-green-300",
|
||||||
|
HOLD: "bg-yellow-500/10 text-yellow-300",
|
||||||
|
SELL: "bg-red-500/10 text-red-300",
|
||||||
|
STRONG_SELL: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
return styles[rec] || "bg-gray-500/10 text-gray-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [topStocks, setTopStocks] = useState<ScreeningResultItem[]>([]);
|
||||||
|
const [services, setServices] = useState<{ name: string; data: HealthResponse | null }[]>([]);
|
||||||
|
const [stats, setStats] = useState({ stocks: 0, screened: 0, catalysts: 0, running: 0, total: 6 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [health, screening, streams] = await Promise.allSettled([
|
||||||
|
fetchAllHealth(),
|
||||||
|
fetchScreeningLatest(8),
|
||||||
|
fetchAllStreams(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (health.status === "fulfilled") {
|
||||||
|
setServices(health.value);
|
||||||
|
const running = health.value.filter((s) => s.data?.status === "ok").length;
|
||||||
|
setStats((prev) => ({ ...prev, running, total: health.value.length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screening.status === "fulfilled" && screening.value.results.length > 0) {
|
||||||
|
setTopStocks(screening.value.results);
|
||||||
|
setStats((prev) => ({ ...prev, screened: screening.value.results.length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streams.status === "fulfilled") {
|
||||||
|
let totalStocks = 0;
|
||||||
|
let catalystCount = 0;
|
||||||
|
for (const s of streams.value) {
|
||||||
|
if (!s.data) continue;
|
||||||
|
for (const [name, info] of Object.entries(s.data.streams)) {
|
||||||
|
if (name === "queue:raw-stocks") totalStocks = info.length;
|
||||||
|
if (name === "queue:catalysts") catalystCount = info.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStats((prev) => ({
|
||||||
|
...prev,
|
||||||
|
stocks: totalStocks || prev.stocks,
|
||||||
|
catalysts: catalystCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="space-y-6">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
{/* Stats */}
|
||||||
src="/next.svg"
|
<div className="grid grid-cols-4 gap-4">
|
||||||
alt="Next.js logo"
|
<StatCard
|
||||||
width={100}
|
title="Total Stocks"
|
||||||
height={20}
|
value={loading ? "..." : stats.stocks.toLocaleString()}
|
||||||
priority
|
subtitle="KIS 수집 기준"
|
||||||
|
icon={<BarChart3 size={18} />}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<StatCard
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
title="Screened"
|
||||||
To get started, edit the page.tsx file.
|
value={loading ? "..." : String(stats.screened)}
|
||||||
</h1>
|
subtitle="Top candidates"
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
icon={<TrendingUp size={18} />}
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
trend="up"
|
||||||
<a
|
/>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<StatCard
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
title="Catalysts"
|
||||||
>
|
value={loading ? "..." : String(stats.catalysts)}
|
||||||
Templates
|
subtitle="감지된 카탈리스트"
|
||||||
</a>{" "}
|
icon={<Zap size={18} />}
|
||||||
or the{" "}
|
trend="up"
|
||||||
<a
|
/>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<StatCard
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
title="Pipeline"
|
||||||
>
|
value={loading ? "..." : `${stats.running}/${stats.total}`}
|
||||||
Learning
|
subtitle="Services running"
|
||||||
</a>{" "}
|
icon={<Activity size={18} />}
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{/* Top Recommendations */}
|
||||||
|
<div className="col-span-2 bg-card border border-card-border rounded-xl">
|
||||||
|
<div className="p-4 border-b border-card-border flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold">Top Recommendations</h2>
|
||||||
|
<Link href="/screening" className="text-xs text-accent hover:underline">
|
||||||
|
View All →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted text-left border-b border-card-border">
|
||||||
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Code</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Score</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">PER</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">ROE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted">Loading...</td></tr>
|
||||||
|
) : topStocks.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted">
|
||||||
|
스크리닝 결과가 없습니다. Screening을 실행하세요.
|
||||||
|
</td></tr>
|
||||||
|
) : (
|
||||||
|
topStocks.map((s) => (
|
||||||
|
<tr
|
||||||
|
key={s.stock_code}
|
||||||
|
className="border-b border-card-border/50 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 text-muted">{s.rank}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Link
|
||||||
|
href={`/stock/${s.stock_code}`}
|
||||||
|
className="font-mono text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{s.stock_code}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 font-medium">{s.stock_name || s.stock_code}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right font-mono">
|
||||||
|
{Number(s.composite_score).toFixed(1)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right font-mono text-muted">
|
||||||
|
{s.per_score != null ? Number(s.per_score).toFixed(1) : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right font-mono text-muted">
|
||||||
|
{s.roe_score != null ? Number(s.roe_score).toFixed(1) : "-"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Status */}
|
||||||
|
<div className="bg-card border border-card-border rounded-xl">
|
||||||
|
<div className="p-4 border-b border-card-border flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold">Service Status</h2>
|
||||||
|
<Link href="/pipeline" className="text-xs text-accent hover:underline">
|
||||||
|
Details →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted">Loading...</p>
|
||||||
|
) : (
|
||||||
|
services.map((s) => (
|
||||||
|
<div key={s.name} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium">{s.name}</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded ${
|
||||||
|
s.data?.status === "ok"
|
||||||
|
? "bg-green-500/10 text-green-300"
|
||||||
|
: "bg-red-500/10 text-red-300"
|
||||||
|
}`}>
|
||||||
|
{s.data?.status === "ok" ? "Running" : "Down"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
187
src/app/pipeline/page.tsx
Normal file
187
src/app/pipeline/page.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Activity, RefreshCw, CheckCircle, Clock, AlertCircle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fetchAllHealth,
|
||||||
|
fetchAllStreams,
|
||||||
|
type HealthResponse,
|
||||||
|
type StreamInfo,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
type ServiceStatus = "running" | "idle" | "error";
|
||||||
|
|
||||||
|
interface ServiceState {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
status: ServiceStatus;
|
||||||
|
description: string;
|
||||||
|
uptime: number;
|
||||||
|
streams: Record<string, { length: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_META: Record<string, { label: string; description: string }> = {
|
||||||
|
dart: { label: "dart-collector", description: "DART API (공시 + 재무제표)" },
|
||||||
|
kis: { label: "kis-collector", description: "KIS API (주가 + 시장 데이터)" },
|
||||||
|
news: { label: "news-crawler", description: "네이버 파이낸스 뉴스 크롤링" },
|
||||||
|
screening: { label: "screener", description: "정량 스크리닝 + 밸류에이션" },
|
||||||
|
catalyst: { label: "catalyst", description: "카탈리스트 감지 (공시/뉴스 분석)" },
|
||||||
|
analysis: { label: "llm-analyzer", description: "Claude LLM 정성 분석" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: ServiceStatus }) {
|
||||||
|
switch (status) {
|
||||||
|
case "running": return <CheckCircle size={16} className="text-accent-green" />;
|
||||||
|
case "idle": return <Clock size={16} className="text-accent-yellow" />;
|
||||||
|
case "error": return <AlertCircle size={16} className="text-accent-red" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: ServiceStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "running": return "Running";
|
||||||
|
case "idle": return "Idle";
|
||||||
|
case "error": return "Error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number) {
|
||||||
|
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||||
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PipelinePage() {
|
||||||
|
const [services, setServices] = useState<ServiceState[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [healthResults, streamResults] = await Promise.allSettled([
|
||||||
|
fetchAllHealth(),
|
||||||
|
fetchAllStreams(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const healthMap: Record<string, HealthResponse | null> = {};
|
||||||
|
const streamMap: Record<string, StreamInfo | null> = {};
|
||||||
|
|
||||||
|
if (healthResults.status === "fulfilled") {
|
||||||
|
for (const h of healthResults.value) healthMap[h.name] = h.data;
|
||||||
|
}
|
||||||
|
if (streamResults.status === "fulfilled") {
|
||||||
|
for (const s of streamResults.value) streamMap[s.name] = s.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const states: ServiceState[] = Object.entries(SERVICE_META).map(([key, meta]) => {
|
||||||
|
const health = healthMap[key];
|
||||||
|
const stream = streamMap[key];
|
||||||
|
return {
|
||||||
|
name: key,
|
||||||
|
label: meta.label,
|
||||||
|
status: health?.status === "ok" ? "running" : health ? "idle" : "error",
|
||||||
|
description: meta.description,
|
||||||
|
uptime: health?.uptime_seconds ?? 0,
|
||||||
|
streams: stream?.streams ?? {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setServices(states);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
setRefreshing(true);
|
||||||
|
await load();
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity size={24} className="text-accent" />
|
||||||
|
<h1 className="text-2xl font-bold">Pipeline Monitor</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-card-border px-4 py-2 rounded-lg text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={refreshing ? "animate-spin" : ""} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline Flow Diagram */}
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-muted mb-4">Data Flow</h2>
|
||||||
|
<div className="flex items-center gap-2 text-xs overflow-x-auto pb-2">
|
||||||
|
{["dart-collector", "kis-collector", "news-crawler"].map((name) => (
|
||||||
|
<div key={name} className="bg-blue-500/10 border border-blue-500/20 rounded-lg px-3 py-2 text-blue-300 text-center min-w-[120px]">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<span className="text-muted px-2">→ Redis →</span>
|
||||||
|
<div className="bg-green-500/10 border border-green-500/20 rounded-lg px-3 py-2 text-green-300 text-center min-w-[100px]">
|
||||||
|
screener
|
||||||
|
</div>
|
||||||
|
<span className="text-muted px-2">→</span>
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-3 py-2 text-yellow-300 text-center min-w-[100px]">
|
||||||
|
catalyst
|
||||||
|
</div>
|
||||||
|
<span className="text-muted px-2">→</span>
|
||||||
|
<div className="bg-purple-500/10 border border-purple-500/20 rounded-lg px-3 py-2 text-purple-300 text-center min-w-[120px]">
|
||||||
|
llm-analyzer
|
||||||
|
</div>
|
||||||
|
<span className="text-muted px-2">→</span>
|
||||||
|
<div className="bg-accent/10 border border-accent/20 rounded-lg px-3 py-2 text-accent text-center min-w-[100px]">
|
||||||
|
results
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="col-span-2 text-center text-muted py-8">Loading services...</p>
|
||||||
|
) : (
|
||||||
|
services.map((svc) => (
|
||||||
|
<div key={svc.name} className="bg-card border border-card-border rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={svc.status} />
|
||||||
|
<h3 className="font-semibold">{svc.label}</h3>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
svc.status === "running" ? "bg-green-500/10 text-green-300"
|
||||||
|
: svc.status === "idle" ? "bg-yellow-500/10 text-yellow-300"
|
||||||
|
: "bg-red-500/10 text-red-300"
|
||||||
|
}`}>
|
||||||
|
{statusLabel(svc.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mb-4">{svc.description}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{Object.entries(svc.streams).map(([name, info]) => (
|
||||||
|
<div key={name} className="flex items-center justify-between bg-white/5 rounded-lg px-3 py-2">
|
||||||
|
<span className="font-mono text-accent">{name}</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{info.length >= 0 ? `${info.length} msgs` : "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-muted">
|
||||||
|
Uptime: {svc.uptime > 0 ? formatUptime(svc.uptime) : "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
src/app/screening/page.tsx
Normal file
231
src/app/screening/page.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Filter, Play, ArrowUpDown, Loader2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
fetchScreeningLatest,
|
||||||
|
triggerScreening,
|
||||||
|
type ScreeningResultItem,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
type Strategy = "balanced" | "value" | "growth" | "quality";
|
||||||
|
type Market = "all" | "KOSPI" | "KOSDAQ";
|
||||||
|
|
||||||
|
const strategyDescriptions: Record<Strategy, string> = {
|
||||||
|
balanced: "PER, PBR, ROE, FCF Yield 균등 배분",
|
||||||
|
value: "PER, PBR 가중 - 저평가 종목 선별",
|
||||||
|
growth: "PEG, EV/EBITDA 가중 - 성장주 선별",
|
||||||
|
quality: "ROE, Debt Ratio 가중 - 우량주 선별",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScreeningPage() {
|
||||||
|
const [strategy, setStrategy] = useState<Strategy>("balanced");
|
||||||
|
const [market, setMarket] = useState<Market>("all");
|
||||||
|
const [topN, setTopN] = useState(50);
|
||||||
|
const [results, setResults] = useState<ScreeningResultItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [lastRun, setLastRun] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchScreeningLatest(topN)
|
||||||
|
.then((data) => {
|
||||||
|
setResults(data.results);
|
||||||
|
setLastRun(data.screened_at ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [topN]);
|
||||||
|
|
||||||
|
async function handleRun() {
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
await triggerScreening(strategy, market, topN);
|
||||||
|
// 큐에 들어간 후 잠시 대기 후 결과 조회
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchScreeningLatest(topN);
|
||||||
|
setResults(data.results);
|
||||||
|
setLastRun(data.screened_at ?? null);
|
||||||
|
} catch {}
|
||||||
|
setRunning(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Filter size={24} className="text-accent" />
|
||||||
|
<h1 className="text-2xl font-bold">Screening</h1>
|
||||||
|
{lastRun && (
|
||||||
|
<span className="text-xs text-muted">Last run: {new Date(lastRun).toLocaleString("ko-KR")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={running}
|
||||||
|
className="flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||||
|
{running ? "Running..." : "Run Screening"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-muted mb-4">Screening Parameters</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted mb-2">Strategy</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(["balanced", "value", "growth", "quality"] as Strategy[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStrategy(s)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
strategy === s
|
||||||
|
? "bg-accent/15 text-accent border border-accent/30"
|
||||||
|
: "bg-white/5 text-muted hover:text-foreground border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium capitalize">{s}</span>
|
||||||
|
<p className="text-xs text-muted mt-0.5">{strategyDescriptions[s]}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted mb-2">Market</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["all", "KOSPI", "KOSDAQ"] as Market[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMarket(m)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||||
|
market === m
|
||||||
|
? "bg-accent/15 text-accent"
|
||||||
|
: "bg-white/5 text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "all" ? "All" : m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-xs text-muted mb-2 mt-6">Top N</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={topN}
|
||||||
|
onChange={(e) => setTopN(Number(e.target.value))}
|
||||||
|
className="w-24 bg-white/5 border border-card-border rounded-lg px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted mb-2">Strategy Weights</label>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{[
|
||||||
|
{ label: "PER", value: strategy === "value" ? 25 : 15 },
|
||||||
|
{ label: "PBR", value: strategy === "value" ? 25 : 15 },
|
||||||
|
{ label: "PEG", value: strategy === "growth" ? 30 : 15 },
|
||||||
|
{ label: "ROE", value: strategy === "quality" ? 30 : 15 },
|
||||||
|
{ label: "FCF Yield", value: strategy === "value" ? 20 : 15 },
|
||||||
|
{ label: "Debt Ratio", value: strategy === "quality" ? 25 : 15 },
|
||||||
|
].map((w) => (
|
||||||
|
<div key={w.label} className="flex items-center gap-2">
|
||||||
|
<span className="w-16 text-muted">{w.label}</span>
|
||||||
|
<div className="flex-1 bg-white/5 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-accent rounded-full h-2 transition-all"
|
||||||
|
style={{ width: `${w.value * 3}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right text-muted">{w.value}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
|
<div className="bg-card border border-card-border rounded-xl">
|
||||||
|
<div className="p-4 border-b border-card-border flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold">Results</h2>
|
||||||
|
<span className="text-xs text-muted">{results.length} stocks</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted text-left border-b border-card-border">
|
||||||
|
<th className="px-4 py-3 font-medium">#</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Code</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Market</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Sector</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right cursor-pointer">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Score <ArrowUpDown size={12} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">PER</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">PBR</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">ROE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-muted">Loading...</td></tr>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-muted">
|
||||||
|
결과가 없습니다. Run Screening을 실행하세요.
|
||||||
|
</td></tr>
|
||||||
|
) : (
|
||||||
|
results.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.stock_code}
|
||||||
|
className="border-b border-card-border/50 hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-muted">{row.rank}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/stock/${row.stock_code}`} className="font-mono text-accent hover:underline">
|
||||||
|
{row.stock_code}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium">{row.stock_name || row.stock_code}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
row.market === "KOSPI" ? "bg-blue-500/10 text-blue-300" : "bg-purple-500/10 text-purple-300"
|
||||||
|
}`}>
|
||||||
|
{row.market || "-"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted">{row.sector || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-medium">
|
||||||
|
{Number(row.composite_score).toFixed(1)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-muted">
|
||||||
|
{row.per_score != null ? Number(row.per_score).toFixed(1) : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-muted">
|
||||||
|
{row.pbr_score != null ? Number(row.pbr_score).toFixed(1) : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-muted">
|
||||||
|
{row.roe_score != null ? `${Number(row.roe_score).toFixed(1)}%` : "-"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/app/stock/[code]/page.tsx
Normal file
211
src/app/stock/[code]/page.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
|
ArrowLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StatCard } from "@/components/stat-card";
|
||||||
|
import {
|
||||||
|
fetchStock,
|
||||||
|
fetchAnalysisResults,
|
||||||
|
fetchCatalystResults,
|
||||||
|
type StockDetail,
|
||||||
|
type AnalysisResult,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
function getRecBadge(rec: string) {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
STRONG_BUY: "bg-green-500/20 text-green-400 border-green-500/30",
|
||||||
|
BUY: "bg-green-500/10 text-green-300 border-green-500/20",
|
||||||
|
HOLD: "bg-yellow-500/10 text-yellow-300 border-yellow-500/20",
|
||||||
|
SELL: "bg-red-500/10 text-red-300 border-red-500/20",
|
||||||
|
STRONG_SELL: "bg-red-500/20 text-red-400 border-red-500/30",
|
||||||
|
};
|
||||||
|
return styles[rec] || "bg-gray-500/10 text-gray-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StockDetailPage({ params }: { params: Promise<{ code: string }> }) {
|
||||||
|
const { code } = use(params);
|
||||||
|
const [stock, setStock] = useState<StockDetail | null>(null);
|
||||||
|
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
|
||||||
|
const [disclosures, setDisclosures] = useState<Array<{ title: string; disclosed_at?: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [stockData, analysisData, catalystData] = await Promise.allSettled([
|
||||||
|
fetchStock(code),
|
||||||
|
fetchAnalysisResults(code),
|
||||||
|
fetchCatalystResults(code),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (stockData.status === "fulfilled") setStock(stockData.value);
|
||||||
|
if (analysisData.status === "fulfilled" && analysisData.value.analyses.length > 0) {
|
||||||
|
setAnalysis(analysisData.value.analyses[0]);
|
||||||
|
}
|
||||||
|
if (catalystData.status === "fulfilled") {
|
||||||
|
setDisclosures(catalystData.value.disclosures);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-64 text-muted">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestPrice = stock?.prices?.[0];
|
||||||
|
const prevPrice = stock?.prices?.[1];
|
||||||
|
const changePct = latestPrice?.close && prevPrice?.close
|
||||||
|
? ((latestPrice.close - prevPrice.close) / prevPrice.close * 100)
|
||||||
|
: 0;
|
||||||
|
const v = stock?.valuation;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/stock" className="text-muted hover:text-foreground transition-colors">
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">{stock?.stock_name || code}</h1>
|
||||||
|
<span className="font-mono text-muted">{code}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
stock?.market === "KOSPI" ? "bg-blue-500/10 text-blue-300" : "bg-purple-500/10 text-purple-300"
|
||||||
|
}`}>
|
||||||
|
{stock?.market || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted mt-1">{stock?.sector || ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-right">
|
||||||
|
<div className="text-3xl font-bold font-mono">
|
||||||
|
{latestPrice?.close != null ? Number(latestPrice.close).toLocaleString() : "-"}
|
||||||
|
<span className="text-sm text-muted ml-1">KRW</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-mono ${changePct > 0 ? "text-accent-red" : changePct < 0 ? "text-accent-green" : "text-muted"}`}>
|
||||||
|
{changePct > 0 ? "+" : ""}{changePct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Valuation Metrics */}
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
<StatCard title="PER" value={v?.per != null ? Number(v.per).toFixed(1) : "-"} subtitle="Price/Earnings" />
|
||||||
|
<StatCard title="PBR" value={v?.pbr != null ? Number(v.pbr).toFixed(2) : "-"} subtitle="Price/Book" />
|
||||||
|
<StatCard title="ROE" value={v?.roe != null ? `${Number(v.roe).toFixed(1)}%` : "-"} subtitle="Return on Equity" trend={v?.roe && Number(v.roe) > 0 ? "up" : undefined} />
|
||||||
|
<StatCard title="Debt Ratio" value={v?.debt_ratio != null ? `${Number(v.debt_ratio).toFixed(1)}%` : "-"} subtitle="Total Debt/Equity" />
|
||||||
|
<StatCard title="FCF Yield" value={v?.fcf_yield != null ? `${Number(v.fcf_yield).toFixed(1)}%` : "-"} subtitle="Free CF / Market Cap" trend={v?.fcf_yield && Number(v.fcf_yield) > 0 ? "up" : undefined} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* LLM Analysis */}
|
||||||
|
<div className="bg-card border border-card-border rounded-xl">
|
||||||
|
<div className="p-4 border-b border-card-border flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
|
<Shield size={16} className="text-accent" />
|
||||||
|
AI Analysis
|
||||||
|
</h2>
|
||||||
|
{analysis && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${getRecBadge(analysis.recommendation)}`}>
|
||||||
|
{analysis.recommendation}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{(analysis.confidence * 100).toFixed(0)}% confidence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{analysis ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs text-muted font-medium mb-1">Summary</h3>
|
||||||
|
<p className="text-sm leading-relaxed">{analysis.summary}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs text-muted font-medium mb-1">Valuation</h3>
|
||||||
|
<p className="text-sm leading-relaxed">{analysis.valuation_comment}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">AI 분석 결과가 없습니다. LLM Analyzer를 실행하세요.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Catalysts & Disclosures */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{analysis && analysis.catalysts.length > 0 && (
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-4">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2 mb-3">
|
||||||
|
<Zap size={16} className="text-accent-green" />
|
||||||
|
Catalysts
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{analysis.catalysts.map((c, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-green mt-1.5 shrink-0" />
|
||||||
|
{c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && analysis.risk_factors.length > 0 && (
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-4">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle size={16} className="text-accent-yellow" />
|
||||||
|
Risk Factors
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{analysis.risk_factors.map((r, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-yellow mt-1.5 shrink-0" />
|
||||||
|
{r}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{disclosures.length > 0 && (
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-4">
|
||||||
|
<h2 className="font-semibold mb-3">Recent Disclosures</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{disclosures.slice(0, 5).map((d, i) => (
|
||||||
|
<li key={i} className="text-sm">
|
||||||
|
<span className="text-foreground">{d.title}</span>
|
||||||
|
{d.disclosed_at && (
|
||||||
|
<span className="text-xs text-muted ml-2">
|
||||||
|
{new Date(d.disclosed_at).toLocaleDateString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!analysis && disclosures.length === 0 && (
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-4">
|
||||||
|
<p className="text-sm text-muted">카탈리스트 및 공시 데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/stock/page.tsx
Normal file
115
src/app/stock/page.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Search, TrendingUp } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { fetchStocks, type StockListItem } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function StockListPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [market, setMarket] = useState("all");
|
||||||
|
const [stocks, setStocks] = useState<StockListItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchStocks({ market, search, limit: 60 });
|
||||||
|
setStocks(data.stocks);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch {
|
||||||
|
// gateway 미연결 시 빈 리스트
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [market, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(load, search ? 300 : 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [load, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TrendingUp size={24} className="text-accent" />
|
||||||
|
<h1 className="text-2xl font-bold">Stocks</h1>
|
||||||
|
<span className="text-xs text-muted ml-2">{total.toLocaleString()} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filter */}
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="relative max-w-md flex-1">
|
||||||
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="종목명 또는 코드 검색..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-card border border-card-border rounded-lg pl-10 pr-4 py-2.5 text-sm placeholder:text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["all", "KOSPI", "KOSDAQ"].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMarket(m)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||||
|
market === m
|
||||||
|
? "bg-accent/15 text-accent"
|
||||||
|
: "bg-white/5 text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === "all" ? "All" : m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-muted py-12">Loading...</p>
|
||||||
|
) : stocks.length === 0 ? (
|
||||||
|
<p className="text-center text-muted py-12">
|
||||||
|
{search ? `"${search}" 검색 결과가 없습니다.` : "수집된 종목이 없습니다. KIS 수집을 실행하세요."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{stocks.map((stock) => (
|
||||||
|
<Link
|
||||||
|
key={stock.stock_code}
|
||||||
|
href={`/stock/${stock.stock_code}`}
|
||||||
|
className="bg-card border border-card-border rounded-xl p-4 hover:border-accent/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-mono text-sm text-accent group-hover:text-accent">
|
||||||
|
{stock.stock_code}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
stock.market === "KOSPI" ? "bg-blue-500/10 text-blue-300" : "bg-purple-500/10 text-purple-300"
|
||||||
|
}`}>
|
||||||
|
{stock.market}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold mb-1">{stock.stock_name}</h3>
|
||||||
|
<p className="text-xs text-muted mb-3">{stock.sector || ""}</p>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<span className="text-lg font-mono font-bold">
|
||||||
|
{stock.close != null ? Number(stock.close).toLocaleString() : "-"}
|
||||||
|
</span>
|
||||||
|
{stock.change_pct != null && (
|
||||||
|
<span className={`text-sm font-mono ${
|
||||||
|
stock.change_pct > 0 ? "text-accent-red" : stock.change_pct < 0 ? "text-accent-green" : "text-muted"
|
||||||
|
}`}>
|
||||||
|
{stock.change_pct > 0 ? "+" : ""}{Number(stock.change_pct).toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/sidebar.tsx
Normal file
57
src/components/sidebar.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Filter,
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/screening", label: "Screening", icon: Filter },
|
||||||
|
{ href: "/stock", label: "Stock", icon: TrendingUp },
|
||||||
|
{ href: "/pipeline", label: "Pipeline", icon: Activity },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-60 border-r border-card-border bg-card flex flex-col">
|
||||||
|
<div className="p-5 border-b border-card-border">
|
||||||
|
<h1 className="text-lg font-bold text-accent">Stock Analysis</h1>
|
||||||
|
<p className="text-xs text-muted mt-1">AI-powered KR stock platform</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href ||
|
||||||
|
(item.href !== "/" && pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-accent/15 text-accent font-medium"
|
||||||
|
: "text-muted hover:text-foreground hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t border-card-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
|
||||||
|
<span className="text-xs text-muted">Redis Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/stat-card.tsx
Normal file
31
src/components/stat-card.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
trend?: "up" | "down" | "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ title, value, subtitle, icon, trend }: StatCardProps) {
|
||||||
|
const trendColor =
|
||||||
|
trend === "up"
|
||||||
|
? "text-accent-green"
|
||||||
|
: trend === "down"
|
||||||
|
? "text-accent-red"
|
||||||
|
: "text-muted";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-card-border rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-muted">{title}</span>
|
||||||
|
{icon && <span className="text-muted">{icon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className={`text-xs mt-1 ${trendColor}`}>{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/lib/api.ts
Normal file
220
src/lib/api.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Gateway API client.
|
||||||
|
*
|
||||||
|
* All calls go through APISIX gateway: /api/v1/{service}/{path}
|
||||||
|
* Browser-side: NEXT_PUBLIC_API_URL (e.g. http://localhost:9080/api/v1)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:9080/api/v1";
|
||||||
|
|
||||||
|
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Health / Streams ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
service: string;
|
||||||
|
status: string;
|
||||||
|
redis: boolean;
|
||||||
|
uptime_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamInfo {
|
||||||
|
streams: Record<string, { length: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICES = ["dart", "kis", "news", "screening", "catalyst", "analysis"] as const;
|
||||||
|
export type ServiceName = (typeof SERVICES)[number];
|
||||||
|
|
||||||
|
export async function fetchHealth(svc: ServiceName) {
|
||||||
|
return fetchJSON<HealthResponse>(`/${svc}/health`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStreams(svc: ServiceName) {
|
||||||
|
return fetchJSON<StreamInfo>(`/${svc}/streams`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllHealth() {
|
||||||
|
const results = await Promise.allSettled(SERVICES.map((s) => fetchHealth(s)));
|
||||||
|
return SERVICES.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
data: results[i].status === "fulfilled" ? results[i].value : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllStreams() {
|
||||||
|
const results = await Promise.allSettled(SERVICES.map((s) => fetchStreams(s)));
|
||||||
|
return SERVICES.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
data: results[i].status === "fulfilled" ? results[i].value : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── KIS: Stocks ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StockListItem {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
market: string;
|
||||||
|
sector: string | null;
|
||||||
|
industry: string | null;
|
||||||
|
close: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
market_cap: number | null;
|
||||||
|
change_pct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockListResponse {
|
||||||
|
total: number;
|
||||||
|
stocks: StockListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockDetail {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
market: string;
|
||||||
|
sector: string | null;
|
||||||
|
industry: string | null;
|
||||||
|
prices: Array<{
|
||||||
|
date: string;
|
||||||
|
open: number | null;
|
||||||
|
high: number | null;
|
||||||
|
low: number | null;
|
||||||
|
close: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
market_cap: number | null;
|
||||||
|
}>;
|
||||||
|
valuation: {
|
||||||
|
per: number | null;
|
||||||
|
pbr: number | null;
|
||||||
|
peg: number | null;
|
||||||
|
roe: number | null;
|
||||||
|
debt_ratio: number | null;
|
||||||
|
fcf_yield: number | null;
|
||||||
|
ev_ebitda: number | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStocks(params?: { market?: string; search?: string; limit?: number; offset?: number }) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.market && params.market !== "all") qs.set("market", params.market);
|
||||||
|
if (params?.search) qs.set("search", params.search);
|
||||||
|
if (params?.limit) qs.set("limit", String(params.limit));
|
||||||
|
if (params?.offset) qs.set("offset", String(params.offset));
|
||||||
|
const q = qs.toString();
|
||||||
|
return fetchJSON<StockListResponse>(`/kis/stocks${q ? `?${q}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStock(code: string) {
|
||||||
|
return fetchJSON<StockDetail>(`/kis/stocks/${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screening ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScreeningResultItem {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string | null;
|
||||||
|
market: string | null;
|
||||||
|
sector: string | null;
|
||||||
|
composite_score: number;
|
||||||
|
per_score: number | null;
|
||||||
|
pbr_score: number | null;
|
||||||
|
roe_score: number | null;
|
||||||
|
rank: number | null;
|
||||||
|
strategy: string;
|
||||||
|
screened_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreeningResponse {
|
||||||
|
run_id: string | null;
|
||||||
|
strategy?: string;
|
||||||
|
screened_at?: string | null;
|
||||||
|
results: ScreeningResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScreeningLatest(limit = 50) {
|
||||||
|
return fetchJSON<ScreeningResponse>(`/screening/results/latest?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScreeningResults(runId: string) {
|
||||||
|
return fetchJSON<ScreeningResponse>(`/screening/results/${runId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerScreening(strategy = "balanced", market = "all", topN = 50) {
|
||||||
|
return fetchJSON<{ status: string; message_id: string; strategy: string }>("/screening/screen", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ strategy, market, top_n: topN }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Catalyst ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CatalystResponse {
|
||||||
|
stock_code: string;
|
||||||
|
disclosures: Array<{
|
||||||
|
_id: string;
|
||||||
|
stock_code: string;
|
||||||
|
title: string;
|
||||||
|
dart_id?: string;
|
||||||
|
disclosed_at?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCatalystResults(stockCode: string) {
|
||||||
|
return fetchJSON<CatalystResponse>(`/catalyst/results/${stockCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LLM Analysis ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
_id: string;
|
||||||
|
stock_code: string;
|
||||||
|
analysis_id?: string;
|
||||||
|
summary: string;
|
||||||
|
valuation_comment: string;
|
||||||
|
risk_factors: string[];
|
||||||
|
catalysts: string[];
|
||||||
|
recommendation: string;
|
||||||
|
confidence: number;
|
||||||
|
analyzed_at: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResponse {
|
||||||
|
stock_code: string;
|
||||||
|
analyses: AnalysisResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAnalysisResults(stockCode: string) {
|
||||||
|
return fetchJSON<AnalysisResponse>(`/analysis/results/${stockCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Trigger endpoints ───────────────────────────────────
|
||||||
|
|
||||||
|
export async function triggerDartFinancials(stockCodes: string[], year = 2024) {
|
||||||
|
return fetchJSON<{ status: string; message_id: string }>("/dart/collect/financials", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ stock_codes: stockCodes, year }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerKisCollect(market = "all") {
|
||||||
|
return fetchJSON<{ status: string; message_id: string }>("/kis/collect/stocks", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ market }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerNewsCrawl(stockCodes: string[], maxPages = 3) {
|
||||||
|
return fetchJSON<{ status: string; message_id: string }>("/news/collect/news", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ stock_codes: stockCodes, max_pages: maxPages }),
|
||||||
|
});
|
||||||
|
}
|
||||||
61
src/lib/types.ts
Normal file
61
src/lib/types.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export interface Stock {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
market: "KOSPI" | "KOSDAQ";
|
||||||
|
sector: string | null;
|
||||||
|
industry: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyPrice {
|
||||||
|
stock_code: string;
|
||||||
|
date: string;
|
||||||
|
open: number | null;
|
||||||
|
high: number | null;
|
||||||
|
low: number | null;
|
||||||
|
close: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
market_cap: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreeningResult {
|
||||||
|
run_id: string;
|
||||||
|
stock_code: string;
|
||||||
|
stock_name?: string;
|
||||||
|
strategy: string;
|
||||||
|
composite_score: number;
|
||||||
|
per_score: number | null;
|
||||||
|
pbr_score: number | null;
|
||||||
|
roe_score: number | null;
|
||||||
|
rank: number | null;
|
||||||
|
screened_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMAnalysis {
|
||||||
|
analysis_id: string;
|
||||||
|
stock_code: string;
|
||||||
|
summary: string;
|
||||||
|
valuation_comment: string;
|
||||||
|
risk_factors: string[];
|
||||||
|
catalysts: string[];
|
||||||
|
recommendation: "STRONG_BUY" | "BUY" | "HOLD" | "SELL" | "STRONG_SELL";
|
||||||
|
confidence: number;
|
||||||
|
analyzed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineStatus {
|
||||||
|
stream: string;
|
||||||
|
length: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalystResult {
|
||||||
|
stock_code: string;
|
||||||
|
catalyst_score: number;
|
||||||
|
detected_catalysts: Array<{
|
||||||
|
category: string;
|
||||||
|
keyword: string;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
is_value_trap: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user