From 3b66a47316f85eea2295aa1b9c3ace984668055d Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 04:03:31 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs(plan):=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=86=8C=EC=8A=A4=20pykrx=20=E2=86=92=20FDR=20+=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=8A=A4=ED=81=AC=EB=9E=98=ED=95=91=20(Ta?= =?UTF-8?q?sk=200.1/0.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측 결과 pykrx의 시장 전체 함수 (get_market_ticker_list, get_market_cap, get_market_ohlcv_by_ticker)가 모두 KRX 인증 요구로 깨짐. Task 0.1 의존성을 finance-datareader + bs4 + lxml 로 교체하고 Task 0.3 snapshot.py는 FDR + 네이버 frgn 스크래핑 방식으로 재작성 (implementer dispatch 시 인라인 안내). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-12-stock-screener-board.md | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-05-12-stock-screener-board.md b/docs/superpowers/plans/2026-05-12-stock-screener-board.md index 54e52e1..2db098f 100644 --- a/docs/superpowers/plans/2026-05-12-stock-screener-board.md +++ b/docs/superpowers/plans/2026-05-12-stock-screener-board.md @@ -34,8 +34,16 @@ python -m venv .venv .\.venv\Scripts\Activate.ps1 pip install -r requirements.txt pip install pytest httpx pandas -# (requirements.txt에 pykrx 추가는 Task 0.1에서 진행. 그 이후 다음으로 pykrx도 설치:) -# pip install pykrx +# (requirements.txt 변경은 Task 0.1에서. 추가 의존성 설치:) +# pip install finance-datareader beautifulsoup4 lxml + +# ⚠️ 데이터 소스 변경 노트 (2026-05-12 실측 후 결정): +# plan의 spec은 "pykrx 하이브리드"였으나, 실측 결과 pykrx의 시장 전체 함수 +# (get_market_ticker_list / get_market_cap / get_market_ohlcv_by_ticker)가 모두 KRX +# 인증 요구로 인해 비인증 호출 시 깨짐. 따라서 실제 구현은: +# - 종목 마스터 + 당일 일봉 + 5년치 일봉: FinanceDataReader (fdr) +# - 외국인/기관 수급: 네이버 금융 종목별 frgn 페이지 스크래핑 (시총 상위 500종목) +# Task 0.3 snapshot.py 코드는 implementer dispatch 시 새 방향으로 안내됨. ``` | 작업 | 어디서 실행 | @@ -60,9 +68,17 @@ pip install pytest httpx pandas - Create: `web-backend/stock-lab/app/screener/__init__.py` - Modify: `web-backend/stock-lab/app/db.py` (스크리너 스키마 함수 호출 1줄 추가) -- [ ] **Step 1: requirements.txt에 pykrx 추가** +- [ ] **Step 1: requirements.txt에 데이터 라이브러리 추가** -`web-backend/stock-lab/requirements.txt`에 `pykrx>=1.0.45` 한 줄 추가. (기존 줄 위 또는 아래 어느 곳이든.) +`web-backend/stock-lab/requirements.txt`에 다음 의존성 추가: + +``` +finance-datareader>=0.9.96 +beautifulsoup4>=4.12 +lxml>=5.0 +``` + +(`httpx`는 보통 이미 있으나 없으면 함께 추가.) 기존 pykrx 라인은 추가하지 않습니다 (실측 결과 시장 전체 함수가 KRX 인증 요구로 깨짐). - [ ] **Step 2: screener 패키지 생성** @@ -100,32 +116,33 @@ See docs/superpowers/specs/2026-05-12-stock-screener-board-design.md __all__ = [] ``` -- [ ] **Step 3: 로컬 venv에 pykrx 설치** +- [ ] **Step 3: 로컬 venv에 데이터 라이브러리 설치** ```powershell cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab .\.venv\Scripts\Activate.ps1 -pip install pykrx +pip install finance-datareader beautifulsoup4 lxml ``` -Expected: 설치 성공. 실패하면 numpy/pandas 호환성 점검(pykrx는 pandas≥1.5 요구). +Expected: 설치 성공. > NAS 운영 컨테이너 재빌드는 본 plan 마지막의 **최종 배포** 단계에서 `git push` → webhook으로 자동 수행. 지금은 로컬 venv 동작만 검증. -- [ ] **Step 4: pykrx 동작 smoke test (one-off, 로컬 venv)** +- [ ] **Step 4: FDR + 네이버 동작 smoke test (one-off, 로컬 venv)** ```powershell -python -c "from pykrx import stock; print(stock.get_market_ticker_list('20260512', market='KOSPI')[:5])" +python -c "import FinanceDataReader as fdr; df = fdr.StockListing('KRX'); print('rows:', df.shape[0]); print(df.head(3)[['Code','Name','Market','Marcap','Close']])" +python -c "import httpx; from bs4 import BeautifulSoup; r = httpx.get('https://finance.naver.com/item/frgn.naver?code=005930', headers={'User-Agent':'Mozilla/5.0'}); print('status:', r.status_code); soup = BeautifulSoup(r.text,'lxml'); print('rows:', len(soup.select('table.type2 tr')))" ``` -Expected: 5개 KOSPI 종목 코드 출력. 실패 시 IP 차단/네트워크 확인. +Expected: FDR rows ≥ 2,800. naver status 200, table rows > 5. - [ ] **Step 5: Commit** ```bash cd C:\Users\jaeoh\Desktop\workspace\web-backend git add stock-lab/requirements.txt stock-lab/app/screener/__init__.py -git commit -m "chore(stock-lab): pykrx 의존성 + screener 패키지 골격" +git commit -m "chore(stock-lab): FDR/네이버 데이터 의존성 + screener 패키지 골격" ``` --- From 42ebd5a87c9b4c02dc161006cf5e25b52c63ebf5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:11:51 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(stock):=20screener=20API=20=ED=97=AC?= =?UTF-8?q?=ED=8D=BC=207=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/api.js b/src/api.js index 3f01a47..a146af6 100644 --- a/src/api.js +++ b/src/api.js @@ -695,3 +695,12 @@ export const getReviewHistory = (limit = 4) => export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) => apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount }); +// ---- Stock Screener ---- +export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes'); +export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings'); +export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body); +export const runScreener = (body) => apiPost('/api/stock/screener/run', body); +export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh'); +export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`); +export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`); + From cd6072727f160390cf5f3f3619e9253771c0c6f4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:13:26 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(stock):=20/stock/screener=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20+=20=EC=9E=84=EC=8B=9C=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/stock/screener/Screener.jsx | 10 ++++++++++ src/routes.jsx | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 src/pages/stock/screener/Screener.jsx diff --git a/src/pages/stock/screener/Screener.jsx b/src/pages/stock/screener/Screener.jsx new file mode 100644 index 0000000..0f149ec --- /dev/null +++ b/src/pages/stock/screener/Screener.jsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function Screener() { + return ( +
+

스크리너

+

구현 중 — Task 4.3에서 본 페이지 골격 + hooks + 컴포넌트가 추가됩니다.

+
+ ); +} diff --git a/src/routes.jsx b/src/routes.jsx index 0fff027..12890d0 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -19,6 +19,7 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto')); const Travel = lazy(() => import('./pages/travel/Travel')); const Stock = lazy(() => import('./pages/stock/Stock')); const StockTrade = lazy(() => import('./pages/stock/StockTrade')); +const Screener = lazy(() => import('./pages/stock/screener/Screener')); const Subscription = lazy(() => import('./pages/subscription/Subscription')); const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab')); const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream')); @@ -160,6 +161,10 @@ export const appRoutes = [ path: 'stock/trade', element: , }, + { + path: 'stock/screener', + element: , + }, { path: 'realestate', element: , From bc2c020f71ed1e1fc1b1db3954fb604948f1aa9e Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:15:36 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(stock):=20/stock/screener=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B3=A8=EA=B2=A9=20+=20hooks=204?= =?UTF-8?q?=EA=B0=9C=20+=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20stub=206?= =?UTF-8?q?=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/stock/screener/Screener.css | 51 +++++++++++++ src/pages/stock/screener/Screener.jsx | 71 +++++++++++++++++-- .../stock/screener/components/GatePanel.jsx | 3 + .../screener/components/GlobalControls.jsx | 10 +++ .../stock/screener/components/NodePanel.jsx | 3 + .../stock/screener/components/ResultTable.jsx | 25 +++++++ .../screener/components/RunHistoryList.jsx | 17 +++++ .../screener/components/TelegramPreview.jsx | 9 +++ .../screener/hooks/useScreenerHistory.js | 32 +++++++++ .../stock/screener/hooks/useScreenerMeta.js | 11 +++ .../stock/screener/hooks/useScreenerRun.js | 31 ++++++++ .../screener/hooks/useScreenerSettings.js | 26 +++++++ 12 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/pages/stock/screener/Screener.css create mode 100644 src/pages/stock/screener/components/GatePanel.jsx create mode 100644 src/pages/stock/screener/components/GlobalControls.jsx create mode 100644 src/pages/stock/screener/components/NodePanel.jsx create mode 100644 src/pages/stock/screener/components/ResultTable.jsx create mode 100644 src/pages/stock/screener/components/RunHistoryList.jsx create mode 100644 src/pages/stock/screener/components/TelegramPreview.jsx create mode 100644 src/pages/stock/screener/hooks/useScreenerHistory.js create mode 100644 src/pages/stock/screener/hooks/useScreenerMeta.js create mode 100644 src/pages/stock/screener/hooks/useScreenerRun.js create mode 100644 src/pages/stock/screener/hooks/useScreenerSettings.js diff --git a/src/pages/stock/screener/Screener.css b/src/pages/stock/screener/Screener.css new file mode 100644 index 0000000..231b358 --- /dev/null +++ b/src/pages/stock/screener/Screener.css @@ -0,0 +1,51 @@ +.screener-page { + padding: 24px; + color: var(--text, #e5e7eb); + background: var(--bg, #0b0f17); + min-height: 100vh; +} + +.screener-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 24px; +} + +.screener-header h1 { + font-size: 28px; + margin: 0 0 4px 0; +} + +.screener-header .meta { + color: #9ca3af; + font-size: 13px; + margin: 0; +} + +.screener-header nav a { + margin-left: 12px; + color: #9ca3af; + text-decoration: none; +} + +.screener-grid { + display: grid; + grid-template-columns: 320px 1fr 280px; + gap: 24px; +} + +@media (max-width: 1023px) { + .screener-grid { grid-template-columns: 1fr; } +} + +.screener-loading { padding: 80px; text-align: center; color: #9ca3af; } + +.screener-card { + background: #0f1623; + border: 1px solid #1f2937; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} +.screener-card h3 { margin: 0 0 12px 0; font-size: 15px; } diff --git a/src/pages/stock/screener/Screener.jsx b/src/pages/stock/screener/Screener.jsx index 0f149ec..c06b4ca 100644 --- a/src/pages/stock/screener/Screener.jsx +++ b/src/pages/stock/screener/Screener.jsx @@ -1,10 +1,71 @@ import React from 'react'; +import { Link } from 'react-router-dom'; +import './Screener.css'; + +import { useScreenerMeta } from './hooks/useScreenerMeta'; +import { useScreenerSettings } from './hooks/useScreenerSettings'; +import { useScreenerRun } from './hooks/useScreenerRun'; +import { useScreenerHistory } from './hooks/useScreenerHistory'; + +import GatePanel from './components/GatePanel'; +import NodePanel from './components/NodePanel'; +import GlobalControls from './components/GlobalControls'; +import ResultTable from './components/ResultTable'; +import TelegramPreview from './components/TelegramPreview'; +import RunHistoryList from './components/RunHistoryList'; export default function Screener() { - return ( -
-

스크리너

-

구현 중 — Task 4.3에서 본 페이지 골격 + hooks + 컴포넌트가 추가됩니다.

+ const { meta, loading: metaLoading } = useScreenerMeta(); + const { settings, dirty, setLocal, save } = useScreenerSettings(); + const { result, running, runPreview, runSave } = useScreenerRun(); + const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory(); + + const activeResult = selectedRun || result; + + if (metaLoading || !meta || !settings) { + return
로딩 중…
; + } + + return ( +
+
+
+

스크리너

+

+ 최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'} + · 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'} +

- ); + +
+ +
+ + +
+ + +
+ + +
+
+ ); } diff --git a/src/pages/stock/screener/components/GatePanel.jsx b/src/pages/stock/screener/components/GatePanel.jsx new file mode 100644 index 0000000..ab0aa7e --- /dev/null +++ b/src/pages/stock/screener/components/GatePanel.jsx @@ -0,0 +1,3 @@ +export default function GatePanel({ meta, value, onChange }) { + return

{meta?.label ?? '게이트'}

TODO: 게이트 파라미터 폼 (Task 4.5)

; +} diff --git a/src/pages/stock/screener/components/GlobalControls.jsx b/src/pages/stock/screener/components/GlobalControls.jsx new file mode 100644 index 0000000..dd8990c --- /dev/null +++ b/src/pages/stock/screener/components/GlobalControls.jsx @@ -0,0 +1,10 @@ +export default function GlobalControls({ settings, setSettings, onRun, onSave, onPersist, dirty, running }) { + return ( +
+

실행 옵션

+ + + +
+ ); +} diff --git a/src/pages/stock/screener/components/NodePanel.jsx b/src/pages/stock/screener/components/NodePanel.jsx new file mode 100644 index 0000000..bdfac8b --- /dev/null +++ b/src/pages/stock/screener/components/NodePanel.jsx @@ -0,0 +1,3 @@ +export default function NodePanel({ meta, weights, params, onWeights, onParams }) { + return

점수 노드 ({meta.length})

TODO: 노드별 카드 (Task 4.4)

; +} diff --git a/src/pages/stock/screener/components/ResultTable.jsx b/src/pages/stock/screener/components/ResultTable.jsx new file mode 100644 index 0000000..3393286 --- /dev/null +++ b/src/pages/stock/screener/components/ResultTable.jsx @@ -0,0 +1,25 @@ +export default function ResultTable({ result }) { + if (!result) return

아직 결과 없음. "지금 실행"을 눌러보세요.

; + return ( +
+

Top {result.top_n} · 통과 {result.survivors_count}

+ + + + + + {(result.results || []).map((r) => ( + + + + + + + + + ))} + +
#종목총점진입손절익절R%
{r.rank}{r.name} ({r.ticker}){r.total_score?.toFixed?.(1)}{r.entry_price?.toLocaleString?.()}{r.stop_price?.toLocaleString?.()}{r.target_price?.toLocaleString?.()}{r.r_pct?.toFixed?.(1)}
+
+ ); +} diff --git a/src/pages/stock/screener/components/RunHistoryList.jsx b/src/pages/stock/screener/components/RunHistoryList.jsx new file mode 100644 index 0000000..d1b2ee9 --- /dev/null +++ b/src/pages/stock/screener/components/RunHistoryList.jsx @@ -0,0 +1,17 @@ +export default function RunHistoryList({ runs, loading, onSelect, selectedId }) { + if (loading) return

로딩…

; + return ( +
+

최근 실행

+
    + {(runs || []).map((r) => ( +
  • onSelect(r.id)}> + {r.asof} · {r.mode} +
  • + ))} +
+
+ ); +} diff --git a/src/pages/stock/screener/components/TelegramPreview.jsx b/src/pages/stock/screener/components/TelegramPreview.jsx new file mode 100644 index 0000000..02b655c --- /dev/null +++ b/src/pages/stock/screener/components/TelegramPreview.jsx @@ -0,0 +1,9 @@ +export default function TelegramPreview({ payload }) { + if (!payload) return null; + return ( +
+

텔레그램 미리보기

+
{payload.text}
+
+ ); +} diff --git a/src/pages/stock/screener/hooks/useScreenerHistory.js b/src/pages/stock/screener/hooks/useScreenerHistory.js new file mode 100644 index 0000000..e16b8ee --- /dev/null +++ b/src/pages/stock/screener/hooks/useScreenerHistory.js @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { listScreenerRuns, getScreenerRun } from '../../../../api'; + +export function useScreenerHistory() { + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedRun, setSelectedRun] = useState(null); + + useEffect(() => { + listScreenerRuns(30).then((r) => { setRuns(r); setLoading(false); }); + }, []); + + async function selectRun(id) { + if (!id) { setSelectedRun(null); return; } + const detail = await getScreenerRun(id); + setSelectedRun({ + asof: detail.meta.asof, + mode: detail.meta.mode, + status: detail.meta.status, + run_id: detail.meta.id, + survivors_count: detail.meta.survivors_count, + weights: detail.meta.weights, + top_n: detail.meta.top_n, + results: detail.results, + telegram_payload: null, + warnings: [], + meta: detail.meta, + }); + } + + return { runs, runs_loading: loading, selectedRun, selectRun }; +} diff --git a/src/pages/stock/screener/hooks/useScreenerMeta.js b/src/pages/stock/screener/hooks/useScreenerMeta.js new file mode 100644 index 0000000..f8fc413 --- /dev/null +++ b/src/pages/stock/screener/hooks/useScreenerMeta.js @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react'; +import { getScreenerNodes } from '../../../../api'; + +export function useScreenerMeta() { + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + useEffect(() => { + getScreenerNodes().then((m) => { setMeta(m); setLoading(false); }); + }, []); + return { meta, loading }; +} diff --git a/src/pages/stock/screener/hooks/useScreenerRun.js b/src/pages/stock/screener/hooks/useScreenerRun.js new file mode 100644 index 0000000..ac4e4ab --- /dev/null +++ b/src/pages/stock/screener/hooks/useScreenerRun.js @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { runScreener } from '../../../../api'; + +export function useScreenerRun() { + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + + async function call(mode, settings) { + setRunning(true); + try { + const body = { + mode, + weights: settings.weights, + node_params: settings.node_params, + gate_params: settings.gate_params, + top_n: settings.top_n, + }; + const r = await runScreener(body); + setResult(r); + return r; + } finally { + setRunning(false); + } + } + + return { + result, running, + runPreview: (s) => call('preview', s), + runSave: (s) => call('manual_save', s), + }; +} diff --git a/src/pages/stock/screener/hooks/useScreenerSettings.js b/src/pages/stock/screener/hooks/useScreenerSettings.js new file mode 100644 index 0000000..8ce4851 --- /dev/null +++ b/src/pages/stock/screener/hooks/useScreenerSettings.js @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import { getScreenerSettings, saveScreenerSettings } from '../../../../api'; + +export function useScreenerSettings() { + const [remote, setRemote] = useState(null); + const [local, setLocal] = useState(null); + + useEffect(() => { + getScreenerSettings().then((s) => { setRemote(s); setLocal(s); }); + }, []); + + const dirty = remote && local && JSON.stringify(remote) !== JSON.stringify(local); + + async function save() { + if (!local) return; + const saved = await saveScreenerSettings({ + weights: local.weights, node_params: local.node_params, gate_params: local.gate_params, + top_n: local.top_n, rr_ratio: local.rr_ratio, + atr_window: local.atr_window, atr_stop_mult: local.atr_stop_mult, + }); + setRemote(saved); + setLocal(saved); + } + + return { settings: local, dirty, setLocal, save }; +} From a11475db579d42b346006d45d778b80b638b05fd Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:18:22 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(stock):=20NodeCard=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=8F=BC=20(param=5Fschema=20=EA=B8=B0=EB=B0=98)?= =?UTF-8?q?=20+=20NodePanel=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/stock/screener/Screener.css | 10 +++ .../stock/screener/components/NodeCard.jsx | 80 +++++++++++++++++++ .../stock/screener/components/NodePanel.jsx | 20 ++++- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/pages/stock/screener/components/NodeCard.jsx diff --git a/src/pages/stock/screener/Screener.css b/src/pages/stock/screener/Screener.css index 231b358..43e2617 100644 --- a/src/pages/stock/screener/Screener.css +++ b/src/pages/stock/screener/Screener.css @@ -49,3 +49,13 @@ margin-bottom: 16px; } .screener-card h3 { margin: 0 0 12px 0; font-size: 15px; } + +.node-card { + background: #0a0f1a; + border: 1px solid #1f2937; + border-radius: 6px; + padding: 10px; + font-size: 13px; +} +.node-card-header { font-weight: 500; margin-bottom: 6px; } +.weight-row, .param-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; } diff --git a/src/pages/stock/screener/components/NodeCard.jsx b/src/pages/stock/screener/components/NodeCard.jsx new file mode 100644 index 0000000..7626285 --- /dev/null +++ b/src/pages/stock/screener/components/NodeCard.jsx @@ -0,0 +1,80 @@ +import React from 'react'; + +export default function NodeCard({ meta, weight, params, onWeightChange, onParamsChange }) { + const enabled = (weight ?? 0) > 0; + + return ( +
+
+ +
+
+
+ 가중치 + onWeightChange(parseFloat(e.target.value))} + style={{ flex: 1 }} + /> + {(weight ?? 0).toFixed(1)} +
+ {Object.entries(meta.param_schema?.properties || {}).map(([key, prop]) => ( + onParamsChange({ ...params, [key]: v })} + /> + ))} +
+
+ ); +} + +function ParamRow({ paramKey, prop, value, disabled, onChange }) { + const type = prop.type; + if (type === 'integer' || type === 'number') { + return ( +
+ {paramKey} + onChange(type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value))} + style={{ width: 80 }} + /> +
+ ); + } + if (type === 'boolean') { + return ( +
+ +
+ ); + } + // object/array는 MVP에서 read-only JSON 표시 (RsRating의 weights 등) + return ( +
+ {paramKey}: {JSON.stringify(value)} +
+ ); +} diff --git a/src/pages/stock/screener/components/NodePanel.jsx b/src/pages/stock/screener/components/NodePanel.jsx index bdfac8b..6953e26 100644 --- a/src/pages/stock/screener/components/NodePanel.jsx +++ b/src/pages/stock/screener/components/NodePanel.jsx @@ -1,3 +1,21 @@ +import NodeCard from './NodeCard'; + export default function NodePanel({ meta, weights, params, onWeights, onParams }) { - return

점수 노드 ({meta.length})

TODO: 노드별 카드 (Task 4.4)

; + return ( +
+

점수 노드 ({meta.length})

+
+ {meta.map((m) => ( + onWeights({ ...weights, [m.name]: w })} + onParamsChange={(p) => onParams({ ...params, [m.name]: p })} + /> + ))} +
+
+ ); } From 1e8542f6c721fe700892ee4c204b5204b943b740 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:19:36 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(stock):=20GatePanel=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=8F=BC=20+=20GlobalControls=20(TopN/ATR/RR=20+?= =?UTF-8?q?=203=EB=B2=84=ED=8A=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stock/screener/components/GatePanel.jsx | 40 ++++++++++++++++++- .../screener/components/GlobalControls.jsx | 39 ++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/pages/stock/screener/components/GatePanel.jsx b/src/pages/stock/screener/components/GatePanel.jsx index ab0aa7e..1fb568d 100644 --- a/src/pages/stock/screener/components/GatePanel.jsx +++ b/src/pages/stock/screener/components/GatePanel.jsx @@ -1,3 +1,41 @@ export default function GatePanel({ meta, value, onChange }) { - return

{meta?.label ?? '게이트'}

TODO: 게이트 파라미터 폼 (Task 4.5)

; + if (!meta) return null; + const props = meta.param_schema?.properties || {}; + return ( +
+

{meta.label}

+

+ 통과 조건 — 통과한 종목만 점수 노드에 전달 +

+ {Object.entries(props).map(([key, prop]) => ( + onChange({ ...value, [key]: v })} /> + ))} +
+ ); +} + +function GateField({ paramKey, prop, value, onChange }) { + if (prop.type === 'integer') { + return ( +
+ + onChange(parseInt(e.target.value, 10))} + style={{ flex: 1 }} /> +
+ ); + } + if (prop.type === 'boolean') { + return ( +
+ +
+ ); + } + return null; } diff --git a/src/pages/stock/screener/components/GlobalControls.jsx b/src/pages/stock/screener/components/GlobalControls.jsx index dd8990c..b55c9aa 100644 --- a/src/pages/stock/screener/components/GlobalControls.jsx +++ b/src/pages/stock/screener/components/GlobalControls.jsx @@ -2,9 +2,42 @@ export default function GlobalControls({ settings, setSettings, onRun, onSave, o return (

실행 옵션

- - - +
+ + setSettings({ ...settings, top_n: parseInt(e.target.value, 10) })} + min={5} max={100} style={{ width: 80 }} /> +
+
+ + setSettings({ ...settings, atr_window: parseInt(e.target.value, 10) })} + min={5} max={50} style={{ width: 80 }} /> +
+
+ + setSettings({ ...settings, atr_stop_mult: parseFloat(e.target.value) })} + min={0.5} max={5} style={{ width: 80 }} /> +
+
+ + setSettings({ ...settings, rr_ratio: parseFloat(e.target.value) })} + min={1} max={10} style={{ width: 80 }} /> +
+ + +
); } From c42d3fe8d4c4f0148f94a647f535d368a348066f Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:21:05 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(stock):=20ResultTable=20=EB=B3=B8?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20+=20ScoreChips=20(=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=EC=B9=A9=20+=2070=EC=A0=90=20=EA=B0=95=EC=A1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/stock/screener/Screener.css | 9 +++ .../stock/screener/components/ResultTable.jsx | 65 ++++++++++++++----- .../stock/screener/components/ScoreChips.jsx | 32 +++++++++ 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 src/pages/stock/screener/components/ScoreChips.jsx diff --git a/src/pages/stock/screener/Screener.css b/src/pages/stock/screener/Screener.css index 43e2617..b843321 100644 --- a/src/pages/stock/screener/Screener.css +++ b/src/pages/stock/screener/Screener.css @@ -59,3 +59,12 @@ } .node-card-header { font-weight: 500; margin-bottom: 6px; } .weight-row, .param-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; } + +.screener-table { + width: 100%; + font-size: 13px; + border-collapse: collapse; +} +.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; } +.screener-table td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; } +.screener-table tr:hover { background: #0a0f1a; } diff --git a/src/pages/stock/screener/components/ResultTable.jsx b/src/pages/stock/screener/components/ResultTable.jsx index 3393286..0f849e2 100644 --- a/src/pages/stock/screener/components/ResultTable.jsx +++ b/src/pages/stock/screener/components/ResultTable.jsx @@ -1,25 +1,54 @@ +import ScoreChips from './ScoreChips'; + export default function ResultTable({ result }) { - if (!result) return

아직 결과 없음. "지금 실행"을 눌러보세요.

; + if (!result) { + return ( +
+

아직 결과 없음. "지금 실행"을 눌러보세요.

+
+ ); + } + return (
-

Top {result.top_n} · 통과 {result.survivors_count}

- - - - - - {(result.results || []).map((r) => ( - - - - - - - +
+

+ Top {result.top_n} · 통과 {result.survivors_count} · {result.asof} +

+ {result.warnings?.length > 0 && ( +
+ ⚠ {result.warnings.join(' · ')} +
+ )} +
+ +
+
#종목총점진입손절익절R%
{r.rank}{r.name} ({r.ticker}){r.total_score?.toFixed?.(1)}{r.entry_price?.toLocaleString?.()}{r.stop_price?.toLocaleString?.()}{r.target_price?.toLocaleString?.()}{r.r_pct?.toFixed?.(1)}
+ + + + - ))} - -
#종목총점노드진입손절익절R%
+ + + {(result.results || []).map((r) => ( + + {r.rank} + {r.name}
{r.ticker} + {r.total_score?.toFixed(1)} + + {r.entry_price?.toLocaleString?.()} + {r.stop_price?.toLocaleString?.()} + {r.target_price?.toLocaleString?.()} + {r.r_pct?.toFixed?.(1)} + + ))} + + +
); } diff --git a/src/pages/stock/screener/components/ScoreChips.jsx b/src/pages/stock/screener/components/ScoreChips.jsx new file mode 100644 index 0000000..34ed5df --- /dev/null +++ b/src/pages/stock/screener/components/ScoreChips.jsx @@ -0,0 +1,32 @@ +const NODE_ICONS = { + foreign_buy: { icon: '👤', label: '외국인' }, + volume_surge: { icon: '⚡', label: '거래량' }, + momentum: { icon: '🚀', label: '모멘텀' }, + high52w: { icon: '🆙', label: '52w고' }, + rs_rating: { icon: '💪', label: 'RS' }, + ma_alignment: { icon: '📈', label: '정배열' }, + vcp_lite: { icon: '🌀', label: 'VCP' }, +}; + +export default function ScoreChips({ scores }) { + return ( +
+ {Object.entries(scores || {}).map(([name, s]) => { + const meta = NODE_ICONS[name]; + if (!meta) return null; + const active = s >= 70; + return ( + + {meta.icon}{Math.round(s)} + + ); + })} +
+ ); +} From 3abd46c0fdff5367563e609296289029c1de2466 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:22:18 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs(stock):=20CLAUDE.md=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EB=84=88=20API=20=ED=91=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20Stock=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 7 +++++++ src/pages/stock/Stock.jsx | 3 +++ 2 files changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f917ec2..6138eab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ | `/lotto` | `Lotto` | 로또 추천/통계 | | `/stock` | `Stock` | 주식 뉴스/지수 | | `/stock/trade` | `StockTrade` | 주식 트레이딩 | +| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | | `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) | | `/realestate/property` | `RealEstate` | 관심 단지 정보 | | `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) | @@ -85,6 +86,12 @@ proxy: { | 주식 | GET | `/api/stock/news`, `/api/stock/indices` | | 트레이딩 | GET | `/api/trade/balance` | | 트레이딩 | POST | `/api/trade/order` | +| 스크리너 | GET | `/api/stock/screener/nodes` | +| 스크리너 | GET/PUT | `/api/stock/screener/settings` | +| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` | +| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` | +| 스크리너 | GET | `/api/stock/screener/runs?limit=N` | +| 스크리너 | GET | `/api/stock/screener/runs/:id` | | 포트폴리오 | GET/POST | `/api/portfolio` | | 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` | | 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` | diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx index 1dc7170..c6f04ee 100644 --- a/src/pages/stock/Stock.jsx +++ b/src/pages/stock/Stock.jsx @@ -245,6 +245,9 @@ const Stock = () => { 거래 데스크 + + 스크리너 +
From e6659a416a2cc382d4df7a428469a4b0d35893ed Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:23:16 +0900 Subject: [PATCH 9/9] =?UTF-8?q?style(stock):=20=EC=8A=A4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EB=84=88=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=A0=81=EC=B8=B5=20?= =?UTF-8?q?+=20=ED=91=9C=20=EA=B0=80=EB=A1=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/stock/screener/Screener.css | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pages/stock/screener/Screener.css b/src/pages/stock/screener/Screener.css index b843321..ea0a224 100644 --- a/src/pages/stock/screener/Screener.css +++ b/src/pages/stock/screener/Screener.css @@ -36,7 +36,19 @@ } @media (max-width: 1023px) { - .screener-grid { grid-template-columns: 1fr; } + .screener-page { padding: 16px; } + .screener-header { flex-direction: column; align-items: flex-start; gap: 8px; } + .screener-grid { grid-template-columns: 1fr; gap: 16px; } + .screener-left { order: 1; } + .screener-center { order: 2; } + .screener-right { order: 3; } + .screener-table { font-size: 12px; } + .screener-table th, .screener-table td { padding: 6px 4px; } +} + +@media (max-width: 640px) { + .screener-page { padding: 12px; } + .screener-card { padding: 12px; } } .screener-loading { padding: 80px; text-align: center; color: #9ca3af; }