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>
31 KiB
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가 트리거, 텔레그램 전송 책임 |
비목표 (후속 슬라이스에 명시 예약)
- AI 뉴스 호재/악재 노드
- 노드 캔버스 UI (react-flow)
- 주간 자가학습 (가중치 자동 조정 제안)
- DART 공시·재무제표 노드
- 분봉 기반 노드 (한투 API)
- 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
- 멀티 프리셋 ("공격형"/"안정형")
- 백테스트 화면
- KRX 호가단위 적용
- 메트릭/대시보드 (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.py에 backfill(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_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 신규 헬퍼
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 로그에만. 응답은 일반 메시지 |
/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
공통 케이스:
- 알려진 입력 → 알려진 출력 (회귀 방지)
- 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
- 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
- 극값 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가
/runfailed 응답을 받으면 텔레그램 자체 알림 - 백업: 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.mdAPI 테이블에 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에 자동 노출.