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