Compare commits
18 Commits
3bf7ce446f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 534ded59e8 | |||
| f4b78da176 | |||
| aeeab6704f | |||
| 6222b56716 | |||
| 9e9eed2162 | |||
| 06affd9614 | |||
| b0eda14982 | |||
| 1f55d24ce6 | |||
| 6eb4ab1204 | |||
| 78b77e2691 | |||
| 1813db761f | |||
| 01d9b2f872 | |||
| b9dabd07e0 | |||
| a8e411ec22 | |||
| f261a80d52 | |||
| 42e9c8df27 | |||
| c84c6b5bac | |||
| 094366a162 |
10
CLAUDE.md
10
CLAUDE.md
@@ -26,7 +26,7 @@
|
|||||||
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||||
| `/todo` | `Todo` | 태스크 보드 |
|
| `/todo` | `Todo` | 태스크 보드 |
|
||||||
| `/blog-lab` | `BlogMarketing` | 블로그 마케팅 수익화 대시보드 |
|
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||||
|
|
||||||
@@ -113,9 +113,11 @@ proxy: {
|
|||||||
| 여행 | POST | `/api/travel/sync` |
|
| 여행 | POST | `/api/travel/sync` |
|
||||||
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
|
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
|
||||||
| 여행 | PUT | `/api/travel/regions/:id` |
|
| 여행 | PUT | `/api/travel/regions/:id` |
|
||||||
| 블로그마케팅 | POST | `/api/blog-marketing/research`, `/api/blog-marketing/generate` |
|
| 인스타 | GET | `/api/insta/status`, `/api/insta/news/articles`, `/api/insta/keywords`, `/api/insta/slates`, `/api/insta/slates/:id` |
|
||||||
| 블로그마케팅 | GET | `/api/blog-marketing/posts`, `/api/blog-marketing/dashboard` |
|
| 인스타 | POST | `/api/insta/news/collect`, `/api/insta/keywords/extract`, `/api/insta/slates`, `/api/insta/slates/:id/render` |
|
||||||
| 블로그마케팅 | POST | `/api/blog-marketing/market/:id`, `/api/blog-marketing/review/:id` |
|
| 인스타 | DELETE | `/api/insta/slates/:id` |
|
||||||
|
| 인스타 | GET/PUT | `/api/insta/templates/prompts/:name` |
|
||||||
|
| 인스타 | GET | `/api/insta/tasks/:task_id` |
|
||||||
| 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
|
| 에이전트 | GET | `/api/agent-office/agents`, `/api/agent-office/states` |
|
||||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||||
|
|||||||
858
docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
858
docs/superpowers/plans/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
# Signal V2 Phase 1 — stock WebAI API 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:** Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 polling 할 stock 의 인증된 입력 계약 3종 (`/api/webai/portfolio`, `/api/webai/news-sentiment`, X-WebAI-Key 인증 인프라) 을 신설.
|
||||||
|
|
||||||
|
**Architecture:** stock FastAPI app 에 `/api/webai/*` prefix 의 신규 endpoint 2개 추가. 인증은 `verify_webai_key` FastAPI dependency (단일 정적 키 `WEBAI_API_KEY` 환경변수 비교). nginx 에 `/api/webai/` location + `limit_req` rate limit. 기존 `/api/portfolio` 무변경, web-ui 영향 0.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI / pytest + TestClient / sqlite3 / nginx (limit_req_zone)
|
||||||
|
|
||||||
|
**Spec:** `web-ui/docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 책임 |
|
||||||
|
|------|------|
|
||||||
|
| `web-backend/stock/app/auth.py` (신규) | `verify_webai_key` FastAPI dependency — X-WebAI-Key 헤더 검증, env 미설정 503, 인증 실패 401 + logger.warning |
|
||||||
|
| `web-backend/stock/app/main.py` (수정) | 2 신규 endpoint: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment`. 기존 `get_portfolio()` 응답 위에 pnl_pct augment mapper |
|
||||||
|
| `web-backend/stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 |
|
||||||
|
| `web-backend/stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 + 공통 4 = 12 통합 케이스 |
|
||||||
|
| `web-backend/nginx/default.conf` (수정) | `limit_req_zone webai` + `/api/webai/` location |
|
||||||
|
| `web-backend/docker-compose.yml` (수정) | stock 컨테이너 env 에 `WEBAI_API_KEY` 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1: auth.py + verify_webai_key 단위 테스트 (TDD)
|
||||||
|
Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스 (TDD)
|
||||||
|
Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스 (TDD)
|
||||||
|
Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date)
|
||||||
|
Task 5: docker-compose env 추가
|
||||||
|
Task 6: nginx config (rate limit + location + 헤더 forward)
|
||||||
|
Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
각 Task 는 TDD 패턴 (test 먼저 → fail 확인 → 구현 → pass → commit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: auth.py + verify_webai_key 단위 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web-backend/stock/app/auth.py`
|
||||||
|
- Create: `web-backend/stock/app/test_webai_auth.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `web-backend/stock/app/test_webai_auth.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request() -> Request:
|
||||||
|
"""Minimal Request stub for verify_webai_key (only request.url.path + request.client used)."""
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"path": "/api/webai/test",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("1.2.3.4", 12345),
|
||||||
|
}
|
||||||
|
return Request(scope=scope)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_with_valid_key_passes(monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
verify_webai_key(_make_request(), x_webai_key="secret-key-abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_without_key_raises_401(monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_webai_key(_make_request(), x_webai_key=None)
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
assert "X-WebAI-Key" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_with_wrong_key_raises_401(monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "secret-key-abc")
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_webai_key(_make_request(), x_webai_key="wrong-key")
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_returns_503_when_env_missing(monkeypatch):
|
||||||
|
monkeypatch.delenv("WEBAI_API_KEY", raising=False)
|
||||||
|
from app.auth import verify_webai_key
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
verify_webai_key(_make_request(), x_webai_key="anything")
|
||||||
|
assert exc.value.status_code == 503
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v`
|
||||||
|
Expected: ImportError: cannot import name 'verify_webai_key' from 'app.auth' (또는 ModuleNotFoundError: No module named 'app.auth')
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `web-backend/stock/app/auth.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
logger = logging.getLogger("stock")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_webai_key(
|
||||||
|
request: Request,
|
||||||
|
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
/api/webai/* 보호용 FastAPI dependency.
|
||||||
|
|
||||||
|
- WEBAI_API_KEY env 미설정 → 503 (다른 endpoint 무영향)
|
||||||
|
- 헤더 누락 또는 키 불일치 → 401 + logger.warning(ip)
|
||||||
|
"""
|
||||||
|
configured = os.getenv("WEBAI_API_KEY", "").strip()
|
||||||
|
if not configured:
|
||||||
|
logger.error("WEBAI_API_KEY not configured — refusing /api/webai/* request")
|
||||||
|
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||||
|
|
||||||
|
if not x_webai_key or x_webai_key != configured:
|
||||||
|
remote = request.client.host if request.client else "?"
|
||||||
|
logger.warning("auth_fail path=%s remote=%s", request.url.path, remote)
|
||||||
|
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_auth.py -v`
|
||||||
|
Expected: 4 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/auth.py web-backend/stock/app/test_webai_auth.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock-webai): add X-WebAI-Key auth dependency + tests
|
||||||
|
|
||||||
|
verify_webai_key FastAPI dependency: 401 on missing/wrong key,
|
||||||
|
503 when WEBAI_API_KEY env unset. 4 unit tests pass.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: /api/webai/portfolio + 응답 보강 + 통합 4 케이스
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper)
|
||||||
|
- Create: `web-backend/stock/app/test_webai_endpoints.py` (portfolio 4 케이스)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests (portfolio 4 케이스)**
|
||||||
|
|
||||||
|
Create `web-backend/stock/app/test_webai_endpoints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.screener.schema import ensure_screener_schema
|
||||||
|
from app.db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_db_and_auth(tmp_path, monkeypatch):
|
||||||
|
db_path = tmp_path / "stock.db"
|
||||||
|
# 기본 stock DB 스키마
|
||||||
|
monkeypatch.setenv("STOCK_DB_PATH", str(db_path))
|
||||||
|
init_db()
|
||||||
|
# screener 스키마 (news_sentiment, krx_master 등)
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
ensure_screener_schema(c)
|
||||||
|
c.close()
|
||||||
|
# WEBAI_API_KEY 활성화
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
HEADERS_OK = {"X-WebAI-Key": "test-secret"}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_portfolio(broker="키움", ticker="005930", name="삼성전자",
|
||||||
|
quantity=100, avg_price=75000.0, purchase_price=75500.0):
|
||||||
|
from app.db import add_portfolio_item
|
||||||
|
return add_portfolio_item(broker, ticker, name, quantity, avg_price,
|
||||||
|
purchase_price=purchase_price)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_normal_response_includes_pnl_pct(client, monkeypatch):
|
||||||
|
_seed_portfolio()
|
||||||
|
|
||||||
|
# current_price 모킹 — profit_rate 4.67% 만들기
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "get_current_prices_detail",
|
||||||
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "2026-05-15T15:30:00"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body["holdings"]) == 1
|
||||||
|
h = body["holdings"][0]
|
||||||
|
assert h["pnl_pct"] is not None
|
||||||
|
assert abs(h["pnl_pct"] - 0.0467) < 0.0005 # 0.0467 ± rounding
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_summary_has_total_pnl_pct(client, monkeypatch):
|
||||||
|
_seed_portfolio()
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "get_current_prices_detail",
|
||||||
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||||
|
body = r.json()
|
||||||
|
assert "total_pnl_pct" in body["summary"]
|
||||||
|
assert abs(body["summary"]["total_pnl_pct"] - 0.0467) < 0.0005
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_pnl_pct_matches_profit_rate_divided_100(client, monkeypatch):
|
||||||
|
_seed_portfolio()
|
||||||
|
from app import main
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "get_current_prices_detail",
|
||||||
|
lambda tickers: {"005930": {"price": 78500.0, "session": "REGULAR", "as_of": "x"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/webai/portfolio", headers=HEADERS_OK)
|
||||||
|
h = r.json()["holdings"][0]
|
||||||
|
assert h["pnl_pct"] == round(h["profit_rate"] / 100, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_portfolio_missing_key_returns_401(client):
|
||||||
|
r = client.get("/api/webai/portfolio")
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "X-WebAI-Key" in r.json()["detail"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 4 failed with 404 (endpoint not defined yet) — except `missing_key_returns_401` 도 404 (endpoint 자체가 없으므로)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Modify `web-backend/stock/app/main.py` — add right after the imports block (around line 27):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .auth import verify_webai_key
|
||||||
|
```
|
||||||
|
|
||||||
|
And add the new endpoint right after the existing `get_portfolio()` function (after line 384):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _augment_portfolio_with_pnl_pct(raw: dict) -> dict:
|
||||||
|
"""Add pnl_pct (ratio) to each holding and total_pnl_pct to summary."""
|
||||||
|
holdings = []
|
||||||
|
for h in raw["holdings"]:
|
||||||
|
pnl_pct = round(h["profit_rate"] / 100, 6) if h.get("profit_rate") is not None else None
|
||||||
|
holdings.append({**h, "pnl_pct": pnl_pct})
|
||||||
|
|
||||||
|
summary = dict(raw["summary"])
|
||||||
|
rate = summary.get("total_profit_rate")
|
||||||
|
summary["total_pnl_pct"] = round(rate / 100, 6) if rate is not None else 0.0
|
||||||
|
|
||||||
|
return {"holdings": holdings, "cash": raw["cash"], "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_portfolio():
|
||||||
|
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||||
|
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 4 passed
|
||||||
|
|
||||||
|
Also run full stock suite to verify no regression:
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||||
|
Expected: 86 + 4 = 90 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock-webai): /api/webai/portfolio + pnl_pct augment
|
||||||
|
|
||||||
|
Reuses get_portfolio() and adds pnl_pct (ratio, profit_rate/100) to
|
||||||
|
each holding plus total_pnl_pct to summary. 4 integration tests pass.
|
||||||
|
verify_webai_key dependency enforced.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: /api/webai/news-sentiment + DB SELECT + 통합 4 케이스
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock/app/main.py` (신규 endpoint + helper)
|
||||||
|
- Modify: `web-backend/stock/app/test_webai_endpoints.py` (news-sentiment 4 케이스 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests (news-sentiment 4 케이스)**
|
||||||
|
|
||||||
|
Append to `web-backend/stock/app/test_webai_endpoints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _seed_news_sentiment(date_str: str, rows: list[tuple]):
|
||||||
|
"""rows: list of (ticker, score_raw, reason, news_count)."""
|
||||||
|
db_path = os.environ["STOCK_DB_PATH"]
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
for ticker, score, reason, news_count in rows:
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR REPLACE INTO news_sentiment "
|
||||||
|
"(ticker, date, score_raw, reason, news_count, source) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, 'articles')",
|
||||||
|
(ticker, date_str, score, reason, news_count)
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_krx_master(rows: list[tuple]):
|
||||||
|
"""rows: list of (ticker, name)."""
|
||||||
|
db_path = os.environ["STOCK_DB_PATH"]
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
import datetime as dt
|
||||||
|
now = dt.datetime.utcnow().isoformat()
|
||||||
|
for ticker, name in rows:
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR REPLACE INTO krx_master "
|
||||||
|
"(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)",
|
||||||
|
(ticker, name, now)
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_returns_latest_date_when_no_param(client):
|
||||||
|
_seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")])
|
||||||
|
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)])
|
||||||
|
_seed_news_sentiment("2026-05-15", [
|
||||||
|
("005930", 6.2, "HBM 양산 가시화", 12),
|
||||||
|
("000660", 5.5, "PPI 우려에도 강세", 8),
|
||||||
|
])
|
||||||
|
|
||||||
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["date"] == "2026-05-15"
|
||||||
|
assert body["count"] == 2
|
||||||
|
# sorted by score DESC
|
||||||
|
assert body["items"][0]["ticker"] == "005930"
|
||||||
|
assert body["items"][0]["score"] == 6.2
|
||||||
|
assert body["items"][0]["name"] == "삼성전자"
|
||||||
|
assert body["items"][0]["reason"] == "HBM 양산 가시화"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_filters_by_date_param(client):
|
||||||
|
_seed_krx_master([("005930", "삼성전자")])
|
||||||
|
_seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)])
|
||||||
|
_seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)])
|
||||||
|
|
||||||
|
r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK)
|
||||||
|
body = r.json()
|
||||||
|
assert body["date"] == "2026-05-14"
|
||||||
|
assert body["count"] == 1
|
||||||
|
assert body["items"][0]["reason"] == "yesterday"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_empty_table_returns_count_zero(client):
|
||||||
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||||
|
body = r.json()
|
||||||
|
assert body["date"] is None
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert body["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_items_sorted_by_score_desc(client):
|
||||||
|
_seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")])
|
||||||
|
_seed_news_sentiment("2026-05-15", [
|
||||||
|
("A", 1.0, "low", 1),
|
||||||
|
("B", 9.0, "high", 1),
|
||||||
|
("C", 5.0, "mid", 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK)
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert [i["score"] for i in items] == [9.0, 5.0, 1.0]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py::test_webai_news_sentiment_returns_latest_date_when_no_param app/test_webai_endpoints.py::test_webai_news_sentiment_filters_by_date_param app/test_webai_endpoints.py::test_webai_news_sentiment_empty_table_returns_count_zero app/test_webai_endpoints.py::test_webai_news_sentiment_items_sorted_by_score_desc -v`
|
||||||
|
Expected: 4 failed with 404 (endpoint not defined)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Modify `web-backend/stock/app/main.py` — add right after the portfolio endpoint added in Task 2:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _fetch_news_sentiment_dump(date: str | None) -> dict:
|
||||||
|
"""news_sentiment 일별 dump (krx_master JOIN, score DESC)."""
|
||||||
|
from .db import _conn # _conn() is the shared connection helper
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
# 1) date resolve — None 이면 최신 date
|
||||||
|
if date is None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(date) FROM news_sentiment"
|
||||||
|
).fetchone()
|
||||||
|
date = row[0] if row and row[0] else None
|
||||||
|
|
||||||
|
if date is None:
|
||||||
|
return {"date": None, "count": 0, "items": []}
|
||||||
|
|
||||||
|
# 2) JOIN krx_master.name (없으면 ticker 그대로)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ns.ticker,
|
||||||
|
COALESCE(km.name, ns.ticker) AS name,
|
||||||
|
ns.score_raw,
|
||||||
|
ns.reason,
|
||||||
|
ns.news_count,
|
||||||
|
ns.source
|
||||||
|
FROM news_sentiment ns
|
||||||
|
LEFT JOIN krx_master km ON km.ticker = ns.ticker
|
||||||
|
WHERE ns.date = ?
|
||||||
|
ORDER BY ns.score_raw DESC
|
||||||
|
""",
|
||||||
|
(date,)
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"ticker": r[0], "name": r[1], "score": r[2],
|
||||||
|
"reason": r[3], "news_count": r[4], "source": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"date": date, "count": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_news_sentiment(date: str | None = None):
|
||||||
|
"""web-ai 전용 news sentiment 일별 dump."""
|
||||||
|
return _fetch_news_sentiment_dump(date)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 8 passed (4 portfolio + 4 news-sentiment)
|
||||||
|
|
||||||
|
Run full suite:
|
||||||
|
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||||
|
Expected: 86 + 8 = 94 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/main.py web-backend/stock/app/test_webai_endpoints.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock-webai): /api/webai/news-sentiment daily dump
|
||||||
|
|
||||||
|
JOINs news_sentiment with krx_master for name fallback. Sorted by
|
||||||
|
score DESC. Date param defaults to latest. Empty table returns
|
||||||
|
{date: null, count: 0, items: []}. 4 integration tests pass.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 공통 통합 4 케이스 (401 leak / 503 / wrong key / unknown date)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/stock/app/test_webai_endpoints.py` (공통 4 케이스 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the tests**
|
||||||
|
|
||||||
|
Append to `web-backend/stock/app/test_webai_endpoints.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_webai_401_response_has_no_payload_leak(client):
|
||||||
|
"""인증 실패 응답에는 portfolio/sentiment 데이터가 없어야 한다."""
|
||||||
|
_seed_portfolio()
|
||||||
|
r = client.get("/api/webai/portfolio") # 헤더 없음
|
||||||
|
assert r.status_code == 401
|
||||||
|
body = r.json()
|
||||||
|
assert "holdings" not in body
|
||||||
|
assert "cash" not in body
|
||||||
|
assert "summary" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_503_when_env_missing(client, monkeypatch):
|
||||||
|
"""WEBAI_API_KEY env 미설정 시 503, 다른 endpoint 영향 없음."""
|
||||||
|
monkeypatch.delenv("WEBAI_API_KEY", raising=False)
|
||||||
|
|
||||||
|
r1 = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "anything"})
|
||||||
|
assert r1.status_code == 503
|
||||||
|
|
||||||
|
# 기존 endpoint 무영향 — /api/portfolio 는 200 (빈 portfolio)
|
||||||
|
r2 = client.get("/api/portfolio")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_wrong_key_returns_401(client):
|
||||||
|
r = client.get("/api/webai/portfolio", headers={"X-WebAI-Key": "wrong"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_webai_news_sentiment_unknown_date_returns_empty(client):
|
||||||
|
r = client.get("/api/webai/news-sentiment?date=1999-01-01", headers=HEADERS_OK)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert body["items"] == []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd web-backend/stock && python -m pytest app/test_webai_endpoints.py -v`
|
||||||
|
Expected: 12 passed (4 + 4 + 4)
|
||||||
|
|
||||||
|
Also run full stock suite:
|
||||||
|
Run: `cd web-backend/stock && python -m pytest --ignore=app/test_scraper.py -q`
|
||||||
|
Expected: 86 + 12 = 98 passed (note: spec said 101, but 86 stock + 4 auth + 12 endpoint = 102; the count in the spec was approximate, actual = current_baseline + 4 + 12)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-backend/stock/app/test_webai_endpoints.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
test(stock-webai): edge cases — 401 no leak, 503 env missing, unknown date
|
||||||
|
|
||||||
|
Verifies auth failure responses contain no portfolio/sentiment data,
|
||||||
|
503 when WEBAI_API_KEY env unset (existing endpoints unaffected),
|
||||||
|
news-sentiment unknown date returns empty result.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: docker-compose env 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/docker-compose.yml` (stock 서비스 env)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the stock environment block**
|
||||||
|
|
||||||
|
Run: `grep -n -A 20 "^ stock:" web-backend/docker-compose.yml | head -30`
|
||||||
|
Expected: stock 서비스 블록 출력. environment 또는 env_file 항목 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add WEBAI_API_KEY to stock env**
|
||||||
|
|
||||||
|
Edit `web-backend/docker-compose.yml` — find the `stock:` service block and add `WEBAI_API_KEY=${WEBAI_API_KEY}` line to the `environment:` list.
|
||||||
|
|
||||||
|
Example final state (excerpt):
|
||||||
|
```yaml
|
||||||
|
stock:
|
||||||
|
container_name: stock
|
||||||
|
build:
|
||||||
|
context: ./stock
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- STOCK_DB_PATH=/app/data/stock.db
|
||||||
|
- WEBAI_API_KEY=${WEBAI_API_KEY}
|
||||||
|
# ... other vars
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify compose config**
|
||||||
|
|
||||||
|
Run: `cd web-backend && docker compose config | grep -A 30 "stock:" | grep WEBAI_API_KEY`
|
||||||
|
Expected: `WEBAI_API_KEY: ""` (env 미설정 시 빈 문자열) 또는 실제 값
|
||||||
|
|
||||||
|
If the line is missing, re-check the edit.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-backend
|
||||||
|
git add docker-compose.yml
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
chore(stock-webai): pass WEBAI_API_KEY env to stock container
|
||||||
|
|
||||||
|
Required by /api/webai/* endpoints. Operator must set WEBAI_API_KEY
|
||||||
|
in NAS /volume1/docker/webpage/.env before deploy.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: nginx config (rate limit + location + 헤더 forward)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/nginx/default.conf`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add limit_req_zone to http {} block**
|
||||||
|
|
||||||
|
Edit `web-backend/nginx/default.conf` — find the existing `limit_req_zone` directive (or the top of `http {}` block / top of `server {}` context) and add:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /api/webai/* rate limit — web-ai pull worker (default 60/min, burst 20)
|
||||||
|
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||||
|
```
|
||||||
|
|
||||||
|
Place it at the top of the http context (before any server blocks) or alongside existing limit_req_zone directives.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add /api/webai/ location block**
|
||||||
|
|
||||||
|
In the same file, find the existing `location /api/stock/` (or similar) block inside the relevant `server {}` and add the new location BEFORE it (to ensure prefix matching priority is explicit):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/webai/ {
|
||||||
|
limit_req zone=webai burst=20 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
proxy_pass http://stock:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Validate nginx config syntax**
|
||||||
|
|
||||||
|
Run: `cd web-backend && docker compose run --rm --no-deps frontend nginx -t -c /etc/nginx/conf.d/default.conf 2>&1 | tail -5`
|
||||||
|
|
||||||
|
If frontend image isn't built locally, use:
|
||||||
|
Run: `docker run --rm -v "$(pwd)/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine nginx -t 2>&1`
|
||||||
|
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`
|
||||||
|
|
||||||
|
If the test fails due to missing upstream resolution (`host not found in upstream "stock"`), that's expected outside the compose network — the syntax check is what matters here. Ignore upstream resolution errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-backend
|
||||||
|
git add nginx/default.conf
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(nginx-webai): /api/webai/ location with rate limit + X-WebAI-Key forward
|
||||||
|
|
||||||
|
limit_req_zone webai:5m rate=60r/m, burst=20 nodelay, return 429 on
|
||||||
|
limit hit. Proxies to stock:8000 with X-Real-IP, X-Forwarded-For,
|
||||||
|
and X-WebAI-Key headers preserved.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 배포 + 사용자 .env 갱신 + manual smoke 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- 운영 `.env` (NAS `/volume1/docker/webpage/.env`) — 사용자 수동
|
||||||
|
- web-ai `.env` (Windows PC) — 사용자 수동 (Phase 2 진입 시 사용, 본 Phase 에서 미사용 OK)
|
||||||
|
|
||||||
|
**This task requires user action (NAS SSH + push). The implementer should pause and request the user to perform these steps. Do NOT mark the task complete until the user reports smoke test results.**
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate WEBAI_API_KEY (사용자)**
|
||||||
|
|
||||||
|
Sample command for the user to run locally:
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the output. This is the `WEBAI_API_KEY` value.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update NAS .env (사용자)**
|
||||||
|
|
||||||
|
SSH to NAS:
|
||||||
|
```bash
|
||||||
|
ssh user@gahusb.synology.me
|
||||||
|
sudo vi /volume1/docker/webpage/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Add line:
|
||||||
|
```
|
||||||
|
WEBAI_API_KEY=<the key generated in Step 1>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push web-backend (사용자)**
|
||||||
|
|
||||||
|
Locally:
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-backend && git push
|
||||||
|
```
|
||||||
|
Wait for Gitea webhook → deployer rsync + docker compose up.
|
||||||
|
|
||||||
|
If deployer DEPLOY_FAIL false alarm (known issue, see graduation experience):
|
||||||
|
```bash
|
||||||
|
ssh user@gahusb.synology.me
|
||||||
|
cd /volume1/docker/webpage
|
||||||
|
docker compose up -d --build stock frontend
|
||||||
|
docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|frontend"
|
||||||
|
```
|
||||||
|
Expected: both `healthy`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke — auth success**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export WEBAI_API_KEY=<the value>
|
||||||
|
curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio | head -c 200
|
||||||
|
```
|
||||||
|
Expected: 200 JSON beginning with `{"holdings":[`. If portfolio empty, `{"holdings":[],"cash":[...`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment" | head -c 300
|
||||||
|
```
|
||||||
|
Expected: 200 JSON with `"date":` and `"items":` keys.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual smoke — auth failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -s https://gahusb.synology.me/api/webai/portfolio | head -5
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
...
|
||||||
|
{"detail":"invalid or missing X-WebAI-Key"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -s -H "X-WebAI-Key: wrong" https://gahusb.synology.me/api/webai/portfolio | head -5
|
||||||
|
```
|
||||||
|
Expected: 401 with same detail.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual smoke — rate limit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 120); do
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" \
|
||||||
|
-H "X-WebAI-Key: $WEBAI_API_KEY" \
|
||||||
|
https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
done | sort | uniq -c
|
||||||
|
```
|
||||||
|
Expected: significant `200` count plus some `429` (rate limit triggered). Example:
|
||||||
|
```
|
||||||
|
85 200
|
||||||
|
35 429
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see all 200 (no 429), rate limit may not be applied. Check nginx logs and config.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify web-ui unchanged**
|
||||||
|
|
||||||
|
Open https://gahusb.synology.me/ in browser. Navigate to `/stock` page. Verify the portfolio list still loads correctly (no errors). This confirms `/api/portfolio` (legacy, no auth) is unaffected.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify 503 fallback (optional, requires env removal + redeploy)**
|
||||||
|
|
||||||
|
This is optional and disruptive — only run if you want to verify the 503 fallback explicitly. Skip in normal deploys.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh user@gahusb.synology.me
|
||||||
|
cd /volume1/docker/webpage
|
||||||
|
# Comment out WEBAI_API_KEY in .env temporarily
|
||||||
|
sed -i 's/^WEBAI_API_KEY=/#WEBAI_API_KEY=/' .env
|
||||||
|
docker compose up -d stock
|
||||||
|
sleep 5
|
||||||
|
curl -s -o /dev/null -w "%{http_code}\n" -H "X-WebAI-Key: anything" https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
# Expected: 503
|
||||||
|
# Restore:
|
||||||
|
sed -i 's/^#WEBAI_API_KEY=/WEBAI_API_KEY=/' .env
|
||||||
|
docker compose up -d stock
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Report results to user (운영 검증 게이트)**
|
||||||
|
|
||||||
|
Report to the user:
|
||||||
|
- Step 4 (auth success): PASS / FAIL with details
|
||||||
|
- Step 5 (auth failure): PASS / FAIL
|
||||||
|
- Step 6 (rate limit): PASS (some 429 observed) / FAIL (all 200)
|
||||||
|
- Step 7 (web-ui unchanged): PASS / FAIL
|
||||||
|
|
||||||
|
Only after the user confirms all PASS, mark Task 7 complete. If any FAIL, investigate before proceeding to Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (plan author runs this)
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
|
||||||
|
| Spec § | 요구사항 | Plan task |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| §2 포함 ① portfolio + pnl_pct | Task 2 ✅ |
|
||||||
|
| §2 포함 ② news-sentiment | Task 3 ✅ |
|
||||||
|
| §2 포함 ③ X-WebAI-Key 인증 | Task 1 ✅ |
|
||||||
|
| §2 포함 ④ nginx rate limit | Task 6 ✅ |
|
||||||
|
| §2 포함 ⑤ 인증 실패 logger | Task 1 (logger.warning 호출 포함) ✅ |
|
||||||
|
| §2 포함 ⑥ 15 테스트 (4 unit + 12 integration) | Task 1 (4) + Task 2 (4) + Task 3 (4) + Task 4 (4) = 16. Note: spec said 15, plan delivers 16 (4 auth + 4 portfolio + 4 sentiment + 4 common). Counted higher, no gap. ✅ |
|
||||||
|
| §4.1 portfolio shape with pnl_pct | Task 2 Step 3 ✅ |
|
||||||
|
| §4.2 news-sentiment shape | Task 3 Step 3 ✅ |
|
||||||
|
| §4.3 401 leak free | Task 4 Step 1 (`test_webai_401_response_has_no_payload_leak`) ✅ |
|
||||||
|
| §4.4 503 when env missing | Task 1 (unit) + Task 4 (integration) ✅ |
|
||||||
|
| §5 auth.py implementation | Task 1 Step 3 ✅ |
|
||||||
|
| §6 nginx config | Task 6 ✅ |
|
||||||
|
| §10 DoD | Task 7 covers manual smoke + web-ui verification ✅ |
|
||||||
|
|
||||||
|
No gaps.
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No "TBD" / "implement later" / vague descriptions found. Every step has executable code or commands.
|
||||||
|
|
||||||
|
**3. Type consistency:**
|
||||||
|
- `verify_webai_key(request, x_webai_key)` signature consistent across Tasks 1, 2, 3 ✅
|
||||||
|
- `_augment_portfolio_with_pnl_pct(raw)` defined in Task 2, no later reference (helper internal to main.py) ✅
|
||||||
|
- `_fetch_news_sentiment_dump(date)` defined in Task 3, signature consistent ✅
|
||||||
|
- `HEADERS_OK = {"X-WebAI-Key": "test-secret"}` defined in Task 2, reused in Tasks 3 and 4 ✅
|
||||||
|
- `_seed_portfolio()` defined in Task 2, reused in Task 4 ✅
|
||||||
|
- `_seed_news_sentiment()` / `_seed_krx_master()` defined in Task 3, consistent ✅
|
||||||
|
- `WEBAI_API_KEY` env var name consistent across all tasks ✅
|
||||||
|
|
||||||
|
Plan passes self-review.
|
||||||
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,402 @@
|
|||||||
|
# web-ai V1 → signal_v1 Rename 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:** `web-ai/` 루트의 모든 V1 자산 (main_server.py + modules/ + data/ + tests/ + 진입점 스크립트 + 문서 + 로그) 을 `web-ai/signal_v1/` 안으로 atomic mv 하고, web-ai 루트에 신규 가이드 (`CLAUDE.md`, `start.bat`) 추가. V2 (`signal_v2/`) 추가 전 신/구 격리.
|
||||||
|
|
||||||
|
**Architecture:** 단일 atomic commit (stock-lab → stock graduation 과 동일 패턴). `git mv` 로 history 보존, `load_dotenv()` 호출만 경로 명시. cwd 기반 V1 코드라 import 변경 0. Phase 6 deprecation 시 `rm -rf signal_v1/` 단순화.
|
||||||
|
|
||||||
|
**Tech Stack:** git mv / Python load_dotenv path 갱신 / pytest 회귀 확인
|
||||||
|
|
||||||
|
**Spec:** `web-ui/docs/superpowers/specs/2026-05-16-web-ai-v1-rename-to-signal-v1.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조 (Task 2 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── .env ← 그대로 (V1 + V2 공유)
|
||||||
|
├── .gitignore ← 그대로
|
||||||
|
├── CLAUDE.md ← 신규 (web-ai 레벨 가이드)
|
||||||
|
├── start.bat ← 신규 (signal_v1 진입 wrapper)
|
||||||
|
├── signal_v1/ ← 신규 디렉토리
|
||||||
|
│ ├── CLAUDE.md ← 기존 V1 가이드 (mv)
|
||||||
|
│ ├── KIS_SETUP.md
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── main_server.py ← load_dotenv 경로 명시 갱신
|
||||||
|
│ ├── warmup_and_restart.py ← load_dotenv 경로 명시 갱신
|
||||||
|
│ ├── watchlist_manager.py
|
||||||
|
│ ├── backtester.py
|
||||||
|
│ ├── backtest_runner.py
|
||||||
|
│ ├── theme_manager.py
|
||||||
|
│ ├── start.bat ← 사용 안 함 (cleanup 안 함, 향후)
|
||||||
|
│ ├── modules/ ← 전체
|
||||||
|
│ ├── data/ ← 전체 (runtime data 보존)
|
||||||
|
│ ├── tests/ ← 전체
|
||||||
|
│ └── (log/json 파일들)
|
||||||
|
└── (signal_v2/ 는 Phase 2 spec)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Atomic refactor (사전 점검 + git mv + 신규 파일 + 검증 + commit)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Source repo: `C:\Users\jaeoh\Desktop\workspace\web-ai` (별도 Gitea repo: `ai-trade.git`, branch `main`)
|
||||||
|
- Create: `web-ai/signal_v1/` (디렉토리)
|
||||||
|
- Create: `web-ai/CLAUDE.md` (신규)
|
||||||
|
- Create: `web-ai/start.bat` (신규)
|
||||||
|
- Move (git mv): web-ai 루트의 모든 V1 자산 → signal_v1/
|
||||||
|
- Modify: `web-ai/signal_v1/main_server.py` (load_dotenv 명시 경로)
|
||||||
|
- Modify: `web-ai/signal_v1/warmup_and_restart.py` (load_dotenv 명시 경로)
|
||||||
|
- (필요 시) Modify: `signal_v1/modules/config.py` 또는 다른 load_dotenv 위치
|
||||||
|
|
||||||
|
- [ ] **Step 1: 사전 — 자동매매 봇 정지 확인 + git status clean**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
Expected: `nothing to commit, working tree clean`. 만약 dirty 면 implementer 는 BLOCKED 보고. 사용자가 stash 또는 commit 처리.
|
||||||
|
|
||||||
|
또한: V1 자동매매 봇이 실행 중이면 mv 도중 파일 잠금 위험. PowerShell:
|
||||||
|
```powershell
|
||||||
|
Get-Process python -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime
|
||||||
|
```
|
||||||
|
실행 중 Python 프로세스 발견 시 사용자에게 종료 요청. (장외 시간대에 작업 가정.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 사전 grep — load_dotenv 호출 위치 파악**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
grep -rn "load_dotenv" --include="*.py" .
|
||||||
|
```
|
||||||
|
Expected: 1~3개 hit. 각 hit 의 파일 경로 기록 (Step 6 에서 갱신). 일반적으로 main_server.py, warmup_and_restart.py, modules/config.py 중 1~2곳에 있음.
|
||||||
|
|
||||||
|
만약 hit 0 이면 V1 이 `.env` 를 다른 방식 (예: pydantic-settings) 으로 로드. 코드 경로 추가 grep:
|
||||||
|
```bash
|
||||||
|
grep -rn "BaseSettings\|env_file\|\.env" --include="*.py" .
|
||||||
|
```
|
||||||
|
어느 방식이든 cwd 가 signal_v1/ 으로 바뀌면 `.env` 가 parent (`web-ai/.env`) 에 있다는 사실을 코드가 알아야 함.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 사전 baseline — 현 pytest 통과 개수 측정**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
python -m pytest tests/unit -q 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
Expected output 형태: `N passed in Xs` (또는 `N passed, M warnings ...`). N 값을 baseline 으로 기록 (Step 13 에서 비교).
|
||||||
|
|
||||||
|
만약 baseline 자체가 실패면 implementer 는 DONE_WITH_CONCERNS 보고 — 사용자 결정 (pre-existing failure 라면 무시하고 진행 가능).
|
||||||
|
|
||||||
|
- [ ] **Step 4: signal_v1 디렉토리 생성**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
mkdir signal_v1
|
||||||
|
```
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
ls -d signal_v1
|
||||||
|
```
|
||||||
|
Expected: `signal_v1`
|
||||||
|
|
||||||
|
- [ ] **Step 5: git mv 실행 (V1 자산 모두)**
|
||||||
|
|
||||||
|
다음 항목을 모두 `signal_v1/` 안으로 이동. `git mv` 사용 (history 보존):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
|
||||||
|
# 진입점 + 스크립트
|
||||||
|
git mv main_server.py signal_v1/
|
||||||
|
git mv warmup_and_restart.py signal_v1/
|
||||||
|
git mv watchlist_manager.py signal_v1/
|
||||||
|
git mv backtester.py signal_v1/
|
||||||
|
git mv backtest_runner.py signal_v1/
|
||||||
|
git mv theme_manager.py signal_v1/
|
||||||
|
git mv start.bat signal_v1/
|
||||||
|
|
||||||
|
# 문서 (현 V1 가이드)
|
||||||
|
git mv CLAUDE.md signal_v1/
|
||||||
|
git mv KIS_SETUP.md signal_v1/
|
||||||
|
git mv README.md signal_v1/
|
||||||
|
|
||||||
|
# 디렉토리
|
||||||
|
git mv modules signal_v1/
|
||||||
|
git mv data signal_v1/
|
||||||
|
git mv tests signal_v1/
|
||||||
|
|
||||||
|
# 로그 / IPC / 캐시
|
||||||
|
git mv bot_ipc.json signal_v1/ 2>/dev/null || true
|
||||||
|
git mv bot_output.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv daily_launcher.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv server.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv telegram_bot.log signal_v1/ 2>/dev/null || true
|
||||||
|
git mv warmup.log signal_v1/ 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
`__pycache__/` 는 gitignore 이므로 git mv 불가능. 단순 mv:
|
||||||
|
```bash
|
||||||
|
mv __pycache__ signal_v1/ 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
git status --short | head -30
|
||||||
|
ls signal_v1/
|
||||||
|
ls
|
||||||
|
```
|
||||||
|
Expected: `signal_v1/` 안에 모든 V1 자산이 있고, web-ai 루트에는 `.env`, `.gitignore`, `signal_v1/` 만 (still untracked: none yet for new files).
|
||||||
|
|
||||||
|
- [ ] **Step 6: load_dotenv 경로 명시 갱신**
|
||||||
|
|
||||||
|
Step 2 에서 식별한 각 `load_dotenv()` 호출을 명시 경로로 변경. 가장 빈도 높은 패턴 (main_server.py 의 시작 부분):
|
||||||
|
|
||||||
|
기존 (cwd 기준):
|
||||||
|
```python
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
```
|
||||||
|
|
||||||
|
신규 (명시 경로, signal_v1 의 parent = web-ai 루트):
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# web-ai/.env (signal_v1/ 의 parent) 명시 로드
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 2 에서 식별한 모든 위치에 동일 패턴 적용. 만약 V1 이 `BaseSettings` (pydantic) 사용 시:
|
||||||
|
```python
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
class Config:
|
||||||
|
env_file = str(Path(__file__).parent.parent / ".env")
|
||||||
|
```
|
||||||
|
|
||||||
|
만약 V1 이 그냥 `os.getenv(...)` 만 쓰고 어딘가에서 명시적으로 load 하지 않는다면 (uvicorn 이 시작 시 cwd 의 .env 를 자동 로드 시) — 시작 wrapper (`web-ai/start.bat`) 가 `cd signal_v1` 후 실행하면 cwd=signal_v1 → `.env` 못 찾음. 해결: Step 7 의 `start.bat` 에서 명시적으로 `cd /d "%~dp0"` (= web-ai 루트) 후 `python signal_v1/main_server.py` 실행.
|
||||||
|
|
||||||
|
근데 그러면 main_server.py 안의 다른 상대 경로 (`data/kis_token.json` 등) 가 cwd=web-ai 일 때 `web-ai/data/kis_token.json` 을 찾음 → 잘못된 경로.
|
||||||
|
|
||||||
|
**결정**: cwd 는 `signal_v1/` 으로 두고 `load_dotenv(Path(__file__).parent.parent / ".env")` 명시. 다른 상대 경로는 cwd=signal_v1 기준이라 `data/...` 그대로 작동.
|
||||||
|
|
||||||
|
각 갱신 후 git status:
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git status --short | head -10
|
||||||
|
```
|
||||||
|
Expected: signal_v1/main_server.py 등 modified 표시.
|
||||||
|
|
||||||
|
- [ ] **Step 7: 신규 파일 — web-ai/CLAUDE.md**
|
||||||
|
|
||||||
|
Create `web-ai/CLAUDE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# web-ai — Workspace 가이드
|
||||||
|
|
||||||
|
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
| 경로 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||||
|
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||||
|
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||||
|
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||||
|
|
||||||
|
## 운영 가이드
|
||||||
|
|
||||||
|
- V1 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||||
|
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||||
|
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||||
|
|
||||||
|
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||||
|
|
||||||
|
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||||
|
|
||||||
|
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: 신규 파일 — web-ai/start.bat**
|
||||||
|
|
||||||
|
Create `web-ai/start.bat`:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0\signal_v1"
|
||||||
|
python main_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: git add 신규 파일**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add CLAUDE.md start.bat
|
||||||
|
git add signal_v1/main_server.py signal_v1/warmup_and_restart.py # load_dotenv 갱신
|
||||||
|
# 추가로 갱신한 다른 .py 파일이 있으면 모두 add
|
||||||
|
```
|
||||||
|
|
||||||
|
git status 점검:
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
Expected: 모든 git mv + 신규 + modify 변경이 staged 상태.
|
||||||
|
|
||||||
|
- [ ] **Step 10: 잔여 grep — `from web-ai` 같은 잘못된 import 0건 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
grep -rn "from web-ai\|import web-ai" --include="*.py" signal_v1/
|
||||||
|
```
|
||||||
|
Expected: 0 lines.
|
||||||
|
|
||||||
|
또한 V1 코드 안에 hardcoded 절대 경로 (예: `C:\Users\jaeoh\Desktop\workspace\web-ai\data\...`) 검사:
|
||||||
|
```bash
|
||||||
|
grep -rn "web-ai.data\|web-ai/data\|web-ai\\\\data" --include="*.py" signal_v1/
|
||||||
|
```
|
||||||
|
Expected: 0 lines.
|
||||||
|
|
||||||
|
만약 hit 있으면 implementer 는 DONE_WITH_CONCERNS 보고, 사용자가 조정.
|
||||||
|
|
||||||
|
- [ ] **Step 11: signal_v1 안에서 pytest 자동 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||||
|
python -m pytest tests/unit -q 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: Step 3 의 baseline 과 동일한 PASS 개수 (회귀 없음).
|
||||||
|
|
||||||
|
만약 import 오류 (`ModuleNotFoundError: No module named 'modules'`) 가 발생하면 conftest.py 가 sys.path 를 수정하지 않을 가능성. 확인:
|
||||||
|
```bash
|
||||||
|
cat tests/unit/conftest.py | head -20
|
||||||
|
```
|
||||||
|
필요 시 `sys.path.insert(0, str(Path(__file__).parent.parent.parent))` 추가. 단, 기존 conftest 가 cwd 기반이면 cwd=signal_v1 에서 작동해야 함.
|
||||||
|
|
||||||
|
만약 다른 failure 면 BLOCKED 보고 — 사용자 진단.
|
||||||
|
|
||||||
|
- [ ] **Step 12: 잠시 후 다시 git status — 추가 untracked 없는지 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
Expected: 모든 변경이 staged. 만약 새 untracked (pytest cache 등) 있으면 .gitignore 패턴 또는 무시.
|
||||||
|
|
||||||
|
- [ ] **Step 13: 단일 commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor: web-ai V1 assets → signal_v1/ (graduation prep)
|
||||||
|
|
||||||
|
Atomic mv of root V1 assets (main_server.py + modules/ + data/ +
|
||||||
|
tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory.
|
||||||
|
load_dotenv() updated to load web-ai/.env explicitly via Path.
|
||||||
|
|
||||||
|
Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat
|
||||||
|
(signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2.
|
||||||
|
|
||||||
|
Tests: signal_v1/tests/unit baseline preserved (no regression).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
git log -1 --stat
|
||||||
|
```
|
||||||
|
Expected: 1 commit, 다수 rename + 2 신규 (CLAUDE.md / start.bat) + 1-3 modified (load_dotenv 위치).
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
When done, report:
|
||||||
|
- DONE: commit SHA, baseline test count (Step 3) + post-mv count (Step 11), 자동 grep 결과 (0 lines).
|
||||||
|
- DONE_WITH_CONCERNS: implementation 됐지만 hardcoded path / pre-existing test fail 등 발견 — 상세 보고.
|
||||||
|
- NEEDS_CONTEXT: load_dotenv 패턴이 spec 예상과 다름, 또는 conftest 추가 fix 필요 등.
|
||||||
|
- BLOCKED: working tree dirty / pytest baseline 자체 실패 / git mv 충돌.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 사용자 수동 — 운영 검증 + push
|
||||||
|
|
||||||
|
**This task requires user action. Pause and request user to perform.**
|
||||||
|
|
||||||
|
- [ ] **Step 1: V1 자동매매 봇 정상 시작 검증**
|
||||||
|
|
||||||
|
사용자가 PowerShell 에서:
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ai
|
||||||
|
.\start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 출력 (수십 줄):
|
||||||
|
- `Config.validate()` 성공 (환경변수 누락 없음)
|
||||||
|
- KIS OAuth `access_token` 발급 (또는 cached token 로드)
|
||||||
|
- Telegram Bot started + `Conflict` 없음
|
||||||
|
- ProcessWatchdog 시작
|
||||||
|
- Uvicorn 0.0.0.0:8000 listening
|
||||||
|
- 봇 사이클 (장중이면) 또는 idle (장외)
|
||||||
|
|
||||||
|
만약 `FileNotFoundError: .env` 또는 KIS auth 실패 시 — load_dotenv 경로 오류. Task 1 으로 돌아가 Step 6 조정.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Telegram /status 명령 응답 검증**
|
||||||
|
|
||||||
|
사용자가 텔레그램에서 `/status` 명령. 봇이 정상 응답하면 IPC + SharedMemory + Telegram Bot 모두 정상.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 30분 관측**
|
||||||
|
|
||||||
|
콘솔 또는 telegram_bot.log 에 에러 없음 + Watchdog 30초 간격 health check PASS 확인.
|
||||||
|
|
||||||
|
만약 자식 프로세스 (Trading Bot / Telegram Bot) 가 자동 종료 → restart loop → 재실패 시 Task 1 으로 돌아가 진단.
|
||||||
|
|
||||||
|
- [ ] **Step 4: git push (사용자, Gitea 자격증명)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-ai
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
만약 자격증명 실패 시 사용자가 수동으로 처리 (메모리 `feedback_nas_deploy_paths.md` 의 Gitea 자격증명 패턴).
|
||||||
|
|
||||||
|
- [ ] **Step 5: 결과 보고 (사용자 → 컨트롤러)**
|
||||||
|
|
||||||
|
- Step 1 (start.bat 시작): PASS / FAIL — 첫 에러 메시지 공유
|
||||||
|
- Step 2 (/status 응답): PASS / FAIL
|
||||||
|
- Step 3 (30분 관측): PASS (no errors) / FAIL — 관측된 에러
|
||||||
|
- Step 4 (push): PASS / FAIL
|
||||||
|
|
||||||
|
전부 PASS 시 Task 2 완료 → Phase 2 brainstorming 재개 (이미 6 결정 + 디자인 섹션 1-2 OK).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
|
||||||
|
| Spec § | 요구사항 | Plan task |
|
||||||
|
|--------|----------|----------|
|
||||||
|
| §2 포함 (V1 자산 mv) | Task 1 Step 5 ✅ |
|
||||||
|
| §2 포함 (web-ai/CLAUDE.md 신규) | Task 1 Step 7 ✅ |
|
||||||
|
| §2 포함 (web-ai/start.bat 신규) | Task 1 Step 8 ✅ |
|
||||||
|
| §2 범위 외 (Python import 변경 없음) | Task 1 Step 10 의 grep 으로 검증 ✅ |
|
||||||
|
| §3.3 web-ai/CLAUDE.md 정확한 내용 | Task 1 Step 7 — 동일 markdown 본문 포함 ✅ |
|
||||||
|
| §3.3 web-ai/start.bat 정확한 내용 | Task 1 Step 8 — 동일 bat 본문 포함 ✅ |
|
||||||
|
| §3.4 load_dotenv 경로 갱신 | Task 1 Step 2 (grep) + Step 6 (갱신) ✅ |
|
||||||
|
| §4 작업 순서 (사전 검토 → mv → 검증 → push → 사용자 검증) | Task 1 Step 1-13 + Task 2 ✅ |
|
||||||
|
| §5 위험 (.env 로드 실패, 자동매매 중단 등) | Task 2 Step 1 의 first-start verification + load_dotenv 명시 ✅ |
|
||||||
|
| §6.1 자동 검증 (pytest + grep) | Task 1 Step 3 (baseline) + Step 11 (post-mv) + Step 10 (grep) ✅ |
|
||||||
|
| §6.2 수동 검증 (start.bat + /status + 30분 관측) | Task 2 Step 1-3 ✅ |
|
||||||
|
| §8 DoD 8 항목 | 전체 (Task 1 + Task 2 합) ✅ |
|
||||||
|
|
||||||
|
No gaps.
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No "TBD" / "implement later". load_dotenv 갱신은 Step 2 grep 결과에 의존하지만, Step 6 에 정확한 갱신 패턴 (2 코드 예시) 포함 — placeholder 아님.
|
||||||
|
|
||||||
|
**3. Type consistency:** N/A (refactor only, 새 함수/타입 0). 모든 step 의 명령어와 파일 경로 일관 — `signal_v1/` 표기 + `web-ai/` 표기 통일.
|
||||||
|
|
||||||
|
Plan passes self-review.
|
||||||
@@ -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.
|
||||||
@@ -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배
|
||||||
|
|||||||
369
docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
369
docs/superpowers/specs/2026-05-15-signal-v2-phase1-webai-api.md
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
# Confidence Signal Pipeline V2 — Phase 1: stock WebAI API Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-15
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- stock-lab → stock graduation (`2026-05-15-stock-lab-rename-to-stock.md`) — 본 spec 부터 새 이름 `stock` 사용
|
||||||
|
**브레인스토밍 결정 7개**: scope=B / auth=A(정적키) / portfolio shape=B(pnl_pct 추가) / news-sentiment=A(일별 dump) / endpoint 구조=1(/api/webai 분리) / rate limit=B(nginx + 인증 로그) / 테스트=B(pytest schema 검증)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
Confidence Signal Pipeline V2 의 Phase 2 (web-ai pull worker) 가 stock 컨테이너에서 polling 으로 가져갈 **입력 계약 3종**을 stock 측에 신설.
|
||||||
|
|
||||||
|
stock 의 가치 발굴 데이터 (portfolio, news sentiment, screener 점수) 를 web-ai 가 안전하게 polling 할 수 있는 인증된 endpoint 묶음 = Phase 2 진입 전 필수 의존성.
|
||||||
|
|
||||||
|
**Why**: Phase 0 §3 책임 분리 — "stock = 가치 발굴, web-ai = 시점 분석". web-ai 가 NAS DB 직접 접근 안 함, 모든 데이터는 stock API 경유. 본 Phase 가 이 API 표면을 정의.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함 (Phase 1)
|
||||||
|
|
||||||
|
- ① 새 endpoint `GET /api/webai/portfolio` — 기존 portfolio 응답 + `pnl_pct` 필드 보강 + `X-WebAI-Key` 인증
|
||||||
|
- ② 새 endpoint `GET /api/webai/news-sentiment` — news_sentiment 테이블 일별 dump + 인증
|
||||||
|
- ③ X-WebAI-Key 인증 인프라 — `verify_webai_key` FastAPI dependency, env `WEBAI_API_KEY`
|
||||||
|
- ④ nginx `/api/webai/*` location + `limit_req` rate limit (분당 60 + burst 20)
|
||||||
|
- ⑤ 인증 실패 logger (path + remote_addr 1회 기록)
|
||||||
|
- ⑥ 단위 + 통합 테스트 15 케이스
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- `/api/webai/screener/run` 신규 endpoint **불필요** — web-ai 는 기존 `/api/stock/screener/run` `{mode:"preview"}` 직접 호출 (Phase 2 client 구현 시 동작 검증)
|
||||||
|
- 기존 `/api/portfolio` 의 무인증 외부 노출 보안 강화 — 별도 슬라이스 (사용자 인증 도입은 Lab 사이트 통합 로그인 검토 시점)
|
||||||
|
- portfolio 의 `entry_date` / `days_held` / `position_weight` 등 추가 필드 — backlog (V2 운영 후 sell signal 정밀화 시)
|
||||||
|
- HMAC 서명, mTLS, IP allowlist — 단일 클라이언트 시나리오 + 정적 키로 충분
|
||||||
|
- nginx rate limit 응답 시간/에러율 메트릭 + 알림 — Phase 7 운영 모니터링 슬라이스
|
||||||
|
- 운영 .env 변경 자동화 — 사용자 1회 수동 갱신
|
||||||
|
- web-ui 변경 — Phase 1 은 백엔드 + 인프라만
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 web-backend 코드
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `stock/app/auth.py` (신규) | `verify_webai_key()` FastAPI dependency |
|
||||||
|
| `stock/app/main.py` | 신규 endpoint 2개: `GET /api/webai/portfolio`, `GET /api/webai/news-sentiment` (둘 다 `dependencies=[Depends(verify_webai_key)]`). portfolio 는 기존 `get_portfolio()` 호출 + `pnl_pct` 보강 mapper |
|
||||||
|
| `stock/app/test_webai_auth.py` (신규) | `verify_webai_key` 단위 3 케이스 |
|
||||||
|
| `stock/app/test_webai_endpoints.py` (신규) | 두 endpoint × 4 케이스 + 공통 4 케이스 = 12 케이스 |
|
||||||
|
| `nginx/default.conf` | `limit_req_zone webai` 정의 + `/api/webai/` location + `X-WebAI-Key` 헤더 forward |
|
||||||
|
| `docker-compose.yml` | stock 의 env 에 `WEBAI_API_KEY=${WEBAI_API_KEY}` 추가 |
|
||||||
|
|
||||||
|
### 3.2 운영 (사용자 1회)
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| 운영 `.env` (NAS `/volume1/docker/webpage/.env`) | `WEBAI_API_KEY=<랜덤 32~64자>` 추가 |
|
||||||
|
| Windows web-ai 의 `.env` | `WEBAI_API_KEY=<동일 값>` 추가 (Phase 2 진입 시점에 사용) |
|
||||||
|
|
||||||
|
### 3.3 web-ui
|
||||||
|
|
||||||
|
**변경 없음**. 기존 `/api/portfolio` 호출 무영향.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 계약
|
||||||
|
|
||||||
|
### 4.1 `GET /api/webai/portfolio`
|
||||||
|
|
||||||
|
요청:
|
||||||
|
```
|
||||||
|
GET /api/webai/portfolio HTTP/1.1
|
||||||
|
X-WebAI-Key: <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 200 — 기존 `/api/portfolio` 응답 + 각 holdings 항목에 `pnl_pct` (비율) 추가 + summary 에 `total_pnl_pct` 추가:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"holdings": [
|
||||||
|
{
|
||||||
|
"id": 1, "broker": "키움", "ticker": "005930", "name": "삼성전자",
|
||||||
|
"quantity": 100, "avg_price": 75000, "purchase_price": 75500,
|
||||||
|
"current_price": 78500, "price_session": "REGULAR",
|
||||||
|
"price_as_of": "2026-05-15T15:30:00",
|
||||||
|
"eval_amount": 7850000, "profit_amount": 350000,
|
||||||
|
"profit_rate": 4.67,
|
||||||
|
"pnl_pct": 0.0467
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cash": [{"broker": "키움", "cash": 1000000}],
|
||||||
|
"summary": {
|
||||||
|
"total_buy": 7550000, "total_eval": 7850000,
|
||||||
|
"total_profit": 350000, "total_profit_rate": 4.67, "total_pnl_pct": 0.0467,
|
||||||
|
"total_cash": 1000000, "total_assets": 8850000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- `pnl_pct = profit_rate / 100`
|
||||||
|
- 빈 portfolio 시 응답은 `{"holdings": [], "cash": [...], "summary": {..., "total_pnl_pct": 0.0}}`
|
||||||
|
- `profit_rate` 가 null 인 holding (현재가 조회 실패) 의 `pnl_pct` 도 null
|
||||||
|
|
||||||
|
### 4.2 `GET /api/webai/news-sentiment?date=YYYY-MM-DD`
|
||||||
|
|
||||||
|
요청:
|
||||||
|
```
|
||||||
|
GET /api/webai/news-sentiment HTTP/1.1
|
||||||
|
X-WebAI-Key: <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
쿼리:
|
||||||
|
- `date` (옵션) — `YYYY-MM-DD`. 생략 시 news_sentiment 테이블의 최신 date.
|
||||||
|
|
||||||
|
응답 200:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-05-15",
|
||||||
|
"count": 87,
|
||||||
|
"items": [
|
||||||
|
{"ticker": "005930", "name": "삼성전자", "score": 6.2,
|
||||||
|
"reason": "HBM 양산 가시화", "news_count": 12, "source": "articles"},
|
||||||
|
{"ticker": "000660", "name": "SK하이닉스", "score": 5.5,
|
||||||
|
"reason": "...", "news_count": 8, "source": "articles"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- `score` = news_sentiment.score_raw 그대로 (단위 -10 ~ +10 가정, ai_news/analyzer.py 결정)
|
||||||
|
- `name` = krx_master JOIN (없으면 ticker 그대로)
|
||||||
|
- `source` = 디버그용 (articles / scraper / etc.)
|
||||||
|
- 정렬 = `score DESC` (web-ai 가 자체 필터링)
|
||||||
|
- 테이블 empty 또는 지정 date 데이터 없음 → `{"date": null, "count": 0, "items": []}`
|
||||||
|
|
||||||
|
### 4.3 인증 실패 (모든 `/api/webai/*` 공통)
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 401 Unauthorized
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"detail": "invalid or missing X-WebAI-Key"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 페이로드 leak 없음 (응답에 endpoint 별 데이터 0)
|
||||||
|
- stock logger 에 `WARNING auth_fail path=/api/webai/portfolio remote=1.2.3.4` 1회 기록 (IP 만, 키는 로그하지 않음)
|
||||||
|
|
||||||
|
### 4.4 운영 .env 누락 시
|
||||||
|
|
||||||
|
env `WEBAI_API_KEY` 가 빈 문자열 또는 미정의 시:
|
||||||
|
- startup 시점에 stock logger 가 `ERROR WEBAI_API_KEY not configured` 1회 출력
|
||||||
|
- `/api/webai/*` 호출은 모두 503 `{"detail": "webai auth not configured"}`
|
||||||
|
- 다른 endpoint (`/api/portfolio`, `/api/stock/*`) 영향 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 인증 구현
|
||||||
|
|
||||||
|
`stock/app/auth.py`:
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import Header, HTTPException, Request
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_WEBAI_API_KEY = os.getenv("WEBAI_API_KEY", "").strip()
|
||||||
|
|
||||||
|
def verify_webai_key(
|
||||||
|
request: Request,
|
||||||
|
x_webai_key: str | None = Header(default=None, alias="X-WebAI-Key"),
|
||||||
|
):
|
||||||
|
if not _WEBAI_API_KEY:
|
||||||
|
logger.error("WEBAI_API_KEY not configured — refusing all /api/webai/* requests")
|
||||||
|
raise HTTPException(status_code=503, detail="webai auth not configured")
|
||||||
|
if not x_webai_key or x_webai_key != _WEBAI_API_KEY:
|
||||||
|
logger.warning(
|
||||||
|
"auth_fail path=%s remote=%s",
|
||||||
|
request.url.path,
|
||||||
|
request.client.host if request.client else "?",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=401, detail="invalid or missing X-WebAI-Key")
|
||||||
|
```
|
||||||
|
|
||||||
|
디자인 노트:
|
||||||
|
- env 누락 시 import-time crash 회피 → 다른 endpoint 무영향. 호출 시점에만 503.
|
||||||
|
- 키 비교는 `==` (constant-time 비교 불필요 — 단일 정적 키, timing attack 가치 낮음, 회전 후 즉시 무효화 가능).
|
||||||
|
- 헤더 이름은 alias `X-WebAI-Key` (FastAPI 가 `x_webai_key` 매개변수로 받음).
|
||||||
|
|
||||||
|
`stock/app/main.py` 적용:
|
||||||
|
```python
|
||||||
|
from .auth import verify_webai_key
|
||||||
|
|
||||||
|
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_portfolio():
|
||||||
|
raw = get_portfolio() # 기존 함수 그대로 호출 (내부 분리: 응답 dict 생성 로직을 함수로)
|
||||||
|
return _augment_portfolio_with_pnl_pct(raw)
|
||||||
|
|
||||||
|
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_news_sentiment(date: str | None = None):
|
||||||
|
return _fetch_news_sentiment_dump(date)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. nginx config
|
||||||
|
|
||||||
|
`web-backend/nginx/default.conf` 변경:
|
||||||
|
|
||||||
|
### 6.1 `http {}` 블록 상단 (기존 limit_req_zone 옆에 추가)
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=webai:5m rate=60r/m;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `server {}` 블록 내 신규 location (`/api/stock/` location 위에 우선순위)
|
||||||
|
```nginx
|
||||||
|
location /api/webai/ {
|
||||||
|
limit_req zone=webai burst=20 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
proxy_pass http://stock:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-WebAI-Key $http_x_webai_key;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
디자인 노트:
|
||||||
|
- `60r/m` = 분당 60 요청, `burst=20 nodelay` = 짧은 spike 20 까지 허용.
|
||||||
|
- web-ai 폴링 빈도 (장중 분당 3 call) 대비 20배 여유 — 정상 운영 시 절대 hit 안 됨.
|
||||||
|
- 한도 초과 시 429. web-ai 측 retry/backoff 는 Phase 2 client 구현 (본 Phase 외).
|
||||||
|
- `X-WebAI-Key` 헤더 명시적 forward (nginx 가 underscore 헤더를 기본 drop 하므로 dash 헤더는 OK, 그래도 안전상 명시).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 테스트
|
||||||
|
|
||||||
|
### 7.1 단위 (`stock/app/test_webai_auth.py`, 3 케이스)
|
||||||
|
|
||||||
|
| 케이스 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_verify_with_valid_key_passes` | `WEBAI_API_KEY=secret` + 헤더 `X-WebAI-Key: secret` → 통과 |
|
||||||
|
| `test_verify_without_key_raises_401` | 헤더 누락 → HTTPException 401 |
|
||||||
|
| `test_verify_with_wrong_key_raises_401` | 헤더 `X-WebAI-Key: wrong` → HTTPException 401 |
|
||||||
|
|
||||||
|
### 7.2 통합 (`stock/app/test_webai_endpoints.py`, 12 케이스)
|
||||||
|
|
||||||
|
FastAPI TestClient + `WEBAI_API_KEY` monkeypatch + 임시 sqlite seed.
|
||||||
|
|
||||||
|
portfolio:
|
||||||
|
- `test_portfolio_normal_response_includes_pnl_pct`
|
||||||
|
- `test_portfolio_summary_has_total_pnl_pct`
|
||||||
|
- `test_portfolio_pnl_pct_matches_profit_rate_divided_100`
|
||||||
|
- `test_portfolio_missing_key_returns_401`
|
||||||
|
|
||||||
|
news-sentiment:
|
||||||
|
- `test_news_sentiment_returns_latest_date_when_no_param`
|
||||||
|
- `test_news_sentiment_filters_by_date_param`
|
||||||
|
- `test_news_sentiment_empty_table_returns_count_zero`
|
||||||
|
- `test_news_sentiment_items_sorted_by_score_desc`
|
||||||
|
|
||||||
|
공통:
|
||||||
|
- `test_401_response_has_no_payload_leak`
|
||||||
|
- `test_503_when_webai_key_not_configured`
|
||||||
|
- `test_wrong_key_returns_401`
|
||||||
|
- `test_news_sentiment_unknown_date_returns_empty`
|
||||||
|
|
||||||
|
### 7.3 Manual smoke (배포 후)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 정상 통과
|
||||||
|
curl -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
# → 200, JSON 응답에 pnl_pct 필드 존재
|
||||||
|
|
||||||
|
# 인증 실패
|
||||||
|
curl -i https://gahusb.synology.me/api/webai/portfolio
|
||||||
|
# → 401 + {"detail": "invalid or missing X-WebAI-Key"}
|
||||||
|
|
||||||
|
# news-sentiment
|
||||||
|
curl -H "X-WebAI-Key: $WEBAI_API_KEY" "https://gahusb.synology.me/api/webai/news-sentiment?date=2026-05-15"
|
||||||
|
# → 200, items 배열
|
||||||
|
|
||||||
|
# rate limit
|
||||||
|
for i in {1..100}; do curl -s -o /dev/null -w "%{http_code}\n" \
|
||||||
|
-H "X-WebAI-Key: $WEBAI_API_KEY" \
|
||||||
|
https://gahusb.synology.me/api/webai/portfolio; done | sort | uniq -c
|
||||||
|
# → 200 다수 + 429 일부
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| 운영 .env 의 `WEBAI_API_KEY` 누락 → web-ai 호출 503 | startup 시점 ERROR log + Phase 2 web-ai 구현 시 startup health check 로 즉시 발견 |
|
||||||
|
| 키 노출 (.env 유출) | 회전 — NAS .env + web-ai .env 동시 갱신 + 컨테이너 재기동. 다운타임 ~10초 |
|
||||||
|
| nginx rate limit 너무 빡빡해서 web-ai 정상 폴링 차단 | `60r/m + burst=20` 은 web-ai 폴링 (분당 3 call) 대비 20배 여유. Phase 7 운영 모니터링에서 조정 |
|
||||||
|
| pnl_pct 단위 실수 (백분율 vs 비율) | 단위 명세 (비율, 0.047) 명시 + `test_portfolio_pnl_pct_matches_profit_rate_divided_100` 으로 검증 |
|
||||||
|
| news_sentiment 테이블 empty | 응답 `{"date": null, "count": 0, "items": []}` (테스트 케이스 포함) |
|
||||||
|
| `/api/webai/portfolio` vs `/api/portfolio` 응답 drift | 둘 다 동일 `get_portfolio()` 내부 함수 호출 + webai 측 augment mapper 만 적용. drift 회피 |
|
||||||
|
| nginx 가 underscore 헤더 drop | `X-WebAI-Key` (dash) 사용으로 회피. 명시적 forward 도 추가 |
|
||||||
|
| 외부에서 endpoint 무인증 접근 시도 | logger.warning 으로 IP 1회 기록 (대량 시도 시 IDS/alert 검토는 별도) |
|
||||||
|
| 키 brute force 시도 | nginx rate limit 분당 60 + 키 64자 랜덤 → 현실적 brute force 불가능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | ~10초 (stock + nginx 재기동) |
|
||||||
|
| 사용자 영향 | 없음 (web-ui 무변경) |
|
||||||
|
| 운영 .env 갱신 | 1회 (`WEBAI_API_KEY=<랜덤>`) |
|
||||||
|
| frontend 재배포 | 불필요 |
|
||||||
|
| 다른 lab 영향 | 없음 |
|
||||||
|
| DB 마이그레이션 | 없음 (news_sentiment 테이블 기존, 추가 컬럼 없음) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Phase 1 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `stock/app/auth.py` 신규 + 단위 테스트 3 PASS
|
||||||
|
- [ ] `stock/app/main.py` 의 2 신규 endpoint + 통합 테스트 12 PASS
|
||||||
|
- [ ] `nginx/default.conf` 의 `limit_req_zone webai` + `/api/webai/` location 추가
|
||||||
|
- [ ] `docker-compose.yml` 의 stock env `WEBAI_API_KEY` 추가
|
||||||
|
- [ ] 운영 .env 갱신 (사용자 1회) — 본 Phase plan 의 마지막 task
|
||||||
|
- [ ] 배포 후 manual smoke 4 항목 PASS (정상 200 / 인증 누락 401 / news-sentiment 200 / rate limit 429)
|
||||||
|
- [ ] stock pytest 전체 86 + 신규 15 = **101 PASS**
|
||||||
|
- [ ] web-ui 영향 없음 검증 (web-ui 의 `/api/portfolio` 정상 동작)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 2 와의 관계
|
||||||
|
|
||||||
|
본 Phase 1 완료 후 즉시 **Phase 2 (web-ai pull worker + signal API client)** spec → plan → 구현. 의존성:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 1 spec/plan/실행] → [Phase 2 spec/plan/실행]
|
||||||
|
1주 2주
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 2 의 입력 계약 = 본 spec 의 §4 API 계약. Phase 2 client 가 본 endpoint 들을 polling + 캐시 + retry.
|
||||||
|
|
||||||
|
Phase 2 시작 시점 검증 항목:
|
||||||
|
- web-ai 의 `.env` 에 `WEBAI_API_KEY` 설정
|
||||||
|
- web-ai 의 httpx client 가 `X-WebAI-Key` 헤더 자동 첨부
|
||||||
|
- 429 응답 시 backoff 정책 (exponential, max 60s)
|
||||||
|
- 5xx 응답 시 short retry (3회) 후 alert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Backlog (본 spec NOT)
|
||||||
|
|
||||||
|
V2 운영 후 별도 슬라이스로:
|
||||||
|
|
||||||
|
- `/api/webai/screener/run` 신규 endpoint — 현재 `/api/stock/screener/run` 직접 호출, drift 발견 시 분리
|
||||||
|
- portfolio 의 `entry_date` / `days_held` / `position_weight` 추가 — sell signal 정밀화 시
|
||||||
|
- ticker filter — news-sentiment 의 `?tickers=` 옵션 (Top-20 만 가져올 때 payload 절약)
|
||||||
|
- 사용자 인증 도입 (Lab 사이트 통합 로그인) — 기존 `/api/portfolio` 무인증 외부 노출 해결
|
||||||
|
- nginx 응답 시간/에러율 메트릭 + 텔레그램 alert — Phase 7 모니터링 통합
|
||||||
|
- HMAC 서명 옵션 — 외부 노출 endpoint 추가 시 검토
|
||||||
|
- Key rotation 자동화 — 일정 운영 안정화 후
|
||||||
@@ -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 회피)
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# web-ai V1 루트 → `signal_v1/` Rename Design
|
||||||
|
|
||||||
|
**작성일**: 2026-05-16
|
||||||
|
**작성자**: gahusb
|
||||||
|
**상태**: Approved for implementation
|
||||||
|
**선행 spec**:
|
||||||
|
- Confidence Signal Pipeline V2 Phase 0 (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
|
||||||
|
- stock-lab → stock graduation (`2026-05-15-stock-lab-rename-to-stock.md`) — 동일 atomic refactor 패턴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`web-ai/` 디렉토리에 V1 자동매매 시스템 (main_server.py + modules/ + 자체 LSTM + KIS + Telegram Bot) 과 V2 시그널 파이프라인 (`signal_v2/` Phase 2 시작) 이 함께 거주할 예정. V1 자산을 모두 `signal_v1/` 하위로 격리해 신/구 분리 명확.
|
||||||
|
|
||||||
|
**Why**: 사용자 명시 ("기존 기능들도 봤을때 헷갈리지 않게 signal_v2에서 사용하는거 아니면 web-ai/signal_v1 으로 몰아넣어줘"). V2 Phase 6 deprecation 시점에 `rm -rf signal_v1/` 단순화. Phase 2 spec 작성 전에 새 이름 `signal_v1/` 기준으로 진행하면 후속 갱신 비용 회피.
|
||||||
|
|
||||||
|
본 리네이밍은 **Phase 2 brainstorming 의 도중 분기**한 별도 슬라이스 — stock-lab → stock graduation 과 동일 패턴.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `git mv` web-ai 루트의 모든 V1 자산을 `signal_v1/` 안으로:
|
||||||
|
- 진입점: `main_server.py`, `warmup_and_restart.py`, `watchlist_manager.py`, `backtester.py`, `theme_manager.py`, `backtest_runner.py`
|
||||||
|
- 모듈: `modules/` (전체)
|
||||||
|
- 데이터: `data/` (전체 — runtime data 보존)
|
||||||
|
- 테스트: `tests/` (전체)
|
||||||
|
- 스크립트: `start.bat`
|
||||||
|
- 문서: `KIS_SETUP.md`, `README.md`, `CLAUDE.md` (기존 V1 가이드)
|
||||||
|
- 로그: `bot_ipc.json`, `bot_output.log`, `daily_launcher.log`, `server.log`, `telegram_bot.log`, `warmup.log`
|
||||||
|
- `__pycache__/` (gitignore)
|
||||||
|
- `web-ai/CLAUDE.md` 신규 — web-ai 루트의 새 가이드 (signal_v1 + signal_v2 디렉토리 안내, 공유 `.env`, Phase 6 deprecation 계획)
|
||||||
|
- `web-ai/start.bat` 신규 — `cd signal_v1 && python main_server.py` (또는 절대 경로 형태)
|
||||||
|
- 운영 검증: 자체 자동매매 봇 정상 기동 + Telegram Bot polling + KIS 토큰 로딩
|
||||||
|
|
||||||
|
### 범위 외 (NOT)
|
||||||
|
|
||||||
|
- Python import 경로 변경 — `signal_v1/` 안에서 진입점 실행 시 cwd 가 `signal_v1/` 이라 기존 `from modules.X` 그대로 작동. import 전면 갱신 불필요.
|
||||||
|
- `signal_v2/` 디렉토리 생성 — Phase 2 spec 의 작업.
|
||||||
|
- `.env` 분리 — V1 + V2 환경변수 모두 `web-ai/.env` 한 곳 (signal_v1 의 python 진입점이 cwd 기준 `.env` 로드 시 path 갱신 필요, 단순 조정).
|
||||||
|
- `.gitignore` — 기존 패턴 그대로 (`signal_v1/__pycache__`, `signal_v1/data/*.db` 등은 일반 패턴으로 커버).
|
||||||
|
- 다른 lab / web-backend / web-ui 영향 — 0.
|
||||||
|
- start_signal_v2.bat — Phase 2 spec 의 작업.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 매트릭스
|
||||||
|
|
||||||
|
### 3.1 web-ai 루트 (작업 전)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── .env ← 유지
|
||||||
|
├── .gitignore ← 유지
|
||||||
|
├── CLAUDE.md ← signal_v1/ 로 mv (현 V1 가이드)
|
||||||
|
├── KIS_SETUP.md ← signal_v1/ 로 mv
|
||||||
|
├── README.md ← signal_v1/ 로 mv
|
||||||
|
├── main_server.py ← signal_v1/ 로 mv
|
||||||
|
├── warmup_and_restart.py ← signal_v1/ 로 mv
|
||||||
|
├── watchlist_manager.py ← signal_v1/ 로 mv
|
||||||
|
├── backtester.py ← signal_v1/ 로 mv
|
||||||
|
├── backtest_runner.py ← signal_v1/ 로 mv
|
||||||
|
├── theme_manager.py ← signal_v1/ 로 mv
|
||||||
|
├── start.bat ← signal_v1/ 로 mv (이후 web-ai/start.bat 신규)
|
||||||
|
├── modules/ ← signal_v1/ 로 mv
|
||||||
|
├── data/ ← signal_v1/ 로 mv
|
||||||
|
├── tests/ ← signal_v1/ 로 mv
|
||||||
|
├── __pycache__/ ← signal_v1/ 로 mv (gitignore)
|
||||||
|
├── bot_ipc.json ← signal_v1/ 로 mv
|
||||||
|
├── bot_output.log ← signal_v1/ 로 mv
|
||||||
|
├── daily_launcher.log ← signal_v1/ 로 mv
|
||||||
|
├── server.log ← signal_v1/ 로 mv
|
||||||
|
├── telegram_bot.log ← signal_v1/ 로 mv
|
||||||
|
└── warmup.log ← signal_v1/ 로 mv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 web-ai 루트 (작업 후)
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── .env ← 공유 (V1 + V2 변수)
|
||||||
|
├── .gitignore ← 기존
|
||||||
|
├── CLAUDE.md ← 신규 (web-ai 레벨 가이드)
|
||||||
|
├── start.bat ← 신규 (signal_v1 진입)
|
||||||
|
├── signal_v1/
|
||||||
|
│ ├── CLAUDE.md ← 기존 V1 가이드 (이동)
|
||||||
|
│ ├── KIS_SETUP.md
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── main_server.py
|
||||||
|
│ ├── warmup_and_restart.py
|
||||||
|
│ ├── ... (이하 모든 V1 자산)
|
||||||
|
│ ├── start.bat ← 이동본 (사용 안 함, 향후 정리)
|
||||||
|
│ ├── modules/
|
||||||
|
│ ├── data/
|
||||||
|
│ ├── tests/
|
||||||
|
│ └── (log 파일들)
|
||||||
|
└── signal_v2/ ← Phase 2 작업 (본 spec 외)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 신규 파일 2개 — 정확한 내용
|
||||||
|
|
||||||
|
**`web-ai/CLAUDE.md` (신규)**:
|
||||||
|
```markdown
|
||||||
|
# web-ai — Workspace 가이드
|
||||||
|
|
||||||
|
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
| 경로 | 역할 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||||
|
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||||
|
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||||
|
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||||
|
|
||||||
|
## 운영 가이드
|
||||||
|
|
||||||
|
- V1 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||||
|
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||||
|
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||||
|
|
||||||
|
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||||
|
|
||||||
|
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||||
|
|
||||||
|
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||||
|
```
|
||||||
|
|
||||||
|
**`web-ai/start.bat` (신규)**:
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0\signal_v1"
|
||||||
|
python main_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 운영 영향 — `.env` 로드 경로
|
||||||
|
|
||||||
|
기존 V1 코드 (`signal_v1/modules/config.py` 등) 는 `load_dotenv()` 호출 시 cwd 또는 절대 경로의 `.env` 를 찾음. cwd 가 `signal_v1/` 이라면 `.env` 가 `web-ai/.env` (parent) 이라 못 찾을 수 있음.
|
||||||
|
|
||||||
|
**해결**: 진입점 (`signal_v1/main_server.py` 등) 의 `load_dotenv()` 호출에 명시적 경로 추가:
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# web-ai/.env (signal_v1/ 의 parent) 명시 로드
|
||||||
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
```
|
||||||
|
|
||||||
|
작업 매트릭스:
|
||||||
|
- `signal_v1/main_server.py` 의 `load_dotenv()` 1-2 줄 갱신
|
||||||
|
- `signal_v1/warmup_and_restart.py` 동일
|
||||||
|
- `signal_v1/modules/config.py` 같은 환경변수 로딩 위치 점검
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 작업 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사전 검토 (10분)
|
||||||
|
- web-ai 자체 자동매매 봇 운영 중 → 작업 시간대 결정 (장외: 평일 16:00 이후 / 주말)
|
||||||
|
- 본 spec §3 매트릭스 모든 파일 grep cross-check
|
||||||
|
- .env 로드 위치 grep — `load_dotenv` 호출 모두 찾기
|
||||||
|
- 데이터 파일 (data/, *.log, *.json) 손실 위험 없음 확인 (git mv 는 history 보존)
|
||||||
|
|
||||||
|
2. atomic refactor (1 commit)
|
||||||
|
- mkdir signal_v1
|
||||||
|
- git mv (위 매트릭스 항목 전부) signal_v1/
|
||||||
|
- signal_v1/main_server.py 외 .env 로드 위치 갱신
|
||||||
|
- web-ai/CLAUDE.md 신규
|
||||||
|
- web-ai/start.bat 신규
|
||||||
|
|
||||||
|
3. 로컬 검증 (cwd=signal_v1)
|
||||||
|
- python -m pytest tests/unit -q (기존 V1 테스트 통과)
|
||||||
|
- python main_server.py 시작 검증
|
||||||
|
- .env 로딩 확인 (KIS / Telegram / Ollama 환경변수)
|
||||||
|
- 봇 정상 시작 → telegram 알림 도착 → /status 응답 → 종료
|
||||||
|
|
||||||
|
4. git push (web-ai repo)
|
||||||
|
- sub Gitea: https://gitea.gahusb.synology.me/gahusb/ai-trade.git
|
||||||
|
- 본 작업은 NAS deploy 와 무관 (web-ai 는 로컬 Windows 머신).
|
||||||
|
|
||||||
|
5. 사용자 수동 검증
|
||||||
|
- 시장 시작 (다음 평일 09:00) 시점 봇 정상 동작 확인 또는 일/주말 가짜 트리거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 위험 및 완화
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| `.env` 로드 실패 → KIS 토큰 못 가져옴 → 자동매매 중단 | 진입점 (main_server.py / warmup_and_restart.py) 의 `load_dotenv` 명시 경로 추가. 시작 직후 KIS auth 확인 |
|
||||||
|
| 자동매매 중 작업 → 거래 중단 | 작업 시간대를 장외 (평일 16:00+ 또는 주말) 로 제한 |
|
||||||
|
| Python import 회귀 | `signal_v1/` cwd 기준 `from modules.X` 그대로. 외부 import 불필요. 기존 76+ 테스트 통과로 검증 |
|
||||||
|
| 데이터 파일 (data/models/, data/ensemble_history.json 등) 손실 | git mv 사용 — history 보존, 파일 내용 무변경. 사전 git status 로 dirty 없음 확인 |
|
||||||
|
| Telegram Bot 중복 polling (이전 프로세스 미종료) | start.bat 재시작 시 main_server.py 의 좀비 정리 로직 자동 동작 |
|
||||||
|
| .env 의 절대 경로 참조 (e.g. `data/kis_token.json` 같은 상대 경로) | cwd 변경 영향 — 진입점이 working directory 를 `signal_v1/` 으로 설정하면 기존 상대 경로 그대로 작동. start.bat 의 `cd /d "%~dp0\signal_v1"` 가 보장 |
|
||||||
|
| 향후 web-ai 레벨 외부 호출 (e.g. agent-office → web-ai :8000) | V1 main_server.py 는 port 8000 유지. URL 변경 없음. |
|
||||||
|
| signal_v2 진입점이 signal_v1 의 IPC 와 충돌 | Phase 2 가 별도 port :8001 + 별도 디렉토리. IPC SharedMemory 이름 분리 (V1 의 `web_ai_bot_ipc` 그대로 유지, V2 는 IPC 사용 안 함) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 / 검증
|
||||||
|
|
||||||
|
### 6.1 자동
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# V1 테스트 전체 통과
|
||||||
|
cd /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||||
|
python -m pytest tests/unit -q
|
||||||
|
# Expected: 기존 PASS 개수 그대로
|
||||||
|
|
||||||
|
# stock-lab → stock 의 잔여 참조 패턴 검증과 동일 — V1 안에서 import 회귀 없음
|
||||||
|
grep -rn "from web-ai" /c/Users/jaeoh/Desktop/workspace/web-ai/signal_v1
|
||||||
|
# Expected: 0 lines (없어야 함)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 수동
|
||||||
|
|
||||||
|
- `cd web-ai && start.bat` (또는 `cd web-ai/signal_v1 && python main_server.py`)
|
||||||
|
- 콘솔 로그에 KIS 인증 성공 / Telegram Bot connected / Ollama 모델 로드 확인
|
||||||
|
- Telegram /status 명령 → 정상 응답
|
||||||
|
- 30분 관측 후 Watchdog 정상 (자식 프로세스 healthy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영 영향
|
||||||
|
|
||||||
|
| 항목 | 영향 |
|
||||||
|
|------|------|
|
||||||
|
| 다운타임 | 작업 시간 + 첫 시작 검증 = ~30분 |
|
||||||
|
| 사용자 영향 | V1 자동매매 봇 일시 중단 (장외 시간대 진행 권장) |
|
||||||
|
| `.env` 갱신 | 없음 (위치 그대로, 진입점만 명시 경로 변경) |
|
||||||
|
| frontend 영향 | 없음 |
|
||||||
|
| 다른 lab / web-backend | 없음 (web-ai 외부 의존 0) |
|
||||||
|
| Gitea push | web-ai repo 만 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 완료 조건 (DoD)
|
||||||
|
|
||||||
|
- [ ] `web-ai/signal_v1/` 디렉토리 신설 + 매트릭스의 모든 V1 자산 mv 완료 (git history 보존)
|
||||||
|
- [ ] `web-ai/CLAUDE.md` 신규 (web-ai 레벨 가이드)
|
||||||
|
- [ ] `web-ai/start.bat` 신규 (signal_v1 cd 후 main_server.py)
|
||||||
|
- [ ] `signal_v1/main_server.py`, `warmup_and_restart.py` 등의 `load_dotenv()` 가 `web-ai/.env` 를 명시 로드
|
||||||
|
- [ ] `signal_v1/tests/unit/` 전체 pytest 통과 (기존 baseline 그대로)
|
||||||
|
- [ ] `cd web-ai && start.bat` 으로 V1 봇 정상 시작 + Telegram /status 응답
|
||||||
|
- [ ] grep `from web-ai\.` 또는 `from web-ai/` 결과 0 lines
|
||||||
|
- [ ] web-ai repo push 완료 (단일 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 2 와의 관계
|
||||||
|
|
||||||
|
본 리네이밍 완료 후 즉시 **Phase 2 brainstorming 재개**. Phase 2 spec 은:
|
||||||
|
- 새 이름 `web-ai/signal_v2/` 기준
|
||||||
|
- Phase 2 의 모든 결정 (배치 = 별도 FastAPI app :8001 / scope = 3 항목 / scheduler = asyncio cron / client = httpx + 자체 retry / rate limit = SQLite / test = pytest-asyncio) 그대로 반영
|
||||||
|
- 디자인 섹션 1 (목표/scope) + 섹션 2 (파일 구조 = web-ai/signal_v2/) 의 검토 완료 상태에서 섹션 3-7 진행
|
||||||
|
|
||||||
|
```
|
||||||
|
[본 리네이밍 spec/plan/실행] → [Phase 2 spec 작성 재개]
|
||||||
|
~30분-1시간 ~15분 (남은 섹션)
|
||||||
|
```
|
||||||
@@ -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 운영 검증 후
|
||||||
121
src/api.js
121
src/api.js
@@ -479,113 +479,90 @@ export function deleteBlogPost(id) {
|
|||||||
return apiDelete(`/api/blog/posts/${id}`);
|
return apiDelete(`/api/blog/posts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 블로그 마케팅 API ────────────────────────────────────────────────────────
|
// ── insta-lab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getBlogMarketingStatus() {
|
export function getInstaStatus() {
|
||||||
return apiGet('/api/blog-marketing/status');
|
return apiGet('/api/insta/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startResearch(keyword) {
|
export function instaCollectNews(categories) {
|
||||||
return apiPost('/api/blog-marketing/research', { keyword });
|
return apiPost('/api/insta/news/collect', categories ? { categories } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResearchHistory(limit = 30) {
|
export function getInstaArticles({ category, days = 7 } = {}) {
|
||||||
return apiGet(`/api/blog-marketing/research/history?limit=${limit}`);
|
const q = new URLSearchParams();
|
||||||
|
if (category) q.set('category', category);
|
||||||
|
q.set('days', String(days));
|
||||||
|
return apiGet(`/api/insta/news/articles?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResearchDetail(id) {
|
export function instaExtractKeywords(categories) {
|
||||||
return apiGet(`/api/blog-marketing/research/${id}`);
|
return apiPost('/api/insta/keywords/extract', categories ? { categories } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteResearch(id) {
|
export function getInstaKeywords({ category, used } = {}) {
|
||||||
return apiDelete(`/api/blog-marketing/research/${id}`);
|
const q = new URLSearchParams();
|
||||||
|
if (category) q.set('category', category);
|
||||||
|
if (used !== undefined) q.set('used', used ? 'true' : 'false');
|
||||||
|
const qs = q.toString();
|
||||||
|
return apiGet(`/api/insta/keywords${qs ? '?' + qs : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingTask(taskId) {
|
export function createInstaSlate({ keyword, category, keyword_id }) {
|
||||||
return apiGet(`/api/blog-marketing/task/${encodeURIComponent(taskId)}`);
|
return apiPost('/api/insta/slates', { keyword, category, keyword_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startGenerate(keywordId) {
|
export function getInstaSlates(limit = 50) {
|
||||||
return apiPost('/api/blog-marketing/generate', { keyword_id: keywordId });
|
return apiGet(`/api/insta/slates?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startReview(postId) {
|
export function getInstaSlate(id) {
|
||||||
return apiPost(`/api/blog-marketing/review/${postId}`);
|
return apiGet(`/api/insta/slates/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startRegenerate(postId) {
|
export function renderInstaSlate(id) {
|
||||||
return apiPost(`/api/blog-marketing/regenerate/${postId}`);
|
return apiPost(`/api/insta/slates/${id}/render`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingPosts(status, limit = 50) {
|
export function deleteInstaSlate(id) {
|
||||||
const qs = new URLSearchParams();
|
return apiDelete(`/api/insta/slates/${id}`);
|
||||||
if (status) qs.set('status', status);
|
|
||||||
if (limit) qs.set('limit', String(limit));
|
|
||||||
const q = qs.toString();
|
|
||||||
return apiGet(`/api/blog-marketing/posts${q ? '?' + q : ''}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingPost(id) {
|
export function getInstaAssetUrl(slateId, page) {
|
||||||
return apiGet(`/api/blog-marketing/posts/${id}`);
|
return `/api/insta/slates/${slateId}/assets/${page}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBlogMarketingPost(id, data) {
|
export function getInstaTask(taskId) {
|
||||||
return apiPut(`/api/blog-marketing/posts/${id}`, data);
|
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBlogMarketingPost(id) {
|
export function getInstaPrompt(name) {
|
||||||
return apiDelete(`/api/blog-marketing/posts/${id}`);
|
return apiGet(`/api/insta/templates/prompts/${encodeURIComponent(name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publishBlogMarketingPost(id, naverUrl) {
|
export function putInstaPrompt(name, template, description = '') {
|
||||||
return apiPost(`/api/blog-marketing/posts/${id}/publish`, { naver_url: naverUrl || '' });
|
return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlogMarketingCommissions(postId) {
|
// ── insta-lab trends ──
|
||||||
const qs = postId ? `?post_id=${postId}` : '';
|
export function getInstaTrends({ source, category, days = 1 } = {}) {
|
||||||
return apiGet(`/api/blog-marketing/commissions${qs}`);
|
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 addBlogMarketingCommission(data) {
|
export function instaCollectTrends(categories) {
|
||||||
return apiPost('/api/blog-marketing/commissions', data);
|
return apiPost('/api/insta/trends/collect', categories ? { categories } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBlogMarketingCommission(id, data) {
|
export function getInstaPreferences() {
|
||||||
return apiPut(`/api/blog-marketing/commissions/${id}`, data);
|
return apiGet('/api/insta/preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBlogMarketingCommission(id) {
|
export function putInstaPreferences(categories) {
|
||||||
return apiDelete(`/api/blog-marketing/commissions/${id}`);
|
return apiPut('/api/insta/preferences', { categories });
|
||||||
}
|
|
||||||
|
|
||||||
export function getBlogMarketingDashboard() {
|
|
||||||
return apiGet('/api/blog-marketing/dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마케터 단계
|
|
||||||
export function startMarket(postId) {
|
|
||||||
return apiPost(`/api/blog-marketing/market/${postId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 브랜드커넥트 링크 CRUD
|
|
||||||
export function getBrandLinks(params = {}) {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.post_id) qs.set('post_id', String(params.post_id));
|
|
||||||
if (params.keyword_id) qs.set('keyword_id', String(params.keyword_id));
|
|
||||||
const q = qs.toString();
|
|
||||||
return apiGet(`/api/blog-marketing/links${q ? '?' + q : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBrandLink(data) {
|
|
||||||
return apiPost('/api/blog-marketing/links', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateBrandLink(id, data) {
|
|
||||||
return apiPut(`/api/blog-marketing/links/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteBrandLink(id) {
|
|
||||||
return apiDelete(`/api/blog-marketing/links/${id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent Office ──────────────────────────────────
|
// ── Agent Office ──────────────────────────────────
|
||||||
|
|||||||
@@ -125,3 +125,12 @@ export const IconBuilding = () =>
|
|||||||
<rect x="11" y="16" width="3" height="3" />
|
<rect x="11" y="16" width="3" height="3" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IconInsta = () =>
|
||||||
|
svg(
|
||||||
|
<>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="5" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" strokeWidth="0" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
/* ── Blog Marketing ─────────────────────────────────────────────────────── */
|
|
||||||
.bm { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
|
||||||
|
|
||||||
/* 헤더 */
|
|
||||||
.bm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
||||||
.bm-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
|
||||||
.bm-status { display: flex; gap: 8px; margin-left: auto; }
|
|
||||||
.bm-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(16,185,129,.15); color: #10b981; }
|
|
||||||
.bm-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
|
||||||
|
|
||||||
/* 탭 바 */
|
|
||||||
.bm-tabs { display: flex; gap: 4px; border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 20px; }
|
|
||||||
.bm-tab { padding: 8px 16px; font-size: 0.85rem; background: none; border: none; color: rgba(255,255,255,.45); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
|
|
||||||
.bm-tab:hover { color: rgba(255,255,255,.7); }
|
|
||||||
.bm-tab--active { color: #10b981; border-bottom-color: #10b981; }
|
|
||||||
|
|
||||||
/* ── Dashboard 탭 ─────────────────────────────────────────────────────────── */
|
|
||||||
.bm-dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
||||||
.bm-dash-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
|
||||||
.bm-dash-card__label { font-size: 0.75rem; color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
|
||||||
.bm-dash-card__value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
|
||||||
.bm-dash-card__value--green { color: #10b981; }
|
|
||||||
|
|
||||||
.bm-dash-section { margin-bottom: 24px; }
|
|
||||||
.bm-dash-section h3 { font-size: 0.9rem; font-weight: 600; color: rgba(255,255,255,.6); margin-bottom: 12px; }
|
|
||||||
|
|
||||||
.bm-top-posts { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.bm-top-post { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
|
||||||
.bm-top-post__title { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.bm-top-post__rev { font-size: 0.85rem; font-weight: 600; color: #10b981; margin-left: 12px; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* ── Research 탭 ──────────────────────────────────────────────────────────── */
|
|
||||||
.bm-research-form { display: flex; gap: 8px; margin-bottom: 20px; }
|
|
||||||
.bm-research-input { flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.9rem; outline: none; }
|
|
||||||
.bm-research-input:focus { border-color: #10b981; }
|
|
||||||
.bm-research-input::placeholder { color: rgba(255,255,255,.25); }
|
|
||||||
|
|
||||||
.bm-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.bm-btn--primary { background: #10b981; color: #fff; }
|
|
||||||
.bm-btn--primary:hover { background: #059669; }
|
|
||||||
.bm-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
|
||||||
.bm-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
|
||||||
.bm-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
|
||||||
.bm-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
|
||||||
.bm-btn--danger:hover { background: rgba(239,68,68,.25); }
|
|
||||||
.bm-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
|
||||||
|
|
||||||
.bm-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: bm-spin .6s linear infinite; display: inline-block; }
|
|
||||||
@keyframes bm-spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* 분석 카드 */
|
|
||||||
.bm-analyses { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.bm-analysis-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
|
||||||
.bm-analysis-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
||||||
.bm-analysis-card__keyword { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); }
|
|
||||||
.bm-analysis-card__date { font-size: 0.7rem; color: rgba(255,255,255,.3); }
|
|
||||||
.bm-analysis-card__scores { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
||||||
.bm-score { text-align: center; }
|
|
||||||
.bm-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; margin-bottom: 2px; }
|
|
||||||
.bm-score__value { font-size: 1.1rem; font-weight: 700; }
|
|
||||||
.bm-score__value--high { color: #10b981; }
|
|
||||||
.bm-score__value--mid { color: #fbbf24; }
|
|
||||||
.bm-score__value--low { color: #ef4444; }
|
|
||||||
.bm-analysis-card__summary { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
|
||||||
.bm-analysis-card__actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
||||||
|
|
||||||
/* ── Write 탭 ─────────────────────────────────────────────────────────────── */
|
|
||||||
.bm-write-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,.3); }
|
|
||||||
.bm-write-empty p { font-size: 0.85rem; margin-top: 8px; }
|
|
||||||
|
|
||||||
.bm-progress { margin-bottom: 20px; }
|
|
||||||
.bm-progress__bar { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
|
||||||
.bm-progress__fill { height: 100%; background: #10b981; border-radius: 2px; transition: width .3s; }
|
|
||||||
.bm-progress__text { font-size: 0.75rem; color: rgba(255,255,255,.4); }
|
|
||||||
|
|
||||||
.bm-preview { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
|
||||||
.bm-preview__title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
|
||||||
.bm-preview__body { font-size: 0.85rem; color: rgba(255,255,255,.6); line-height: 1.7; max-height: 400px; overflow-y: auto; }
|
|
||||||
.bm-preview__body h1, .bm-preview__body h2, .bm-preview__body h3 { color: var(--text-primary, #e4e4e7); margin: 16px 0 8px; }
|
|
||||||
.bm-preview__body table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
|
||||||
.bm-preview__body th, .bm-preview__body td { border: 1px solid rgba(255,255,255,.1); padding: 6px 10px; font-size: 0.8rem; }
|
|
||||||
.bm-preview__body th { background: rgba(255,255,255,.06); }
|
|
||||||
.bm-preview__tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
|
|
||||||
.bm-tag { font-size: 0.7rem; padding: 2px 8px; border-radius: 4px; background: rgba(16,185,129,.12); color: #10b981; }
|
|
||||||
|
|
||||||
.bm-review-box { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
|
||||||
.bm-review-box h4 { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #e4e4e7); margin-bottom: 10px; }
|
|
||||||
.bm-review-scores { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
||||||
.bm-review-score { text-align: center; min-width: 60px; }
|
|
||||||
.bm-review-score__label { font-size: 0.65rem; color: rgba(255,255,255,.4); display: block; }
|
|
||||||
.bm-review-score__val { font-size: 1rem; font-weight: 700; }
|
|
||||||
.bm-review-total { font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; }
|
|
||||||
.bm-review-total--pass { color: #10b981; }
|
|
||||||
.bm-review-total--fail { color: #ef4444; }
|
|
||||||
.bm-review-feedback { font-size: 0.8rem; color: rgba(255,255,255,.5); line-height: 1.5; }
|
|
||||||
|
|
||||||
.bm-write-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* ── Posts 탭 ─────────────────────────────────────────────────────────────── */
|
|
||||||
.bm-posts-filter { display: flex; gap: 4px; margin-bottom: 16px; }
|
|
||||||
.bm-filter-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 0.75rem; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); cursor: pointer; transition: all .15s; }
|
|
||||||
.bm-filter-btn--active { background: rgba(16,185,129,.15); color: #10b981; }
|
|
||||||
|
|
||||||
.bm-posts-list { display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.bm-post-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 14px 16px; }
|
|
||||||
.bm-post-card__top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
|
|
||||||
.bm-post-card__title { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; }
|
|
||||||
.bm-post-card__status { font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
|
|
||||||
.bm-post-card__status--draft { background: rgba(255,255,255,.08); color: rgba(255,255,255,.5); }
|
|
||||||
.bm-post-card__status--reviewed { background: rgba(96,165,250,.15); color: #60a5fa; }
|
|
||||||
.bm-post-card__status--published { background: rgba(16,185,129,.15); color: #10b981; }
|
|
||||||
.bm-post-card__excerpt { font-size: 0.8rem; color: rgba(255,255,255,.4); margin-bottom: 8px; line-height: 1.4; }
|
|
||||||
.bm-post-card__meta { font-size: 0.7rem; color: rgba(255,255,255,.25); display: flex; gap: 12px; }
|
|
||||||
.bm-post-card__actions { display: flex; gap: 6px; margin-top: 10px; }
|
|
||||||
|
|
||||||
/* 발행 모달 */
|
|
||||||
.bm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.bm-modal { background: #1e1e24; border: 1px solid rgba(255,255,255,.1); border-radius: 14px; padding: 24px; width: 90%; max-width: 440px; }
|
|
||||||
.bm-modal h3 { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin-bottom: 12px; }
|
|
||||||
.bm-modal__input { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: var(--text-primary, #e4e4e7); font-size: 0.85rem; outline: none; margin-bottom: 14px; }
|
|
||||||
.bm-modal__input:focus { border-color: #10b981; }
|
|
||||||
.bm-modal__buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
|
||||||
|
|
||||||
/* ── 공통 빈 상태 ─────────────────────────────────────────────────────────── */
|
|
||||||
.bm-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,.25); font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* ── 모바일 ───────────────────────────────────────────────────────────────── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.bm-tabs {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bm-tabs > * {
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.bm { padding: 16px 10px 60px; }
|
|
||||||
.bm-header h1 { font-size: 1.2rem; }
|
|
||||||
.bm-status { display: none; }
|
|
||||||
.bm-tab { padding: 6px 10px; font-size: 0.8rem; }
|
|
||||||
.bm-dash-cards { grid-template-columns: 1fr; }
|
|
||||||
.bm-research-form { flex-direction: column; }
|
|
||||||
.bm-analysis-card__scores { gap: 10px; }
|
|
||||||
.bm-write-actions { flex-direction: column; }
|
|
||||||
.bm-post-card__actions { flex-wrap: wrap; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.bm-spinner { animation: none; }
|
|
||||||
}
|
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import PullToRefresh from '../../components/PullToRefresh';
|
|
||||||
import FAB from '../../components/FAB';
|
|
||||||
import {
|
|
||||||
getBlogMarketingStatus,
|
|
||||||
startResearch,
|
|
||||||
getResearchHistory,
|
|
||||||
getResearchDetail,
|
|
||||||
deleteResearch,
|
|
||||||
getBlogMarketingTask,
|
|
||||||
startGenerate,
|
|
||||||
startReview,
|
|
||||||
startRegenerate,
|
|
||||||
startMarket,
|
|
||||||
getBlogMarketingPosts,
|
|
||||||
getBlogMarketingPost,
|
|
||||||
deleteBlogMarketingPost,
|
|
||||||
publishBlogMarketingPost,
|
|
||||||
getBlogMarketingDashboard,
|
|
||||||
getBlogMarketingCommissions,
|
|
||||||
addBlogMarketingCommission,
|
|
||||||
deleteBlogMarketingCommission,
|
|
||||||
getBrandLinks,
|
|
||||||
createBrandLink,
|
|
||||||
deleteBrandLink,
|
|
||||||
} from '../../api';
|
|
||||||
import './BlogMarketing.css';
|
|
||||||
|
|
||||||
/* ────────────────────── 유틸 ────────────────────── */
|
|
||||||
function fmtDate(iso) {
|
|
||||||
if (!iso) return '';
|
|
||||||
return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
function fmtMoney(n) {
|
|
||||||
if (n == null) return '-';
|
|
||||||
return n.toLocaleString('ko-KR') + '원';
|
|
||||||
}
|
|
||||||
function copyHtmlToClipboard(html) {
|
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
|
||||||
const plainBlob = new Blob([html.replace(/<[^>]*>/g, '')], { type: 'text/plain' });
|
|
||||||
navigator.clipboard.write([
|
|
||||||
new ClipboardItem({ 'text/html': blob, 'text/plain': plainBlob }),
|
|
||||||
]).then(() => alert('본문이 클립보드에 복사되었습니다! (서식 포함)'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreColor(v, max = 100) {
|
|
||||||
const r = v / max;
|
|
||||||
if (r >= 0.6) return 'bm-score__value--high';
|
|
||||||
if (r >= 0.3) return 'bm-score__value--mid';
|
|
||||||
return 'bm-score__value--low';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────── 폴링 훅 ────────────────────── */
|
|
||||||
function usePollTask(onDone) {
|
|
||||||
const [taskId, setTaskId] = useState(null);
|
|
||||||
const [task, setTask] = useState(null);
|
|
||||||
const timer = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!taskId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const t = await getBlogMarketingTask(taskId);
|
|
||||||
if (cancelled) return;
|
|
||||||
setTask(t);
|
|
||||||
if (t.status === 'succeeded' || t.status === 'failed') {
|
|
||||||
setTaskId(null);
|
|
||||||
onDone?.(t);
|
|
||||||
} else {
|
|
||||||
timer.current = setTimeout(poll, 1500);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
poll();
|
|
||||||
return () => { cancelled = true; clearTimeout(timer.current); };
|
|
||||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
return { taskId, task, start: setTaskId, clear: () => { setTaskId(null); setTask(null); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════════════ */
|
|
||||||
export default function BlogMarketing() {
|
|
||||||
const [tab, setTab] = useState('dashboard');
|
|
||||||
const [status, setStatus] = useState(null);
|
|
||||||
|
|
||||||
const loadStatus = useCallback(() => {
|
|
||||||
return getBlogMarketingStatus().then(setStatus).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStatus();
|
|
||||||
}, [loadStatus]);
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ id: 'research', label: 'Research' },
|
|
||||||
{ id: 'write', label: 'Write' },
|
|
||||||
{ id: 'posts', label: 'Posts' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PullToRefresh onRefresh={loadStatus}>
|
|
||||||
<div className="bm">
|
|
||||||
<header className="bm-header">
|
|
||||||
<h1>Blog Lab</h1>
|
|
||||||
{status && (
|
|
||||||
<div className="bm-status">
|
|
||||||
<span className={`bm-badge ${status.naver_api ? '' : 'bm-badge--off'}`}>
|
|
||||||
Naver {status.naver_api ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
<span className={`bm-badge ${status.claude_api ? '' : 'bm-badge--off'}`}>
|
|
||||||
Claude {status.claude_api ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav className="bm-tabs">
|
|
||||||
{tabs.map(t => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
className={`bm-tab ${tab === t.id ? 'bm-tab--active' : ''}`}
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{tab === 'dashboard' && <DashboardTab />}
|
|
||||||
{tab === 'research' && <ResearchTab />}
|
|
||||||
{tab === 'write' && <WriteTab />}
|
|
||||||
{tab === 'posts' && <PostsTab />}
|
|
||||||
|
|
||||||
<FAB onClick={() => setTab('research')} label="키워드 분석" />
|
|
||||||
</div>
|
|
||||||
</PullToRefresh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Dashboard 탭 ═════════════════════════════════════ */
|
|
||||||
function DashboardTab() {
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getBlogMarketingDashboard().then(setData).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!data) return <div className="bm-empty">로딩 중...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bm-dash-cards">
|
|
||||||
<DashCard label="총 포스트" value={data.total_posts} />
|
|
||||||
<DashCard label="발행 완료" value={data.published_posts} />
|
|
||||||
<DashCard label="총 클릭" value={data.total_clicks.toLocaleString()} />
|
|
||||||
<DashCard label="총 수익" value={fmtMoney(data.total_revenue)} green />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.top_posts?.length > 0 && (
|
|
||||||
<div className="bm-dash-section">
|
|
||||||
<h3>Top 5 포스트 (수익 기준)</h3>
|
|
||||||
<div className="bm-top-posts">
|
|
||||||
{data.top_posts.map(p => (
|
|
||||||
<div key={p.id} className="bm-top-post">
|
|
||||||
<span className="bm-top-post__title">{p.title || '(제목 없음)'}</span>
|
|
||||||
<span className="bm-top-post__rev">{fmtMoney(p.total_revenue)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.monthly?.length > 0 && (
|
|
||||||
<div className="bm-dash-section">
|
|
||||||
<h3>월별 수익</h3>
|
|
||||||
<div className="bm-top-posts">
|
|
||||||
{data.monthly.map(m => (
|
|
||||||
<div key={m.month} className="bm-top-post">
|
|
||||||
<span className="bm-top-post__title">{m.month}</span>
|
|
||||||
<span style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginRight: 12 }}>
|
|
||||||
클릭 {m.clicks} / 구매 {m.purchases}
|
|
||||||
</span>
|
|
||||||
<span className="bm-top-post__rev">{fmtMoney(m.revenue)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashCard({ label, value, green }) {
|
|
||||||
return (
|
|
||||||
<div className="bm-dash-card">
|
|
||||||
<div className="bm-dash-card__label">{label}</div>
|
|
||||||
<div className={`bm-dash-card__value ${green ? 'bm-dash-card__value--green' : ''}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Research 탭 ══════════════════════════════════════ */
|
|
||||||
function ResearchTab() {
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [analyses, setAnalyses] = useState([]);
|
|
||||||
const [expanded, setExpanded] = useState(null);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(() => {
|
|
||||||
getResearchHistory(30).then(r => setAnalyses(r.analyses || [])).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { loadHistory(); }, [loadHistory]);
|
|
||||||
|
|
||||||
const poll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded') loadHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
if (!keyword.trim() || poll.taskId) return;
|
|
||||||
try {
|
|
||||||
const { task_id } = await startResearch(keyword.trim());
|
|
||||||
poll.start(task_id);
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
if (!confirm('이 분석을 삭제할까요?')) return;
|
|
||||||
await deleteResearch(id);
|
|
||||||
setAnalyses(prev => prev.filter(a => a.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerate = async (analysisId) => {
|
|
||||||
try {
|
|
||||||
const { task_id } = await startGenerate(analysisId);
|
|
||||||
alert(`글 생성 시작! (task: ${task_id.slice(0, 8)})\nWrite 탭에서 확인하세요.`);
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bm-research-form">
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="분석할 키워드를 입력하세요 (예: 무선 이어폰 추천)"
|
|
||||||
value={keyword}
|
|
||||||
onChange={e => setKeyword(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
||||||
disabled={!!poll.taskId}
|
|
||||||
/>
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handleSearch} disabled={!!poll.taskId}>
|
|
||||||
{poll.taskId ? <><span className="bm-spinner" /> 분석 중...</> : '분석'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{poll.task && poll.task.status !== 'succeeded' && poll.task.status !== 'failed' && (
|
|
||||||
<div className="bm-progress">
|
|
||||||
<div className="bm-progress__bar">
|
|
||||||
<div className="bm-progress__fill" style={{ width: `${poll.task.progress || 0}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="bm-progress__text">{poll.task.message || '처리 중...'}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bm-analyses">
|
|
||||||
{analyses.length === 0 && !poll.taskId && (
|
|
||||||
<div className="bm-empty">아직 분석 결과가 없습니다. 키워드를 입력해 첫 분석을 시작하세요!</div>
|
|
||||||
)}
|
|
||||||
{analyses.map(a => (
|
|
||||||
<div key={a.id} className="bm-analysis-card">
|
|
||||||
<div className="bm-analysis-card__header">
|
|
||||||
<span className="bm-analysis-card__keyword">{a.keyword}</span>
|
|
||||||
<span className="bm-analysis-card__date">{fmtDate(a.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-analysis-card__scores">
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">경쟁도</span>
|
|
||||||
<span className={`bm-score__value ${scoreColor(a.competition)}`}>{a.competition}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">기회</span>
|
|
||||||
<span className={`bm-score__value ${scoreColor(a.opportunity)}`}>{a.opportunity}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">블로그</span>
|
|
||||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
||||||
{(a.blog_total || 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">쇼핑</span>
|
|
||||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
||||||
{(a.shop_total || 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{a.avg_price != null && (
|
|
||||||
<div className="bm-score">
|
|
||||||
<span className="bm-score__label">평균가</span>
|
|
||||||
<span className="bm-score__value" style={{ color: 'rgba(255,255,255,.6)' }}>
|
|
||||||
{fmtMoney(a.avg_price)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded === a.id && a.top_products?.length > 0 && (
|
|
||||||
<div className="bm-analysis-card__summary">
|
|
||||||
<strong>상위 상품:</strong>
|
|
||||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
|
||||||
{a.top_products.map((p, i) => (
|
|
||||||
<li key={i}>{p.title} — {fmtMoney(p.lprice)} ({p.mallName})</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bm-analysis-card__actions">
|
|
||||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => handleGenerate(a.id)}>
|
|
||||||
글 생성
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bm-btn bm-btn--secondary bm-btn--sm"
|
|
||||||
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
|
||||||
>
|
|
||||||
{expanded === a.id ? '접기' : '상세'}
|
|
||||||
</button>
|
|
||||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(a.id)}>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Write 탭 ═════════════════════════════════════════ */
|
|
||||||
function WriteTab() {
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [selected, setSelected] = useState(null);
|
|
||||||
const [post, setPost] = useState(null);
|
|
||||||
|
|
||||||
// 브랜드 링크 상태
|
|
||||||
const [links, setLinks] = useState([]);
|
|
||||||
const [showLinkForm, setShowLinkForm] = useState(false);
|
|
||||||
const [linkForm, setLinkForm] = useState({ url: '', product_name: '', description: '', placement_hint: '' });
|
|
||||||
|
|
||||||
const loadPosts = useCallback(() => {
|
|
||||||
Promise.all([
|
|
||||||
getBlogMarketingPosts('draft', 20),
|
|
||||||
getBlogMarketingPosts('marketed', 20),
|
|
||||||
]).then(([draftRes, marketedRes]) => {
|
|
||||||
const all = [...(draftRes.posts || []), ...(marketedRes.posts || [])];
|
|
||||||
all.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
setPosts(all);
|
|
||||||
if (all.length > 0 && !selected) setSelected(all[0].id);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
useEffect(() => { loadPosts(); }, [loadPosts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) { setPost(null); setLinks([]); return; }
|
|
||||||
getBlogMarketingPost(selected).then(setPost).catch(() => {});
|
|
||||||
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => setLinks([]));
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const reviewPoll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded' && t.result_id) {
|
|
||||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const regenPoll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded' && t.result_id) {
|
|
||||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const marketPoll = usePollTask((t) => {
|
|
||||||
if (t.status === 'succeeded' && t.result_id) {
|
|
||||||
getBlogMarketingPost(t.result_id).then(setPost).catch(() => {});
|
|
||||||
loadPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleReview = async () => {
|
|
||||||
if (!post) return;
|
|
||||||
try {
|
|
||||||
const { task_id } = await startReview(post.id);
|
|
||||||
reviewPoll.start(task_id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegenerate = async () => {
|
|
||||||
if (!post) return;
|
|
||||||
try {
|
|
||||||
const { task_id } = await startRegenerate(post.id);
|
|
||||||
regenPoll.start(task_id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarket = async () => {
|
|
||||||
if (!post) return;
|
|
||||||
if (links.length === 0) {
|
|
||||||
alert('마케터 실행 전 브랜드커넥트 링크를 먼저 추가하세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { task_id } = await startMarket(post.id);
|
|
||||||
marketPoll.start(task_id);
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
if (!post) return;
|
|
||||||
copyHtmlToClipboard(post.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddLink = async () => {
|
|
||||||
if (!linkForm.url.trim() || !linkForm.product_name.trim()) {
|
|
||||||
alert('URL과 상품명은 필수입니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await createBrandLink({ ...linkForm, post_id: selected });
|
|
||||||
setLinkForm({ url: '', product_name: '', description: '', placement_hint: '' });
|
|
||||||
setShowLinkForm(false);
|
|
||||||
getBrandLinks({ post_id: selected }).then(r => setLinks(r.links || [])).catch(() => {});
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLink = async (linkId) => {
|
|
||||||
if (!confirm('이 링크를 삭제할까요?')) return;
|
|
||||||
await deleteBrandLink(linkId);
|
|
||||||
setLinks(prev => prev.filter(l => l.id !== linkId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const activePoll = reviewPoll.task || regenPoll.task || marketPoll.task;
|
|
||||||
const isProcessing = activePoll && activePoll.status !== 'succeeded' && activePoll.status !== 'failed';
|
|
||||||
|
|
||||||
if (posts.length === 0 && !post) {
|
|
||||||
return (
|
|
||||||
<div className="bm-write-empty">
|
|
||||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>✍</div>
|
|
||||||
<p>아직 작성 중인 글이 없습니다.<br />Research 탭에서 키워드를 분석하고 글 생성을 시작하세요.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{posts.length > 1 && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
||||||
{posts.map(p => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
className={`bm-filter-btn ${selected === p.id ? 'bm-filter-btn--active' : ''}`}
|
|
||||||
onClick={() => setSelected(p.id)}
|
|
||||||
>
|
|
||||||
{p.title?.slice(0, 20) || `${p.status === 'marketed' ? 'Marketed' : 'Draft'} #${p.id}`}
|
|
||||||
{p.status === 'marketed' && <span style={{ marginLeft: 4, fontSize: '0.7rem', color: '#f59e0b' }}>[M]</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isProcessing && activePoll && (
|
|
||||||
<div className="bm-progress">
|
|
||||||
<div className="bm-progress__bar">
|
|
||||||
<div className="bm-progress__fill" style={{ width: `${activePoll.progress || 0}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="bm-progress__text">{activePoll.message || '처리 중...'}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{post && (
|
|
||||||
<>
|
|
||||||
{/* 브랜드커넥트 링크 섹션 */}
|
|
||||||
<div className="bm-links-section" style={{ marginBottom: 16, padding: 12, background: 'rgba(255,255,255,0.04)', borderRadius: 8 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
||||||
<h4 style={{ margin: 0, fontSize: '0.9rem' }}>브랜드커넥트 링크 ({links.length})</h4>
|
|
||||||
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => setShowLinkForm(!showLinkForm)}>
|
|
||||||
{showLinkForm ? '취소' : '+ 링크 추가'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showLinkForm && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12, padding: 12, background: 'rgba(0,0,0,0.2)', borderRadius: 6 }}>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="제휴 링크 URL (필수)"
|
|
||||||
value={linkForm.url}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, url: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="상품명 (필수)"
|
|
||||||
value={linkForm.product_name}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, product_name: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="상품 설명 (선택)"
|
|
||||||
value={linkForm.description}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, description: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bm-research-input"
|
|
||||||
placeholder="배치 힌트 (선택, 예: 본문 중간 자연스럽게)"
|
|
||||||
value={linkForm.placement_hint}
|
|
||||||
onChange={e => setLinkForm(p => ({ ...p, placement_hint: e.target.value }))}
|
|
||||||
style={{ fontSize: '0.85rem' }}
|
|
||||||
/>
|
|
||||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={handleAddLink}>등록</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{links.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{links.map(l => (
|
|
||||||
<div key={l.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', background: 'rgba(255,255,255,0.03)', borderRadius: 4, fontSize: '0.8rem' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<strong>{l.product_name}</strong>
|
|
||||||
{l.description && <span style={{ marginLeft: 8, color: 'rgba(255,255,255,.4)' }}>{l.description}</span>}
|
|
||||||
</div>
|
|
||||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDeleteLink(l.id)} style={{ fontSize: '0.7rem', padding: '2px 6px' }}>삭제</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bm-preview">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div className="bm-preview__title">{post.title || '(제목 없음)'}</div>
|
|
||||||
<span className={`bm-post-card__status bm-post-card__status--${post.status}`} style={{ fontSize: '0.75rem' }}>
|
|
||||||
{post.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-preview__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
||||||
{post.tags?.length > 0 && (
|
|
||||||
<div className="bm-preview__tags">
|
|
||||||
{post.tags.map((t, i) => <span key={i} className="bm-tag">#{t}</span>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{post.review_detail && post.review_score != null && (
|
|
||||||
<div className="bm-review-box">
|
|
||||||
<h4>품질 리뷰 결과</h4>
|
|
||||||
<div className="bm-review-scores">
|
|
||||||
{Object.entries(post.review_detail.scores || {}).map(([k, v]) => (
|
|
||||||
<div key={k} className="bm-review-score">
|
|
||||||
<span className="bm-review-score__label">{k}</span>
|
|
||||||
<span className={`bm-review-score__val ${scoreColor(v, 10)}`}>{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={`bm-review-total ${post.review_detail.pass ? 'bm-review-total--pass' : 'bm-review-total--fail'}`}>
|
|
||||||
총점: {post.review_score}/60 {post.review_detail.pass ? '(통과)' : '(미달)'}
|
|
||||||
</div>
|
|
||||||
{post.review_detail.feedback && (
|
|
||||||
<div className="bm-review-feedback">{post.review_detail.feedback}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bm-write-actions">
|
|
||||||
{post.status === 'draft' && (
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handleMarket} disabled={isProcessing} title={links.length === 0 ? '브랜드 링크를 먼저 추가하세요' : ''}>
|
|
||||||
{marketPoll.taskId ? <><span className="bm-spinner" /> 마케팅 중...</> : '마케터 실행'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handleReview} disabled={isProcessing}>
|
|
||||||
{reviewPoll.taskId ? <><span className="bm-spinner" /> 리뷰 중...</> : '품질 리뷰'}
|
|
||||||
</button>
|
|
||||||
<button className="bm-btn bm-btn--secondary" onClick={handleRegenerate} disabled={isProcessing}>
|
|
||||||
{regenPoll.taskId ? <><span className="bm-spinner" /> 재생성 중...</> : '재생성'}
|
|
||||||
</button>
|
|
||||||
<button className="bm-btn bm-btn--secondary" onClick={handleCopy}>
|
|
||||||
본문 복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════ Posts 탭 ═════════════════════════════════════════ */
|
|
||||||
function PostsTab() {
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [publishModal, setPublishModal] = useState(null);
|
|
||||||
const [naverUrl, setNaverUrl] = useState('');
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
getBlogMarketingPosts(filter || undefined).then(r => setPosts(r.posts || [])).catch(() => {});
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
if (!confirm('이 포스트를 삭제할까요?')) return;
|
|
||||||
await deleteBlogMarketingPost(id);
|
|
||||||
setPosts(prev => prev.filter(p => p.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePublish = async () => {
|
|
||||||
if (!publishModal) return;
|
|
||||||
await publishBlogMarketingPost(publishModal, naverUrl);
|
|
||||||
setPublishModal(null);
|
|
||||||
setNaverUrl('');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (body) => {
|
|
||||||
copyHtmlToClipboard(body);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{ id: '', label: '전체' },
|
|
||||||
{ id: 'draft', label: 'Draft' },
|
|
||||||
{ id: 'marketed', label: 'Marketed' },
|
|
||||||
{ id: 'reviewed', label: 'Reviewed' },
|
|
||||||
{ id: 'published', label: 'Published' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="bm-posts-filter">
|
|
||||||
{filters.map(f => (
|
|
||||||
<button
|
|
||||||
key={f.id}
|
|
||||||
className={`bm-filter-btn ${filter === f.id ? 'bm-filter-btn--active' : ''}`}
|
|
||||||
onClick={() => setFilter(f.id)}
|
|
||||||
>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bm-posts-list">
|
|
||||||
{posts.length === 0 && <div className="bm-empty">포스트가 없습니다.</div>}
|
|
||||||
{posts.map(p => (
|
|
||||||
<div key={p.id} className="bm-post-card">
|
|
||||||
<div className="bm-post-card__top">
|
|
||||||
<span className="bm-post-card__title">{p.title || '(제목 없음)'}</span>
|
|
||||||
<span className={`bm-post-card__status bm-post-card__status--${p.status}`}>
|
|
||||||
{p.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{p.excerpt && <div className="bm-post-card__excerpt">{p.excerpt}</div>}
|
|
||||||
<div className="bm-post-card__meta">
|
|
||||||
{p.review_score != null && <span>리뷰: {p.review_score}/60</span>}
|
|
||||||
{p.naver_url && <a href={p.naver_url} target="_blank" rel="noreferrer" style={{ color: '#10b981' }}>네이버 링크</a>}
|
|
||||||
<span>{fmtDate(p.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bm-post-card__actions">
|
|
||||||
<button className="bm-btn bm-btn--secondary bm-btn--sm" onClick={() => handleCopy(p.body)}>복사</button>
|
|
||||||
{p.status !== 'published' && (
|
|
||||||
<button className="bm-btn bm-btn--primary bm-btn--sm" onClick={() => { setPublishModal(p.id); setNaverUrl(''); }}>
|
|
||||||
발행
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="bm-btn bm-btn--danger bm-btn--sm" onClick={() => handleDelete(p.id)}>삭제</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{publishModal && (
|
|
||||||
<div className="bm-modal-overlay" onClick={() => setPublishModal(null)}>
|
|
||||||
<div className="bm-modal" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3>네이버 블로그 발행</h3>
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'rgba(255,255,255,.4)', marginBottom: 12 }}>
|
|
||||||
본문을 네이버 블로그에 붙여넣기한 후, 발행된 URL을 입력하세요.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className="bm-modal__input"
|
|
||||||
placeholder="https://blog.naver.com/..."
|
|
||||||
value={naverUrl}
|
|
||||||
onChange={e => setNaverUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="bm-modal__buttons">
|
|
||||||
<button className="bm-btn bm-btn--secondary" onClick={() => setPublishModal(null)}>취소</button>
|
|
||||||
<button className="bm-btn bm-btn--primary" onClick={handlePublish}>발행 완료</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
205
src/pages/insta/InstaCards.css
Normal file
205
src/pages/insta/InstaCards.css
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/* ── InstaCards ──────────────────────────────────────────────────────────── */
|
||||||
|
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.ic-header h1 { font-size: 1.5rem; font-weight: 700; color: var(--text-primary, #e4e4e7); margin: 0; }
|
||||||
|
.ic-status-badges { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
.ic-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; background: rgba(236,72,153,.15); color: #ec4899; }
|
||||||
|
.ic-badge--on { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.ic-badge--off { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
|
/* 버튼 공통 */
|
||||||
|
.ic-btn { padding: 8px 18px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.ic-btn--primary { background: #ec4899; color: #fff; }
|
||||||
|
.ic-btn--primary:hover { background: #db2777; }
|
||||||
|
.ic-btn--primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.ic-btn--secondary { background: rgba(255,255,255,.08); color: rgba(255,255,255,.7); }
|
||||||
|
.ic-btn--secondary:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.ic-btn--secondary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||||
|
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||||
|
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
||||||
|
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
|
||||||
|
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ic-layout { grid-template-columns: 320px 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 섹션 카드 */
|
||||||
|
.ic-section { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; padding: 16px; }
|
||||||
|
.ic-section__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.6); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; }
|
||||||
|
|
||||||
|
/* 트리거 패널 */
|
||||||
|
.ic-trigger-buttons { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ic-task-status { margin-top: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; font-size: 0.8rem; }
|
||||||
|
.ic-task-status__label { color: rgba(255,255,255,.4); margin-bottom: 4px; }
|
||||||
|
.ic-task-status__msg { color: var(--text-primary, #e4e4e7); }
|
||||||
|
.ic-task-status__progress { margin-top: 6px; height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; }
|
||||||
|
.ic-task-status__fill { height: 100%; background: #ec4899; border-radius: 2px; transition: width .3s; }
|
||||||
|
|
||||||
|
/* 카테고리 필터 */
|
||||||
|
.ic-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.ic-filter-btn { padding: 4px 12px; border-radius: 99px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: rgba(255,255,255,.5); font-size: 0.75rem; cursor: pointer; transition: all .15s; }
|
||||||
|
.ic-filter-btn--active { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
|
||||||
|
|
||||||
|
/* 키워드 목록 */
|
||||||
|
.ic-keywords { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ic-keyword-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(255,255,255,.03); border-radius: 8px; }
|
||||||
|
.ic-keyword-row__kw { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
|
||||||
|
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
|
||||||
|
|
||||||
|
/* 슬레이트 그리드 */
|
||||||
|
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||||
|
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
|
||||||
|
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
|
||||||
|
.ic-slate-card--active { border-color: #ec4899; }
|
||||||
|
.ic-slate-thumb { width: 100%; aspect-ratio: 4/5; object-fit: cover; background: rgba(255,255,255,.06); display: block; }
|
||||||
|
.ic-slate-thumb--placeholder { width: 100%; aspect-ratio: 4/5; background: rgba(255,255,255,.04); display: flex; align-items: center; justify-content: center; font-size: 1.8rem; }
|
||||||
|
.ic-slate-card__info { padding: 8px; }
|
||||||
|
.ic-slate-card__kw { font-size: 0.78rem; font-weight: 600; color: var(--text-primary, #e4e4e7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ic-slate-card__meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||||
|
.ic-slate-card__date { font-size: 0.65rem; color: rgba(255,255,255,.3); }
|
||||||
|
|
||||||
|
/* 상태 뱃지 */
|
||||||
|
.ic-status-badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
|
||||||
|
.ic-status-badge--draft { background: rgba(161,161,170,.15); color: #a1a1aa; }
|
||||||
|
.ic-status-badge--rendered { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||||
|
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
|
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
|
/* 슬레이트 상세 패널 */
|
||||||
|
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
|
||||||
|
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
|
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
|
||||||
|
.ic-detail__actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
|
||||||
|
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
|
||||||
|
|
||||||
|
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
||||||
|
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
|
||||||
|
.ic-caption-text { font-size: 0.85rem; color: var(--text-primary, #e4e4e7); line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.ic-hashtags { font-size: 0.8rem; color: #60a5fa; line-height: 1.8; word-break: break-all; }
|
||||||
|
|
||||||
|
/* 프롬프트 에디터 */
|
||||||
|
.ic-prompt-editor { margin-top: 20px; }
|
||||||
|
.ic-prompt-editor__title { font-size: 0.85rem; font-weight: 700; color: rgba(255,255,255,.5); margin-bottom: 12px; text-transform: uppercase; }
|
||||||
|
.ic-prompt-block { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||||
|
.ic-prompt-block__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.ic-prompt-block__name { font-size: 0.8rem; font-weight: 700; color: rgba(255,255,255,.7); flex: 1; }
|
||||||
|
.ic-prompt-block__date { font-size: 0.68rem; color: rgba(255,255,255,.3); }
|
||||||
|
.ic-prompt-textarea { width: 100%; min-height: 140px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); border-radius: 6px; color: var(--text-primary, #e4e4e7); font-size: 0.8rem; font-family: monospace; line-height: 1.5; padding: 10px; resize: vertical; box-sizing: border-box; outline: none; }
|
||||||
|
.ic-prompt-textarea:focus { border-color: #ec4899; }
|
||||||
|
.ic-prompt-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* 빈 상태 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
895
src/pages/insta/InstaCards.jsx
Normal file
895
src/pages/insta/InstaCards.jsx
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
|
import {
|
||||||
|
getInstaStatus,
|
||||||
|
instaCollectNews,
|
||||||
|
instaExtractKeywords,
|
||||||
|
getInstaKeywords,
|
||||||
|
createInstaSlate,
|
||||||
|
getInstaSlates,
|
||||||
|
getInstaSlate,
|
||||||
|
renderInstaSlate,
|
||||||
|
deleteInstaSlate,
|
||||||
|
getInstaAssetUrl,
|
||||||
|
getInstaTask,
|
||||||
|
getInstaPrompt,
|
||||||
|
putInstaPrompt,
|
||||||
|
getInstaTrends,
|
||||||
|
instaCollectTrends,
|
||||||
|
getInstaPreferences,
|
||||||
|
putInstaPreferences,
|
||||||
|
} from '../../api';
|
||||||
|
import './InstaCards.css';
|
||||||
|
|
||||||
|
/* ────────────────────── 유틸 ────────────────────── */
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
return (
|
||||||
|
<span className={`ic-status-badge ic-status-badge--${status || 'draft'}`}>
|
||||||
|
{status || 'draft'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────── 폴링 훅 ────────────────────── */
|
||||||
|
function usePollTask(onDone) {
|
||||||
|
const [taskId, setTaskId] = useState(null);
|
||||||
|
const [task, setTask] = useState(null);
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const t = await getInstaTask(taskId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setTask(t);
|
||||||
|
if (t.status === 'succeeded' || t.status === 'failed') {
|
||||||
|
setTaskId(null);
|
||||||
|
onDone?.(t);
|
||||||
|
} else {
|
||||||
|
timer.current = setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) timer.current = setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
task,
|
||||||
|
start: setTaskId,
|
||||||
|
clear: () => { setTaskId(null); setTask(null); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────── TaskStatusBox ────────────────────── */
|
||||||
|
function TaskStatusBox({ task }) {
|
||||||
|
if (!task) return null;
|
||||||
|
const pct = task.progress != null ? task.progress : (task.status === 'succeeded' ? 100 : 0);
|
||||||
|
return (
|
||||||
|
<div className="ic-task-status">
|
||||||
|
<div className="ic-task-status__label">
|
||||||
|
{task.status === 'succeeded' ? '완료' : task.status === 'failed' ? '실패' : '진행 중'}
|
||||||
|
</div>
|
||||||
|
<div className="ic-task-status__msg">{task.message || task.error || ''}</div>
|
||||||
|
<div className="ic-task-status__progress">
|
||||||
|
<div className="ic-task-status__fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 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() {
|
||||||
|
const [status, setStatus] = 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(() => {
|
||||||
|
return getInstaStatus().then(setStatus).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<h1>Insta Cards</h1>
|
||||||
|
{status && (
|
||||||
|
<div className="ic-status-badges">
|
||||||
|
<span className={`ic-badge ${status.naver_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
|
||||||
|
Naver {status.naver_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
<span className={`ic-badge ${status.anthropic_api ? 'ic-badge--on' : 'ic-badge--off'}`}>
|
||||||
|
AI {status.anthropic_api ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ic-layout">
|
||||||
|
{/* 왼쪽: 트리거 + 키워드 */}
|
||||||
|
<div>
|
||||||
|
<TriggerPanel />
|
||||||
|
<div style={{ height: 16 }} />
|
||||||
|
<KeywordsPanel onCreateSlate={handleCreateSlate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 슬레이트 목록 + 상세 */}
|
||||||
|
<div>
|
||||||
|
<SlatesPanel
|
||||||
|
selectedId={selectedSlateId}
|
||||||
|
onSelect={setSelectedSlateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PromptTemplatesEditor />
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Trends 탭 (3 new panels) ── */}
|
||||||
|
{activeTab === 'trends' && (
|
||||||
|
<div className="ic-trends-grid">
|
||||||
|
<AccountFocusPanel />
|
||||||
|
<ExternalTrendsPanel onCreateSlate={handleCreateSlate} />
|
||||||
|
<PreferenceImpactPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 트리거 패널 ══════════════════════════════════════ */
|
||||||
|
function TriggerPanel() {
|
||||||
|
const collectPoll = usePollTask();
|
||||||
|
const keywordsPoll = usePollTask();
|
||||||
|
|
||||||
|
async function handleCollect() {
|
||||||
|
try {
|
||||||
|
const res = await instaCollectNews();
|
||||||
|
collectPoll.start(res.task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert('뉴스 수집 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeywords() {
|
||||||
|
try {
|
||||||
|
const res = await instaExtractKeywords();
|
||||||
|
keywordsPoll.start(res.task_id);
|
||||||
|
} catch (e) {
|
||||||
|
alert('키워드 추출 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectBusy = !!collectPoll.taskId;
|
||||||
|
const kwBusy = !!keywordsPoll.taskId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-section">
|
||||||
|
<p className="ic-section__title">트리거</p>
|
||||||
|
<div className="ic-trigger-buttons">
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--primary"
|
||||||
|
onClick={handleCollect}
|
||||||
|
disabled={collectBusy}
|
||||||
|
>
|
||||||
|
{collectBusy && <span className="ic-spinner" />}
|
||||||
|
뉴스 수집
|
||||||
|
</button>
|
||||||
|
<TaskStatusBox task={collectPoll.task} />
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--secondary"
|
||||||
|
onClick={handleKeywords}
|
||||||
|
disabled={kwBusy}
|
||||||
|
>
|
||||||
|
{kwBusy && <span className="ic-spinner" />}
|
||||||
|
키워드 추출
|
||||||
|
</button>
|
||||||
|
<TaskStatusBox task={keywordsPoll.task} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 키워드 목록 ══════════════════════════════════════ */
|
||||||
|
const CATEGORIES = ['전체', 'economy', 'psychology', 'celebrity'];
|
||||||
|
|
||||||
|
function KeywordsPanel({ onCreateSlate }) {
|
||||||
|
const [category, setCategory] = useState('전체');
|
||||||
|
const [keywords, setKeywords] = useState([]);
|
||||||
|
const [creating, setCreating] = useState(null); // keyword_id being created
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const cat = category === '전체' ? undefined : category;
|
||||||
|
getInstaKeywords({ category: cat }).then((r) => setKeywords(r.items || [])).catch(() => {});
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// 부모(InstaCards)의 handleCreateSlate에 위임 — progress 배너 + 스크롤 + 자동 미리보기 공통화
|
||||||
|
async function handleCreate(kw) {
|
||||||
|
if (creating) return;
|
||||||
|
setCreating(kw.id);
|
||||||
|
try {
|
||||||
|
await onCreateSlate?.({
|
||||||
|
keyword: kw.keyword,
|
||||||
|
category: kw.category,
|
||||||
|
keyword_id: kw.id,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreating(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-section">
|
||||||
|
<p className="ic-section__title">트렌딩 키워드</p>
|
||||||
|
|
||||||
|
{/* 카테고리 필터 */}
|
||||||
|
<div className="ic-filter">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
className={`ic-filter-btn ${category === c ? 'ic-filter-btn--active' : ''}`}
|
||||||
|
onClick={() => setCategory(c)}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* progress 표시는 상단 ic-slate-progress 배너에서 일괄 처리 */}
|
||||||
|
|
||||||
|
{keywords.length === 0 ? (
|
||||||
|
<div className="ic-empty">키워드가 없습니다. 키워드 추출을 실행하세요.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-keywords">
|
||||||
|
{keywords.map((kw) => (
|
||||||
|
<div key={kw.id} className="ic-keyword-row">
|
||||||
|
<span className="ic-keyword-row__kw">{kw.keyword}</span>
|
||||||
|
<span className="ic-keyword-row__meta">
|
||||||
|
{kw.category} · {kw.articles_count ?? 0}건
|
||||||
|
</span>
|
||||||
|
<span className="ic-keyword-row__score">{kw.score?.toFixed(1) ?? '-'}</span>
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||||
|
onClick={() => handleCreate(kw)}
|
||||||
|
disabled={!!creating}
|
||||||
|
>
|
||||||
|
{creating === kw.id ? <span className="ic-spinner" /> : '🎴'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 슬레이트 목록 ══════════════════════════════════ */
|
||||||
|
function SlatesPanel({ selectedId, onSelect }) {
|
||||||
|
const [slates, setSlates] = useState([]);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
|
||||||
|
const loadSlates = useCallback(() => {
|
||||||
|
getInstaSlates(50).then((r) => setSlates(r.items || [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadSlates(); }, [loadSlates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) { setDetail(null); return; }
|
||||||
|
getInstaSlate(selectedId).then(setDetail).catch(() => setDetail(null));
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
function handleSelect(id) {
|
||||||
|
onSelect(id === selectedId ? null : id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (!confirm('슬레이트를 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await deleteInstaSlate(id);
|
||||||
|
if (selectedId === id) onSelect(null);
|
||||||
|
loadSlates();
|
||||||
|
} catch (e) {
|
||||||
|
alert('삭제 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRender(id) {
|
||||||
|
try {
|
||||||
|
const res = await renderInstaSlate(id);
|
||||||
|
// Re-render is fire-and-forget from the panel; user can refresh detail
|
||||||
|
alert('재렌더 요청 완료 (task: ' + res.task_id + ')');
|
||||||
|
setTimeout(loadSlates, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
alert('재렌더 실패: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="ic-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<p className="ic-section__title" style={{ margin: 0, flex: 1 }}>슬레이트 목록</p>
|
||||||
|
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={loadSlates}>↻ 새로고침</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slates.length === 0 ? (
|
||||||
|
<div className="ic-empty">슬레이트가 없습니다. 카드를 생성해 보세요.</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-slates-grid">
|
||||||
|
{slates.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`ic-slate-card ${selectedId === s.id ? 'ic-slate-card--active' : ''}`}
|
||||||
|
onClick={() => handleSelect(s.id)}
|
||||||
|
>
|
||||||
|
{s.status === 'rendered' || s.status === 'sent' ? (
|
||||||
|
<img
|
||||||
|
className="ic-slate-thumb"
|
||||||
|
src={getInstaAssetUrl(s.id, 1)}
|
||||||
|
alt={s.keyword}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="ic-slate-thumb--placeholder">🎴</div>
|
||||||
|
)}
|
||||||
|
<div className="ic-slate-card__info">
|
||||||
|
<div className="ic-slate-card__kw">{s.keyword}</div>
|
||||||
|
<div className="ic-slate-card__meta">
|
||||||
|
<span className="ic-slate-card__date">{fmtDate(s.created_at)}</span>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 슬레이트 상세 */}
|
||||||
|
{detail && (
|
||||||
|
<SlateDetail
|
||||||
|
slate={detail}
|
||||||
|
onDelete={() => handleDelete(detail.id)}
|
||||||
|
onRender={() => handleRender(detail.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||||
|
function SlateDetail({ slate, onDelete, onRender }) {
|
||||||
|
const pages = slate.assets || [];
|
||||||
|
const pageCount = pages.length > 0 ? pages.length : 10;
|
||||||
|
|
||||||
|
function copyCaption() {
|
||||||
|
const text = [slate.suggested_caption, slate.hashtags?.join(' ')].filter(Boolean).join('\n\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => alert('클립보드에 복사되었습니다!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-detail">
|
||||||
|
<div className="ic-detail__header">
|
||||||
|
<div className="ic-detail__title">
|
||||||
|
{slate.keyword}
|
||||||
|
<span style={{ marginLeft: 8 }}><StatusBadge status={slate.status} /></span>
|
||||||
|
</div>
|
||||||
|
<div className="ic-detail__actions">
|
||||||
|
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||||
|
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지 이미지 스트립 */}
|
||||||
|
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||||
|
<div className="ic-pages-strip">
|
||||||
|
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||||
|
<img
|
||||||
|
key={page}
|
||||||
|
className="ic-page-img"
|
||||||
|
src={getInstaAssetUrl(slate.id, page)}
|
||||||
|
alt={`Page ${page}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||||
|
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 캡션 */}
|
||||||
|
{slate.suggested_caption && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">
|
||||||
|
캡션
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--secondary ic-btn--sm"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
onClick={copyCaption}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ic-caption-text">{slate.suggested_caption}</div>
|
||||||
|
{slate.hashtags?.length > 0 && (
|
||||||
|
<div className="ic-hashtags" style={{ marginTop: 8 }}>
|
||||||
|
{slate.hashtags.join(' ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커버 카피 (1/10) */}
|
||||||
|
{slate.cover_copy && typeof slate.cover_copy === 'object' && (
|
||||||
|
<div className="ic-caption-box">
|
||||||
|
<div className="ic-caption-box__label">🎯 커버 (1/10)</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 프롬프트 템플릿 에디터 ══════════════════════════ */
|
||||||
|
const PROMPT_NAMES = ['slate_writer', 'category_seeds'];
|
||||||
|
|
||||||
|
function PromptTemplatesEditor() {
|
||||||
|
const [prompts, setPrompts] = useState({});
|
||||||
|
const [drafts, setDrafts] = useState({});
|
||||||
|
const [saving, setSaving] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
PROMPT_NAMES.forEach((name) => {
|
||||||
|
getInstaPrompt(name)
|
||||||
|
.then((p) => {
|
||||||
|
setPrompts((prev) => ({ ...prev, [name]: p }));
|
||||||
|
setDrafts((prev) => ({ ...prev, [name]: p.template }));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setPrompts((prev) => ({ ...prev, [name]: null }));
|
||||||
|
setDrafts((prev) => ({ ...prev, [name]: '' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSave(name) {
|
||||||
|
setSaving((prev) => ({ ...prev, [name]: true }));
|
||||||
|
try {
|
||||||
|
const updated = await putInstaPrompt(name, drafts[name] || '', prompts[name]?.description || '');
|
||||||
|
setPrompts((prev) => ({ ...prev, [name]: updated }));
|
||||||
|
alert(`${name} 저장 완료`);
|
||||||
|
} catch (e) {
|
||||||
|
alert('저장 실패: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving((prev) => ({ ...prev, [name]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-prompt-editor" style={{ marginTop: 24 }}>
|
||||||
|
<p className="ic-prompt-editor__title">프롬프트 템플릿</p>
|
||||||
|
{PROMPT_NAMES.map((name) => (
|
||||||
|
<div key={name} className="ic-prompt-block">
|
||||||
|
<div className="ic-prompt-block__head">
|
||||||
|
<span className="ic-prompt-block__name">{name}</span>
|
||||||
|
{prompts[name]?.updated_at && (
|
||||||
|
<span className="ic-prompt-block__date">
|
||||||
|
최종 수정: {fmtDate(prompts[name].updated_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{prompts[name]?.description && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,.4)', marginBottom: 6 }}>
|
||||||
|
{prompts[name].description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
className="ic-prompt-textarea"
|
||||||
|
value={drafts[name] ?? ''}
|
||||||
|
onChange={(e) => setDrafts((prev) => ({ ...prev, [name]: e.target.value }))}
|
||||||
|
placeholder={`${name} 템플릿을 입력하세요...`}
|
||||||
|
/>
|
||||||
|
<div className="ic-prompt-save-row">
|
||||||
|
<button
|
||||||
|
className="ic-btn ic-btn--primary ic-btn--sm"
|
||||||
|
onClick={() => handleSave(name)}
|
||||||
|
disabled={saving[name]}
|
||||||
|
>
|
||||||
|
{saving[name] ? <span className="ic-spinner" /> : null}
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
IconMusic,
|
IconMusic,
|
||||||
IconLab,
|
IconLab,
|
||||||
IconTodo,
|
IconTodo,
|
||||||
IconBlogMarketing,
|
IconInsta,
|
||||||
IconPortfolio,
|
IconPortfolio,
|
||||||
} from './components/Icons';
|
} from './components/Icons';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
|||||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||||
const Todo = lazy(() => import('./pages/todo/Todo'));
|
const Todo = lazy(() => import('./pages/todo/Todo'));
|
||||||
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
const MusicStudio = lazy(() => import('./pages/music/MusicStudio'));
|
||||||
const BlogMarketing = lazy(() => import('./pages/blog-marketing/BlogMarketing'));
|
const InstaCards = lazy(() => import('./pages/insta/InstaCards'));
|
||||||
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
|
const Portfolio = lazy(() => import('./pages/portfolio/Portfolio'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
@@ -103,13 +103,13 @@ export const navLinks = [
|
|||||||
accent: '#f43f5e',
|
accent: '#f43f5e',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'blog-lab',
|
id: 'insta',
|
||||||
label: 'Blog Lab',
|
label: 'Insta',
|
||||||
path: '/blog-lab',
|
path: '/insta',
|
||||||
subtitle: 'MONETIZE',
|
subtitle: 'CARD FEED',
|
||||||
description: 'AI 블로그 마케팅으로 수익을 만드는 연구소',
|
description: '뉴스에서 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드',
|
||||||
icon: <IconBlogMarketing />,
|
icon: <IconInsta />,
|
||||||
accent: '#10b981',
|
accent: '#ec4899',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'todo',
|
id: 'todo',
|
||||||
@@ -190,8 +190,8 @@ export const appRoutes = [
|
|||||||
element: <MusicStudio />,
|
element: <MusicStudio />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'blog-lab',
|
path: 'insta',
|
||||||
element: <BlogMarketing />,
|
element: <InstaCards />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'todo',
|
path: 'todo',
|
||||||
|
|||||||
Reference in New Issue
Block a user