From bc2c020f71ed1e1fc1b1db3954fb604948f1aa9e Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 14:15:36 +0900 Subject: [PATCH] =?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 }; +}