Compare commits
7 Commits
a8e411ec22
...
b0eda14982
| Author | SHA1 | Date | |
|---|---|---|---|
| b0eda14982 | |||
| 1f55d24ce6 | |||
| 6eb4ab1204 | |||
| 78b77e2691 | |||
| 1813db761f | |||
| 01d9b2f872 | |||
| b9dabd07e0 |
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
@@ -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 직접 호출 결정
|
||||||
@@ -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 시점)
|
||||||
@@ -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 회피)
|
||||||
21
src/api.js
21
src/api.js
@@ -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}`);
|
||||||
|
|||||||
@@ -100,3 +100,70 @@
|
|||||||
|
|
||||||
/* 빈 상태 */
|
/* 빈 상태 */
|
||||||
.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; }
|
||||||
|
|||||||
@@ -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,11 +96,225 @@ 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: 'google_trends', 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>🌐 Google Trends</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);
|
||||||
|
|
||||||
|
/* ── 탭 상태 (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 +323,35 @@ export default function InstaCards() {
|
|||||||
loadStatus();
|
loadStatus();
|
||||||
}, [loadStatus]);
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
/* ── handleCreateSlate: 키워드 → 슬레이트 생성 (Trends 탭에서도 공유) ── */
|
||||||
|
const handleCreateSlate = useCallback(async ({ keyword, category, keyword_id } = {}) => {
|
||||||
|
try {
|
||||||
|
await createInstaSlate({ keyword, category, keyword_id });
|
||||||
|
setSelectedSlateId(null);
|
||||||
|
} catch (e) {
|
||||||
|
alert('카드 생성 실패: ' + 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>
|
||||||
|
|
||||||
|
{/* ── Cards 탭 (기존 5-패널) ── */}
|
||||||
|
{activeTab === 'cards' && (
|
||||||
|
<>
|
||||||
|
<PullToRefresh onRefresh={loadStatus}>
|
||||||
|
<div>
|
||||||
{/* 헤더 + 상태 배너 */}
|
{/* 헤더 + 상태 배너 */}
|
||||||
<header className="ic-header">
|
<header className="ic-header">
|
||||||
<h1>Insta Cards</h1>
|
<h1>Insta Cards</h1>
|
||||||
@@ -143,6 +387,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user