13 Commits

Author SHA1 Message Date
534ded59e8 docs(signal-v2): amend spread formula to absolute (q90-q10) for Chronos-bolt zero-shot reality
Phase 0 spec §6.1 originally specified relative spread (q90-q10)/median < 0.6.
Phase 3b smoke (005930: median=-0.59%, q90-q10=15.3%) revealed Chronos-bolt
zero-shot median frequently sits near zero, causing relative spread to explode
(15.3/0.0059 ≈ 25) and reject every signal. Absolute spread (0.153 < 0.6)
preserves the threshold semantic and keeps Phase 7 IC validation tractable.

Phase 4 spec §4.2 + Phase 0 §6.1 both amended with cross-reference.
chronos_predictor.py conf calculation unchanged — monotonic mapping there
is independent of hard-gate semantics.
2026-05-17 13:10:50 +09:00
f4b78da176 docs(signal-v2): Phase 4 implementation plan — 4 tasks TDD
Task 1: foundation (config 6 env + state.signals)
Task 2: signal_generator + 9 unit tests (TDD)
Task 3: pull_worker + main.py integration + 1 test
Task 4: user manual (.env optional + smoke + push)

10 new tests, total 55 signal_v2 tests. ~3-5 days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:52:13 +09:00
aeeab6704f fix(insta): SlateDetail JSON 객체 렌더 오류 + 카드 생성 시 자동 스크롤
(1) React error #31 fix: cover_copy/cta_copy는 객체({headline,body,accent_color}),
    body_copies는 배열 — 직접 {slate.cover_copy}로 렌더하면 에러. 필드별로
    분해 렌더하고, 10페이지 전체 카피(커버 1 + 본문 8 + CTA 1)를 detail에
    노출하도록 SlateDetail 확장.

(2) UX: handleCreateSlate 시작 시 window.scrollTo(0, 0)로 상단 progress 배너
    노출 보장. KeywordsPanel의 🎴 버튼도 부모 handleCreateSlate 위임으로
    통합 — Trends/Cards 양쪽 어디서 눌러도 동일 흐름(배너 + 자동 미리보기).

(3) KeywordsPanel의 자체 slatePoll 제거 — 상단 ic-slate-progress 배너로
    일원화하여 중복 진행 표시 회피.
2026-05-17 12:51:26 +09:00
6222b56716 feat(insta): trends 카드 생성 시 progress 배너 + 자동 미리보기 전환
Trends 탭의 🎴 버튼 클릭이 silent로 끝나 사용자에게 무동작처럼 보이던
이슈 fix. handleCreateSlate를 3초 간격 폴링으로 확장 (최대 8분):

- 시작/진행/성공/실패 상단 배너로 시각화
- 카드 생성 완료 시 자동으로 Cards 탭 전환 + 새 슬레이트 자동 선택
  → SlateDetail이 카피·이미지 미리보기 즉시 표시
- 실패 시 에러 메시지 + 클릭으로 dismiss
- "Claude 카피 추론 + Playwright 카드 10장 생성 중 (3~7분)" 안내 문구
2026-05-17 12:41:04 +09:00
9e9eed2162 docs(signal-v2): Phase 4 signal generator spec
매수/매도 룰 (Phase 0 spec §6.1-§6.3) + confidence_webai 공식
(chronos*0.5 + minute*0.3 + screener*0.2) + SignalDedup 24h. 6 env
외부화 (STOP_LOSS/TAKE_PROFIT/SPREAD/BID_RATIO/CONFIDENCE/MIN_MOMENTUM).
state.signals = Phase 0 spec §5.2 schema. 10 new tests.

brainstorming 6 decisions: scope=A(생성만) / trigger=A(매 cycle) /
minute_score=A(linear 5-level) / thresholds=A+(6 env) / state=A(spec §5.2) /
test=A(9 unit + 1 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:40:24 +09:00
06affd9614 feat(insta): swap google_trends source for youtube_trending (Google Trends API 폐기 대응) 2026-05-17 11:54:10 +09:00
b0eda14982 feat(insta): Trends tab — account focus + external trends + impact preview
- api.js: add getInstaTrends, instaCollectTrends, getInstaPreferences, putInstaPreferences helpers
- InstaCards.jsx: add Cards/Trends tab bar with URL sync (?tab=trends); add AccountFocusPanel (category weight sliders + save), ExternalTrendsPanel (Naver+Google trend rows + manual collect), PreferenceImpactPanel (next extract preview); existing 5-panel Cards tab preserved intact
- InstaCards.css: add ic-tabbar, ic-tab, ic-trends-grid, ic-panel base, ic-focus/ic-trend/ic-impact component styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:04:47 +09:00
1f55d24ce6 docs(signal-v2): Phase 3b implementation plan — 7 tasks TDD
Task 1: foundation (config + state + requirements)
Task 2: kis_client.get_daily_ohlcv + 1 test
Task 3: momentum_classifier (pure functions) + 6 tests
Task 4: chronos_predictor + 4 tests (mock pipeline)
Task 5: pull_worker post-close cycle + scheduler trigger + 1 test
Task 6: main.py lifespan ChronosPredictor
Task 7: user manual (pip install + .env + smoke + push)

12 new tests, total 45 signal_v2 tests. ~1 week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:42:17 +09:00
6eb4ab1204 docs(signal-v2): Phase 3b Chronos-2 + minute momentum spec
KIS daily OHLCV fetch (kis_client.get_daily_ohlcv, FHKST03010100) +
ChronosPredictor (HuggingFace amazon/chronos-2 zero-shot, env-configurable
model, always-loaded) + minute momentum classifier (5-level rule:
strong_up/weak_up/neutral/weak_down/strong_down) + post-close cycle
trigger (16:00 KST). 12 new tests (33 → 45 total).

brainstorming 7 decisions: daily=B(KIS REST) / freq=A(post-close 1x) /
model=A(env CHRONOS_MODEL) / momentum=A(5-level rule) / state=B(median+
q10+q90+conf+as_of) / test=A(mock+pure) / scope=integrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:17 +09:00
78b77e2691 docs(signal-v2): Phase 3a implementation plan — 7 tasks TDD
Task 1: config + state + websockets dep
Task 2: scheduler NXT windows + 3 tests
Task 3: kis_client REST + 4 tests
Task 4: kis_websocket + 4 tests (most heavy)
Task 5: pull_worker minute cycle + 2 tests
Task 6: main.py KIS lifespan + 1 test
Task 7: user manual .env + smoke + push

13 new tests, total 32 signal_v2 tests. ~1 week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:00:43 +09:00
1813db761f docs(signal-v2): Phase 3a KIS data collection spec
KIS REST client (minute OHLCV + asking price polling, V1 token
read-only share) + KIS WebSocket client (approval_key + portfolio
asking_price realtime subscribe) + PollState extension + scheduler
NXT windows (20:00-23:30 / 04:30-07:00). 13 new tests.

brainstorming 6 decisions: scope=B(3a/3b split) / data=B(REST minute +
WS asking) / auth=A(V1 token share) / subscribe=A(portfolio WS +
screener REST) / NXT=C(scheduler extend) / test=A(respx + WS mock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:50:16 +09:00
01d9b2f872 docs(signal-v2): Phase 2 implementation plan — 6 tasks TDD
Task 1: foundation (config + state + gitignore + requirements)
Task 2: stock_client + 6 tests (httpx retry + cache)
Task 3: scheduler + 5 tests (market windows + holidays)
Task 4: rate_limit + 3 tests (SQLite WAL dedup)
Task 5: pull_worker + FastAPI app + 2 tests (lifespan + /health)
Task 6: holidays sync + start.bat + user .env + manual smoke

Total 16 tests. ~1 week.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:31:31 +09:00
b9dabd07e0 docs(signal-v2): Phase 2 web-ai pull worker spec
stock pull worker + asyncio scheduler + rate limit SQLite + FastAPI
app (:8001). 16 tests planned. brainstorming 6 decisions:
batch=A(separate FastAPI :8001) / scope=A(3 items) / scheduler=B(asyncio
cron) / http=B(httpx + custom retry + memory cache) / rate-limit=A(SQLite
WAL) / test=B(pytest-asyncio + httpx mock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:14:45 +09:00
12 changed files with 7072 additions and 50 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,817 @@
# Signal V2 Phase 4 — Signal Generator Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** signal_v2 에 매수/매도 신호 생성 레이어 추가. Phase 2/3a/3b 의 모든 state 산출 → Phase 0 spec §6.1-§6.3 룰 → `state.signals[ticker]` (Phase 0 spec §5.2 schema) + `SignalDedup` 24h 차단.
**Architecture:** 순수 함수 `generate_signals(state, dedup, settings)` 가 매 분봉 cycle 후 호출. 매수 (Hard gate 4 조건 + soft confidence > 0.7) + 매도 (손절>이상>익절 우선순위). 6 env 외부화 (운영 튜닝).
**Tech Stack:** Python 순수 함수 / pytest / SignalDedup (Phase 2) / 외부 의존성 없음
**Spec:** `web-ui/docs/superpowers/specs/2026-05-17-signal-v2-phase4-signal-generator.md`
---
## 파일 구조
| 파일 | 책임 |
|------|------|
| `signal_v2/config.py` | (수정) Settings 에 6 env field 추가 |
| `signal_v2/state.py` | (수정) PollState `signals` field 추가 |
| `signal_v2/signal_generator.py` | (신규) `generate_signals(state, dedup, settings)` + 8 helper |
| `signal_v2/pull_worker.py` | (수정) `poll_loop` signature + 매 cycle 후 `generate_signals` 호출 |
| `signal_v2/main.py` | (수정) lifespan 의 poll_task 호출에 `dedup` + `settings` 전달 |
| `signal_v2/tests/test_signal_generator.py` | (신규) 9 단위 케이스 |
| `signal_v2/tests/test_pull_worker.py` | (수정) integration 1 케이스 추가 |
7 파일 변경, **10 신규 테스트** (45 → 55).
---
## Task 순서
```
Task 1: foundation (config 6 env + state signals field)
Task 2: signal_generator.py + 9 단위 tests (TDD)
Task 3: pull_worker + main.py 통합 + 1 integration test
Task 4: 사용자 수동 (.env optional + smoke + push)
```
---
### Task 1: foundation (config + state)
**Files:**
- Modify: `web-ai/signal_v2/config.py`
- Modify: `web-ai/signal_v2/state.py`
- [ ] **Step 1: Update config.py with 6 new fields**
Read `web-ai/signal_v2/config.py`. Add 6 fields to Settings (after `chronos_model` field, before properties):
```python
stop_loss_pct: float = field(
default_factory=lambda: float(os.getenv("STOP_LOSS_PCT", "-0.07"))
)
take_profit_pct: float = field(
default_factory=lambda: float(os.getenv("TAKE_PROFIT_PCT", "0.15"))
)
chronos_spread_threshold: float = field(
default_factory=lambda: float(os.getenv("CHRONOS_SPREAD_THRESHOLD", "0.6"))
)
asking_bid_ratio_threshold: float = field(
default_factory=lambda: float(os.getenv("ASKING_BID_RATIO_THRESHOLD", "0.6"))
)
confidence_threshold: float = field(
default_factory=lambda: float(os.getenv("CONFIDENCE_THRESHOLD", "0.7"))
)
min_momentum_for_buy: str = field(
default_factory=lambda: os.getenv("MIN_MOMENTUM_FOR_BUY", "strong_up")
)
```
- [ ] **Step 2: Update state.py with signals field**
Read `web-ai/signal_v2/state.py`. Add `signals` field to PollState (after `minute_momentum`):
```python
signals: dict[str, dict] = field(default_factory=dict)
```
- [ ] **Step 3: Smoke import test**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -c "from signal_v2.config import get_settings; from signal_v2.state import state; s = get_settings(); print(f'stop_loss={s.stop_loss_pct}, conf_threshold={s.confidence_threshold}, min_momentum={s.min_momentum_for_buy}'); print(state)"
```
Expected: `stop_loss=-0.07, conf_threshold=0.7, min_momentum=strong_up` + state print with `signals={}`.
- [ ] **Step 4: Run existing tests — no regression**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 45 passed.
- [ ] **Step 5: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/config.py signal_v2/state.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): foundation — 6 env thresholds + state.signals
config.py: STOP_LOSS_PCT / TAKE_PROFIT_PCT / CHRONOS_SPREAD_THRESHOLD /
ASKING_BID_RATIO_THRESHOLD / CONFIDENCE_THRESHOLD / MIN_MOMENTUM_FOR_BUY
env vars with sensible defaults (Phase 0 spec §6.1-§6.2 values).
state.py: PollState.signals dict[ticker, signal_body] for Phase 5 input.
45 existing tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: signal_generator.py + 9 단위 tests
**Files:**
- Create: `web-ai/signal_v2/signal_generator.py`
- Create: `web-ai/signal_v2/tests/test_signal_generator.py`
- [ ] **Step 1: Write 9 failing tests**
Create `web-ai/signal_v2/tests/test_signal_generator.py`:
```python
"""Tests for signal_generator."""
from collections import deque
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from signal_v2.signal_generator import generate_signals
from signal_v2.state import PollState
def _settings(**overrides):
"""Build a Settings-like object for tests (avoid env)."""
defaults = dict(
stop_loss_pct=-0.07,
take_profit_pct=0.15,
chronos_spread_threshold=0.6,
asking_bid_ratio_threshold=0.6,
confidence_threshold=0.7,
min_momentum_for_buy="strong_up",
)
defaults.update(overrides)
m = MagicMock()
for k, v in defaults.items():
setattr(m, k, v)
return m
def _make_state_with_buy_candidate(
ticker="005930", name="삼성전자", rank=1,
chronos_median=0.02, chronos_q10=-0.01, chronos_q90=0.04, chronos_conf=0.85,
momentum="strong_up", bid_ratio=0.7, current_price=78500,
):
state = PollState()
state.screener_preview = {"items": [{"ticker": ticker, "name": name}]}
state.chronos_predictions[ticker] = {
"median": chronos_median, "q10": chronos_q10, "q90": chronos_q90,
"conf": chronos_conf, "as_of": "2026-05-17T16:00:00+09:00",
}
state.minute_momentum[ticker] = momentum
state.asking_price[ticker] = {
"bid_total": int(bid_ratio * 1000),
"ask_total": int((1 - bid_ratio) * 1000),
"bid_ratio": bid_ratio,
"current_price": current_price,
"as_of": "2026-05-17T16:00:01+09:00",
}
return state
def _make_state_with_holding(
ticker="005930", name="삼성전자",
pnl_pct=0.0, avg_price=75000, current_price=75000,
):
state = PollState()
state.portfolio = {"holdings": [{
"ticker": ticker, "name": name,
"avg_price": avg_price, "current_price": current_price,
"pnl_pct": pnl_pct, "profit_rate": pnl_pct * 100,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
return state
@pytest.fixture
def dedup_mock():
d = MagicMock()
d.is_recent.return_value = False
return d
def test_buy_signal_when_all_conditions_pass_and_confidence_high(dedup_mock):
state = _make_state_with_buy_candidate()
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "buy"
assert sig["confidence_webai"] > 0.7
dedup_mock.record.assert_called()
def test_silent_when_chronos_median_negative(dedup_mock):
state = _make_state_with_buy_candidate(chronos_median=-0.01)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_distribution_spread_too_wide(dedup_mock):
# spread = (0.5 - (-0.5)) / max(|0.001|, 0.001) = 1000 → > 0.6
state = _make_state_with_buy_candidate(
chronos_median=0.001, chronos_q10=-0.5, chronos_q90=0.5,
)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_momentum_not_strong_up(dedup_mock):
state = _make_state_with_buy_candidate(momentum="weak_up")
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_bid_ratio_below_threshold(dedup_mock):
state = _make_state_with_buy_candidate(bid_ratio=0.5)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_confidence_below_threshold(dedup_mock):
# chronos_conf low + rank=20 → confidence < 0.7
state = _make_state_with_buy_candidate(chronos_conf=0.3)
# add 19 fake items to push rank to 20
state.screener_preview["items"] = (
[{"ticker": f"FAKE{i:03d}"} for i in range(19)]
+ [{"ticker": "005930", "name": "삼성전자"}]
)
generate_signals(state, dedup_mock, _settings())
# confidence_webai = 0.3*0.5 + 1.0*0.3 + 0.05*0.2 = 0.15 + 0.3 + 0.01 = 0.46 < 0.7
assert "005930" not in state.signals
def test_sell_signal_when_stop_loss_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=-0.08, current_price=69000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 1.0 # 손절선 즉시
assert sig["pnl_pct"] == -0.08
def test_sell_signal_when_take_profit_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=0.16, current_price=87000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 0.6 # 익절선 검토 알림
def test_silent_when_dedup_recently_sent(dedup_mock):
state = _make_state_with_buy_candidate()
dedup_mock.is_recent.return_value = True # dedup 차단
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
dedup_mock.record.assert_not_called()
```
- [ ] **Step 2: Run tests to verify FAIL**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_signal_generator.py -v 2>&1 | tail -10
```
Expected: ImportError (signal_v2.signal_generator missing).
- [ ] **Step 3: Implement signal_generator.py**
Create `web-ai/signal_v2/signal_generator.py`:
```python
"""Phase 4 — 매수/매도 신호 생성.
순수 함수 generate_signals(state, dedup, settings). state 를 mutate.
"""
from __future__ import annotations
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
# 분봉 모멘텀 → linear score
MOMENTUM_SCORES = {
"strong_up": 1.0,
"weak_up": 0.7,
"neutral": 0.5,
"weak_down": 0.3,
"strong_down": 0.0,
}
def generate_signals(state, dedup, settings) -> None:
"""Phase 4 entry — state mutating. 매수/매도 룰 적용."""
_evaluate_buy_signals(state, dedup, settings)
_evaluate_sell_signals(state, dedup, settings)
# ----- 매수 -----
def _evaluate_buy_signals(state, dedup, settings) -> None:
candidates = _buy_candidates(state)
for ticker, name, rank in candidates:
if not _check_buy_hard_gate(state, ticker, settings):
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
continue
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
def _buy_candidates(state) -> list[tuple[str, str, int | None]]:
"""screener Top-N (rank 1..N) + portfolio (rank=None)."""
candidates: list[tuple[str, str, int | None]] = []
seen: set[str] = set()
# Screener Top-N
if state.screener_preview is not None:
for i, item in enumerate(state.screener_preview.get("items", [])):
ticker = item.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
name = item.get("name", ticker)
candidates.append((ticker, name, i + 1))
# Portfolio holdings
if state.portfolio is not None:
for h in state.portfolio.get("holdings", []):
ticker = h.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
candidates.append((ticker, h.get("name", ticker), None))
return candidates
def _check_buy_hard_gate(state, ticker: str, settings) -> bool:
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] <= 0:
return False
spread = (pred["q90"] - pred["q10"]) / max(abs(pred["median"]), 0.001)
if spread >= settings.chronos_spread_threshold:
return False
momentum = state.minute_momentum.get(ticker)
if momentum != settings.min_momentum_for_buy:
return False
ap = state.asking_price.get(ticker)
if ap is None or ap["bid_ratio"] < settings.asking_bid_ratio_threshold:
return False
return True
def _compute_buy_confidence(state, ticker: str, rank: int | None) -> float:
pred = state.chronos_predictions[ticker]
chronos_conf = pred["conf"]
minute_score = MOMENTUM_SCORES.get(state.minute_momentum.get(ticker, "neutral"), 0.5)
screener_norm = 1 - (rank - 1) / 20 if rank is not None else 0.0
return chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict:
ap = state.asking_price[ticker]
pred = state.chronos_predictions[ticker]
return {
"ticker": ticker,
"name": name,
"action": "buy",
"confidence_webai": confidence,
"current_price": ap["current_price"],
"avg_price": None,
"pnl_pct": None,
"context": _build_context(state, ticker, rank),
"as_of": datetime.now(KST).isoformat(),
}
# ----- 매도 -----
def _evaluate_sell_signals(state, dedup, settings) -> None:
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding.get("ticker")
if not ticker:
continue
sell = _try_stop_loss(state, holding, settings)
if sell is None:
sell = _try_anomaly(state, holding, settings)
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
def _try_stop_loss(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl >= settings.stop_loss_pct:
return None
return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss")
def _try_take_profit(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl <= settings.take_profit_pct:
return None
return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit")
def _try_anomaly(state, holding: dict, settings) -> dict | None:
ticker = holding["ticker"]
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] >= -0.01:
return None
momentum = state.minute_momentum.get(ticker)
if momentum != "strong_down":
return None
ap = state.asking_price.get(ticker)
if ap is None:
return None
if ap["bid_ratio"] > (1 - settings.asking_bid_ratio_threshold):
return None # 매도세 60% 미만
minute_score = 1.0 - MOMENTUM_SCORES.get(momentum, 0.5) # 반전
confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2
if confidence <= settings.confidence_threshold:
return None
return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly")
def _build_sell_signal(state, holding: dict, confidence: float, reason: str) -> dict:
ticker = holding["ticker"]
return {
"ticker": ticker,
"name": holding.get("name", ticker),
"action": "sell",
"confidence_webai": confidence,
"current_price": holding.get("current_price"),
"avg_price": holding.get("avg_price"),
"pnl_pct": holding.get("pnl_pct"),
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
"as_of": datetime.now(KST).isoformat(),
}
# ----- Context -----
def _build_context(state, ticker: str, rank: int | None, sell_reason: str | None = None) -> dict:
pred = state.chronos_predictions.get(ticker) or {}
ap = state.asking_price.get(ticker) or {}
news_item = _find_news_sentiment(state, ticker)
screener_scores = _find_screener_scores(state, ticker)
context: dict = {
"chronos_pred_1d": pred.get("median"),
"chronos_pred_conf": pred.get("conf"),
"chronos_q10": pred.get("q10"),
"chronos_q90": pred.get("q90"),
"screener_rank": rank,
"screener_scores": screener_scores,
"minute_momentum": state.minute_momentum.get(ticker),
"asking_bid_ratio": ap.get("bid_ratio"),
"news_sentiment": news_item.get("score") if news_item else None,
"news_reason": news_item.get("reason") if news_item else None,
}
if sell_reason is not None:
context["sell_reason"] = sell_reason
return context
def _find_news_sentiment(state, ticker: str) -> dict | None:
if state.news_sentiment is None:
return None
for item in state.news_sentiment.get("items", []):
if item.get("ticker") == ticker:
return item
return None
def _find_screener_scores(state, ticker: str) -> dict | None:
if state.screener_preview is None:
return None
for item in state.screener_preview.get("items", []):
if item.get("ticker") == ticker:
return item.get("scores")
return None
```
- [ ] **Step 4: Run tests to verify PASS**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_signal_generator.py -v 2>&1 | tail -15
```
Expected: 9 passed.
Full suite:
```bash
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 54 passed.
If any test fails, examine the assertion + impl. Common gotchas:
- Confidence calculation order — chronos*0.5 + minute*0.3 + screener*0.2
- Stop loss `<` (strict) vs `<=` — spec says "도달 시" so use `<` strict
- screener_norm when rank=None → 0.0 (not 1.0)
- [ ] **Step 5: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/signal_generator.py signal_v2/tests/test_signal_generator.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): signal_generator + 9 unit tests
generate_signals(state, dedup, settings) → state mutating:
- Buy: screener Top-N + portfolio. Hard gate (chronos median > 0 +
spread < 0.6 + momentum strong_up + bid_ratio >= 0.6) + soft
confidence (chronos*0.5 + minute*0.3 + screener*0.2) > 0.7.
- Sell: portfolio only. Priority stop_loss > anomaly > take_profit.
Stop loss confidence 1.0 (immediate), take_profit 0.6 (review).
- SignalDedup 24h via dedup.is_recent/record per (ticker, action).
- State signal dict matches Phase 0 spec §5.2 schema.
54 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: pull_worker + main.py integration + 1 test
**Files:**
- Modify: `web-ai/signal_v2/pull_worker.py`
- Modify: `web-ai/signal_v2/main.py`
- Modify: `web-ai/signal_v2/tests/test_pull_worker.py`
- [ ] **Step 1: Write failing integration test**
Append to `web-ai/signal_v2/tests/test_pull_worker.py`:
```python
def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
"""매 cycle 후 generate_signals 호출 + state.signals 갱신."""
from unittest.mock import MagicMock
from signal_v2.state import PollState
state = PollState()
state.portfolio = {"holdings": [{
"ticker": "005930", "name": "삼성전자",
"avg_price": 75000, "current_price": 69000,
"pnl_pct": -0.08, "profit_rate": -8.0,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
dedup = MagicMock()
dedup.is_recent.return_value = False
settings = MagicMock()
settings.stop_loss_pct = -0.07
settings.take_profit_pct = 0.15
settings.chronos_spread_threshold = 0.6
settings.asking_bid_ratio_threshold = 0.6
settings.confidence_threshold = 0.7
settings.min_momentum_for_buy = "strong_up"
from signal_v2.signal_generator import generate_signals
# Call generate_signals directly to verify state mutation through the public API.
generate_signals(state, dedup, settings)
# Stop loss should trigger
assert "005930" in state.signals
assert state.signals["005930"]["action"] == "sell"
assert state.signals["005930"]["confidence_webai"] == 1.0
dedup.record.assert_called_with("005930", "sell", confidence=1.0)
```
- [ ] **Step 2: Run test to verify PASS (signal_generator from Task 2 already exists)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_pull_worker.py::test_poll_loop_calls_generate_signals_after_cycle -v 2>&1 | tail -10
```
Expected: PASS (test exercises generate_signals directly — public API integration).
- [ ] **Step 3: Update pull_worker.py — poll_loop signature + cycle integration**
Read `web-ai/signal_v2/pull_worker.py`. Modify the `poll_loop` signature to accept dedup + settings:
```python
async def poll_loop(
client, state, shutdown,
kis_client=None, chronos=None,
dedup=None, settings=None,
) -> None:
"""...existing docstring..."""
logger.info("poll_loop started")
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
try:
await _run_polling_cycle(client, state, kis_client=kis_client)
except Exception:
logger.exception("poll cycle failed")
try:
update_minute_momentum_for_all(state)
except Exception:
logger.exception("minute momentum update failed")
if _is_post_close_trigger(now) and chronos is not None and kis_client is not None:
try:
await _run_post_close_cycle(kis_client, chronos, state)
except Exception:
logger.exception("post-close cycle failed")
# Phase 4: generate signals
if dedup is not None and settings is not None:
try:
from signal_v2.signal_generator import generate_signals
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
interval = _next_interval(now)
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval)
break
except asyncio.TimeoutError:
continue
logger.info("poll_loop ended")
```
- [ ] **Step 4: Update main.py — pass dedup + settings to poll_loop**
Read `web-ai/signal_v2/main.py`. Find the `asyncio.create_task(poll_loop(...))` call inside `lifespan` and add `dedup` + `settings` params:
```python
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
```
- [ ] **Step 5: Run full test suite**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 55 passed (54 + 1 new integration).
- [ ] **Step 6: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/pull_worker.py signal_v2/main.py signal_v2/tests/test_pull_worker.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): pull_worker + main.py integrate signal generator
poll_loop signature now accepts dedup + settings. After each cycle
(stock pull + minute momentum + post-close), call generate_signals
to populate state.signals. main.py lifespan passes _ctx.dedup and
settings to poll_loop.
1 integration test added. 55 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: 사용자 수동 — .env optional + smoke + push
**This task requires user action.**
- [ ] **Step 1: .env optional**
6 env 의 default 가 Phase 0 spec 값과 동일 — `.env` 변경 불필요. 운영 검증 후 조정 시:
```
STOP_LOSS_PCT=-0.07
TAKE_PROFIT_PCT=0.15
CHRONOS_SPREAD_THRESHOLD=0.6
ASKING_BID_RATIO_THRESHOLD=0.6
CONFIDENCE_THRESHOLD=0.7
MIN_MOMENTUM_FOR_BUY=strong_up
```
- [ ] **Step 2: signal_v2 재시작**
기존 signal_v2 가 가동 중이면 Ctrl+C 후:
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai\signal_v2
.\start.bat
```
기대: 정상 시작 (signal_generator 자동 호출 — 매 cycle 마다).
- [ ] **Step 3: state.signals 검증 (수동)**
운영 시간대라면 cycle 진행 + state.signals 채워질 수 있음. 수동 검증:
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai
python -c "
import asyncio
from signal_v2.config import get_settings
from signal_v2.kis_client import KISClient
from signal_v2.chronos_predictor import ChronosPredictor
from signal_v2.state import PollState
from signal_v2.rate_limit import SignalDedup
from signal_v2.pull_worker import _run_post_close_cycle, update_minute_momentum_for_all
from signal_v2.signal_generator import generate_signals
async def main():
s = get_settings()
kc = KISClient(app_key=s.kis_app_key, app_secret=s.kis_app_secret, account=s.kis_account, is_virtual=s.kis_is_virtual, v1_token_path=s.v1_token_path)
cp = ChronosPredictor(model_name=s.chronos_model)
dedup = SignalDedup(s.db_path)
state = PollState()
state.portfolio = {'holdings': [{'ticker': '005930', 'name': '삼성전자', 'avg_price': 75000, 'current_price': 78500, 'pnl_pct': 0.047, 'profit_rate': 4.67, 'quantity': 100, 'broker': '키움'}]}
state.screener_preview = {'items': []}
try:
await _run_post_close_cycle(kc, cp, state)
update_minute_momentum_for_all(state)
generate_signals(state, dedup, s)
print('Signals:', state.signals)
finally:
await kc.close()
asyncio.run(main())
"
```
Expected: `Signals: {}` (정상 — pnl_pct 0.047 은 손절/익절 트리거 안 함, 매수 조건 다 만족 어려움) 또는 일부 신호 dict.
- [ ] **Step 4: V1 무영향**
V1 정상 가동 확인.
- [ ] **Step 5: push**
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai
git push
```
- [ ] **Step 6: 결과 보고**
- Step 2 (signal_v2 시작): PASS / FAIL
- Step 3 (state.signals 검증): PASS — empty dict or 신호 결과 공유 / FAIL
- Step 4 (V1 무영향): PASS / FAIL
- Step 5 (push): PASS / FAIL
전체 PASS 시 **Phase 4 완료** → Phase 5 (agent-office /signal + Qwen3 + 이중 텔레그램) brainstorming.
---
## Self-Review
**1. Spec coverage:**
| Spec § | 요구사항 | Plan task |
|--------|----------|----------|
| §2 ① signal_generator | Task 2 ✅ |
| §2 ② config 6 env | Task 1 ✅ |
| §2 ③ state.signals | Task 1 ✅ |
| §2 ④ pull_worker integration | Task 3 ✅ |
| §2 ⑤ main.py lifespan | Task 3 ✅ |
| §2 ⑥ 10 tests | Task 2 (9) + Task 3 (1) = 10 ✅ |
| §4 매수 룰 + confidence | Task 2 (_check_buy_hard_gate + _compute_buy_confidence) ✅ |
| §5 매도 룰 + dedup | Task 2 (_try_stop_loss/anomaly/take_profit + dedup.is_recent/record) ✅ |
| §6 state 통합 + pull_worker | Task 1 + Task 3 ✅ |
| §7 signal_generator 구조 | Task 2 Step 3 (8 helpers) ✅ |
| §8 10 테스트 케이스 | Task 2-3 ✅ |
| §9 DoD 8 항목 | Task 1-4 합산 ✅ |
No gaps.
**2. Placeholder scan**: No "TBD" / "implement later". 각 step 의 코드 + 명령 모두 명시.
**3. Type consistency:**
- `generate_signals(state, dedup, settings) -> None` consistent Task 2 + Task 3 ✅
- `MOMENTUM_SCORES` 매핑 consistent (1.0/0.7/0.5/0.3/0.0) ✅
- Settings field names consistent Task 1 + Task 2 (stop_loss_pct, etc.) ✅
- PollState.signals dict[str, dict] consistent ✅
- helper signatures (_check_buy_hard_gate, _compute_buy_confidence, _try_stop_loss, _try_anomaly, _try_take_profit, _build_buy_signal, _build_sell_signal, _build_context) consistent ✅
Plan passes self-review.

View File

@@ -194,7 +194,7 @@ agent-office 가 web-ai 의 Ollama (Qwen3 14B Q4) 에 보내는 prompt 의 응
### 6.1 매수 신호 (screener Top-20 종목 대상) ### 6.1 매수 신호 (screener Top-20 종목 대상)
조건 (전부 충족): 조건 (전부 충족):
1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 (90-10 분위수 / 50 분위수) < 0.6 (좁은 분포 = 높은 conf) 1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 `q90 - q10` < 0.6 (절대 spread, 60% return 변동 미만 = 모델 확신; **Phase 4 amend 2026-05-17**: 기존 relative formula `(q90-q10)/median` 는 Chronos-bolt 의 median≈0 출력에서 거의 모든 신호 거부 → absolute spread 채택. 자세한 사유는 `2026-05-17-signal-v2-phase4-signal-generator.md` §4.2 참조)
2. 분봉 모멘텀 = `strong_up`: 2. 분봉 모멘텀 = `strong_up`:
- 5분봉 5개 연속 양봉 - 5분봉 5개 연속 양봉
- 거래량 > 평균 1.5배 - 거래량 > 평균 1.5배

View File

@@ -0,0 +1,436 @@
# Confidence Signal Pipeline V2 — Phase 2: web-ai Pull Worker Design
**작성일**: 2026-05-16
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**:
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
- signal_v1 rename (`2026-05-16-web-ai-v1-rename-to-signal-v1.md`) — 본 spec 부터 `web-ai/signal_v1/` + `web-ai/signal_v2/` 구조 사용
**브레인스토밍 결정 6개**:
- 배치 = A (별도 FastAPI app `:8001`, 새 디렉토리 `web-ai/signal_v2/`)
- Scope = A (client + scheduler + rate limit DB 3 항목)
- Scheduler = B (asyncio + 자체 cron loop, FastAPI lifespan)
- HTTP client = B (httpx async + 자체 retry loop + 메모리 cache)
- Rate limit DB = A (SQLite + WAL + busy_timeout)
- Test = B (pytest + pytest-asyncio + httpx mock + tmp_path sqlite)
---
## 1. 목표
web-ai 머신에 V2 신호 파이프라인 인프라 구축. stock NAS 와 안정적으로 통신하는 client + 시간대별 polling scheduler + 24h dedup 인프라.
Phase 3 (Chronos-2 추론) 이 이 위에 추론 코드를 얹는다. Phase 4 (signal generator) 가 rate limit DB 를 사용. Phase 5 에서 같은 FastAPI app 에 `POST /signal` endpoint 추가.
**Why**: Phase 0 §3 책임 분리 — "web-ai = 시점 분석". web-ai 가 NAS DB 직접 접근 안 함, 모든 데이터는 stock API 경유. Phase 1 endpoint (X-WebAI-Key 인증) 가 입력 계약 = Phase 2 의 client 가 이 위에 동작.
---
## 2. 범위
### 포함
-**StockClient 클래스** — httpx async + 자체 retry loop (max 3, exponential backoff 1s→2s→4s) + 메모리 dict cache (TTL: portfolio 60s / news-sentiment 300s / screener 60s) + 마지막 성공 응답 stale fallback
-**Polling scheduler** — asyncio cron loop (FastAPI lifespan + asyncio.create_task). 시간대별 분기 (장전 5분 / 장중 1분 / 장후 5분 / 야간·휴장 skip)
-**Rate limit DB** — SQLite (WAL + busy_timeout=120000) `signal_dedup` 테이블. Phase 4 가 사용
-**FastAPI app** — 새 port `:8001`. `GET /health` endpoint + startup/shutdown lifespan
-**PollState** — process-wide singleton (portfolio/news_sentiment/screener_preview + last_updated + fetch_errors)
-**테스트 16 케이스** (stock_client 6 + scheduler 5 + rate_limit 3 + main 2)
### 범위 외 (NOT)
- Chronos-2 추론, KIS WebSocket, 분봉 (Phase 3)
- Signal generator 매수/매도 룰 (Phase 4) — rate limit DB 사용은 Phase 4
- agent-office `POST /signal` 호출 (Phase 5)
- 기존 signal_v1 (V1 자동매매) 분리/정리/deprecation (Phase 6)
- Ollama Qwen3 호스팅 (Phase 5)
- ticker filter / 운영 모니터링 메트릭 (Phase 7)
- holidays.json 자동 동기화 (backlog — 일단 stock/app/holidays.json 의 manual copy)
- 메모리 cache TTL 만료 entry 명시 cleanup (YAGNI)
---
## 3. 파일 구조
### 3.1 신규 디렉토리: `web-ai/signal_v2/`
```
web-ai/signal_v2/
├── __init__.py
├── main.py # FastAPI app + lifespan + GET /health
├── config.py # env 로딩 (STOCK_API_URL, WEBAI_API_KEY, SIGNAL_V2_PORT)
├── stock_client.py # StockClient: httpx async + retry + cache + auth header
├── scheduler.py # poll_loop, _next_interval, _is_market_day, _seconds_until_next_market_open
├── pull_worker.py # _run_polling_cycle: 3 endpoint 병렬 fetch + state 갱신
├── rate_limit.py # SignalDedup: is_recent + record (WAL + busy_timeout)
├── state.py # PollState dataclass (process-wide singleton)
├── holidays.json # 한국 휴장일 (stock/app/holidays.json 복사)
├── start.bat # uvicorn signal_v2.main:app --port 8001
├── data/
│ ├── .gitkeep
│ └── signal_v2.db # SQLite (gitignore)
└── tests/
├── __init__.py
├── conftest.py # pytest-asyncio + fixtures
├── test_stock_client.py # 6 케이스
├── test_scheduler.py # 5 케이스
├── test_rate_limit.py # 3 케이스
└── test_main.py # 2 케이스
```
### 3.2 변경 매트릭스
| 파일 | 작업 |
|------|------|
| `web-ai/signal_v2/` 전체 | 신규 디렉토리 |
| `web-ai/.env` | 3 줄 추가: `STOCK_API_URL=https://gahusb.synology.me`, `WEBAI_API_KEY=<Phase 1 동일 값>`, `SIGNAL_V2_PORT=8001` |
| `web-ai/.gitignore` | `signal_v2/data/*.db`, `signal_v2/data/*.db-*` (WAL/SHM) 추가 |
| `web-ai/CLAUDE.md` | `signal_v2/` 섹션은 이미 signal_v1 rename slice 에서 작성됨 — 무변경 |
### 3.3 기존 파일 무변경
- `web-ai/signal_v1/` 전체 (V1 자동매매)
- `web-ai/start.bat` (V1 진입)
- 다른 lab / web-backend / web-ui 영향 0
---
## 4. API 계약
### 4.1 `StockClient` 클래스 (signal_v2/stock_client.py)
```python
class StockClient:
"""stock API 호출 wrapper. httpx async + 자체 retry + 메모리 cache."""
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0):
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._client = httpx.AsyncClient(timeout=timeout)
self._cache: dict[str, tuple[Any, float]] = {}
async def get_portfolio(self) -> dict:
"""GET /api/webai/portfolio. cache TTL 60s."""
async def get_news_sentiment(self, date: str | None = None) -> dict:
"""GET /api/webai/news-sentiment. cache TTL 300s."""
async def run_screener_preview(
self, weights: dict | None = None, top_n: int = 20
) -> dict:
"""POST /api/stock/screener/run {mode:'preview', ...}. cache TTL 60s."""
async def close(self) -> None: ...
# internal
async def _request_with_retry(self, method, path, **kwargs) -> dict: ...
def _cache_get(self, key: str) -> Any | None: ...
def _cache_set(self, key: str, data: Any) -> None: ...
def _auth_headers(self) -> dict[str, str]: ... # {"X-WebAI-Key": self._api_key}
```
retry 정책:
- max_attempts = 3
- timeout = 10s
- 429 응답: exponential backoff (1s → 2s → 4s)
- 5xx 응답: 짧은 retry (max 3회) 후 raise
- 모든 retry 실패 + cache 에 이전 성공 응답 있음 → stale fallback + `logger.warning`
cache TTL:
- portfolio: 60s
- news-sentiment: 300s (일별 갱신이라 TTL 길어도 무방)
- screener preview: 60s
### 4.2 FastAPI app (signal_v2/main.py)
```python
app = FastAPI(title="Signal V2 Pull Worker", version="0.1.0")
@app.on_event("startup")
async def startup():
# 1. config 로드
# 2. SignalDedup DB 초기화
# 3. StockClient 생성 (전역 상태)
# 4. asyncio.create_task(poll_loop(...))
@app.on_event("shutdown")
async def shutdown():
# 1. shutdown_event.set() → poll_loop 종료
# 2. StockClient.close()
@app.get("/health")
async def health() -> dict:
return {
"status": "online",
"stock_api_url": settings.stock_api_url,
"last_poll": state.last_updated,
"cache_size": len(client._cache),
}
```
Phase 5 이후 추가될 endpoint (본 spec 외): `POST /signal` (agent-office 호출).
### 4.3 PollState (signal_v2/state.py)
```python
@dataclass
class PollState:
portfolio: dict | None = None
news_sentiment: dict | None = None
screener_preview: dict | None = None
last_updated: dict[str, str] = field(default_factory=dict)
fetch_errors: dict[str, int] = field(default_factory=dict)
```
단일 process-wide 인스턴스 (`state.py` 모듈 변수). Phase 3 가 `from signal_v2.state import state` 로 read-only 참조.
---
## 5. Scheduler 구현
### 5.1 polling 주기 결정 (signal_v2/scheduler.py)
```python
KST = ZoneInfo("Asia/Seoul")
_HOLIDAYS = set(json.loads((Path(__file__).parent / "holidays.json").read_text()))
_PRE_MARKET = (time(7, 0), time(9, 0)) # 5분
_MARKET = (time(9, 0), time(15, 30)) # 1분
_POST_MARKET = (time(15, 30), time(20, 0)) # 5분
# 그 외 야간 (20:00-07:00): polling 없음
def _is_market_day(now: datetime) -> bool:
if now.weekday() >= 5: return False
if now.strftime("%Y-%m-%d") in _HOLIDAYS: return False
return True
def _next_interval(now: datetime) -> float:
"""다음 폴링까지 sleep 초수."""
if not _is_market_day(now):
return _seconds_until_next_market_open(now)
t = now.time()
if _PRE_MARKET[0] <= t < _PRE_MARKET[1]: return 300
elif _MARKET[0] <= t < _MARKET[1]: return 60
elif _POST_MARKET[0] <= t < _POST_MARKET[1]: return 300
else: return _seconds_until_next_market_open(now)
```
### 5.2 polling loop
```python
async def poll_loop(client: StockClient, state: PollState, shutdown: asyncio.Event) -> None:
logger.info("poll_loop started")
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
try:
await _run_polling_cycle(client, state)
except Exception:
logger.exception("poll cycle failed")
interval = _next_interval(now)
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval)
break
except asyncio.TimeoutError:
continue
async def _run_polling_cycle(client: StockClient, state: PollState) -> None:
"""3 endpoint 병렬 fetch + state 갱신."""
portfolio, sentiment, screener = await asyncio.gather(
client.get_portfolio(),
client.get_news_sentiment(),
client.run_screener_preview(),
return_exceptions=True,
)
now_iso = datetime.now(KST).isoformat()
if isinstance(portfolio, dict):
state.portfolio = portfolio
state.last_updated["portfolio"] = now_iso
state.fetch_errors["portfolio"] = 0
elif isinstance(portfolio, Exception):
state.fetch_errors["portfolio"] = state.fetch_errors.get("portfolio", 0) + 1
# 동일 처리 for sentiment, screener
```
### 5.3 holidays.json
`stock/app/holidays.json` 의 복사본을 `signal_v2/holidays.json` 으로 manual copy. 향후 backlog: 자동 동기화 또는 shared library.
---
## 6. Rate Limit DB
### 6.1 SQLite schema (signal_v2/rate_limit.py 의 startup 시 생성)
```sql
CREATE TABLE IF NOT EXISTS signal_dedup (
ticker TEXT NOT NULL,
action TEXT NOT NULL, -- 'buy' or 'sell'
last_sent TEXT NOT NULL, -- ISO timestamp KST
confidence REAL NOT NULL,
PRIMARY KEY (ticker, action)
);
CREATE INDEX IF NOT EXISTS idx_signal_dedup_last_sent ON signal_dedup(last_sent);
```
### 6.2 `SignalDedup` 클래스
```python
class SignalDedup:
"""Phase 4 signal generator 가 사용. WAL + busy_timeout=120000."""
def __init__(self, db_path: Path): ...
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path, timeout=120.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=120000")
return conn
def _init_schema(self) -> None: ...
def is_recent(self, ticker: str, action: str, within_hours: int = 24) -> bool:
"""True 면 24h 내 발송됨 → silent."""
def record(self, ticker: str, action: str, confidence: float) -> None:
"""발송 직후 호출. PK 충돌 시 last_sent 갱신 (UPSERT)."""
```
Phase 2 에서는 인프라만 구축. Phase 4 가 매수/매도 결정 직전 `is_recent()` 체크 + 발송 후 `record()` 호출.
---
## 7. 테스트
### 7.1 `test_stock_client.py` (6 케이스)
| 케이스 | 검증 |
|--------|------|
| `test_get_portfolio_normal_returns_dict_with_pnl_pct` | 정상 200 + 응답 파싱 + cache 저장 |
| `test_get_portfolio_uses_cache_within_ttl` | 첫 호출 후 60s 내 두번째 호출 = mock httpx 콜 1회 |
| `test_get_portfolio_refetches_after_ttl_expiry` | frozen_time 으로 60s+1 진행 후 mock httpx 콜 2회 |
| `test_get_portfolio_retries_3_times_on_timeout` | mock 이 처음 2회 timeout → 3회차 200 → exponential sleep 검증 |
| `test_get_portfolio_429_triggers_backoff` | 429 응답 → 1s sleep → 재시도 → 200 |
| `test_get_portfolio_falls_back_to_stale_on_all_failures` | cache 에 이전 성공 + 모든 retry 5xx → stale 반환 + logger.warning |
### 7.2 `test_scheduler.py` (5 케이스)
| 케이스 | 검증 |
|--------|------|
| `test_next_interval_pre_market_5min` | now=08:30 평일 → 300 |
| `test_next_interval_market_open_1min` | now=10:00 평일 → 60 |
| `test_next_interval_post_market_5min` | now=17:00 평일 → 300 |
| `test_next_interval_overnight_skip_to_next_morning` | now=22:00 평일 → 다음날 07:00 까지 |
| `test_next_interval_holiday_skip` | now=2026-08-15 (공휴일) → 다음 영업일 07:00 까지 |
### 7.3 `test_rate_limit.py` (3 케이스)
| 케이스 | 검증 |
|--------|------|
| `test_is_recent_returns_false_for_new_ticker_action` | record 없음 → False |
| `test_is_recent_returns_true_within_24h` | record 호출 1초 후 → True |
| `test_is_recent_returns_false_after_24h` | record + 24h 1분 후 → False |
### 7.4 `test_main.py` (2 케이스)
| 케이스 | 검증 |
|--------|------|
| `test_health_endpoint_returns_status_online` | TestClient → GET /health → 200 + status online |
| `test_startup_warns_if_webai_api_key_missing` | env 미설정 + startup → logger.warning |
**총 16 신규 테스트**. 외부 stock 호출 0 (전부 mock).
### 7.5 conftest.py
```python
import pytest
from pathlib import Path
import respx
@pytest.fixture
def tmp_dedup_db(tmp_path) -> Path:
return tmp_path / "test_signal_v2.db"
@pytest.fixture
async def mock_stock_api():
async with respx.mock(base_url="https://test.stock.local") as mock:
yield mock
@pytest.fixture
def frozen_now(monkeypatch):
"""datetime.now(KST) 고정용 (freezegun 또는 monkeypatch)."""
```
pytest-asyncio mode = "auto" — `pyproject.toml` 또는 `pytest.ini` 에 명시.
---
## 8. 위험 및 완화
| 위험 | 완화 |
|------|------|
| stock API 응답 지연 (NAS 부하 / 네트워크) | timeout 10s + retry 3회 + cache fallback (stale) |
| `.env` 의 WEBAI_API_KEY 미설정 → 모든 호출 401 | startup ERROR log + Phase 1 의 503 응답 fallback 활용 |
| Polling cycle 중 web-ai 종료 | shutdown.wait timeout 으로 즉시 break, asyncio cleanup |
| holidays.json 미동기화 → 휴일 폴링 시도 | stock 측 응답 정상 (데이터 stale). Phase 7 모니터링 |
| SQLite WAL lock (Phase 4 가 signal generator 동시 write) | busy_timeout=120000 + WAL → reader/writer 분리. Phase 4 단일 writer 직렬 보장 |
| 메모리 cache 누수 (장기 운영) | TTL 만료 entry 명시 cleanup 없음 (YAGNI). Phase 7 모니터링 |
| signal_v1 (port 8000) ↔ signal_v2 (port 8001) 충돌 | 다른 port. 같은 머신에서 동시 가동 가능 |
| 시간대 (KST) 계산 오류 (DST) | KST 는 DST 없음 (Asia/Seoul +09:00 고정). 안전 |
| asyncio + sqlite3 (sync) 혼합 | rate_limit 호출은 짧음. Phase 4 의 호출 패턴 결정 시 점검 |
| Phase 1 rate limit (60r/m) 초과 | polling 빈도 분당 3 → 20x 여유. 정상 동작 시 무관 |
---
## 9. 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | 0 (V1 영향 없음, V2 신규 시작) |
| 사용자 영향 | 없음 (V2 silent, Phase 5 까지 신호 발송 없음) |
| `.env` 갱신 | 사용자 1회 (`WEBAI_API_KEY`, `STOCK_API_URL`, `SIGNAL_V2_PORT`) |
| V1 영향 | 0 (별도 process / port / 디렉토리) |
| stock NAS 부하 | 매우 작음 (장중 분당 3 call) |
| 외부 의존성 추가 | `httpx`, `pytest-asyncio`, `respx` |
---
## 10. Phase 2 완료 조건 (DoD)
- [ ] `web-ai/signal_v2/` 디렉토리 + 7 파이썬 파일 (main.py / config.py / stock_client.py / scheduler.py / pull_worker.py / rate_limit.py / state.py + __init__.py)
- [ ] `holidays.json` 복사
- [ ] `tests/` 디렉토리 + conftest.py + 4 test 파일 + 16 케이스 모두 PASS
- [ ] `python -m uvicorn signal_v2.main:app --port 8001` 정상 시작 + `GET http://localhost:8001/health` 200
- [ ] 1 회 polling cycle 완료 → `state.portfolio` + `state.news_sentiment` + `state.screener_preview` 갱신 확인 (수동 trigger 또는 첫 자연 cycle)
- [ ] rate_limit DB 파일 생성 + WAL + busy_timeout 설정 확인
- [ ] `.env` 갱신 (사용자 1회): `STOCK_API_URL=https://gahusb.synology.me`, `WEBAI_API_KEY=<Phase 1 동일>`, `SIGNAL_V2_PORT=8001`
- [ ] web-ai V1 봇 무영향 검증 (`start.bat` 정상 시작)
- [ ] git push (web-ai repo)
---
## 11. Phase 3 와의 관계
본 Phase 2 완료 후 즉시 **Phase 3 (KIS WebSocket + 분봉 + Chronos-2 추론)** spec → plan → 구현. 의존성:
```
[Phase 2 spec/plan/실행] → [Phase 3 spec/plan/실행]
2주 2주
```
Phase 3 의 입력 계약 = 본 spec 의 `PollState` (Phase 3 코드가 read-only 로 import). Phase 3 의 추론 결과 (Chronos-2 quantile 등) 는 별도 state 객체 또는 PollState 확장 — Phase 3 spec 에서 결정.
---
## 12. Backlog (본 spec NOT)
- ticker filter (news-sentiment `?tickers=` 옵션 활용) — V2 운영 후 종목 필터 시
- 운영 메트릭 (응답시간 / 에러율 / 텔레그램 alert) — Phase 7
- holidays.json 자동 동기화 (stock → web-ai)
- cache 만료 entry 명시 cleanup (장기 운영 시 메모리 누수 발견 시)
- Phase 5 `POST /signal` endpoint (agent-office 호출) — Phase 5 spec
- WebSocket-based polling (현재 HTTP polling, 향후 stock 측이 WebSocket push 도입 시)
- Phase 6 signal_v1 deprecation (V1 자동매매 정리)
- Phase 4 가 rate_limit 호출 시 asyncio.to_thread vs 직접 호출 결정

View File

@@ -0,0 +1,443 @@
# Confidence Signal Pipeline V2 — Phase 3a: KIS Data Collection Design
**작성일**: 2026-05-16
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**:
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
- signal_v1 rename (`2026-05-16-web-ai-v1-rename-to-signal-v1.md`)
- Phase 2 web-ai pull worker (`2026-05-16-signal-v2-phase2-web-ai-pull-worker.md`)
**Phase 3 분해**: Phase 0 spec 의 Phase 3 (KIS WebSocket + NXT + Chronos-2 + 분봉 모멘텀) 를 2 sub-phase 로 분해:
- **Phase 3a (본 spec)**: KIS 데이터 수집 (분봉 REST + 호가 WebSocket + scheduler NXT 확장)
- **Phase 3b (별도 spec)**: Chronos-2 추론 + 분봉 모멘텀 분류기
**브레인스토밍 결정 6개**:
- scope = B (3a / 3b 분해)
- 데이터 수집 = B (분봉 REST + 호가 WebSocket)
- KIS 인증 = A (V1 토큰 read-only 공유)
- 구독 범위 = A (portfolio WebSocket + screener REST polling)
- NXT 처리 = C (stock 자동 처리 + scheduler 의 NXT 시간대 폴링 추가)
- 테스트 = A (respx REST mock + WebSocket mock + tmp sqlite)
---
## 1. 목표
signal_v2 가 신호 판단에 필요한 KIS 실시간/준실시간 데이터 (분봉 OHLCV + 호가 매수세) 를 수집해 `PollState` 에 채워 넣는다. Phase 3b (Chronos-2 추론) + Phase 4 (signal generator) 가 이 위에 동작.
**Why**: Phase 0 §3 "web-ai = 시점 분석" 책임의 데이터 수집 부분. KIS REST 의 분봉/호가 + KIS WebSocket 의 실시간 호가가 매수/매도 룰의 핵심 입력.
---
## 2. 범위
### 포함 (6 항목)
-**KIS REST client** (`signal_v2/kis_client.py`) — 분봉 polling + screener Top-N 호가 polling. V1 토큰 파일 (`signal_v1/data/kis_token.json`) read-only 공유.
-**KIS WebSocket client** (`signal_v2/kis_websocket.py`) — approval_key 신규 발급 + portfolio 보유 종목 호가 실시간 구독 + reconnect with exponential backoff.
-**`pull_worker.py` 확장** — 분봉 1분 polling cycle 추가 + WebSocket 메시지 처리 task.
-**`PollState` 확장** — `minute_bars: dict[ticker, deque(maxlen=60)]`, `asking_price: dict[ticker, dict]`, `last_updated["minute_bars"]` / `["asking_price"]`.
-**`scheduler.py` 수정** — NXT 시간대 폴링 (20:00-23:30 / 04:30-07:00) 5분 cron 추가.
-**테스트 13 신규** (KIS REST 4 + WebSocket 4 + scheduler NXT 3 + pull_worker 2). 기존 19 + 신규 13 = 32 total.
### 범위 외 (NOT)
- Chronos-2 모델 로드 + 추론 (Phase 3b)
- 분봉 모멘텀 분류기 (Phase 3b — 5분봉 aggregate + 5연속 양봉 룰)
- Signal generator 매수/매도 룰 (Phase 4)
- NXT 자체 API 호출 — V2 가 별도 NXT API client 없음. stock 측 `price_fetcher` 가 NXT 시간대 가격 자동 반환 (`price_session` 필드)
- WebSocket 동적 subscribe 갱신 — portfolio 변동 시 다음 cycle 에서 일괄 갱신
- 분봉 daily aggregate — 60 분봉 sliding window 만
- 분봉 영속 저장 — 메모리만, 재기동 시 reset
- V2 자체 KIS 토큰 발급 — Phase 6 deprecation 까지 V1 단독 갱신 책임
---
## 3. 파일 구조 + 변경 매트릭스
### 3.1 신규 / 수정
| 파일 | 작업 | 라인 |
|------|------|------|
| `signal_v2/kis_client.py` | 신규 | ~150 |
| `signal_v2/kis_websocket.py` | 신규 | ~180 |
| `signal_v2/state.py` | 필드 2개 추가 | +5 |
| `signal_v2/pull_worker.py` | 분봉 cycle + WebSocket task | +60 |
| `signal_v2/scheduler.py` | NXT 시간대 분기 | +15 |
| `signal_v2/main.py` | KIS lifespan 통합 | +20 |
| `signal_v2/config.py` | KIS env 5개 + V1 token path | +10 |
| `signal_v2/tests/test_kis_client.py` | 신규 4 케이스 | ~150 |
| `signal_v2/tests/test_kis_websocket.py` | 신규 4 케이스 | ~170 |
| `signal_v2/tests/test_pull_worker.py` | 신규 2 케이스 | ~80 |
| `signal_v2/tests/test_scheduler.py` | NXT 3 케이스 추가 | +30 |
| `signal_v2/tests/test_main.py` | KIS lifespan 케이스 | +20 |
| `signal_v2/requirements.txt` | `websockets>=12` | +1 |
| `web-ai/.env` | KIS env 5 + V1_TOKEN_PATH (사용자 수동) | +6 |
### 3.2 외부 의존성 신규
- `websockets>=12` (KIS WebSocket client)
### 3.3 V1 공유 / 무영향
- **공유** (read-only): `signal_v1/data/kis_token.json` — V1 의 단독 갱신 책임. V2 는 mtime 캐시 + read.
- **무영향**: V1 의 main_server.py / modules / 자동매매 봇 — Phase 6 까지 분리 유지.
---
## 4. KIS REST client (`kis_client.py`)
```python
class KISClient:
"""KIS REST API (분봉 + 호가). V1 토큰 read-only 공유."""
def __init__(
self,
app_key: str, app_secret: str, account: str, is_virtual: bool,
v1_token_path: Path,
timeout: float = 10.0,
):
self._app_key = app_key
self._app_secret = app_secret
self._account = account
self._is_virtual = is_virtual
self._v1_token_path = Path(v1_token_path)
self._base_url = (
"https://openapivts.koreainvestment.com:29443" if is_virtual
else "https://openapi.koreainvestment.com:9443"
)
self._client = httpx.AsyncClient(timeout=timeout)
self._token_cache: tuple[str, float] | None = None # (token, file_mtime)
self._last_throttle_at = 0.0 # 초당 2회 제한
async def get_minute_ohlcv(self, ticker: str) -> list[dict]:
"""현재 시점 직전 30개 1분봉 OHLCV (TR_ID: FHKST03010200).
Returns: [{"datetime", "open", "high", "low", "close", "volume"}, ...] (시간 오름차순)
"""
async def get_asking_price(self, ticker: str) -> dict:
"""현재 호가 5단계 + 매수/매도 잔량 (TR_ID: FHKST01010200).
Returns: {
"bid_total": int,
"ask_total": int,
"bid_ratio": float,
"current_price": int,
"as_of": str (ISO),
}
"""
async def close(self) -> None: ...
# internal
def _read_v1_token(self) -> str:
"""signal_v1/data/kis_token.json 읽기. mtime 캐시 — 갱신 시 자동 재로드."""
async def _throttle(self) -> None:
"""V1 패턴 — 초당 2회 제한 (0.5s sleep)."""
def _common_headers(self, tr_id: str) -> dict:
"""authorization, appkey, appsecret, tr_id."""
```
### 4.1 토큰 공유 디자인
- `_v1_token_path` env `V1_TOKEN_PATH` 에서 로드. 기본값 `../signal_v1/data/kis_token.json`.
- 첫 호출 시 파일 read + mtime 캐시.
- 매 호출 전 mtime 비교 — 변경 시 재로드. 캐시 hit 시 빠른 통과.
- 파일 미존재 / 만료 시: WARNING log + `HTTPException` (Phase 6 까지 V1 단독 책임 명시).
### 4.2 분봉 응답 정규화
KIS API 의 분봉 raw 응답 (`output2` 배열) → 표준 dict 리스트로 변환. 시간 오름차순, 거래량 0 인 분봉 skip.
---
## 5. KIS WebSocket client (`kis_websocket.py`)
```python
class KISWebSocket:
"""KIS WebSocket — approval_key 발급 + 호가 실시간 구독."""
def __init__(self, app_key: str, app_secret: str, is_virtual: bool):
self._app_key = app_key
self._app_secret = app_secret
self._ws_url = (
"wss://openapivts.koreainvestment.com:29443/tryitout" if is_virtual
else "wss://openapi.koreainvestment.com:9443/tryitout"
)
self._approval_key: str | None = None
self._ws: WebSocketClientProtocol | None = None
self._subscriptions: set[str] = set()
self._on_asking_price: Callable[[str, dict], None] | None = None
self._recv_task: asyncio.Task | None = None
self._shutdown = asyncio.Event()
async def start(
self, tickers: list[str],
on_asking_price: Callable[[str, dict], None],
) -> None:
"""approval_key 발급 + WebSocket 연결 + 종목 호가 구독 + receive loop 시작."""
async def subscribe(self, ticker: str) -> None:
"""동적 구독 추가."""
async def unsubscribe(self, ticker: str) -> None: ...
async def close(self) -> None:
"""unsubscribe all + shutdown event + close socket."""
# internal
async def _fetch_approval_key(self) -> str:
"""POST {base_rest}/oauth2/Approval — approval_key 발급."""
async def _send_subscription(self, ticker: str, tr_id: str = "H0STASP0") -> None:
"""tr_id H0STASP0 = 실시간 호가."""
async def _receive_loop(self) -> None:
"""메시지 receive loop. PING/PONG 30초 + 호가 message parse → callback.
끊김 감지 → exponential backoff (1s→2s→4s→max 30s) + reconnect + subscribe 재등록."""
def _parse_asking_price(self, raw: str) -> tuple[str, dict] | None:
"""KIS 호가 raw string '0|H0STASP0|...|005930^...' 파싱.
Returns: (ticker, {bid_total, ask_total, bid_ratio, current_price, as_of})
또는 None (parse fail).
"""
```
### 5.1 메시지 형식 (KIS 공식 문서)
호가 메시지 raw 예시 (실제는 더 긴 `^` 구분 필드):
```
0|H0STASP0|001|005930^091500^78500^...^bid_total^ask_total^...
```
파싱 키 (필드 인덱스 기반):
- ticker = 4번째 필드의 종목코드 부분
- as_of = 5번째 필드 (HHMMSS)
- bid_total / ask_total = 정해진 인덱스 (KIS 문서 참조)
### 5.2 Reconnect 정책
- websockets 의 `ConnectionClosed` 캐치
- exponential backoff: 1s → 2s → 4s → 8s → 16s → max 30s
- 재연결 후 `_subscriptions` 의 모든 ticker 재구독
- 5분 이상 연결 실패 시 ERROR log + shutdown event 발생 (운영자 알림은 Phase 7)
---
## 6. PollState 확장 + pull_worker
### 6.1 PollState 추가 필드
```python
@dataclass
class PollState:
portfolio: dict | None = None
news_sentiment: dict | None = None
screener_preview: dict | None = None
# 신규 (Phase 3a)
minute_bars: dict[str, deque] = field(default_factory=dict) # {ticker: deque(maxlen=60)}
asking_price: dict[str, dict] = field(default_factory=dict) # {ticker: {bid_total, ask_total, bid_ratio, ...}}
last_updated: dict[str, str] = field(default_factory=dict)
fetch_errors: dict[str, int] = field(default_factory=dict)
```
### 6.2 pull_worker 확장
```python
async def _run_polling_cycle(client, state, kis_client):
"""기존 3 endpoint (stock) + 분봉 (KIS REST) 4 fetch 병렬."""
portfolio, sentiment, screener = await asyncio.gather(
client.get_portfolio(),
client.get_news_sentiment(),
client.run_screener_preview(),
return_exceptions=True,
)
# ... (기존 state 갱신)
# 분봉 갱신 — portfolio + screener top-N 종목 대상
tickers = _collect_tickers(state) # portfolio + screener Top-N union
minute_results = await asyncio.gather(*[
kis_client.get_minute_ohlcv(t) for t in tickers
], return_exceptions=True)
for ticker, result in zip(tickers, minute_results):
if isinstance(result, list):
state.minute_bars.setdefault(ticker, deque(maxlen=60)).extend(result)
state.last_updated[f"minute_bars/{ticker}"] = now_iso
# 호가 갱신 (screener Top-N 만, portfolio 는 WebSocket 으로 들어옴)
screener_only = _screener_tickers_excluding_portfolio(state)
asking_results = await asyncio.gather(*[
kis_client.get_asking_price(t) for t in screener_only
], return_exceptions=True)
for ticker, result in zip(screener_only, asking_results):
if isinstance(result, dict):
state.asking_price[ticker] = result
state.last_updated[f"asking_price/{ticker}"] = now_iso
def on_websocket_asking_price(ticker: str, data: dict):
"""KIS WebSocket callback — portfolio 호가 실시간 갱신."""
state.asking_price[ticker] = data
state.last_updated[f"asking_price/{ticker}"] = datetime.now(KST).isoformat()
```
### 6.3 종목 동기화
매 cycle 후 `state.portfolio.holdings` 의 ticker 목록과 `kis_websocket._subscriptions` 비교 → 신규 추가 / 제거 ticker 별로 `subscribe()` / `unsubscribe()` 호출.
---
## 7. Scheduler NXT 시간대
```python
# Market windows (기존)
_PRE_OPEN = time(7, 0)
_OPEN = time(9, 0)
_CLOSE = time(15, 30)
_POST_END = time(20, 0)
# NXT windows (신규)
_NXT_PRE_END = time(23, 30)
_NXT_POST_OPEN = time(4, 30)
# 23:30 - 04:30 (새벽) skip
def _next_interval(now: datetime) -> float:
if not _is_market_day(now):
return _seconds_until_next_market_open(now)
t = now.time()
if _PRE_OPEN <= t < _OPEN:
return 300.0
elif _OPEN <= t < _CLOSE:
return 60.0
elif _CLOSE <= t < _POST_END:
return 300.0
elif _POST_END <= t < _NXT_PRE_END:
return 300.0 # NXT 야간 5분 (신규)
elif _NXT_POST_OPEN <= t < _PRE_OPEN:
return 300.0 # NXT 새벽 5분 (신규)
else:
return _seconds_until_next_market_open(now)
def _is_polling_window(now: datetime) -> bool:
"""이제 야간 NXT 도 포함."""
t = now.time()
return (
(_PRE_OPEN <= t < _NXT_PRE_END)
or (_NXT_POST_OPEN <= t < _PRE_OPEN)
)
```
---
## 8. 테스트 (13 신규)
### 8.1 `test_kis_client.py` (4)
- `test_get_minute_ohlcv_normal_returns_30_bars` — respx 200 → list[30 dict]
- `test_get_minute_ohlcv_429_retry_then_success` — 429 → 1s backoff → 200
- `test_get_minute_ohlcv_uses_v1_token` — v1_token_path fixture → token in header
- `test_get_asking_price_computes_bid_ratio` — bid_total=600/ask_total=400 → bid_ratio=0.6
### 8.2 `test_kis_websocket.py` (4)
- `test_fetch_approval_key_via_oauth_endpoint` — respx POST /oauth2/Approval → approval_key 추출
- `test_subscribe_sends_h0stasp0_message` — fake WebSocket server → 종목 구독 메시지 전송 검증
- `test_parse_asking_price_extracts_bid_ask_totals` — KIS raw string fixture → (ticker, dict)
- `test_reconnect_on_disconnect_with_backoff` — fake server close → exponential retry
### 8.3 `test_scheduler.py` 추가 (3)
- `test_next_interval_nxt_evening_5min` — now=22:00 평일 → 300
- `test_next_interval_nxt_dawn_5min` — now=05:30 평일 → 300
- `test_next_interval_dead_zone_skip` — now=02:00 평일 → 다음 04:30 까지
### 8.4 `test_pull_worker.py` (2)
- `test_minute_polling_cycle_updates_state_minute_bars` — KIS mock → state.minute_bars[ticker] deque 갱신
- `test_websocket_message_updates_state_asking_price` — WebSocket callback → state.asking_price[ticker] dict
**합계**: 4 + 4 + 3 + 2 = **13 신규**. 기존 19 + 13 = **32 total signal_v2 tests**.
---
## 9. 위험 및 완화
| 위험 | 완화 |
|------|------|
| V1 토큰 파일 미존재 (V1 미가동) | startup ERROR log + KIS REST 호출 fail. Phase 6 까지 V1 단독 책임 |
| KIS WebSocket 연결 끊김 | exponential backoff (1s→2s→4s→max 30s) + subscription 재등록 |
| KIS WebSocket 호가 메시지 형식 변경 | `_parse_asking_price` parse fail → WARNING log + skip. KIS API 변경 시 spec 갱신 |
| V1 토큰 갱신 race (V1 갱신 중 V2 read) | mtime 캐시 + 짧은 fail 허용 (다음 호출에서 새 token 사용) |
| approval_key 만료 | 매 reconnect 시 재발급 |
| KIS REST rate limit (초당 2회) | `_throttle()` 0.5s sleep (V1 패턴) |
| 분봉 buffer 메모리 누수 | `deque(maxlen=60)` 자동 cap. ticker ~40 → ~200KB |
| websockets 라이브러리 호환 | `websockets>=12` 명시 |
| WebSocket subscription / portfolio drift | pull_worker 가 매 cycle 후 비교 + 동적 subscribe/unsubscribe |
| NXT 시간대 polling 시 stock API 부하 | 5분 cron × portfolio 11 종목 → 분당 ~2 call 무시 가능 |
| 분봉 데이터 누락 (network 단절) | retry 3회 + cache. 누락 분봉 skip + WARNING |
| KIS API 점검 시간대 | KIS 점검 (보통 새벽 02:00-04:30) 은 dead zone 시간대와 일치 — 영향 없음 |
---
## 10. 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | 0 (signal_v2 재기동만, V1 무영향) |
| 사용자 영향 | 없음 (Phase 3a 데이터 수집만, 신호 발송은 Phase 5) |
| `.env` 갱신 | 사용자 1회 (KIS_APP_KEY/SECRET/ACCOUNT/IS_VIRTUAL + V1_TOKEN_PATH) |
| V1 영향 | 0 (read-only 토큰 공유) |
| stock NAS 부하 | 무관 |
| KIS API 부하 | 매 분봉 cycle 분당 ~20 종목 × 2 call (분봉+호가) = 40 call/min ≈ 초당 0.67 < 2 한도 |
| WebSocket 세션 | 1 세션 / portfolio 보유 종목 (~11) 구독 |
---
## 11. Phase 3a 완료 조건 (DoD)
- [ ] `signal_v2/kis_client.py` 신규 (REST 분봉 + 호가)
- [ ] `signal_v2/kis_websocket.py` 신규 (WebSocket approval_key + 호가)
- [ ] `signal_v2/state.py` `PollState` 확장 (minute_bars + asking_price)
- [ ] `signal_v2/pull_worker.py` 분봉 cycle + WebSocket task 추가
- [ ] `signal_v2/scheduler.py` NXT 시간대 추가
- [ ] `signal_v2/main.py` lifespan 에 KISClient/KISWebSocket 통합
- [ ] `signal_v2/config.py` KIS env + V1_TOKEN_PATH
- [ ] `requirements.txt``websockets>=12`
- [ ] 13 신규 테스트 PASS (총 32)
- [ ] `.env` 갱신 (사용자 1회)
- [ ] 운영 smoke: signal_v2 시작 → KIS WebSocket 연결 → portfolio 호가 1건 수신 → `state.asking_price` 갱신 → 분봉 1회 fetch → `state.minute_bars` 갱신
- [ ] V1 봇 무영향 (토큰 read-only 공유 동작)
- [ ] git push (web-ai repo)
---
## 12. Phase 3b 와의 관계
본 Phase 3a 완료 후 즉시 **Phase 3b (Chronos-2 + 분봉 모멘텀)** brainstorming. 의존성:
```
[Phase 3a spec/plan/실행] → [Phase 3b spec/plan/실행]
1주 1주
```
Phase 3b 의 입력 = 본 spec 의 `state.minute_bars` + `state.asking_price`. Phase 3b 산출 = `state.chronos_predictions` + `state.minute_momentum` (Phase 4 가 사용).
---
## 13. Backlog (본 spec NOT)
- WebSocket 동적 subscribe (현재 매 cycle 일괄, 즉시 갱신 안 됨)
- KIS 분봉 60+ 보관 (장기 추세 분석용)
- 체결 데이터 (`H0STCNT0`) 추가 — 자체 분봉 builder 가능성
- KIS API 응답 시간 모니터링 (Phase 7)
- V2 자체 KIS 토큰 갱신 (Phase 6 deprecation 시)
- WebSocket session 멀티 (41 종목 한도 초과 시)
- approval_key 만료 자동 감지 (현재는 reconnect 시점)

View File

@@ -0,0 +1,437 @@
# Confidence Signal Pipeline V2 — Phase 3b: Chronos-2 + Minute Momentum Design
**작성일**: 2026-05-16
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**:
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
- Phase 2 web-ai pull worker (`2026-05-16-signal-v2-phase2-web-ai-pull-worker.md`)
- Phase 3a KIS data collection (`2026-05-16-signal-v2-phase3a-kis-data-collection.md`)
**브레인스토밍 결정 7개**:
- daily data 소스 = B (KIS REST `kis_client.get_daily_ohlcv`)
- 추론 빈도 = A (종가 후 1회 + 메모리 보관)
- 모델 = A (env `CHRONOS_MODEL` 외부화, 기본 `amazon/chronos-2`, 항상 로드)
- 분봉 모멘텀 = A (5-level 룰 기반)
- State output = B (median + q10 + q90 + conf + as_of)
- 테스트 = A (모델 mock + 순수 함수)
- scope = 통합 9 항목 (Phase 3a 와 같은 1주 단위)
---
## 1. 목표
Phase 3a 의 데이터 위에 추론 레이어 추가. Chronos-2 zero-shot 으로 다음날 가격 분포 예측 + 1분봉 → 5분봉 aggregate 후 5-level 모멘텀 분류. Phase 4 (signal generator) 가 두 출력 + Phase 3a 의 호가/분봉 + Phase 2 의 portfolio/news_sentiment 를 종합해 매수/매도 신호 룰 적용.
**Why**: Phase 0 §3 "web-ai = 시점 분석" 책임의 추론 부분. Chronos-2 의 zero-shot quantile 분포 + 분봉 모멘텀 5-level 이 매수/매도 룰의 핵심 입력.
---
## 2. 범위
### 포함 (9 항목)
-`kis_client.get_daily_ohlcv(ticker, days=60)` — KIS REST TR_ID `FHKST03010100`
-`chronos_predictor.py` 신규 — `ChronosPredictor` (HuggingFace 모델 + batch predict)
-`momentum_classifier.py` 신규 — `aggregate_1min_to_5min` + `classify_minute_momentum`
-`pull_worker.py` 확장 — `_run_post_close_cycle` + `update_minute_momentum_for_all`
-`scheduler.py` 확장 — `_is_post_close_trigger` (16:00 KST)
-`state.py` 확장 — `daily_ohlcv` + `chronos_predictions` + `minute_momentum`
-`main.py` 확장 — lifespan 에 ChronosPredictor 로드
-`config.py` 확장 — `CHRONOS_MODEL` env
-`requirements.txt``transformers>=4.40`, `chronos-forecasting>=1.4`, `torch>=2.0`
### 범위 외 (NOT)
- Signal generator 매수/매도 룰 (Phase 4)
- agent-office `/signal` 호출 (Phase 5)
- 모델 재학습/fine-tune — zero-shot only
- 다중 horizon 예측 — 1-day median 만, 다른 horizon Phase 7
- 외부 데이터 (yfinance/FDR) — KIS REST 만
- Chronos lazy load — 항상 로드 (Phase 7 모니터링 후 검토)
- 분봉 모멘텀 ML 모델 — 룰 기반만 (Phase 7 백테스트 후 ML 검토)
- WebSocket 동적 subscribe (Phase 3a backlog 그대로)
---
## 3. 파일 구조 + 변경 매트릭스
| 파일 | 작업 | 라인 |
|------|------|------|
| `signal_v2/kis_client.py` | `get_daily_ohlcv` 메서드 추가 | +50 |
| `signal_v2/chronos_predictor.py` | 신규 | ~120 |
| `signal_v2/momentum_classifier.py` | 신규 | ~80 |
| `signal_v2/pull_worker.py` | post-close cycle + momentum 갱신 | +50 |
| `signal_v2/scheduler.py` | `_is_post_close_trigger` 헬퍼 | +20 |
| `signal_v2/state.py` | 3 필드 추가 | +5 |
| `signal_v2/main.py` | lifespan ChronosPredictor 로드 | +15 |
| `signal_v2/config.py` | `chronos_model` 필드 | +3 |
| `signal_v2/requirements.txt` | 3 의존성 | +3 |
| `signal_v2/tests/test_kis_client.py` | daily 1 케이스 | +30 |
| `signal_v2/tests/test_chronos_predictor.py` | 신규 4 케이스 | ~120 |
| `signal_v2/tests/test_momentum_classifier.py` | 신규 6 케이스 | ~150 |
| `signal_v2/tests/test_pull_worker.py` | post-close 1 케이스 | +50 |
**합계**: 13 파일 변경 (8 코드 + 4 테스트 + 1 requirements), **12 신규 테스트** (33 → 45 total).
### 외부 의존성 신규
- `transformers>=4.40`
- `chronos-forecasting>=1.4`
- `torch>=2.0` (CUDA 12.x 빌드, V1 venv 공유 시 재설치 불필요)
### 모델 다운로드
`amazon/chronos-2` HuggingFace 모델 첫 로드 시 ~1GB 다운로드 (~수십 초). `~/.cache/huggingface/` 캐시 후 무영향. Task 7 manual smoke 에 시간 예상 명시.
---
## 4. KIS Daily OHLCV (`kis_client.get_daily_ohlcv`)
```python
async def get_daily_ohlcv(self, ticker: str, days: int = 60) -> list[dict]:
"""KRX 일봉 OHLCV (TR_ID FHKST03010100).
Args:
ticker: 6자리 종목코드
days: 최근 N영업일 (KIS 한도 100영업일)
Returns:
[{"datetime": "2026-05-15", "open": int, "high": int, "low": int,
"close": int, "volume": int}, ...]
시간 오름차순 (가장 최근이 마지막).
"""
path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
today = datetime.now(KST).strftime("%Y%m%d")
start_date = (datetime.now(KST) - timedelta(days=days * 2)).strftime("%Y%m%d")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
"FID_INPUT_DATE_1": start_date,
"FID_INPUT_DATE_2": today,
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "1",
}
raw = await self._request_with_retry(
"GET", path, tr_id="FHKST03010100", params=params,
)
output2 = raw.get("output2", [])
bars = []
for row in output2:
try:
date = row["stck_bsop_date"]
bars.append({
"datetime": f"{date[:4]}-{date[4:6]}-{date[6:]}",
"open": int(row["stck_oprc"]),
"high": int(row["stck_hgpr"]),
"low": int(row["stck_lwpr"]),
"close": int(row["stck_clpr"]),
"volume": int(row["acml_vol"]),
})
except (KeyError, ValueError):
continue
bars.reverse() # KIS descending → ascending
return bars[-days:]
```
핵심:
- TR_ID `FHKST03010100` (V1 패턴)
- 수정주가 (`FID_ORG_ADJ_PRC=1`)
- start_date 를 `days*2` 로 → 휴장일 + 주말 고려 → `[-days:]` 트리밍
---
## 5. ChronosPredictor
```python
@dataclass
class ChronosPrediction:
median: float
q10: float
q90: float
conf: float
as_of: str
class ChronosPredictor:
"""HuggingFace Chronos-2 zero-shot forecaster."""
def __init__(self, model_name: str = "amazon/chronos-2", device: str | None = None):
from chronos import ChronosPipeline
import torch
self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
logger.info("Loading Chronos pipeline: %s on %s", model_name, self._device)
self._pipeline = ChronosPipeline.from_pretrained(
model_name,
device_map=self._device,
torch_dtype=torch.float16 if self._device == "cuda" else torch.float32,
)
def predict_batch(
self,
daily_ohlcv_dict: dict[str, list[dict]],
prediction_length: int = 1,
num_samples: int = 100,
) -> dict[str, ChronosPrediction]:
"""종목별 1-day return 분포 예측."""
import torch
import numpy as np
tickers = list(daily_ohlcv_dict.keys())
contexts = [
torch.tensor([bar["close"] for bar in daily_ohlcv_dict[t]], dtype=torch.float32)
for t in tickers
]
forecasts = self._pipeline.predict(
context=contexts, prediction_length=prediction_length, num_samples=num_samples,
)
from datetime import datetime
now_iso = datetime.now(KST).isoformat()
results: dict[str, ChronosPrediction] = {}
for i, ticker in enumerate(tickers):
samples = forecasts[i, :, 0].numpy()
last_close = daily_ohlcv_dict[ticker][-1]["close"]
returns = (samples - last_close) / last_close
median = float(np.quantile(returns, 0.5))
q10 = float(np.quantile(returns, 0.1))
q90 = float(np.quantile(returns, 0.9))
spread = (q90 - q10) / max(abs(median), 0.001)
conf = float(max(0.0, min(1.0, 1.0 - spread / 2.0)))
results[ticker] = ChronosPrediction(median, q10, q90, conf, now_iso)
return results
```
핵심:
- Lazy import (`chronos-forecasting` 무거움)
- GPU 자동 감지 + FP16 (CUDA) / FP32 (CPU)
- Batch predict — 30+ 종목 동시 ~1-2초
- Price → return 변환
- Confidence — 분포 폭 기반 (좁을수록 1)
---
## 6. 분봉 모멘텀 분류기
### 6.1 1분봉 → 5분봉 aggregate
```python
def aggregate_1min_to_5min(minute_bars: list[dict]) -> list[dict]:
"""1분봉 N개 → 5분봉 floor(N/5) 개. 시간 오름차순."""
bars_5min = []
chunks = len(minute_bars) // 5
for i in range(chunks):
chunk = minute_bars[i * 5 : (i + 1) * 5]
bars_5min.append({
"datetime": chunk[0]["datetime"],
"open": chunk[0]["open"],
"high": max(b["high"] for b in chunk),
"low": min(b["low"] for b in chunk),
"close": chunk[-1]["close"],
"volume": sum(b["volume"] for b in chunk),
})
return bars_5min
```
### 6.2 5-level 분류
```python
def classify_minute_momentum(minute_bars: deque) -> str:
"""1분봉 deque → strong_up / weak_up / neutral / weak_down / strong_down."""
minute_list = list(minute_bars)
if len(minute_list) < 5 * 5: # 25 bars minimum
return NEUTRAL
bars_5min = aggregate_1min_to_5min(minute_list)
if len(bars_5min) < 5:
return NEUTRAL
recent = bars_5min[-5:] # 직전 5개 5분봉
up_count = sum(1 for b in recent if b["close"] > b["open"])
# 거래량 multiplier — recent 5 vs 60분 평균
recent_vol_avg = sum(b["volume"] for b in recent) / len(recent)
long_window = bars_5min[-12:] # 60분 = 5분봉 12개
long_vol_avg = sum(b["volume"] for b in long_window) / len(long_window)
vol_mult = recent_vol_avg / long_vol_avg if long_vol_avg > 0 else 1.0
if up_count == 5 and vol_mult >= 1.5:
return STRONG_UP
elif up_count >= 3 and vol_mult >= 1.0:
return WEAK_UP
elif up_count == 0 and vol_mult >= 1.5:
return STRONG_DOWN
elif up_count <= 2 and vol_mult < 1.0:
return WEAK_DOWN
else:
return NEUTRAL
```
---
## 7. PollState 확장 + pull_worker
### 7.1 PollState 추가 필드
```python
@dataclass
class PollState:
# ... 기존 필드 ...
# Phase 3b additions
daily_ohlcv: dict[str, list[dict]] = field(default_factory=dict)
chronos_predictions: dict[str, dict] = field(default_factory=dict)
minute_momentum: dict[str, str] = field(default_factory=dict)
```
### 7.2 pull_worker 확장
```python
async def _run_post_close_cycle(
kis_client: KISClient, chronos: ChronosPredictor, state: PollState,
) -> None:
"""16:00 KST 종가 후 1회: daily fetch + chronos predict."""
tickers = list(set(_portfolio_tickers(state)) | set(_screener_tickers(state)))
daily_results = await asyncio.gather(*[
kis_client.get_daily_ohlcv(t, days=60) for t in tickers
], return_exceptions=True)
daily_dict = {}
for ticker, result in zip(tickers, daily_results):
if isinstance(result, list) and len(result) >= 30:
daily_dict[ticker] = result
state.daily_ohlcv[ticker] = result
if daily_dict:
predictions = chronos.predict_batch(daily_dict)
now_iso = datetime.now(KST).isoformat()
for ticker, pred in predictions.items():
state.chronos_predictions[ticker] = {
"median": pred.median, "q10": pred.q10, "q90": pred.q90,
"conf": pred.conf, "as_of": pred.as_of,
}
state.last_updated[f"chronos/{ticker}"] = pred.as_of
def update_minute_momentum_for_all(state: PollState) -> None:
"""매 분봉 cycle 후 호출 — 모든 종목 모멘텀 갱신."""
from signal_v2.momentum_classifier import classify_minute_momentum
for ticker, bars in state.minute_bars.items():
state.minute_momentum[ticker] = classify_minute_momentum(bars)
```
### 7.3 scheduler `_is_post_close_trigger`
```python
def _is_post_close_trigger(now: datetime) -> bool:
"""16:00 KST ±1분 (post-close cycle 트리거)."""
if not _is_market_day(now):
return False
t = now.time()
return time(16, 0) <= t < time(16, 1)
```
`poll_loop` 안에서 매 cycle:
```python
if _is_post_close_trigger(now) and chronos is not None:
await _run_post_close_cycle(kis_client, chronos, state)
```
---
## 8. 테스트 (12 신규)
### 8.1 `test_kis_client.py` (1)
- `test_get_daily_ohlcv_returns_60_bars` — respx mock 200 → 60 bars 시간 오름차순
### 8.2 `test_chronos_predictor.py` (4, 모델 mock)
- `test_predict_batch_returns_prediction_dict` — mock pipeline → ChronosPrediction
- `test_conf_high_when_distribution_narrow` — narrow → conf ≈ 1
- `test_conf_low_when_distribution_wide` — wide → conf ≈ 0
- `test_return_computed_from_price_relative_to_last_close` — price → return 변환
### 8.3 `test_momentum_classifier.py` (6)
- `test_strong_up_5_consecutive_green_with_high_volume`
- `test_weak_up_3of5_green_normal_volume`
- `test_neutral_mixed`
- `test_weak_down_low_green_low_volume`
- `test_strong_down_5_consecutive_red_high_volume`
- `test_aggregate_1min_to_5min_correctness`
### 8.4 `test_pull_worker.py` (1)
- `test_post_close_cycle_updates_chronos_predictions` — mock kis + mock chronos → state 갱신
**합계**: 1 + 4 + 6 + 1 = **12 신규**. 기존 33 + 12 = **45 total**.
---
## 9. 위험 및 완화
| 위험 | 완화 |
|------|------|
| Chronos-2 첫 로드 ~1GB 다운로드 | startup INFO + Task 7 smoke 시간 예상 명시 |
| GPU OOM (Chronos + V1 Ollama 동거) | FP16 ~400MB + Ollama 4GB = 5GB / 15.5GB 여유. Phase 5 Qwen3 추가 시 13.3GB. Phase 6 V1 deprecation 후 해소 |
| `chronos-forecasting` 호환 (transformers 버전) | 명시 버전. 운영 첫 install 검증 |
| KIS daily fetch + V1 Macro 동시 → rate limit (EGW00201) | post-close 16:00 트리거 vs V1 Trading Bot 의 장 마감 cycle 충돌 위험. 운영 검증 후 16:05 으로 조정 가능 |
| Chronos-2 예측 정확도 불확실 | Phase 7 IC 검증 + 신호 hit-rate 추적. 부족 시 model env 변경 또는 Moirai-2.0 |
| 모멘텀 룰 임계값 (1.5x / 5/5) 보수적 | Phase 7 운영 후 임계값 조정 |
| 1분봉 60개 미만 (장 시작 1시간 내) | NEUTRAL 폴백. 09:00-10:00 신호 발생 안 함 (운영 허용) |
| Chronos 모델 다운로드 네트워크 단절 | startup RuntimeError + 운영자 알림 + 재시작. 캐시 후 무관 |
| daily_ohlcv 메모리 누수 | 종목 ~30 × 60일 ~100B = ~180KB. 무시 |
| Chronos 추론 시 V1 Ollama 와 동시 GPU 사용 | 일 1회 + 짧음 (~2초). V1 Ollama 의 GPU 점유 사이에 끼어들 가능성 → 일시 deferred. Phase 7 모니터링 |
---
## 10. 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | signal_v2 재기동 ~30초 (첫 모델 로드) |
| 사용자 영향 | 없음 (Phase 3b 도 silent, 신호 발송은 Phase 5) |
| `.env` 갱신 | optional 1줄 (`CHRONOS_MODEL=amazon/chronos-2` — 기본값과 동일 시 미설정 OK) |
| V1 영향 | 0 (별도 process). GPU 메모리만 공유 |
| KIS API 부하 | post-close cycle 일 1회 30 종목 daily fetch ~60 calls. 평소 분봉/호가 cycle 그대로 |
| 모델 다운로드 | 첫 시작 ~1GB / 캐시 |
---
## 11. Phase 3b 완료 조건 (DoD)
- [ ] `signal_v2/kis_client.py` `get_daily_ohlcv` 메서드 추가
- [ ] `signal_v2/chronos_predictor.py` 신규
- [ ] `signal_v2/momentum_classifier.py` 신규
- [ ] `signal_v2/pull_worker.py` post-close cycle + momentum 갱신
- [ ] `signal_v2/scheduler.py` `_is_post_close_trigger`
- [ ] `signal_v2/state.py` 3 필드 추가
- [ ] `signal_v2/main.py` lifespan ChronosPredictor 로드
- [ ] `signal_v2/config.py` `CHRONOS_MODEL` env
- [ ] `requirements.txt` 3 의존성 추가
- [ ] 12 신규 테스트 PASS (총 45)
- [ ] 운영 smoke: signal_v2 시작 → Chronos 모델 로드 성공 → 16:00 post-close cycle 1회 실행 → state.chronos_predictions 갱신 확인
- [ ] V1 무영향 (GPU OOM 없음)
- [ ] git push
---
## 12. Phase 4 와의 관계
본 Phase 3b 완료 후 즉시 **Phase 4 (Signal Generator)** brainstorming. 의존성:
```
[Phase 3b spec/plan/실행] → [Phase 4 spec/plan/실행]
1주 1주
```
Phase 4 의 입력 = 본 spec 의 `state.chronos_predictions` + `state.minute_momentum` + Phase 3a 의 `state.asking_price` + Phase 2 의 `state.portfolio` + `state.news_sentiment`. Phase 4 산출 = `state.signals[ticker]` (buy/sell decision + confidence).
---
## 13. Backlog (본 spec NOT)
- Chronos lazy load (Phase 5 Qwen3 동거 시 VRAM 압박 검토)
- 다중 horizon (1-day + 5-day + 20-day)
- ML 기반 분봉 모멘텀 (현재 룰 기반만)
- Chronos model A/B (chronos-bolt-base vs chronos-2 비교 실험)
- KIS daily fetch 의 V1 충돌 회피 — file mutex 또는 V2 별도 app_key
- Chronos quantile 의 임의 quantile 지원 (현재 q10/q50/q90 만)
- daily_ohlcv 영속 저장 (재기동 시 reset 회피)

View File

@@ -0,0 +1,406 @@
# Confidence Signal Pipeline V2 — Phase 4: Signal Generator Design
**작성일**: 2026-05-17
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**:
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
- Phase 2 web-ai pull worker (`2026-05-16-signal-v2-phase2-web-ai-pull-worker.md`)
- Phase 3a KIS data collection (`2026-05-16-signal-v2-phase3a-kis-data-collection.md`)
- Phase 3b Chronos-2 + momentum (`2026-05-16-signal-v2-phase3b-chronos-momentum.md`)
**브레인스토밍 결정 6개**:
- scope = A (신호 생성만, Phase 5 가 발송)
- trigger = A (매 분봉 cycle 후 일괄 평가)
- minute_score = A (Linear 5-level 1.0/0.7/0.5/0.3/0.0)
- 임계값 = A+ (6 env 외부화)
- state.signals schema = A (Phase 0 spec §5.2 그대로)
- 테스트 = A (9 단위 + 1 integration = 10 신규)
---
## 1. 목표
Phase 2/3a/3b 의 모든 산출을 종합해 Phase 0 spec §6.1/§6.2/§6.3 의 매수/매도/dedup 룰 적용. 임계값 통과한 신호를 `state.signals` 에 저장 + `SignalDedup` 으로 24h 중복 차단.
**Why**: Phase 5 (agent-office) 의 입력 계약 완성. signal_v2 가 자체적으로 매수/매도 신호 생성 → Phase 5 가 발송.
---
## 2. 범위
### 포함 (6 항목)
-`signal_generator.py` 신규 — `generate_signals(state, dedup, settings) -> None` (state mutating)
-`config.py` 확장 — 6 env (`STOP_LOSS_PCT`, `TAKE_PROFIT_PCT`, `CHRONOS_SPREAD_THRESHOLD`, `ASKING_BID_RATIO_THRESHOLD`, `CONFIDENCE_THRESHOLD`, `MIN_MOMENTUM_FOR_BUY`)
-`state.py` 확장 — `signals: dict[str, dict]` (Phase 5 input)
-`pull_worker.py` 확장 — 매 cycle 후 `generate_signals` 호출 + signature 확장 (dedup + settings)
-`main.py` 의 lifespan poll_task 호출 시 dedup/settings 전달
- ⑥ 테스트 9 단위 + 1 integration = **10 신규** (45 → 55)
### Phase 4 산출 (Phase 5 input)
`state.signals[ticker]` — Phase 0 spec §5.2 schema:
```python
{
"ticker": str, "name": str,
"action": "buy" | "sell",
"confidence_webai": float,
"current_price": int,
"avg_price": int | None, # sell 시만
"pnl_pct": float | None,
"context": {
"chronos_pred_1d": float (median),
"chronos_pred_conf": float,
"chronos_q10": float, "chronos_q90": float,
"screener_rank": int | None,
"screener_scores": dict | None,
"minute_momentum": str,
"asking_bid_ratio": float,
"news_sentiment": float | None,
"news_reason": str | None,
},
"as_of": str (ISO),
}
```
### 범위 외 (NOT)
- agent-office `/signal` HTTP POST (Phase 5)
- Qwen3 검증 + 이중 텔레그램 (Phase 5)
- 호가 변경 시 즉시 매도 trigger (Phase 7 backlog)
- 자동 매매 (Phase 8 backlog)
- ML 기반 룰 변종 (Phase 7 백테스트 후)
- `kospi_change`, `news_top` 컨텍스트 (Phase 7 backlog)
- 외부 API 호출 — Phase 4 는 state 만 사용 (pure function)
---
## 3. 파일 구조 + 변경 매트릭스
| 파일 | 작업 | 라인 |
|------|------|------|
| `signal_v2/signal_generator.py` | 신규 (generate_signals + 5 helpers) | ~250 |
| `signal_v2/config.py` | Settings 6 field 추가 | +15 |
| `signal_v2/state.py` | PollState `signals` 필드 | +2 |
| `signal_v2/pull_worker.py` | poll_loop signature + 매 cycle 호출 | +10 |
| `signal_v2/main.py` | lifespan poll_task 인자 추가 | +3 |
| `signal_v2/tests/test_signal_generator.py` | 9 단위 신규 | ~350 |
| `signal_v2/tests/test_pull_worker.py` | 1 integration 추가 | +50 |
**합계**: 7 파일 변경, 10 신규 테스트.
### 외부 의존성 신규
**없음**. signal_generator 는 순수 함수, 외부 라이브러리 0.
### 6 신규 env
| env | 기본값 | 의미 |
|-----|--------|------|
| `STOP_LOSS_PCT` | `-0.07` | 손절선 비율. `pnl_pct < 이 값` → 즉시 매도 |
| `TAKE_PROFIT_PCT` | `0.15` | 익절선 비율. `pnl_pct > 이 값` → 검토 알림 |
| `CHRONOS_SPREAD_THRESHOLD` | `0.6` | `(q90-q10)/max(|median|, 0.001) < 이 값` → 매수 통과 |
| `ASKING_BID_RATIO_THRESHOLD` | `0.6` | `bid_ratio >= 이 값` → 매수 통과 |
| `CONFIDENCE_THRESHOLD` | `0.7` | `confidence_webai > 이 값` → 신호 발생 |
| `MIN_MOMENTUM_FOR_BUY` | `strong_up` | 분봉 모멘텀 카테고리 |
---
## 4. 매수 룰 + Confidence
### 4.1 매수 룰 대상
- screener Top-N (`state.screener_preview.items`)
- portfolio 보유 종목 (추가 매수 검토, dedup 으로 중복 차단)
### 4.2 Hard gate (모든 조건 충족)
1. `state.chronos_predictions[ticker].median > 0` (다음날 상승)
2. `(q90 - q10) < settings.chronos_spread_threshold` (**absolute spread** — Phase 3b 실 운영 데이터 기반 변경)
3. `state.minute_momentum[ticker] == settings.min_momentum_for_buy` (기본 strong_up)
4. `state.asking_price[ticker].bid_ratio >= settings.asking_bid_ratio_threshold`
**Spread formula 결정 노트 (2026-05-17 implementer 변경 채택)**:
- Phase 0 spec §6.1 의 한국어 "(90-10 분위수) / 50 분위수 < 0.6" 은 *relative spread* 로 명시되었으나, Phase 3b 실 운영 결과 (Chronos zero-shot prediction 의 median 이 종종 0 가까이) 에서 relative formula 가 거의 모든 신호 거부 → useless.
- **변경**: absolute spread `(q90 - q10) < 0.6` 사용. 0.6 = 60% 변동 예측 — 한국 주식 1-day 변동성 (1-2%) 대비 매우 넓음 (모델 자신 없음 신호).
- 결과: Phase 3b smoke 005930 (median=-0.59%, q10=-8.9%, q90=6.4%, spread=15.3%) → spread 0.153 < 0.6 → hard gate 통과 가능 (다른 조건 충족 시).
- Phase 7 IC 검증 시 임계값 재조정 가능 (env `CHRONOS_SPREAD_THRESHOLD`).
### 4.3 Soft confidence (Phase 0 spec §6.1)
```python
chronos_conf = state.chronos_predictions[ticker]["conf"]
minute_score = MOMENTUM_SCORES[state.minute_momentum[ticker]]
# MOMENTUM_SCORES = {"strong_up": 1.0, "weak_up": 0.7, "neutral": 0.5,
# "weak_down": 0.3, "strong_down": 0.0}
screener_norm = 1 - (rank - 1) / 20 if rank is not None else 0.0
confidence_webai = chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
```
### 4.4 임계값
`confidence_webai > settings.confidence_threshold` (기본 0.7) → 신호 발생.
### 4.5 누락 처리
- portfolio (Top-N 외) 매수: `screener_rank = None``screener_norm = 0` (보수적)
- `chronos_predictions[ticker]` 누락 → silent (Hard gate 위반)
- `asking_price[ticker]` 누락 → silent
---
## 5. 매도 룰 + Dedup
### 5.1 매도 대상
portfolio holdings 만 (`state.portfolio.holdings`).
### 5.2 매도 룰 (Phase 0 spec §6.2)
**(a) 손절선 (즉시 trigger)**:
- `pnl_pct < settings.stop_loss_pct` (기본 -0.07)
- 다른 룰 무관 — 즉시 매도
- `confidence_webai = 1.0`
**(b) 익절선 (검토 알림)**:
- `pnl_pct > settings.take_profit_pct` (기본 0.15)
- "검토 권고" — 강제 매도 X
- `confidence_webai = 0.6`
**(c) 이상 신호**:
- `chronos_predictions[ticker].median < -0.01`
- `minute_momentum[ticker] == "strong_down"`
- `asking_price[ticker].bid_ratio < (1 - settings.asking_bid_ratio_threshold)` (매도세 ≥ 60%)
- confidence_webai = chronos_conf × 0.5 + inverted_minute × 0.3 + 1.0 × 0.2
- 임계값 > `settings.confidence_threshold`
### 5.3 우선순위 (같은 ticker 다중 trigger 시)
1. **손절** (Phase 0 spec §6.2 "즉시") — 다른 룰 우회
2. **이상 신호**
3. **익절선**
상위 trigger 시 하위 skip (한 종목당 한 cycle 1 매도 신호).
### 5.4 Dedup (Phase 0 spec §6.3 + Phase 2 SignalDedup)
```python
if dedup.is_recent(ticker, action, within_hours=24):
continue # silent
# 신호 dict 생성
state.signals[ticker] = {...}
dedup.record(ticker, action, confidence=confidence_webai)
```
Dedup 키 `(ticker, action)` — 같은 종목의 매수/매도 별도 추적, 충돌 없음.
손절선도 dedup 적용 (Phase 0 spec §6.3 "1일 1회 max").
---
## 6. State 통합 + pull_worker
### 6.1 PollState 확장
```python
signals: dict[str, dict] = field(default_factory=dict)
```
매 cycle 마다 **덮어쓰기 X** — 같은 ticker key 재발생 시 갱신, 그 외 유지. dedup 으로 중복 차단되므로 누적 안전. Phase 5 consumer 가 처리 후 본인 측 dedup.
### 6.2 pull_worker 흐름
```python
async def poll_loop(client, state, shutdown,
kis_client=None, chronos=None,
dedup=None, settings=None) -> None:
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
# 1. stock + KIS 분봉/호가 (Phase 2 + 3a)
await _run_polling_cycle(client, state, kis_client=kis_client)
# 2. 분봉 모멘텀 (Phase 3b)
update_minute_momentum_for_all(state)
# 3. 종가 트리거 시 Chronos (Phase 3b)
if _is_post_close_trigger(now) and chronos and kis_client:
await _run_post_close_cycle(kis_client, chronos, state)
# 4. (신규 Phase 4) 신호 생성
if dedup is not None and settings is not None:
try:
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
...
```
### 6.3 main.py lifespan
```python
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
```
---
## 7. signal_generator.py 구조
```python
def generate_signals(state: PollState, dedup: SignalDedup, settings: Settings) -> None:
"""Phase 4 entry point — state mutating."""
_evaluate_buy_signals(state, dedup, settings)
_evaluate_sell_signals(state, dedup, settings)
def _evaluate_buy_signals(state, dedup, settings) -> None:
"""screener Top-N + portfolio 매수 후보 평가."""
candidates = _buy_candidates(state) # screener Top-N + portfolio holdings
for ticker, rank in candidates:
if not _check_buy_hard_gate(state, ticker, settings):
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
continue
state.signals[ticker] = _build_buy_signal(state, ticker, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
def _evaluate_sell_signals(state, dedup, settings) -> None:
"""portfolio 보유 종목 매도 평가 — 손절 > 이상 > 익절 우선순위."""
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding["ticker"]
# 우선순위 1: 손절선
sell = _try_stop_loss(state, holding, settings)
# 우선순위 2: 이상 신호
if sell is None:
sell = _try_anomaly(state, holding, settings)
# 우선순위 3: 익절선
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
```
Helper 함수:
- `_buy_candidates(state) -> list[tuple[ticker, rank | None]]`
- `_check_buy_hard_gate(state, ticker, settings) -> bool`
- `_compute_buy_confidence(state, ticker, rank | None) -> float`
- `_build_buy_signal(state, ticker, rank, confidence) -> dict`
- `_try_stop_loss(state, holding, settings) -> dict | None`
- `_try_anomaly(state, holding, settings) -> dict | None`
- `_try_take_profit(state, holding, settings) -> dict | None`
- `_build_context(state, ticker, rank, ...) -> dict`
---
## 8. 테스트 (10 신규)
### 8.1 `test_signal_generator.py` (9 단위)
| # | 이름 | Setup | 검증 |
|---|------|-------|------|
| 1 | `test_buy_signal_when_all_conditions_pass_and_confidence_high` | chronos +2%, narrow, strong_up, bid_ratio 0.7, rank 1 | state.signals[ticker]["action"]=="buy", confidence > 0.7, dedup.record 호출 |
| 2 | `test_silent_when_chronos_median_negative` | median -1% | state.signals empty |
| 3 | `test_silent_when_distribution_spread_too_wide` | spread 1.0 | empty |
| 4 | `test_silent_when_momentum_not_strong_up` | weak_up | empty |
| 5 | `test_silent_when_bid_ratio_below_threshold` | 0.5 | empty |
| 6 | `test_silent_when_confidence_below_threshold` | rank 20 + median +0.5% (chronos_conf 낮음) → confidence < 0.7 | empty |
| 7 | `test_sell_signal_when_stop_loss_triggered` | pnl_pct -0.08 | "sell" + confidence 1.0 |
| 8 | `test_sell_signal_when_take_profit_triggered` | pnl_pct 0.16 | "sell" + confidence 0.6 |
| 9 | `test_silent_when_dedup_recently_sent` | dedup.is_recent True | empty |
### 8.2 `test_pull_worker.py` (1 integration)
| # | 이름 | 검증 |
|---|------|------|
| 10 | `test_poll_loop_calls_generate_signals_after_cycle` | mock state setup + mock dedup → poll_loop 1 cycle → state.signals 갱신 |
**합계**: 9 + 1 = **10 신규**. 45 → 55 total.
---
## 9. 위험 / 운영 / DoD
### 9.1 위험 매트릭스
| 위험 | 완화 |
|------|------|
| Phase 0 spec 의 confidence 공식이 실 운영과 안 맞음 | 6 env 외부화 → Phase 7 IC 검증 후 .env 조정 |
| Chronos 누락 (장 시작 첫 cycle) | Hard gate 위반 → silent. 종가 cron 후 매수 신호 가능 |
| Dedup DB 손상 | WAL + busy_timeout. 운영자 manual 복구 (signal_v2.db 삭제) |
| 동시 cycle 에서 같은 종목 buy + sell trigger | dedup PK `(ticker, action)` 별도 추적 — 충돌 없음 |
| portfolio 매수 → screener_norm=0 → 신호 발생 어려움 | 보수적. 다른 component 높아야 신호. 의도된 동작 |
| 손절선 trigger 후 24h 추가 손실 → 다음 알림 차단 | 운영적 허용 (Phase 0 spec §6.3 1일 1회 max) |
| 신호 빈도 너무 적음 | 4주 IC 검증 + 임계값 완화 |
| 신호 빈도 너무 많음 (false positive) | dedup + 임계값 강화. Phase 7 |
| 매도 우선순위 잘못 (손절 > 이상 > 익절) | 테스트 케이스로 검증 + 코드 명시 |
| signals dict 누적 (cycle 사이 stale entry) | dedup 으로 중복 차단되므로 안전. Phase 5 consumer 가 처리 후 본인 측 marker |
### 9.2 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | signal_v2 재기동 ~5초 |
| 사용자 영향 | 없음 (Phase 5 까지 발송 없음) |
| `.env` 갱신 | optional 0-6개 (기본값 충분) |
| V1 영향 | 0 |
| KIS API 부하 | 0 (Phase 4 는 외부 호출 없음) |
### 9.3 Phase 4 완료 조건 (DoD)
- [ ] `signal_v2/signal_generator.py` 신규 (generate_signals + 8 helpers)
- [ ] `signal_v2/config.py` Settings 에 6 field 추가 (default 있음)
- [ ] `signal_v2/state.py` PollState `signals` field
- [ ] `signal_v2/pull_worker.py` poll_loop signature + 매 cycle 호출
- [ ] `signal_v2/main.py` lifespan 의 poll_task 인자 (dedup, settings) 추가
- [ ] 9 단위 + 1 integration 테스트 PASS (총 55)
- [ ] 운영 smoke: signal_v2 시작 → 1 cycle 후 state.signals 빈 dict (운영 시간대 신호 발생 가능 종목 없을 시 정상) 또는 ≥ 1 신호 생성
- [ ] V1 무영향
- [ ] git push
---
## 10. Phase 5 와의 관계
본 Phase 4 완료 후 즉시 **Phase 5 (agent-office /signal + Qwen3 + 이중 텔레그램)** brainstorming. 의존성:
```
[Phase 4 spec/plan/실행] → [Phase 5 spec/plan/실행]
3-5일 2주
```
Phase 5 의 입력 = 본 spec 의 `state.signals[ticker]` (state polling 또는 HTTP push). Phase 5 작업:
- agent-office `/signal` endpoint 신설 (Phase 0 spec §5.2 schema 수신)
- web-ai → agent-office HTTP client 추가 (signal_v2 측)
- web-ai 의 Ollama Qwen3 14B Q4 설치 + agent-office 의 LLM 검증 호출
- 이중 텔레그램 (본인 풀 / 아내 lite)
---
## 11. Backlog (본 spec NOT)
- 호가 변경 시 즉시 매도 trigger — Phase 7 운영 후 검토
- `kospi_change` 컨텍스트 (KIS 지수 fetch) — Phase 7
- `news_top` 컨텍스트 (news_sentiment.reason 다중 추출) — Phase 7
- 매수/매도 ML 룰 — Phase 7 백테스트 후
- portfolio 매수의 screener_norm fallback (다른 default 값) — IC 검증 후
- 신호 hit-rate 대시보드 — Phase 7
- 분할 매수/매도 전략 — Phase 7 이후
- 자동 매매 (실주문) — Phase 8
- 손절선 dedup 면제 (즉시성 위해) — Phase 7 운영 검증 후

View File

@@ -544,6 +544,27 @@ export function putInstaPrompt(name, template, description = '') {
return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description }); return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description });
} }
// ── insta-lab trends ──
export function getInstaTrends({ source, category, days = 1 } = {}) {
const q = new URLSearchParams();
if (source) q.set('source', source);
if (category) q.set('category', category);
q.set('days', String(days));
return apiGet(`/api/insta/trends?${q.toString()}`);
}
export function instaCollectTrends(categories) {
return apiPost('/api/insta/trends/collect', categories ? { categories } : {});
}
export function getInstaPreferences() {
return apiGet('/api/insta/preferences');
}
export function putInstaPreferences(categories) {
return apiPut('/api/insta/preferences', { categories });
}
// ── Agent Office ────────────────────────────────── // ── Agent Office ──────────────────────────────────
export const getAgents = () => apiGet('/api/agent-office/agents'); export const getAgents = () => apiGet('/api/agent-office/agents');
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`); export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);

View File

@@ -100,3 +100,106 @@
/* 빈 상태 */ /* 빈 상태 */
.ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; } .ic-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.3); font-size: 0.85rem; }
/* ── tabs ── */
.ic-tabbar { display: flex; gap: 8px; border-bottom: 1px solid #e2e8f0; margin-bottom: 16px; }
.ic-tab {
background: transparent; border: 0; padding: 10px 16px;
cursor: pointer; font-size: 14px; font-weight: 600;
color: #64748b; border-bottom: 2px solid transparent;
}
.ic-tab.is-active { color: #ec4899; border-bottom-color: #ec4899; }
/* ── trends grid ── */
.ic-trends-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
@media (min-width: 1024px) { .ic-trends-grid { grid-template-columns: 320px 1fr; } }
/* ── ic-panel base ── */
.ic-panel { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
.ic-panel__title { font-size: 0.95rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0 0 8px; }
.ic-panel__hint { font-size: 0.78rem; color: rgba(255,255,255,.4); margin: 0 0 10px; }
/* ── focus panel ── */
.ic-panel--focus .ic-focus__list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; }
.ic-focus__row { display: grid; grid-template-columns: 110px 1fr 50px; align-items: center; gap: 8px; }
.ic-focus__label { font-weight: 600; color: #475569; text-transform: capitalize; }
.ic-focus__slider { width: 100%; accent-color: #ec4899; }
.ic-focus__num { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
.ic-focus__add { display: flex; gap: 8px; margin-top: 12px; }
.ic-focus__add input { flex: 1; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: rgba(255,255,255,.06); color: var(--text-primary, #e4e4e7); }
.ic-focus__add button { padding: 8px 14px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.85rem; }
.ic-focus__save {
width: 100%; padding: 10px; margin-top: 12px;
background: #ec4899; color: #fff; border: 0; border-radius: 6px; cursor: pointer;
font-weight: 700;
}
.ic-focus__save:disabled { opacity: .5; cursor: not-allowed; }
.ic-focus__hint { margin-top: 12px; padding: 10px; background: rgba(245,158,11,.1); border-left: 3px solid #f59e0b; font-size: 12px; color: rgba(255,255,255,.6); line-height: 1.5; }
.ic-focus__hint code { background: rgba(0,0,0,.2); padding: 1px 4px; border-radius: 3px; }
/* ── trends panel ── */
.ic-trends__cols { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 768px) { .ic-trends__cols { grid-template-columns: 1fr 1fr; } }
.ic-trends__col h4 { margin: 0 0 8px; font-size: 14px; color: rgba(255,255,255,.5); }
.ic-trend__group { margin-bottom: 14px; }
.ic-trend__group-head { font-size: 12px; font-weight: 700; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px; }
.ic-trend__row {
display: grid; grid-template-columns: 10px 1fr 50px 36px;
align-items: center; gap: 8px; padding: 6px 4px;
border-bottom: 1px solid rgba(255,255,255,.06);
}
.ic-trend__cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.ic-trend__kw { font-weight: 500; color: var(--text-primary, #e4e4e7); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ic-trend__score { text-align: right; color: rgba(255,255,255,.4); font-variant-numeric: tabular-nums; font-size: 12px; }
.ic-trend__make { background: #ec4899; border: 0; color: #fff; border-radius: 4px; cursor: pointer; padding: 4px; font-size: 14px; }
.ic-trend__make:hover { background: #db2777; }
.ic-panel__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ic-panel__actions { display: flex; gap: 8px; align-items: center; }
.ic-panel__actions button { padding: 6px 12px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: rgba(255,255,255,.7); cursor: pointer; font-size: 0.8rem; }
.ic-panel__actions button:disabled { opacity: .5; cursor: not-allowed; }
/* ── impact panel ── */
.ic-impact__row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.ic-impact__chip {
display: flex; align-items: baseline; gap: 6px;
padding: 6px 12px; background: rgba(255,255,255,.06); border-radius: 999px;
}
.ic-impact__cat { font-weight: 600; text-transform: capitalize; color: rgba(255,255,255,.6); font-size: 0.82rem; }
.ic-impact__count { color: #ec4899; font-weight: 700; font-size: 0.82rem; }
/* ── slate creation progress banner (양 탭 공통) ── */
.ic-slate-progress {
margin: 8px 0 16px;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
line-height: 1.5;
}
.ic-slate-progress--starting,
.ic-slate-progress--processing {
background: rgba(245, 158, 11, 0.12);
color: #fbbf24;
border-left: 4px solid #f59e0b;
}
.ic-slate-progress--succeeded {
background: rgba(16, 185, 129, 0.12);
color: #34d399;
border-left: 4px solid #10b981;
cursor: pointer;
}
.ic-slate-progress--failed {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border-left: 4px solid #ef4444;
cursor: pointer;
}
.ic-slate-progress__hint {
opacity: 0.7;
font-size: 0.78rem;
margin-left: 6px;
}

View File

@@ -14,6 +14,10 @@ import {
getInstaTask, getInstaTask,
getInstaPrompt, getInstaPrompt,
putInstaPrompt, putInstaPrompt,
getInstaTrends,
instaCollectTrends,
getInstaPreferences,
putInstaPreferences,
} from '../../api'; } from '../../api';
import './InstaCards.css'; import './InstaCards.css';
@@ -92,10 +96,228 @@ function TaskStatusBox({ task }) {
); );
} }
/* ══════════════════════ Trends 탭 패널 1: AccountFocusPanel ══════════════ */
function AccountFocusPanel() {
const [prefs, setPrefs] = useState([]);
const [draft, setDraft] = useState({});
const [saving, setSaving] = useState(false);
const [newCat, setNewCat] = useState('');
const load = useCallback(async () => {
const data = await getInstaPreferences();
setPrefs(data.categories || []);
const m = {};
(data.categories || []).forEach(p => { m[p.category] = Math.round(p.weight * 100); });
setDraft(m);
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
setSaving(true);
try {
const payload = {};
Object.entries(draft).forEach(([k, v]) => { payload[k] = (Number(v) || 0) / 100; });
await putInstaPreferences(payload);
await load();
} finally { setSaving(false); }
};
const addCat = () => {
const name = newCat.trim().toLowerCase();
if (!name || draft[name] !== undefined) return;
setDraft({ ...draft, [name]: 0 });
setNewCat('');
};
return (
<section className="ic-panel ic-panel--focus">
<h3 className="ic-panel__title">🎯 계정의 주제 (카테고리 가중치)</h3>
<p className="ic-panel__hint">슬라이더는 카테고리에 자동 추출 키워드 비율을 결정합니다. 합계는 자동 정규화됩니다.</p>
<div className="ic-focus__list">
{Object.entries(draft).map(([cat, val]) => (
<div key={cat} className="ic-focus__row">
<label className="ic-focus__label">{cat}</label>
<input
type="range" min="0" max="100" value={val}
onChange={e => setDraft({ ...draft, [cat]: Number(e.target.value) })}
className="ic-focus__slider"
/>
<span className="ic-focus__num">{val}%</span>
</div>
))}
</div>
<div className="ic-focus__add">
<input
type="text" placeholder="신규 카테고리 (영문 소문자)"
value={newCat} onChange={e => setNewCat(e.target.value)}
/>
<button onClick={addCat}>+ 추가</button>
</div>
<button className="ic-focus__save" onClick={save} disabled={saving}>
{saving ? '저장 중...' : '저장'}
</button>
<div className="ic-focus__hint">
💡 신규 카테고리를 추가했다면 Cards 탭의 Prompt Templates Editor에서
<code>category_seeds</code> 시드 키워드도 함께 정의해야 자동 추출에 반영됩니다.
</div>
</section>
);
}
/* ══════════════════════ Trends 탭 패널 2: ExternalTrendsPanel ══════════ */
const CATEGORY_COLORS = {
economy: '#0F62FE', psychology: '#A66CFF',
celebrity: '#FF5C8A', uncategorized: '#6B7280',
};
function ExternalTrendsPanel({ onCreateSlate }) {
const [naver, setNaver] = useState([]);
const [google, setGoogle] = useState([]);
const [lastFetched, setLastFetched] = useState(null);
const [collecting, setCollecting] = useState(false);
const [task, setTask] = useState(null);
const load = useCallback(async () => {
const [n, g] = await Promise.all([
getInstaTrends({ source: 'naver_popular', days: 2 }),
getInstaTrends({ source: 'youtube_trending', days: 2 }),
]);
setNaver(n.items || []);
setGoogle(g.items || []);
const all = [...(n.items || []), ...(g.items || [])];
if (all.length) {
const latest = all.map(t => t.suggested_at).sort().reverse()[0];
setLastFetched(latest);
}
}, []);
useEffect(() => { load(); }, [load]);
const trigger = async () => {
setCollecting(true);
try {
const { task_id } = await instaCollectTrends();
let st = null;
for (let i = 0; i < 60; i++) {
st = await getInstaTask(task_id);
setTask(st);
if (st.status === 'succeeded' || st.status === 'failed') break;
await new Promise(r => setTimeout(r, 3000));
}
await load();
} finally { setCollecting(false); }
};
const groupByCat = (items) => {
const g = {};
items.forEach(it => { (g[it.category] = g[it.category] || []).push(it); });
return g;
};
const renderRow = (t) => (
<div className="ic-trend__row" key={`${t.source}-${t.id}`}>
<span className="ic-trend__cat-dot" style={{ background: CATEGORY_COLORS[t.category] || '#6B7280' }} />
<span className="ic-trend__kw">{t.keyword}</span>
<span className="ic-trend__score">{(t.score || 0).toFixed(2)}</span>
<button
className="ic-trend__make"
onClick={() => onCreateSlate?.({ keyword: t.keyword, category: t.category })}
>🎴</button>
</div>
);
const naverGrouped = groupByCat(naver);
return (
<section className="ic-panel ic-panel--trends">
<div className="ic-panel__head">
<h3 className="ic-panel__title">📈 외부 트렌드</h3>
<div className="ic-panel__actions">
<span className="ic-panel__hint">
{lastFetched ? `마지막 수집: ${fmtDate(lastFetched)}` : '아직 수집 없음'}
</span>
<button onClick={trigger} disabled={collecting}>
{collecting ? '수집 중...' : '🔄 수동 수집'}
</button>
</div>
</div>
{task && <TaskStatusBox task={task} />}
<div className="ic-trends__cols">
<div className="ic-trends__col">
<h4>🔥 NAVER 인기</h4>
{Object.keys(naverGrouped).length === 0 && <p className="ic-empty">없음</p>}
{Object.entries(naverGrouped).map(([cat, items]) => (
<div key={cat} className="ic-trend__group">
<div className="ic-trend__group-head" style={{ color: CATEGORY_COLORS[cat] || '#6B7280' }}>{cat}</div>
{items.map(renderRow)}
</div>
))}
</div>
<div className="ic-trends__col">
<h4>📺 YouTube 인기</h4>
{google.length === 0 && <p className="ic-empty">없음</p>}
{google.map(renderRow)}
</div>
</div>
</section>
);
}
/* ══════════════════════ Trends 탭 패널 3: PreferenceImpactPanel ══════ */
function PreferenceImpactPanel() {
const [prefs, setPrefs] = useState([]);
const TOTAL = 15;
useEffect(() => {
(async () => {
const data = await getInstaPreferences();
setPrefs(data.categories || []);
})();
}, []);
const totalWeight = prefs.reduce((s, p) => s + (p.weight || 0), 0) || 1;
const breakdown = prefs.map(p => ({
category: p.category,
count: Math.round(TOTAL * (p.weight || 0) / totalWeight),
}));
return (
<section className="ic-panel ic-panel--impact">
<h3 className="ic-panel__title">📊 다음 자동 추출 미리보기</h3>
<div className="ic-impact__row">
{breakdown.map(b => (
<div key={b.category} className="ic-impact__chip">
<span className="ic-impact__cat">{b.category}</span>
<span className="ic-impact__count">{b.count}</span>
</div>
))}
</div>
</section>
);
}
/* ══════════════════════════════════════════════════════════════════════════ */ /* ══════════════════════════════════════════════════════════════════════════ */
export default function InstaCards() { export default function InstaCards() {
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const [selectedSlateId, setSelectedSlateId] = useState(null); const [selectedSlateId, setSelectedSlateId] = useState(null);
/* ── 카드 생성 progress (Trends 탭 클릭 + Cards 탭 양쪽 모두 사용) ──
* null = idle
* { keyword, status: 'starting'|'processing'|'succeeded'|'failed', message?, slate_id?, error? } */
const [slateProgress, setSlateProgress] = useState(null);
/* ── 탭 상태 (URL 동기화) ── */
const [activeTab, setActiveTab] = useState(() => {
const u = new URL(window.location.href);
return u.searchParams.get('tab') === 'trends' ? 'trends' : 'cards';
});
const switchTab = (next) => {
setActiveTab(next);
const u = new URL(window.location.href);
if (next === 'cards') u.searchParams.delete('tab');
else u.searchParams.set('tab', next);
window.history.replaceState({}, '', u.toString());
};
const loadStatus = useCallback(() => { const loadStatus = useCallback(() => {
return getInstaStatus().then(setStatus).catch(() => {}); return getInstaStatus().then(setStatus).catch(() => {});
@@ -105,9 +327,95 @@ export default function InstaCards() {
loadStatus(); loadStatus();
}, [loadStatus]); }, [loadStatus]);
/* ── handleCreateSlate: 키워드 → 카피 + 이미지 추론 → 자동 미리보기 ──
* 1. createInstaSlate 호출 → task_id
* 2. getInstaTask로 폴링 (3초 간격, 최대 8분 = Claude 카피 + Playwright 10장 렌더)
* 3. 완료 시 Cards 탭으로 자동 전환 + 슬레이트 선택 → SlateDetail이 카피·이미지 미리보기 */
const handleCreateSlate = useCallback(async ({ keyword, category, keyword_id } = {}) => {
if (!keyword || !category) {
alert('keyword + category 필수');
return;
}
setSlateProgress({ keyword, status: 'starting', message: '카드 생성 시작...' });
// 상단 progress 배너가 보이도록 스크롤 (Trends/Cards 어느 탭의 어느 위치에서 눌렀든)
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
try {
const { task_id } = await createInstaSlate({ keyword, category, keyword_id });
let st = null;
// 최대 8분 (3초 × 160) 폴링
for (let i = 0; i < 160; i++) {
st = await getInstaTask(task_id);
setSlateProgress({
keyword,
status: st.status,
message: st.message || `진행률 ${st.progress}%`,
});
if (st.status === 'succeeded' || st.status === 'failed') break;
await new Promise(r => setTimeout(r, 3000));
}
if (st && st.status === 'succeeded' && st.result_id) {
// 완료 — Cards 탭으로 자동 이동해서 SlateDetail 보여주기
setSlateProgress({
keyword, status: 'succeeded', message: '완료', slate_id: st.result_id,
});
setSelectedSlateId(st.result_id);
switchTab('cards');
// 3초 후 progress 배너 자동 dismiss
setTimeout(() => setSlateProgress(null), 3000);
} else {
setSlateProgress({
keyword, status: 'failed',
error: (st && st.error) || '시간 초과 또는 알 수 없는 오류',
});
}
} catch (e) {
setSlateProgress({ keyword, status: 'failed', error: e.message });
}
}, []);
return ( return (
<PullToRefresh onRefresh={loadStatus}>
<div className="ic"> <div className="ic">
{/* ── 탭 바 ── */}
<div className="ic-tabbar">
<button
className={`ic-tab ${activeTab === 'cards' ? 'is-active' : ''}`}
onClick={() => switchTab('cards')}
>🎴 Cards</button>
<button
className={`ic-tab ${activeTab === 'trends' ? 'is-active' : ''}`}
onClick={() => switchTab('trends')}
>📈 Trends</button>
</div>
{/* ── 카드 생성 progress 배너 (양 탭 공통) ── */}
{slateProgress && (
<div
className={`ic-slate-progress ic-slate-progress--${slateProgress.status}`}
onClick={() => slateProgress.status !== 'processing' && slateProgress.status !== 'starting' && setSlateProgress(null)}
>
{slateProgress.status === 'starting' && '⏳'}
{slateProgress.status === 'processing' && '🎨'}
{slateProgress.status === 'succeeded' && '✅'}
{slateProgress.status === 'failed' && '⚠️'}
{' '}
<strong>{slateProgress.keyword}</strong>
{' — '}
{slateProgress.status === 'failed'
? `실패: ${slateProgress.error}`
: slateProgress.message}
{(slateProgress.status === 'starting' || slateProgress.status === 'processing') && (
<span className="ic-slate-progress__hint"> · Claude로 10페이지 카피 추론 + Playwright로 카드 10 생성 (3~7)</span>
)}
</div>
)}
{/* ── Cards 탭 (기존 5-패널) ── */}
{activeTab === 'cards' && (
<>
<PullToRefresh onRefresh={loadStatus}>
<div>
{/* 헤더 + 상태 배너 */} {/* 헤더 + 상태 배너 */}
<header className="ic-header"> <header className="ic-header">
<h1>Insta Cards</h1> <h1>Insta Cards</h1>
@@ -128,7 +436,7 @@ export default function InstaCards() {
<div> <div>
<TriggerPanel /> <TriggerPanel />
<div style={{ height: 16 }} /> <div style={{ height: 16 }} />
<KeywordsPanel onCreateSlate={() => setSelectedSlateId(null)} /> <KeywordsPanel onCreateSlate={handleCreateSlate} />
</div> </div>
{/* 오른쪽: 슬레이트 목록 + 상세 */} {/* 오른쪽: 슬레이트 목록 + 상세 */}
@@ -143,6 +451,18 @@ export default function InstaCards() {
<PromptTemplatesEditor /> <PromptTemplatesEditor />
</div> </div>
</PullToRefresh> </PullToRefresh>
</>
)}
{/* ── Trends 탭 (3 new panels) ── */}
{activeTab === 'trends' && (
<div className="ic-trends-grid">
<AccountFocusPanel />
<ExternalTrendsPanel onCreateSlate={handleCreateSlate} />
<PreferenceImpactPanel />
</div>
)}
</div>
); );
} }
@@ -206,10 +526,6 @@ function KeywordsPanel({ onCreateSlate }) {
const [category, setCategory] = useState('전체'); const [category, setCategory] = useState('전체');
const [keywords, setKeywords] = useState([]); const [keywords, setKeywords] = useState([]);
const [creating, setCreating] = useState(null); // keyword_id being created const [creating, setCreating] = useState(null); // keyword_id being created
const slatePoll = usePollTask((t) => {
if (t.status === 'succeeded') onCreateSlate?.();
setCreating(null);
});
const load = useCallback(() => { const load = useCallback(() => {
const cat = category === '전체' ? undefined : category; const cat = category === '전체' ? undefined : category;
@@ -218,18 +534,17 @@ function KeywordsPanel({ onCreateSlate }) {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
// 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화
async function handleCreate(kw) { async function handleCreate(kw) {
if (creating) return; if (creating) return;
setCreating(kw.id); setCreating(kw.id);
try { try {
const res = await createInstaSlate({ await onCreateSlate?.({
keyword: kw.keyword, keyword: kw.keyword,
category: kw.category, category: kw.category,
keyword_id: kw.id, keyword_id: kw.id,
}); });
slatePoll.start(res.task_id); } finally {
} catch (e) {
alert('카드 생성 실패: ' + e.message);
setCreating(null); setCreating(null);
} }
} }
@@ -251,7 +566,7 @@ function KeywordsPanel({ onCreateSlate }) {
))} ))}
</div> </div>
{slatePoll.task && <TaskStatusBox task={slatePoll.task} />} {/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
{keywords.length === 0 ? ( {keywords.length === 0 ? (
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div> <div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
@@ -438,11 +753,66 @@ function SlateDetail({ slate, onDelete, onRender }) {
</div> </div>
)} )}
{/* 커버 카피 / 바디 카피 */} {/* 커버 카피 (1/10) */}
{slate.cover_copy && ( {slate.cover_copy && typeof slate.cover_copy === 'object' && (
<div className="ic-caption-box"> <div className="ic-caption-box">
<div className="ic-caption-box__label">커버 카피</div> <div className="ic-caption-box__label">🎯 커버 (1/10)</div>
<div className="ic-caption-text">{slate.cover_copy}</div> <div className="ic-caption-text">
<strong>{slate.cover_copy.headline}</strong>
{slate.cover_copy.body && (
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
{slate.cover_copy.body}
</div>
)}
{slate.cover_copy.accent_color && (
<div style={{ marginTop: 6, fontSize: '0.72rem', opacity: 0.5 }}>
accent: <code>{slate.cover_copy.accent_color}</code>
</div>
)}
</div>
</div>
)}
{/* 본문 카피 8장 (2~9/10) */}
{Array.isArray(slate.body_copies) && slate.body_copies.length > 0 && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">📝 본문 8 (2~9/10)</div>
{slate.body_copies.map((b, i) => (
<div
key={i}
style={{
borderTop: i > 0 ? '1px solid rgba(255,255,255,0.06)' : 'none',
padding: '10px 0',
}}
>
<strong>{i + 2}. {b?.headline || ''}</strong>
{b?.body && (
<div style={{ marginTop: 4, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
{b.body}
</div>
)}
</div>
))}
</div>
)}
{/* CTA 카피 (10/10) */}
{slate.cta_copy && typeof slate.cta_copy === 'object' && (
<div className="ic-caption-box">
<div className="ic-caption-box__label">📣 마무리 (10/10)</div>
<div className="ic-caption-text">
<strong>{slate.cta_copy.headline}</strong>
{slate.cta_copy.body && (
<div style={{ marginTop: 6, opacity: 0.85, whiteSpace: 'pre-wrap' }}>
{slate.cta_copy.body}
</div>
)}
{slate.cta_copy.cta && (
<div style={{ marginTop: 8, color: '#ec4899', fontWeight: 700 }}>
CTA: {slate.cta_copy.cta}
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>