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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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(' · ')}
+
+ )}
+
+
+
+
+
+
+ | # | 종목 | 총점 | 노드 |
+ 진입 | 손절 | 익절 | 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/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/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:
,