Compare commits
10 Commits
ccf6d4e551
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| e6659a416a | |||
| 3abd46c0fd | |||
| c42d3fe8d4 | |||
| 1e8542f6c7 | |||
| a11475db57 | |||
| bc2c020f71 | |||
| cd6072727f | |||
| 42ebd5a87c | |||
| 3b66a47316 | |||
| f7323a5b72 |
@@ -17,6 +17,7 @@
|
|||||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||||
|
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
@@ -85,6 +86,12 @@ proxy: {
|
|||||||
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
||||||
| 트레이딩 | GET | `/api/trade/balance` |
|
| 트레이딩 | GET | `/api/trade/balance` |
|
||||||
| 트레이딩 | POST | `/api/trade/order` |
|
| 트레이딩 | 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` |
|
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||||
|
|||||||
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -695,3 +695,12 @@ export const getReviewHistory = (limit = 4) =>
|
|||||||
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
||||||
apiPost('/api/lotto/purchase/bulk', { 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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ const Stock = () => {
|
|||||||
<Link className="button ghost" to="/stock/trade">
|
<Link className="button ghost" to="/stock/trade">
|
||||||
거래 데스크
|
거래 데스크
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link className="button ghost" to="/stock/screener">
|
||||||
|
스크리너
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-card">
|
<div className="stock-card">
|
||||||
|
|||||||
82
src/pages/stock/screener/Screener.css
Normal file
82
src/pages/stock/screener/Screener.css
Normal file
@@ -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; }
|
||||||
71
src/pages/stock/screener/Screener.jsx
Normal file
71
src/pages/stock/screener/Screener.jsx
Normal file
@@ -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 <div className="screener-loading">로딩 중…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screener-page">
|
||||||
|
<header className="screener-header">
|
||||||
|
<div>
|
||||||
|
<h1>스크리너</h1>
|
||||||
|
<p className="meta">
|
||||||
|
최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'}
|
||||||
|
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<Link to="/stock">시장</Link>
|
||||||
|
<Link to="/stock/trade">트레이드</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="screener-grid">
|
||||||
|
<aside className="screener-left">
|
||||||
|
<GatePanel meta={meta.gate_nodes[0]} value={settings.gate_params} onChange={(p) => setLocal({...settings, gate_params: p})} />
|
||||||
|
<NodePanel meta={meta.score_nodes} weights={settings.weights} params={settings.node_params}
|
||||||
|
onWeights={(w) => setLocal({...settings, weights: w})}
|
||||||
|
onParams={(p) => setLocal({...settings, node_params: p})} />
|
||||||
|
<GlobalControls settings={settings} setSettings={setLocal}
|
||||||
|
onRun={() => runPreview(settings)}
|
||||||
|
onSave={() => runSave(settings)}
|
||||||
|
onPersist={save}
|
||||||
|
dirty={dirty}
|
||||||
|
running={running} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="screener-center">
|
||||||
|
<ResultTable result={activeResult} />
|
||||||
|
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className="screener-right">
|
||||||
|
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
|
||||||
|
selectedId={selectedRun?.meta?.id} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/pages/stock/screener/components/GatePanel.jsx
Normal file
41
src/pages/stock/screener/components/GatePanel.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export default function GatePanel({ meta, value, onChange }) {
|
||||||
|
if (!meta) return null;
|
||||||
|
const props = meta.param_schema?.properties || {};
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<h3>{meta.label}</h3>
|
||||||
|
<p style={{ fontSize: 11, color: '#9ca3af', marginTop: 0 }}>
|
||||||
|
통과 조건 — 통과한 종목만 점수 노드에 전달
|
||||||
|
</p>
|
||||||
|
{Object.entries(props).map(([key, prop]) => (
|
||||||
|
<GateField key={key} paramKey={key} prop={prop}
|
||||||
|
value={value?.[key] ?? meta.default_params?.[key]}
|
||||||
|
onChange={(v) => onChange({ ...value, [key]: v })} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GateField({ paramKey, prop, value, onChange }) {
|
||||||
|
if (prop.type === 'integer') {
|
||||||
|
return (
|
||||||
|
<div className="param-row">
|
||||||
|
<label style={{ width: 160, fontSize: 12 }}>{paramKey}</label>
|
||||||
|
<input type="number" value={value ?? ''}
|
||||||
|
min={prop.minimum} onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
style={{ flex: 1 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (prop.type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div className="param-row">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
43
src/pages/stock/screener/components/GlobalControls.jsx
Normal file
43
src/pages/stock/screener/components/GlobalControls.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export default function GlobalControls({ settings, setSettings, onRun, onSave, onPersist, dirty, running }) {
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<h3>실행 옵션</h3>
|
||||||
|
<div className="param-row">
|
||||||
|
<label style={{ width: 80, fontSize: 12 }}>Top N</label>
|
||||||
|
<input type="number" value={settings.top_n}
|
||||||
|
onChange={(e) => setSettings({ ...settings, top_n: parseInt(e.target.value, 10) })}
|
||||||
|
min={5} max={100} style={{ width: 80 }} />
|
||||||
|
</div>
|
||||||
|
<div className="param-row">
|
||||||
|
<label style={{ width: 80, fontSize: 12 }}>ATR window</label>
|
||||||
|
<input type="number" value={settings.atr_window}
|
||||||
|
onChange={(e) => setSettings({ ...settings, atr_window: parseInt(e.target.value, 10) })}
|
||||||
|
min={5} max={50} style={{ width: 80 }} />
|
||||||
|
</div>
|
||||||
|
<div className="param-row">
|
||||||
|
<label style={{ width: 80, fontSize: 12 }}>손절 ×ATR</label>
|
||||||
|
<input type="number" value={settings.atr_stop_mult} step={0.1}
|
||||||
|
onChange={(e) => setSettings({ ...settings, atr_stop_mult: parseFloat(e.target.value) })}
|
||||||
|
min={0.5} max={5} style={{ width: 80 }} />
|
||||||
|
</div>
|
||||||
|
<div className="param-row">
|
||||||
|
<label style={{ width: 80, fontSize: 12 }}>R:R 비율</label>
|
||||||
|
<input type="number" value={settings.rr_ratio} step={0.1}
|
||||||
|
onChange={(e) => setSettings({ ...settings, rr_ratio: parseFloat(e.target.value) })}
|
||||||
|
min={1} max={10} style={{ width: 80 }} />
|
||||||
|
</div>
|
||||||
|
<button onClick={onRun} disabled={running}
|
||||||
|
style={{ marginTop: 16, width: '100%', padding: 10, background: '#fbbf24', color: '#0b0f17', border: 'none', borderRadius: 6, fontWeight: 600 }}>
|
||||||
|
{running ? '실행 중…' : '지금 실행 (미리보기)'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onSave} disabled={running}
|
||||||
|
style={{ marginTop: 8, width: '100%', padding: 8 }}>
|
||||||
|
스냅샷 저장
|
||||||
|
</button>
|
||||||
|
<button onClick={onPersist} disabled={!dirty}
|
||||||
|
style={{ marginTop: 8, width: '100%', padding: 8, opacity: dirty ? 1 : 0.5 }}>
|
||||||
|
설정 저장 (디폴트 갱신)
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/pages/stock/screener/components/NodeCard.jsx
Normal file
80
src/pages/stock/screener/components/NodeCard.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function NodeCard({ meta, weight, params, onWeightChange, onParamsChange }) {
|
||||||
|
const enabled = (weight ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="node-card" style={{ opacity: enabled ? 1 : 0.6 }}>
|
||||||
|
<div className="node-card-header">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onWeightChange(e.target.checked ? (weight || 1) : 0)}
|
||||||
|
/>
|
||||||
|
<span>{meta.label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="node-card-body">
|
||||||
|
<div className="weight-row">
|
||||||
|
<span style={{ width: 50, fontSize: 12, color: '#9ca3af' }}>가중치</span>
|
||||||
|
<input
|
||||||
|
type="range" min="0" max="3" step="0.1"
|
||||||
|
value={weight ?? 0}
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(e) => onWeightChange(parseFloat(e.target.value))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={{ width: 32, textAlign: 'right', fontSize: 12 }}>{(weight ?? 0).toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
{Object.entries(meta.param_schema?.properties || {}).map(([key, prop]) => (
|
||||||
|
<ParamRow
|
||||||
|
key={key}
|
||||||
|
paramKey={key}
|
||||||
|
prop={prop}
|
||||||
|
value={params?.[key] ?? meta.default_params?.[key]}
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(v) => onParamsChange({ ...params, [key]: v })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParamRow({ paramKey, prop, value, disabled, onChange }) {
|
||||||
|
const type = prop.type;
|
||||||
|
if (type === 'integer' || type === 'number') {
|
||||||
|
return (
|
||||||
|
<div className="param-row">
|
||||||
|
<span style={{ width: 100, fontSize: 12 }}>{paramKey}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={prop.minimum} max={prop.maximum}
|
||||||
|
step={type === 'integer' ? 1 : 0.1}
|
||||||
|
value={value ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value))}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div className="param-row">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={!!value} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// object/array는 MVP에서 read-only JSON 표시 (RsRating의 weights 등)
|
||||||
|
return (
|
||||||
|
<div className="param-row" style={{ fontSize: 11, color: '#9ca3af' }}>
|
||||||
|
{paramKey}: <code>{JSON.stringify(value)}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/pages/stock/screener/components/NodePanel.jsx
Normal file
21
src/pages/stock/screener/components/NodePanel.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import NodeCard from './NodeCard';
|
||||||
|
|
||||||
|
export default function NodePanel({ meta, weights, params, onWeights, onParams }) {
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<h3>점수 노드 ({meta.length})</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{meta.map((m) => (
|
||||||
|
<NodeCard
|
||||||
|
key={m.name}
|
||||||
|
meta={m}
|
||||||
|
weight={weights[m.name]}
|
||||||
|
params={params[m.name]}
|
||||||
|
onWeightChange={(w) => onWeights({ ...weights, [m.name]: w })}
|
||||||
|
onParamsChange={(p) => onParams({ ...params, [m.name]: p })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/pages/stock/screener/components/ResultTable.jsx
Normal file
54
src/pages/stock/screener/components/ResultTable.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import ScoreChips from './ScoreChips';
|
||||||
|
|
||||||
|
export default function ResultTable({ result }) {
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행"을 눌러보세요.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>
|
||||||
|
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
|
||||||
|
</h3>
|
||||||
|
{result.warnings?.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
|
||||||
|
borderRadius: 4, fontSize: 12,
|
||||||
|
}}>
|
||||||
|
⚠ {result.warnings.join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||||
|
<table className="screener-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
|
||||||
|
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(result.results || []).map((r) => (
|
||||||
|
<tr key={r.ticker}>
|
||||||
|
<td>{r.rank}</td>
|
||||||
|
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||||
|
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||||
|
<td><ScoreChips scores={r.scores} /></td>
|
||||||
|
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||||
|
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||||
|
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||||
|
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/pages/stock/screener/components/RunHistoryList.jsx
Normal file
17
src/pages/stock/screener/components/RunHistoryList.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
|
||||||
|
if (loading) return <section className="screener-card"><p>로딩…</p></section>;
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<h3>최근 실행</h3>
|
||||||
|
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
|
||||||
|
{(runs || []).map((r) => (
|
||||||
|
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
|
||||||
|
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
|
||||||
|
onClick={() => onSelect(r.id)}>
|
||||||
|
{r.asof} · {r.mode}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/pages/stock/screener/components/ScoreChips.jsx
Normal file
32
src/pages/stock/screener/components/ScoreChips.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(scores || {}).map(([name, s]) => {
|
||||||
|
const meta = NODE_ICONS[name];
|
||||||
|
if (!meta) return null;
|
||||||
|
const active = s >= 70;
|
||||||
|
return (
|
||||||
|
<span key={name}
|
||||||
|
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px', borderRadius: 4, fontSize: 11,
|
||||||
|
background: active ? '#fbbf24' : '#1f2937',
|
||||||
|
color: active ? '#0b0f17' : '#9ca3af',
|
||||||
|
}}>
|
||||||
|
{meta.icon}{Math.round(s)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/pages/stock/screener/components/TelegramPreview.jsx
Normal file
9
src/pages/stock/screener/components/TelegramPreview.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default function TelegramPreview({ payload }) {
|
||||||
|
if (!payload) return null;
|
||||||
|
return (
|
||||||
|
<section className="screener-card">
|
||||||
|
<h3>텔레그램 미리보기</h3>
|
||||||
|
<pre style={{whiteSpace:'pre-wrap', fontFamily:'monospace', fontSize:12}}>{payload.text}</pre>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/pages/stock/screener/hooks/useScreenerHistory.js
Normal file
32
src/pages/stock/screener/hooks/useScreenerHistory.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
11
src/pages/stock/screener/hooks/useScreenerMeta.js
Normal file
11
src/pages/stock/screener/hooks/useScreenerMeta.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
31
src/pages/stock/screener/hooks/useScreenerRun.js
Normal file
31
src/pages/stock/screener/hooks/useScreenerRun.js
Normal file
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
26
src/pages/stock/screener/hooks/useScreenerSettings.js
Normal file
26
src/pages/stock/screener/hooks/useScreenerSettings.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
|||||||
const Travel = lazy(() => import('./pages/travel/Travel'));
|
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||||
const Stock = lazy(() => import('./pages/stock/Stock'));
|
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
|
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
||||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||||
@@ -160,6 +161,10 @@ export const appRoutes = [
|
|||||||
path: 'stock/trade',
|
path: 'stock/trade',
|
||||||
element: <StockTrade />,
|
element: <StockTrade />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'stock/screener',
|
||||||
|
element: <Screener />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'realestate',
|
path: 'realestate',
|
||||||
element: <Subscription />,
|
element: <Subscription />,
|
||||||
|
|||||||
Reference in New Issue
Block a user