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:
yakenator
2026-02-23 13:51:40 +09:00
parent c069d398dc
commit dab7d0921d
16 changed files with 1862 additions and 79 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.next
.git
Dockerfile
.dockerignore

21
Dockerfile Normal file
View 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"]

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

507
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;
}

View File

@ -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 (
<html lang="en">
<html lang="ko">
<body
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>
</html>
);

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<StatCard
title="Total Stocks"
value={loading ? "..." : stats.stocks.toLocaleString()}
subtitle="KIS 수집 기준"
icon={<BarChart3 size={18} />}
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
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}
<StatCard
title="Screened"
value={loading ? "..." : String(stats.screened)}
subtitle="Top candidates"
icon={<TrendingUp size={18} />}
trend="up"
/>
<StatCard
title="Catalysts"
value={loading ? "..." : String(stats.catalysts)}
subtitle="감지된 카탈리스트"
icon={<Zap size={18} />}
trend="up"
/>
<StatCard
title="Pipeline"
value={loading ? "..." : `${stats.running}/${stats.total}`}
subtitle="Services running"
icon={<Activity size={18} />}
/>
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>
</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>
);
}

187
src/app/pipeline/page.tsx Normal file
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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;
}