diff --git a/docs/superpowers/specs/2026-05-12-stock-screener-board-design.md b/docs/superpowers/specs/2026-05-12-stock-screener-board-design.md new file mode 100644 index 0000000..f4c1c6b --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-stock-screener-board-design.md @@ -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에 자동 노출.