From dab7d0921d17e6e4f7feeaa35e7ff61af62ae60c Mon Sep 17 00:00:00 2001 From: yakenator Date: Mon, 23 Feb 2026 13:51:40 +0900 Subject: [PATCH] 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 --- .dockerignore | 5 + Dockerfile | 21 ++ next.config.ts | 2 +- package-lock.json | 507 +++++++++++++++++++++++++++++++++- package.json | 5 +- src/app/globals.css | 27 +- src/app/layout.tsx | 12 +- src/app/page.tsx | 249 +++++++++++++---- src/app/pipeline/page.tsx | 187 +++++++++++++ src/app/screening/page.tsx | 231 ++++++++++++++++ src/app/stock/[code]/page.tsx | 211 ++++++++++++++ src/app/stock/page.tsx | 115 ++++++++ src/components/sidebar.tsx | 57 ++++ src/components/stat-card.tsx | 31 +++ src/lib/api.ts | 220 +++++++++++++++ src/lib/types.ts | 61 ++++ 16 files changed, 1862 insertions(+), 79 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 src/app/pipeline/page.tsx create mode 100644 src/app/screening/page.tsx create mode 100644 src/app/stock/[code]/page.tsx create mode 100644 src/app/stock/page.tsx create mode 100644 src/components/sidebar.tsx create mode 100644 src/components/stat-card.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/types.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..79a303d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eff2d80 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 2c0545b..2c617d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "stock-frontend", "version": "0.1.0", "dependencies": { + "ioredis": "^5.9.3", + "lucide-react": "^0.575.0", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "recharts": "^3.7.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -971,6 +974,12 @@ "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": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1226,6 +1235,42 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1233,6 +1278,18 @@ "dev": true, "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": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1524,6 +1581,69 @@ "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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1559,7 +1679,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1575,6 +1695,12 @@ "@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": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -2605,6 +2731,24 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2658,9 +2802,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2726,7 +2991,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "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": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2783,6 +3053,15 @@ "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3026,6 +3305,16 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3479,6 +3768,12 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3935,6 +4230,16 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3977,6 +4282,39 @@ "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": { "version": "3.0.5", "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" } }, + "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4868,6 +5218,15 @@ "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": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4939,7 +5298,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5457,9 +5815,97 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5504,6 +5950,12 @@ "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5873,6 +6325,12 @@ "dev": true, "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6093,6 +6551,12 @@ "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": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6430,6 +6894,37 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c903966..d2a0d35 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,12 @@ "lint": "eslint" }, "dependencies": { + "ioredis": "^5.9.3", + "lucide-react": "^0.575.0", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "recharts": "^3.7.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..d750969 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,33 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #0f172a; + --foreground: #e2e8f0; + --card: #1e293b; + --card-border: #334155; + --accent: #3b82f6; + --accent-green: #22c55e; + --accent-red: #ef4444; + --accent-yellow: #eab308; + --muted: #94a3b8; } @theme inline { --color-background: var(--background); --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-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), Arial, Helvetica, sans-serif; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..028daa6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Sidebar } from "@/components/sidebar"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Stock Analysis", + description: "AI-powered Korean stock analysis platform", }; export default function RootLayout({ @@ -23,11 +24,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} +
+ +
{children}
+
); diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..873cd97 100644 --- a/src/app/page.tsx +++ b/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 = { + 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([]); + 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 ( -
-
- Next.js logo +

Dashboard

+ + {/* Stats */} +
+ } /> -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+ } + trend="up" + /> + } + trend="up" + /> + } + /> +
+ +
+ {/* Top Recommendations */} +
+
+

Top Recommendations

+ + View All → + +
+
+ + + + + + + + + + + + + {loading ? ( + + ) : topStocks.length === 0 ? ( + + ) : ( + topStocks.map((s) => ( + + + + + + + + + )) + )} + +
#CodeNameScorePERROE
Loading...
+ 스크리닝 결과가 없습니다. Screening을 실행하세요. +
{s.rank} + + {s.stock_code} + + {s.stock_name || s.stock_code} + {Number(s.composite_score).toFixed(1)} + + {s.per_score != null ? Number(s.per_score).toFixed(1) : "-"} + + {s.roe_score != null ? Number(s.roe_score).toFixed(1) : "-"} +
+
-
- - Vercel logomark - Deploy Now - - - Documentation - + + {/* Service Status */} +
+
+

Service Status

+ + Details → + +
+
+ {loading ? ( +

Loading...

+ ) : ( + services.map((s) => ( +
+ {s.name} + + {s.data?.status === "ok" ? "Running" : "Down"} + +
+ )) + )} +
-
+
); } diff --git a/src/app/pipeline/page.tsx b/src/app/pipeline/page.tsx new file mode 100644 index 0000000..a628262 --- /dev/null +++ b/src/app/pipeline/page.tsx @@ -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; +} + +const SERVICE_META: Record = { + 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 ; + case "idle": return ; + case "error": return ; + } +} + +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([]); + 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 = {}; + const streamMap: Record = {}; + + 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 ( +
+
+
+ +

Pipeline Monitor

+
+ +
+ + {/* Pipeline Flow Diagram */} +
+

Data Flow

+
+ {["dart-collector", "kis-collector", "news-crawler"].map((name) => ( +
+ {name} +
+ ))} + → Redis → +
+ screener +
+ +
+ catalyst +
+ +
+ llm-analyzer +
+ +
+ results +
+
+
+ + {/* Service Cards */} +
+ {loading ? ( +

Loading services...

+ ) : ( + services.map((svc) => ( +
+
+
+ +

{svc.label}

+
+ + {statusLabel(svc.status)} + +
+

{svc.description}

+ +
+ {Object.entries(svc.streams).map(([name, info]) => ( +
+ {name} + + {info.length >= 0 ? `${info.length} msgs` : "N/A"} + +
+ ))} +
+ +
+ Uptime: {svc.uptime > 0 ? formatUptime(svc.uptime) : "N/A"} +
+
+ )) + )} +
+
+ ); +} diff --git a/src/app/screening/page.tsx b/src/app/screening/page.tsx new file mode 100644 index 0000000..f0dea5d --- /dev/null +++ b/src/app/screening/page.tsx @@ -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 = { + 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("balanced"); + const [market, setMarket] = useState("all"); + const [topN, setTopN] = useState(50); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + const [lastRun, setLastRun] = useState(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 ( +
+
+
+ +

Screening

+ {lastRun && ( + Last run: {new Date(lastRun).toLocaleString("ko-KR")} + )} +
+ +
+ + {/* Filters */} +
+

Screening Parameters

+
+
+ +
+ {(["balanced", "value", "growth", "quality"] as Strategy[]).map((s) => ( + + ))} +
+
+ +
+ +
+ {(["all", "KOSPI", "KOSDAQ"] as Market[]).map((m) => ( + + ))} +
+ + + setTopN(Number(e.target.value))} + className="w-24 bg-white/5 border border-card-border rounded-lg px-3 py-1.5 text-sm" + /> +
+ +
+ +
+ {[ + { 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) => ( +
+ {w.label} +
+
+
+ {w.value}% +
+ ))} +
+
+
+
+ + {/* Results Table */} +
+
+

Results

+ {results.length} stocks +
+
+ + + + + + + + + + + + + + + + {loading ? ( + + ) : results.length === 0 ? ( + + ) : ( + results.map((row) => ( + + + + + + + + + + + + )) + )} + +
#CodeNameMarketSector + + Score + + PERPBRROE
Loading...
+ 결과가 없습니다. Run Screening을 실행하세요. +
{row.rank} + + {row.stock_code} + + {row.stock_name || row.stock_code} + + {row.market || "-"} + + {row.sector || "-"} + {Number(row.composite_score).toFixed(1)} + + {row.per_score != null ? Number(row.per_score).toFixed(1) : "-"} + + {row.pbr_score != null ? Number(row.pbr_score).toFixed(1) : "-"} + + {row.roe_score != null ? `${Number(row.roe_score).toFixed(1)}%` : "-"} +
+
+
+
+ ); +} diff --git a/src/app/stock/[code]/page.tsx b/src/app/stock/[code]/page.tsx new file mode 100644 index 0000000..8eb3abc --- /dev/null +++ b/src/app/stock/[code]/page.tsx @@ -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 = { + 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(null); + const [analysis, setAnalysis] = useState(null); + const [disclosures, setDisclosures] = useState>([]); + 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
Loading...
; + } + + 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 ( +
+ {/* Header */} +
+ + + +
+
+

{stock?.stock_name || code}

+ {code} + + {stock?.market || "-"} + +
+

{stock?.sector || ""}

+
+
+
+ {latestPrice?.close != null ? Number(latestPrice.close).toLocaleString() : "-"} + KRW +
+ 0 ? "text-accent-red" : changePct < 0 ? "text-accent-green" : "text-muted"}`}> + {changePct > 0 ? "+" : ""}{changePct.toFixed(2)}% + +
+
+ + {/* Valuation Metrics */} +
+ + + 0 ? "up" : undefined} /> + + 0 ? "up" : undefined} /> +
+ +
+ {/* LLM Analysis */} +
+
+

+ + AI Analysis +

+ {analysis && ( +
+ + {analysis.recommendation} + + + {(analysis.confidence * 100).toFixed(0)}% confidence + +
+ )} +
+
+ {analysis ? ( + <> +
+

Summary

+

{analysis.summary}

+
+
+

Valuation

+

{analysis.valuation_comment}

+
+ + ) : ( +

AI 분석 결과가 없습니다. LLM Analyzer를 실행하세요.

+ )} +
+
+ + {/* Catalysts & Disclosures */} +
+ {analysis && analysis.catalysts.length > 0 && ( +
+

+ + Catalysts +

+
    + {analysis.catalysts.map((c, i) => ( +
  • + + {c} +
  • + ))} +
+
+ )} + + {analysis && analysis.risk_factors.length > 0 && ( +
+

+ + Risk Factors +

+
    + {analysis.risk_factors.map((r, i) => ( +
  • + + {r} +
  • + ))} +
+
+ )} + + {disclosures.length > 0 && ( +
+

Recent Disclosures

+
    + {disclosures.slice(0, 5).map((d, i) => ( +
  • + {d.title} + {d.disclosed_at && ( + + {new Date(d.disclosed_at).toLocaleDateString("ko-KR")} + + )} +
  • + ))} +
+
+ )} + + {!analysis && disclosures.length === 0 && ( +
+

카탈리스트 및 공시 데이터가 없습니다.

+
+ )} +
+
+
+ ); +} diff --git a/src/app/stock/page.tsx b/src/app/stock/page.tsx new file mode 100644 index 0000000..22a27e4 --- /dev/null +++ b/src/app/stock/page.tsx @@ -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([]); + 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 ( +
+
+ +

Stocks

+ {total.toLocaleString()} total +
+ + {/* Search & Filter */} +
+
+ + 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" + /> +
+
+ {["all", "KOSPI", "KOSDAQ"].map((m) => ( + + ))} +
+
+ + {/* Stock Grid */} + {loading ? ( +

Loading...

+ ) : stocks.length === 0 ? ( +

+ {search ? `"${search}" 검색 결과가 없습니다.` : "수집된 종목이 없습니다. KIS 수집을 실행하세요."} +

+ ) : ( +
+ {stocks.map((stock) => ( + +
+ + {stock.stock_code} + + + {stock.market} + +
+

{stock.stock_name}

+

{stock.sector || ""}

+
+ + {stock.close != null ? Number(stock.close).toLocaleString() : "-"} + + {stock.change_pct != null && ( + 0 ? "text-accent-red" : stock.change_pct < 0 ? "text-accent-green" : "text-muted" + }`}> + {stock.change_pct > 0 ? "+" : ""}{Number(stock.change_pct).toFixed(2)}% + + )} +
+ + ))} +
+ )} +
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx new file mode 100644 index 0000000..ab06dea --- /dev/null +++ b/src/components/sidebar.tsx @@ -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 ( + + ); +} diff --git a/src/components/stat-card.tsx b/src/components/stat-card.tsx new file mode 100644 index 0000000..e294fd9 --- /dev/null +++ b/src/components/stat-card.tsx @@ -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 ( +
+
+ {title} + {icon && {icon}} +
+
{value}
+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..ccecd3e --- /dev/null +++ b/src/lib/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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; +} + +const SERVICES = ["dart", "kis", "news", "screening", "catalyst", "analysis"] as const; +export type ServiceName = (typeof SERVICES)[number]; + +export async function fetchHealth(svc: ServiceName) { + return fetchJSON(`/${svc}/health`); +} + +export async function fetchStreams(svc: ServiceName) { + return fetchJSON(`/${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(`/kis/stocks${q ? `?${q}` : ""}`); +} + +export async function fetchStock(code: string) { + return fetchJSON(`/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(`/screening/results/latest?limit=${limit}`); +} + +export async function fetchScreeningResults(runId: string) { + return fetchJSON(`/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(`/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(`/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 }), + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..66f61a4 --- /dev/null +++ b/src/lib/types.ts @@ -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; +}