Compare commits

12 Commits

Author SHA1 Message Date
295972e0cb Merge feature/stock-screener-board: Stock Screener Board MVP (frontend)
- /stock/screener 라우트 + 페이지 골격 + hooks 4 + 컴포넌트 7
- NodeCard 자동 폼 (param_schema), ScoreChips (노드 70점 강조)
- 모바일 적층 + 표 가로 스크롤
- API 헬퍼 7개 + CLAUDE.md 갱신
- Stock 페이지 nav 링크
2026-05-13 07:23:17 +09:00
e6659a416a style(stock): 스크리너 모바일 적층 + 표 가로 스크롤 2026-05-12 14:23:16 +09:00
3abd46c0fd docs(stock): CLAUDE.md 스크리너 API 표 추가 + Stock 페이지 링크 2026-05-12 14:22:18 +09:00
c42d3fe8d4 feat(stock): ResultTable 본구현 + ScoreChips (노드 칩 + 70점 강조) 2026-05-12 14:21:05 +09:00
1e8542f6c7 feat(stock): GatePanel 자동 폼 + GlobalControls (TopN/ATR/RR + 3버튼) 2026-05-12 14:19:36 +09:00
a11475db57 feat(stock): NodeCard 자동 폼 (param_schema 기반) + NodePanel 통합 2026-05-12 14:18:22 +09:00
bc2c020f71 feat(stock): /stock/screener 페이지 골격 + hooks 4개 + 컴포넌트 stub 6개 2026-05-12 14:15:36 +09:00
cd6072727f feat(stock): /stock/screener 라우트 + 임시 placeholder 2026-05-12 14:13:26 +09:00
42ebd5a87c feat(stock): screener API 헬퍼 7개 2026-05-12 14:11:51 +09:00
3b66a47316 docs(plan): 데이터 소스 pykrx → FDR + 네이버 스크래핑 (Task 0.1/0.3)
실측 결과 pykrx의 시장 전체 함수 (get_market_ticker_list,
get_market_cap, get_market_ohlcv_by_ticker)가 모두 KRX 인증
요구로 깨짐. Task 0.1 의존성을 finance-datareader + bs4 + lxml
로 교체하고 Task 0.3 snapshot.py는 FDR + 네이버 frgn 스크래핑
방식으로 재작성 (implementer dispatch 시 인라인 안내).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 04:03:31 +09:00
f7323a5b72 docs(plan): Stock Screener Board MVP 구현 plan
6 Phase × 35 task. Phase 0(백엔드 기반)·Phase 1(노드 8개 TDD)·
Phase 2(엔진/사이저/텔레그램)·Phase 3(라우터)·Phase 4(프론트)·
Phase 5(agent-office 통합)·Phase 6(백필·검증·배포).
모든 task에 TDD step + 코드 + 명령 명시. 로컬 venv 기반
실행으로 메모리 규약(로컬 docker 금지) 준수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:48:20 +09:00
ccf6d4e551 docs(spec): Stock Screener Board MVP 설계 문서
KRX 강세주 발굴 노드 기반 분석 보드의 첫 슬라이스 설계.
pykrx 일봉·수급 캐시 + 위생 게이트 1 + 점수 노드 7
(외국인 누적 매수·거래량 급증·20일 모멘텀·52주 신고가 근접도·
RS Rating·이평선 정배열·VCP-lite) + 가중합 + ATR 포지션 사이징.
평일 16:30 KST agent-office 자동 잡으로 텔레그램 전송.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:33:35 +09:00
20 changed files with 5749 additions and 0 deletions

View File

@@ -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 }` |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,822 @@
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
- **상태**: 설계 (Draft)
- **작성일**: 2026-05-12
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
---
## 1. 배경 & 목표
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
### 비전 (장기)
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
### 본 슬라이스 (MVP)
| 요소 | 범위 |
|------|------|
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
### 비목표 (후속 슬라이스에 명시 예약)
1. AI 뉴스 호재/악재 노드
2. 노드 캔버스 UI (react-flow)
3. 주간 자가학습 (가중치 자동 조정 제안)
4. DART 공시·재무제표 노드
5. 분봉 기반 노드 (한투 API)
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
7. 멀티 프리셋 ("공격형"/"안정형")
8. 백테스트 화면
9. KRX 호가단위 적용
10. 메트릭/대시보드 (Prometheus 등)
---
## 2. 전체 아키텍처
```
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
│ │
▼ ▼
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
│ │
└──────────► Screener.run() ◄──────────────────┘
ScreenContext.load(asof)
(KRX 마스터·일봉·수급 SQLite 캐시)
HygieneGate.filter() ← Survivors ~500-800종
[ScoreNode.compute() × 7 활성 노드]
combine + rank Top N
position_sizer (entry/stop/target)
┌─────────────┴───────────────┐
▼ ▼
screener_runs + screener_results 응답 JSON (results, telegram_payload)
(mode='auto'·'manual_save') │
agent-office가 telegram_payload 전송
(mode='auto')
```
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
---
## 3. 백엔드 컴포넌트 구조 (stock-lab)
### 3.1 디렉토리
```
web-backend/stock-lab/app/
├─ main.py # router.include_router(screener_router) 1줄 추가
├─ db.py
├─ price_fetcher.py
├─ scraper.py
├─ ai_summarizer.py
├─ holidays.json
├─ test_*.py # 기존
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
└─ screener/ # ← NEW
├─ __init__.py
├─ router.py # FastAPI: /api/stock/screener/*
├─ schemas.py # Pydantic 요청/응답
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
├─ snapshot.py # pykrx 일봉·수급 갱신
├─ position_sizer.py # ATR 기반 진입/손절/익절
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
└─ nodes/
├─ __init__.py
├─ base.py # ScoreNode, GateNode 추상
├─ hygiene.py
├─ foreign_buy.py
├─ volume_surge.py
├─ momentum.py
├─ high52w.py
├─ rs_rating.py
├─ ma_alignment.py
└─ vcp_lite.py
```
### 3.2 핵심 추상
```python
# nodes/base.py
class ScoreNode(ABC):
name: ClassVar[str] # "foreign_buy"
label: ClassVar[str] # "외국인 누적 순매수"
default_params: ClassVar[dict]
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
@abstractmethod
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
"""index=ticker, dtype=float, range 0..100."""
class GateNode(ABC):
name: ClassVar[str]
label: ClassVar[str]
default_params: ClassVar[dict]
param_schema: ClassVar[dict]
@abstractmethod
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
"""returns surviving tickers."""
# engine.py
@dataclass(frozen=True)
class ScreenContext:
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
kospi: pd.Series # date → close (시장 비교용)
asof: datetime.date
@classmethod
def load(cls, asof: datetime.date) -> "ScreenContext": ...
def restrict(self, tickers) -> "ScreenContext": ...
class Screener:
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
node_params: dict[str, dict], gate_params: dict, top_n: int,
sizer_params: dict):
...
def run(self, ctx: ScreenContext) -> "ScreenerResult":
survivors = self.gate.filter(ctx, self.gate_params)
scoped = ctx.restrict(survivors)
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
total = combine(scores, self.weights)
ranked = total.sort_values(ascending=False).head(self.top_n)
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
survivors_count=len(survivors), warnings=[...])
```
### 3.3 registry
```python
# registry.py
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
rs_rating, ma_alignment, vcp_lite, hygiene)
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
"foreign_buy": foreign_buy.ForeignBuy,
"volume_surge": volume_surge.VolumeSurge,
"momentum": momentum.Momentum20,
"high52w": high52w.High52WProximity,
"rs_rating": rs_rating.RsRating,
"ma_alignment": ma_alignment.MaAlignment,
"vcp_lite": vcp_lite.VcpLite,
}
GATE_REGISTRY: dict[str, type[GateNode]] = {
"hygiene": hygiene.HygieneGate,
}
```
---
## 4. 데이터 모델 (stock.db 신규 7테이블)
### 4.1 KRX 캐시 (3테이블)
```sql
CREATE TABLE IF NOT EXISTS krx_master (
ticker TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
is_managed INTEGER NOT NULL DEFAULT 0,
is_preferred INTEGER NOT NULL DEFAULT 0,
is_spac INTEGER NOT NULL DEFAULT 0,
listed_date TEXT, -- 'YYYY-MM-DD'
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS krx_daily_prices (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
volume INTEGER,
value INTEGER, -- 거래대금(원)
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
CREATE TABLE IF NOT EXISTS krx_flow (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
foreign_net INTEGER, -- 원
institution_net INTEGER,
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
```
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py``backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
### 4.2 사용자 설정 (싱글톤 1테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
top_n INTEGER NOT NULL DEFAULT 20,
rr_ratio REAL NOT NULL DEFAULT 2.0,
atr_window INTEGER NOT NULL DEFAULT 14,
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
updated_at TEXT NOT NULL
);
```
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
### 4.3 실행 스냅샷 (2테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asof TEXT NOT NULL,
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
weights_json TEXT NOT NULL,
node_params_json TEXT NOT NULL,
gate_params_json TEXT NOT NULL,
top_n INTEGER NOT NULL,
survivors_count INTEGER,
telegram_sent INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
CREATE TABLE IF NOT EXISTS screener_results (
run_id INTEGER NOT NULL,
rank INTEGER NOT NULL,
ticker TEXT NOT NULL,
name TEXT NOT NULL,
total_score REAL NOT NULL,
scores_json TEXT NOT NULL,
close INTEGER,
market_cap INTEGER,
entry_price INTEGER,
stop_price INTEGER,
target_price INTEGER,
atr14 REAL,
PRIMARY KEY (run_id, ticker),
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
```
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
### 4.4 마이그레이션 방식
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
---
## 5. 노드 8개 알고리즘
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
```text
params:
min_market_cap_won = 50_000_000_000 # 500억 이상
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
min_listed_days = 60 # 신규 상장 60일 미만 제외
skip_managed = true
skip_preferred = true
skip_spac = true
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
출력: 통과 종목 Index (보통 500~800종)
```
### 5.2 외국인 누적 순매수 — `ForeignBuy`
```text
params: window_days = 5
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
score = percentile_rank(raw, 통과군) × 100
debug: foreign_net_sum, market_cap, raw_ratio_pct
```
### 5.3 거래량 급증 — `VolumeSurge`
```text
params: baseline_days = 20, eval_days = 3
baseline = mean(volume[-23:-3])
recent = mean(volume[-3:])
raw = log1p(recent / baseline) # 극값 평탄화
score = percentile_rank(raw, 통과군) × 100
debug: baseline, recent, ratio
```
### 5.4 20일 모멘텀 — `Momentum20`
```text
params: window_days = 20
raw = close[today] / close[today - 20] - 1
score = percentile_rank(raw, 통과군) × 100
debug: return_20d_pct
```
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
```text
params: window_days = 252
high_52w = max(high[-252:])
proximity = close / high_52w # 0..1
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
# 70% 미만 = 0, 100% 도달 = 100, 선형
debug: high_52w, proximity_pct
```
### 5.6 RS Rating — `RsRating`
```text
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
for k in [63, 126, 189, 252] 거래일:
r_stock = close[t]/close[t-k] - 1
r_kospi = kospi[t]/kospi[t-k] - 1
excess_k = r_stock - r_kospi
raw = Σ w_k × excess_k
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
debug: excess_1y, excess_3m, raw
```
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
```text
params: ma_periods = [50, 150, 200]
5개 조건의 만족 개수 / 5 × 100:
① close > MA50
② MA50 > MA150
③ MA150 > MA200
④ close > MA200
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
debug: 각 조건 boolean
```
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
```text
params: short_window = 40, long_window = 252 # 8주 / 52주
daily_range_pct = (high - low) / close
short_vol = mean(daily_range_pct[-40:])
long_vol = mean(daily_range_pct[-252:])
raw = 1 - (short_vol / long_vol) # 양수면 수축
score = percentile_rank(raw, 통과군) × 100
debug: short_vol, long_vol, contraction_ratio
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
```
### 5.9 결합 (`engine.combine`)
```python
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
```
### 5.10 디폴트 가중치
| 노드 | w | 근거 |
|------|----|------|
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
| volume_surge | 1.0 | 표준 |
| momentum | 1.0 | 표준 |
| high52w | **1.2** | 미너비니 SEPA 핵심 |
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
| ma_alignment | 1.0 | Stage 2 확인용 |
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
### 5.11 포지션 사이징 — `position_sizer.py`
```text
params (settings):
atr_window = 14
atr_stop_mult = 2.0 # 2 × ATR 손절
rr_ratio = 2.0 # 익절 = 진입가 + 2R
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
stop = round_won(close - 2.0 × atr14)
target = round_won(entry + 2.0 × (entry - stop))
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
```
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
### 5.12 정규화 시 주의점
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
---
## 6. API 명세 (prefix `/api/stock/screener/*`)
### 6.1 엔드포인트 표
| 메서드 | 경로 | 호출 주체 | 책임 |
|--------|------|----------|------|
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
| GET | `/settings` | 프론트 | 현재 설정 조회 |
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
### 6.2 `/run` 시맨틱
```jsonc
// REQUEST
POST /api/stock/screener/run
{
"mode": "preview" | "manual_save" | "auto",
"asof": "2026-05-12", // 생략 시 직전 거래일
"weights": { ... }, // optional override
"node_params": { ... }, // optional override
"gate_params": { ... }, // optional override
"top_n": 20 // optional override
}
// RESPONSE
{
"asof": "2026-05-12",
"mode": "preview",
"status": "success",
"run_id": null, // manual_save·auto만
"survivors_count": 612,
"weights": { ... }, // 실제 사용된 값
"top_n": 20,
"results": [
{
"rank": 1,
"ticker": "005930",
"name": "삼성전자",
"total_score": 84.3,
"scores": {
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
},
"close": 74500,
"market_cap": 444800000000000,
"entry_price": 74872,
"stop_price": 71200,
"target_price": 82216,
"atr14": 1835.5,
"r_pct": 4.9
}
],
"telegram_payload": null, // auto · manual_save만
"warnings": []
}
```
### 6.3 mode 매트릭스
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|------|------------------|---------|----------------------|----------------|
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
`telegram_payload``status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
### 6.4 `asof` 처리
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
### 6.5 에러 응답
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
| HTTP | body.status | 발생 |
|------|-------------|------|
| 200 | `success` | 정상 분석 완료 |
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
---
## 7. 프론트엔드 구조 (web-ui)
### 7.1 라우팅 & 내비게이션
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
- `src/Router.jsx`: 라우트 추가
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
- 홈(`/`) 허브 카드에 항목 추가
### 7.2 디렉토리
```
src/pages/stock/screener/
├─ Screener.jsx # 페이지 루트
├─ Screener.css
├─ components/
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
│ ├─ GatePanel.jsx # 위생 게이트 1개
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
│ ├─ ResultTable.jsx
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
│ ├─ RunHistoryList.jsx
│ └─ TelegramPreview.jsx
└─ hooks/
├─ useScreenerMeta.js
├─ useScreenerSettings.js
├─ useScreenerRun.js
└─ useScreenerHistory.js
```
### 7.3 `src/api.js` 신규 헬퍼
```js
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}`);
```
### 7.4 레이아웃
```
PC (≥1024px)
┌─────────────────────────────────────────────────────────────┐
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
├──────────────┬──────────────────────────────┬───────────────┤
│ NodePanel │ ResultTable │ RunHistoryList │
│ + GlobalControls │ TelegramPreview │ │
│ [지금 실행] │ │ │
└──────────────┴──────────────────────────────┴───────────────┘
모바일 (<768px) — 세로 적층
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
→ [TelegramPreview 접기] → [RunHistoryList]
```
### 7.5 상태 관리 패턴
- `useScreenerMeta`: 마운트 시 1회, 정적
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
---
## 8. 텔레그램 메시지 포맷
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
```
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
통과 612종 / Top 20 / 본문 1-10
1. *삼성전자* `005930` ⭐ 84.3
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
2. *NAVER* `035420` ⭐ 81.7
👤외 ⚡거 🆙고 💪RS 📈MA
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
⋯ (3-10)
🔗 전체 결과·11~20위:
https://gahusb.synology.me/stock/screener?run_id=42
```
### 노드 아이콘 (점수 ≥70인 노드만 표시)
| 노드 | 아이콘 |
|------|--------|
| foreign_buy | 👤외 |
| volume_surge | ⚡거 |
| momentum | 🚀모 |
| high52w | 🆙고 |
| rs_rating | 💪RS |
| ma_alignment | 📈MA |
| vcp_lite | 🌀VCP |
빌더(`screener/telegram.py`)는 payload만 반환:
```jsonc
{
"chat_target": "default",
"parse_mode": "MarkdownV2",
"text": "..." // 위 메시지
}
```
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
---
## 9. agent-office 통합
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
```text
Trigger: 평일 16:30 KST (Asia/Seoul)
Steps:
1. POST /api/stock/screener/snapshot/refresh
실패해도 다음 단계 진행 (이전 캐시로 분석)
2. POST /api/stock/screener/run { "mode": "auto" }
3. 응답에서 status 확인:
- status == 'skipped_holiday': 종료, 텔레그램 미발신
- status == 'success': telegram_payload 추출 → 발신
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
4. 텔레그램 발신은 agent-office의 기존 채널 사용
```
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
---
## 10. 에러 처리
| 발생 지점 | 정책 |
|----------|------|
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
| 모든 가중치 0 | 422 + message "no active score nodes" |
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
`/run``warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
---
## 11. 테스트 전략
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
### 11.1 단위 테스트 (노드별)
```
app/test_screener_nodes_foreign_buy.py
app/test_screener_nodes_volume_surge.py
app/test_screener_nodes_momentum.py
app/test_screener_nodes_high52w.py
app/test_screener_nodes_rs_rating.py
app/test_screener_nodes_ma_alignment.py
app/test_screener_nodes_vcp_lite.py
app/test_screener_nodes_hygiene.py
app/test_screener_position_sizer.py
```
**공통 케이스**:
1. 알려진 입력 → 알려진 출력 (회귀 방지)
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
### 11.2 통합 테스트
```
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
app/test_screener_telegram.py # 메시지 텍스트 생성
```
### 11.3 픽스쳐
`app/screener/_test_fixtures.py`:
- 5종목 × 60거래일 합성 DataFrame 빌더
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
### 11.4 수동 검증 (verification-before-completion)
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
---
## 12. 운영
- 로그: stock-lab stdout (Docker logs)
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
---
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
- [ ] 백엔드: `app/main.py`에 router include
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
- [ ] 백엔드: `requirements.txt``pykrx` 추가
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx``/stock/screener` 등록
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
- [ ] 배포: `scripts/deploy.bat` 또는 개별
---
## 14. 후속 슬라이스 예약
| # | 슬라이스 | 의존 |
|---|---------|------|
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
| 8 | 멀티 프리셋 | settings 테이블 확장 |
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
---
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
```jsonc
{
"score_nodes": [
{
"name": "foreign_buy",
"label": "외국인 누적 순매수",
"default_params": { "window_days": 5 },
"param_schema": {
"type": "object",
"properties": {
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
}
}
}
// … 7개
],
"gate_nodes": [
{
"name": "hygiene",
"label": "위생 게이트",
"default_params": {
"min_market_cap_won": 50000000000,
"min_avg_value_won": 500000000,
"min_listed_days": 60,
"skip_managed": true,
"skip_preferred": true,
"skip_spac": true,
"skip_halted_days": 3
},
"param_schema": { ... }
}
]
}
```
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.

View File

@@ -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}`);

View File

@@ -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">

View 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; }

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };
}

View 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 };
}

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

View 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 };
}

View File

@@ -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 />,