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/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 패키지 골격" ``` --- 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}`); + 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 = () => { 거래 데스크 + + 스크리너 +
diff --git a/src/pages/stock/screener/Screener.css b/src/pages/stock/screener/Screener.css new file mode 100644 index 0000000..ea0a224 --- /dev/null +++ b/src/pages/stock/screener/Screener.css @@ -0,0 +1,82 @@ +.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-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; } + +.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; } + +.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; } + +.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/Screener.jsx b/src/pages/stock/screener/Screener.jsx new file mode 100644 index 0000000..c06b4ca --- /dev/null +++ b/src/pages/stock/screener/Screener.jsx @@ -0,0 +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() { + 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..1fb568d --- /dev/null +++ b/src/pages/stock/screener/components/GatePanel.jsx @@ -0,0 +1,41 @@ +export default function GatePanel({ meta, value, onChange }) { + 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 new file mode 100644 index 0000000..b55c9aa --- /dev/null +++ b/src/pages/stock/screener/components/GlobalControls.jsx @@ -0,0 +1,43 @@ +export default function GlobalControls({ settings, setSettings, onRun, onSave, onPersist, dirty, running }) { + 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 }} /> +
+ + + +
+ ); +} 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 new file mode 100644 index 0000000..6953e26 --- /dev/null +++ b/src/pages/stock/screener/components/NodePanel.jsx @@ -0,0 +1,21 @@ +import NodeCard from './NodeCard'; + +export default function NodePanel({ meta, weights, params, onWeights, onParams }) { + return ( +
+

점수 노드 ({meta.length})

+
+ {meta.map((m) => ( + onWeights({ ...weights, [m.name]: w })} + onParamsChange={(p) => onParams({ ...params, [m.name]: p })} + /> + ))} +
+
+ ); +} diff --git a/src/pages/stock/screener/components/ResultTable.jsx b/src/pages/stock/screener/components/ResultTable.jsx new file mode 100644 index 0000000..0f849e2 --- /dev/null +++ b/src/pages/stock/screener/components/ResultTable.jsx @@ -0,0 +1,54 @@ +import ScoreChips from './ScoreChips'; + +export default function ResultTable({ result }) { + if (!result) { + return ( +
+

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

+
+ ); + } + + return ( +
+
+

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

+ {result.warnings?.length > 0 && ( +
+ ⚠ {result.warnings.join(' · ')} +
+ )} +
+ +
+ + + + + + + + + {(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 ( +
+

최근 실행

+ +
+ ); +} 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)} + + ); + })} +
+ ); +} 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 }; +} 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: ,