# 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에 자동 노출.