Files
web-page/docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
gahusb 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

31 KiB
Raw Blame History

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 핵심 추상

# 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

# 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테이블)

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.pybackfill(start_date, end_date) 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.

4.2 사용자 설정 (싱글톤 1테이블)

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테이블)

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 (점수 )

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

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

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

params: window_days = 20
raw   = close[today] / close[today - 20] - 1
score = percentile_rank(raw, 통과군) × 100
debug: return_20d_pct

5.5 52주 신고가 근접도 — High52WProximity (룰 기반)

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

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 (룰 기반)

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

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)

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

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 시맨틱

// 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_payloadstatus='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 신규 헬퍼

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만 반환:

{
  "chat_target": "default",
  "parse_mode": "MarkdownV2",
  "text": "..."   // 위 메시지
}

agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.


9. agent-office 통합

agent-office 측에 새 잡(또는 stock_agent 액션) 추가:

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 로그에만. 응답은 일반 메시지

/runwarnings 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.


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.txtpykrx 추가
  • 프론트: 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)

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