307 Commits

Author SHA1 Message Date
c998753eea feat(insta): 카드 탭 트렌딩 키워드 중복 제거 + 10개씩 페이지네이션
KeywordsPanel이 전체 목록을 세로로 길게 표시하던 것을, 동일 keyword
중복 제거(최고 score 유지)·score 내림차순 후 페이지당 10개만 렌더하고
이전(←)/다음(→) 페이저로 탐색하도록 변경. 카테고리 변경 시 첫 페이지 리셋.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 03:03:36 +09:00
a846ab89e6 feat(lotto): 헤더 카드를 자율 학습 시스템으로 업데이트
Why: v1(능동 시그널) + v2(자율 가중치 학습) + v2.1(활동 가시화)로
시스템이 진화한 것을 반영. 기존 '시뮬레이션 추천 시스템' 3 bullet
→ '자율 학습 시뮬레이션' 4 bullet (학습 루프·시그널·시뮬·AI 큐레이터).
2026-05-23 02:43:47 +09:00
ef392f02ed refactor(evolver): Lotto 탭으로 통합 + 다크 테마 + activity 스크롤
- EvolverTab.jsx 신규 생성: evolver 컴포넌트를 탭 body로 추출
- Evolver.jsx → Lotto 페이지 thin wrapper로 교체 (/lotto/evolver URL 유지)
- Lotto.jsx: useLocation으로 pathname 감지 → initialTab 결정
- Functions.jsx: 4번째 탭 '🧬 자율 학습' 추가 + initialTab prop 수용
- Evolver.css: light → dark 테마 전환 (rgba/slate 팔레트), activity-list max-height+scroll 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 02:38:33 +09:00
2543dc335d feat(evolver): Evolver 페이지 + LottoActivityTimeline + EvolverActions + 라우터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 02:19:07 +09:00
b99d720179 feat(evolver): TrialsGrid + BaseDiff + BaseHistory 3 컴포넌트
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:16:15 +09:00
734bc6532e feat(evolver): WinnerCard — Radar + 이전 base overlay + 메타 정보 2026-05-23 02:14:58 +09:00
5fd32030ab feat(evolver): useEvolverApi hook (4 fetch + activity merge sort) 2026-05-23 02:14:16 +09:00
e8d33906ba feat(evolver): api.js에 evolver + lotto activity fetch helpers (6개) 2026-05-23 02:13:35 +09:00
6533743100 fix(stock): 총 매입을 각 종목 매입가의 단순 합으로 표시
요약카드(백엔드 매입가×수량)와 증권사별(매입가 단순 합) 총 매입이 서로
달라 혼란. 박재오 정의대로 총 매입 = Σ매입가(수량 미곱산)로 통일.
getBrokerSummary를 stockUtils.computeBrokerSummary로 추출(테스트 5건),
usePortfolio가 portfolioSummary.total_buy를 프론트 단순 합으로 override해
요약카드·증권사별·AI 프롬프트가 동일 값 사용. 손익은 avg_price×수량 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:15:58 +09:00
e42b643731 refactor(stock): 거래 데스크에서 AI 투자 탭 제거
TAB_AI 탭과 관련 컴포넌트(AiTradeTab)·훅(useAiBalance) 삭제. 헤더 카드는
aib 모의투자 요약 분기를 제거하고 항상 포트폴리오 요약을 표시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:30:44 +09:00
ee5700dc95 feat(agent-office): 모바일 사이드패널 전체화면 토글 + music 에이전트 이미지 교체
모바일 바텀시트(Commands/Tasks)가 55vh로 작아 내용 확인이 불편 → 헤더에
전체화면 토글 버튼 추가(100dvh 확장, 데스크톱은 숨김). music 에이전트
이미지를 agent_music_2로 교체.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:30:38 +09:00
ec5fee8429 chore(agent-office): drop unused break state styling
Backend no longer emits the 'break' state (see web-backend
de8adae). Remove the matching entry from STATE_COLORS and the
.ao-card-dot.break CSS rule. Safe because AgentCard's unknown-state
fallback (DEFAULT_STATE_COLOR) handles any stray legacy value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:44:58 +09:00
96cc5e7839 fix(agent-office): render TaskTab result_data when it's already an object
Old code assumed result_data was a JSON string and ran JSON.parse on it,
falling back to returning the value verbatim on parse error. When the
backend ships result_data as a dict (e.g. compose tasks return
{music_task_id, tracks}), JSON.parse threw, the catch returned the raw
object, and React threw error #31 'Objects are not valid as a React
child' the moment the user expanded the task row.

Extract formatResultData helper: object → JSON.stringify, JSON string
→ parse then pretty-print, plain string → as-is.

Regression tests cover all three input shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:38:06 +09:00
e6742e06ba fix(agent-office): unwrap {tasks}/{logs} response objects before .map
Backend returns {"tasks": [...]} and {"logs": [...]} but TaskTab and
LogTab stored the raw object and called .map on it, throwing
'l.map is not a function' the moment a user opened the Tasks or
Logs tab. Unwrap via Array.isArray check (also covers theoretical
bare-array responses).

Regression test for TaskTab covers all three response shapes:
wrapped object, bare array, and empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:34:08 +09:00
b713f00bf9 feat(agent-office): WS reconnect exponential backoff + status detail
- Replace fixed 3s reconnect with exponential backoff
  (1s/2s/4s/8s/16s/30s, capped). Reduces console noise when
  upstream WebSocket is blocked (e.g. DSM reverse proxy without
  WS upgrade headers).
- ws.onerror swallowed (onclose still schedules reconnect) so the
  browser stops printing an unhandled-error pair per attempt.
- Expose reconnectAttempt in hook; TopBar shows 'Connecting…'
  pre-first-attempt and 'Disconnected · 재연결 시도 #N' after.

Root cause of WS failure is upstream (curl proves the endpoint
itself is fine — see DSM reverse proxy WebSocket headers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:25:18 +09:00
0dce449124 chore(agent-office): convert agent PNGs to WebP (~93% smaller)
ffmpeg libwebp quality=85 compression_level=6.
Total: 11.8MB → 875KB (~11MB saved). Visually indistinguishable on
the card grid at the 9:16 image aspect.

PNG removals were already staged in the previous CommandTab commit;
this commit adds the 6 .webp replacements and points constants.js
imports at .webp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:58:12 +09:00
2c32659f6a fix(agent-office): useAgentManager reconnect via ref to satisfy lint
Previously connect's onclose handler referenced connect itself before
the useCallback declaration, triggering react-hooks/immutability. Hold
the latest connect in a ref (updated in useEffect) and call through it
on reconnect. Same runtime behavior, lint-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:58:04 +09:00
add2d8044c style(agent-office): neutral color for sidepanel state line
Was hardcoded #22c55e (green) regardless of actual state, making
error/break states look healthy. Switch to muted #94a3b8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:57:57 +09:00
2e9b0daec6 fix(agent-office): CommandTab approval state + blog→insta agent
- Approval card gated on 'waiting_approval' (was 'waiting'), matching
  the state useAgentManager emits — previously the approval UI was
  silently suppressed and pendingTask buttons unreachable
- QUICK_ACTIONS/PARAM_ACTIONS: drop blog (agent removed),
  add insta (extract / collect_trends / render)
- Regression test covers the three approval-card branches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:57:41 +09:00
46589c05b1 feat(insta): 슬레이트 캐러셀 + 반응형 레이아웃 전면 개선
문제:
- 페이지 1~10 미리보기가 가로 overflow인데 시각 affordance 없어서 page 2~10 못 봄
- 슬레이트 목록(.ic-slates-grid)이 모바일에서 어색 + 카드 자체가 viewport 밖으로 밀림

수정:
- PagesStrip 컴포넌트 신설: 좌/우 chevron + page 인디케이터(3/10) + 양옆 fade gradient
  + 키보드 ←/→ + scroll-snap + 클릭 페이지 이동 + 활성 카드 핑크 테두리/scale
- .ic-page-img width를 clamp(140px, 42vw, 220px)로 viewport 비례
- .ic-slates-grid 모바일 2칸 강제, 640px+ 부터 auto-fill
- .ic-detail에 min-width: 0 + max-width: 100% (자식이 부모 안 밀게)
- .ic-layout grid-template-columns에 minmax(0, 1fr) — 자식 overflow 시 부모 안정
- .ic 모바일 좌우 padding 12px (768px+ 16px)
2026-05-18 07:30:25 +09:00
2a9c8cb619 style(agent-office): match card image to 941x1672 aspect, fill grid width
- Card image aspect-ratio 1/1 → 941/1672 (real image ratio, no crop)
- object-fit cover → contain (defensive against rounding)
- Drop card aspect-ratio so it grows from natural image+name height
- Drop grid max-width 720px so grid fills the viewport width

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:00:30 +09:00
bcaf217b72 feat(agent-office): commit agent character images
6 PNGs for 5 active agents + 1 shared placeholder. Required by
constants.js imports; without these the build resolves them from
local disk but a clean clone or NAS deploy would 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:46 +09:00
18e309a14b feat(agent-office): replace canvas office with 3x3 agent grid
- AgentOffice renders TopBar + AgentGrid + dynamic right panel
- Right panel: SidePanel (active) / EmptyDetailPanel (initial or placeholder)
- TopBar simplified to connected status only (theme/zoom dropped)
- Wire AgentGrid through useAgentManager state
- Remove canvas/ (9 files), useOfficeCanvas, office-map.json
- New CSS for grid cards (state dot, notification badge, accent border)
- Mobile: 2-column grid + bottom-sheet panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:15 +09:00
80598cda93 refactor(agent-office): SidePanel uses central AGENT_META + image header
- emoji icon replaced with agent_{id}.png image
- AGENT_META imported from constants (single source of truth)
- blog removed, insta added (matches backend agent registry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:59:25 +09:00
e49457ca46 feat(agent-office): EmptyDetailPanel for initial + placeholder views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:58:06 +09:00
e04e2b010c feat(agent-office): AgentGrid renders 9 slots from GRID_SLOTS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:57:20 +09:00
3fd923400f feat(agent-office): PlaceholderCard for unstaffed slots
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:56:02 +09:00
6d1f4914ca test(agent-office): cover pulse class for AgentCard state dot
Adds two tests verifying that working state adds the pulse class and
idle state does not. Pulse animation is part of the design spec §5
but was not covered by the original 8 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:55:24 +09:00
1630109856 feat(agent-office): AgentCard component with state dot + badge
- state→color mapping via STATE_COLORS
- notification badge with 9+ overflow
- active prop for selected card border

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:52:34 +09:00
50d427e367 refactor(agent-office): derive ACTIVE_AGENT_IDS from GRID_SLOTS
Eliminates dual-write drift risk between ACTIVE_AGENT_IDS list
and GRID_SLOTS slot ordering. Single source of truth is now
GRID_SLOTS; ACTIVE_AGENT_IDS is computed from it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:42 +09:00
07f1d34f4c feat(agent-office): centralize AGENT_META + grid slot layout
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- AGENT_META, GRID_SLOTS, STATE_COLORS in single constants module
- blog removed (replaced by insta)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:47:47 +09:00
d2838dfb7a docs(agent-office): implementation plan for 3x3 grid redesign
11 tasks covering AGENT_META centralization, AgentCard/PlaceholderCard/
AgentGrid/EmptyDetailPanel new components, SidePanel image header,
TopBar simplification, canvas removal, build + manual verification.

TDD for pure logic (constants, AgentCard); visual verification for layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:36:52 +09:00
ce09f804b6 docs(agent-office): 3x3 grid redesign design spec
Replace pixel-office canvas with 3x3 agent image grid.
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- Drop blog from AGENT_META, insta replaces it
- New assets dir: src/pages/agent-office/assets/agents/
- Remove canvas/ dir + useOfficeCanvas + office-map.json
- Keep useAgentManager (WebSocket) + 4-tab SidePanel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:26:57 +09:00
534ded59e8 docs(signal-v2): amend spread formula to absolute (q90-q10) for Chronos-bolt zero-shot reality
Phase 0 spec §6.1 originally specified relative spread (q90-q10)/median < 0.6.
Phase 3b smoke (005930: median=-0.59%, q90-q10=15.3%) revealed Chronos-bolt
zero-shot median frequently sits near zero, causing relative spread to explode
(15.3/0.0059 ≈ 25) and reject every signal. Absolute spread (0.153 < 0.6)
preserves the threshold semantic and keeps Phase 7 IC validation tractable.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Total 16 tests. ~1 week.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:14:45 +09:00
a8e411ec22 feat(insta): replace Blog Lab page with Insta cards UI
/blog-lab → /insta route. New InstaCards page consumes insta-lab API
(news/keywords/slates + 10-page card preview + prompt template editor).
25개 blog-marketing API helper 제거, 13개 insta helper 추가.
2026-05-16 02:47:19 +09:00
f261a80d52 docs(signal-v1): web-ai V1 rename plan — 13 step atomic refactor
Single big task (Task 1) for atomic mv + load_dotenv update +
new CLAUDE.md/start.bat + verification. Task 2 = user manual
push + 30-min runtime verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:23:38 +09:00
42e9c8df27 docs(signal-v1): web-ai V1 → signal_v1/ rename spec
atomic refactor (single commit) of web-ai root V1 assets into
signal_v1/ subdirectory. V2 (signal_v2/) Phase 2 will be added
alongside in subsequent slice. Pattern mirrors stock-lab → stock
graduation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:19:00 +09:00
c84c6b5bac docs(signal-v2): Phase 1 implementation plan — 7 tasks TDD
7 tasks: auth.py + verify_webai_key (Task 1) → portfolio + pnl_pct
(Task 2) → news-sentiment (Task 3) → common edge cases (Task 4) →
docker-compose env (Task 5) → nginx config (Task 6) → deploy +
manual smoke (Task 7). 16 tests total (4 unit + 12 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:30:53 +09:00
094366a162 docs(signal-v2): Phase 1 stock WebAI API spec
3 endpoints + X-WebAI-Key auth + nginx rate limit + 15 tests.
brainstorming 7 decisions: scope=B / auth=A(static key) / portfolio=B(pnl_pct) /
news-sentiment=A(daily dump) / endpoint=1(/api/webai prefix) / rate=B(nginx) /
test=B(pytest schema).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:24:37 +09:00
3bf7ce446f docs: rename stock-lab → stock in CLAUDE.md (graduation) 2026-05-15 01:49:16 +09:00
8391919b90 docs(stock): rename plan — stock-lab → stock (6 tasks atomic)
Task 1 grep 사전 검토 → Task 2 web-backend atomic commit
(git mv + docker-compose + agent-office + nginx + 운영 문서) → Task 3 web-ui
CLAUDE.md → Task 4 workspace/CLAUDE.md → Task 5 메모리 (4 파일 + graduation
사례) → Task 6 배포 + NAS 검증.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:37:32 +09:00
ed7e927dc1 docs(stock): rename stock-lab → stock graduation spec
lab 네이밍 정책 (feedback_lab_naming.md) 에 따라 정식 서비스 graduation.
디렉토리/컨테이너/환경변수 갱신, API URL/Python import/DB 파일명은 그대로.

Confidence Signal Pipeline V2 Phase 1 작업 시작 전 선행.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:34:45 +09:00
309bedadeb docs(signal-v2): amend Phase 0 — Chronos-2 + Qwen3 14B Q4 채택 (11 보정)
모델 결정 보정:
- 시점 예측: LSTM → Chronos-2 (Amazon, 120M, zero-shot quantile 분포)
- 2차 검증: Claude Haiku 4.5 (API) → Qwen3 14B Q4 (Ollama on web-ai)

영향:
- VRAM: ~9.3GB / 16GB (Chronos-2 1GB + Qwen3 8.3GB, 여유 6GB)
- LLM 비용: 월 ~₩45,000 (오류 추정) → 실제 0 (로컬 Ollama)
- 응답 시간: 5초 → ~13초 (분봉 흐름 OK)
- Phase 3 -1주 (LSTM 학습 인프라 제거), Phase 5 +0주
- 야간 재학습 cron 폐기 (Chronos-2 zero-shot)

Backlog 추가:
- Qwen3 14B 개발자 보조 endpoint (전략 해석 / 코드 자동화)
- Claude API 폴백 (web-ai 장애 시)
- Kimi K2.6 API 옵션 (저비용 외부 대안)

위험 매트릭스:
- Qwen3 한국어 메시지 품질 — Phase 5 A/B 테스트 1주 (vs Claude Haiku)
- VRAM 동거 swap 위험 — 8B 다운그레이드 fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:21:24 +09:00
ebdfcd758b docs(signal-v2): Phase 0 architecture & contract spec
확신의 신호 파이프라인 V2. 4 서비스 분담:
- stock-lab: 가치 발굴 (8 노드 + ATR)
- web-ai (Windows GPU): 시점 분석 (LSTM + KIS 분봉/NXT + 분봉 모멘텀)
- agent-office: Claude 2차 검증 + 이중 텔레그램 (본인 풀 / 아내 간소화)
- web-ui: 캔버스 settings 편집

6 핵심 결정: pull 채널, KIS 직접, Claude context augmentation, 매수+매도,
양쪽 메시지 단일 콜 생성, 시간대별 폴링.

Phase 1-7 분해 (총 10-12주), backlog, 위험 매트릭스, NOT 범위 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:56:01 +09:00
cefaeca449 docs(ai_news): Phase 1 implementation plan — articles source (8 tasks)
8-task TDD plan. schema(source 컬럼) → articles_source 모듈 + 6 tests
→ analyzer(summary) → pipeline 교체 → telegram 매핑 라인 → router →
scraper deprecate → 운영 검증. 신규 단위 테스트 6 + 갱신 4.

선행 spec: docs/superpowers/specs/2026-05-14-ai-news-articles-source-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:58:27 +09:00
cdfa31b0c1 docs(ai_news): Phase 1 design — articles table reuse (replace Naver scraper)
기존 articles 테이블 재사용 + 종목명 substring 매핑. Naver 차단/중복
인프라 해소, LLM 입력에 summary 포함. weight=0 유지 (검증 전 차단),
news_sentiment.source 컬럼 추가 (Phase 2 비교 baseline).

선행 review (adversarial)에서 가장 강한 비판: "이미 매일 수집 중인
articles 테이블을 무시하고 Naver를 100번 더 긁는 중복" → 해소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:47:25 +09:00
ec3ca5fcfa feat(screener): canvas adds AI news node (12 nodes, 18 edges) 2026-05-13 23:51:09 +09:00
7ebeba2f3d docs(screener): AI news sentiment node implementation plan (15 tasks)
15-task TDD plan for 8th score node ai_news. backend (scraper + analyzer +
pipeline + telegram + node + router) + agent-office (service_proxy + cron
handler + scheduler) + frontend (canvasLayout 1 file). 단위 테스트 22개
(scraper 4, analyzer 4, pipeline 3, telegram 4, node 5, router 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:18:00 +09:00
5e66d96c61 docs(screener): AI news sentiment node design spec
8번째 점수 노드 ai_news 설계. 평일 08:00 KST agent-office cron 으로
시총 상위 100종목 네이버 뉴스 스크래핑 + Claude Haiku 호재/악재 분석,
news_sentiment 일별 저장, 호재/악재 Top 5 텔레그램 알림, 16:30
스크리너 잡이 percentile_rank 로 가중합에 활용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:09:45 +09:00
fde63d757b docs(screener): note canvas mode in page structure 2026-05-13 21:57:10 +09:00
4b64761800 fix(screener): silence ESLint no-empty / no-undef in canvas helpers
빈 catch 블록 3곳에 의도 주석 추가, test-setup.js 의 beforeEach
명시적 import. 우리 신규 코드의 lint error 0으로 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:56:42 +09:00
1449342f96 style(screener): canvas mode styles (toggle, nodes, toolbar, layout) 2026-05-13 21:55:10 +09:00
2effc47593 feat(screener): integrate mode toggle (form|canvas) with lazy canvas 2026-05-13 21:52:31 +09:00
f8574f1b45 feat(screener): CanvasLayout (canvas + result grid) 2026-05-13 21:51:16 +09:00
2da7255c03 feat(screener): ScreenerCanvas root component (react-flow + 11 nodes + 16 edges) 2026-05-13 21:49:19 +09:00
b4ad0b1abf feat(screener): CanvasToolbar floating panel 2026-05-13 21:47:52 +09:00
4e134eb59a feat(screener): ScoreNodeCard with weight slider + active toggle + params 2026-05-13 21:46:09 +09:00
b1a1bb22f9 feat(screener): GateNodeCard for hygiene gate 2026-05-13 21:43:39 +09:00
f10fa062e9 feat(screener): FixedNodeCard for data/combine/result nodes 2026-05-13 21:43:25 +09:00
40e3e2cf39 feat(screener): ModeToggle segment control component 2026-05-13 21:42:07 +09:00
1505518ca6 feat(screener): useCanvasLayout hook (node positions + reset) 2026-05-13 21:40:37 +09:00
2fd2ea33c7 feat(screener): useScreenerMode hook (form|canvas + localStorage) 2026-05-13 21:38:31 +09:00
c60c32b7f2 feat(screener): add canvas layout constants (11 nodes, 16 edges) 2026-05-13 21:35:27 +09:00
5f95f55271 chore(screener): add @xyflow/react + vitest test environment 2026-05-13 21:32:05 +09:00
d73ad9b851 docs(screener): node canvas mode implementation plan (15 tasks)
15-task TDD plan. 의존성 + 테스트 환경 셋업 → 상수/hooks/카드/캔버스 →
Screener.jsx 통합 → 수동 검증 + 배포. 단위 테스트 20개 (4 파일),
react-flow lazy import로 모바일 번들 보호.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:29:22 +09:00
fdf5ef6ce8 docs(screener): node canvas mode design spec
n8n 스타일 노드 캔버스 모드 설계 문서. 폼 모드와 토글로 전환,
같은 settings state 공유. 11 노드 + 16 엣지 고정 토폴로지, react-flow
기반 시각화. 백엔드 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:22:57 +09:00
ca248891c2 feat(stock): 스크리너 모바일 카드 레이아웃 + 비교 적용
데스크탑은 기존 테이블 유지, <768px에서는 종목별 카드로 전환:
- 카드 헤더: #순위 | 종목명+코드 | 총점
- 비교 모드 ON 시: 순위Δ/점수Δ 두 줄
- 노드 칩 (가로 wrap)
- 진입/손절/익절/위험 2×2 그리드 (라벨 + 원 단위)
- 빠진 종목(OUT)도 카드로 회색 표시

CSS: .screener-mobile-list / .screener-mcard / .screener-result-head /
.screener-warn 추가. useIsMobile 훅으로 분기.
2026-05-13 12:26:16 +09:00
55d2adeaf5 feat(stock): 미리보기 결과 세션 히스토리 + 결과 비교 컬럼
- useScreenerRun: 실행 시마다 previewHistory에 누적 (최대 10, 메모리만 —
  새로고침 시 사라짐, DB 부하 없음). top_ticker/score 요약 포함.
- RunHistoryList: '이번 세션 미리보기'와 '저장된 실행' 두 섹션으로 분리.
  미리보기 항목은 클릭으로 결과 표 로드 + '비교' 버튼으로 비교 대상 지정.
- ResultTable: compareWith prop으로 비교 모드. 순위Δ(▲▼NEW)·점수Δ
  컬럼 추가, 이번엔 빠진 종목은 'OUT'으로 별도 섹션에 회색 표시.
- 헤더에 'vs HH:MM:SS (통과 X)' 라벨로 비교 대상 명시.
2026-05-13 08:16:21 +09:00
6fd70dd802 feat(stock): 스크리너 노드/컬럼 hover 설명 추가
- ScoreChips: 아이콘 제거, 풀 라벨 표시 (외국인/거래량급증/20일모멘텀/
  52주신고가/RS레이팅/이평선정배열/VCP수축). title에 노드 의미 + 70점
  강조 안내.
- ResultTable: 각 컬럼 헤더에 ⓘ 마커 + 의미 hover 설명. 진입/손절/익절
  컬럼명에 '(원)' 명시. 상단에 hover 가이드 한 줄 추가.
2026-05-13 07:52:14 +09:00
9f4363cdbb fix(deploy): PowerShell single-quote literal로 path 전달
기존 replace(/\/g, "\\\\") + double-quote escape 패턴이 PowerShell
-Command 컨텍스트에서 한 번 더 escape돼 백슬래시가 두 개씩으로
부풀려져 Test-Path가 실패하던 케이스 fix. single-quote로 raw literal
전달 — env override(NAS_FRONTEND_DEST_WIN)가 의도대로 그대로 적용됨.
2026-05-13 07:31:48 +09:00
295972e0cb Merge feature/stock-screener-board: Stock Screener Board MVP (frontend)
- /stock/screener 라우트 + 페이지 골격 + hooks 4 + 컴포넌트 7
- NodeCard 자동 폼 (param_schema), ScoreChips (노드 70점 강조)
- 모바일 적층 + 표 가로 스크롤
- API 헬퍼 7개 + CLAUDE.md 갱신
- Stock 페이지 nav 링크
2026-05-13 07:23:17 +09:00
e6659a416a style(stock): 스크리너 모바일 적층 + 표 가로 스크롤 2026-05-12 14:23:16 +09:00
3abd46c0fd docs(stock): CLAUDE.md 스크리너 API 표 추가 + Stock 페이지 링크 2026-05-12 14:22:18 +09:00
c42d3fe8d4 feat(stock): ResultTable 본구현 + ScoreChips (노드 칩 + 70점 강조) 2026-05-12 14:21:05 +09:00
1e8542f6c7 feat(stock): GatePanel 자동 폼 + GlobalControls (TopN/ATR/RR + 3버튼) 2026-05-12 14:19:36 +09:00
a11475db57 feat(stock): NodeCard 자동 폼 (param_schema 기반) + NodePanel 통합 2026-05-12 14:18:22 +09:00
bc2c020f71 feat(stock): /stock/screener 페이지 골격 + hooks 4개 + 컴포넌트 stub 6개 2026-05-12 14:15:36 +09:00
cd6072727f feat(stock): /stock/screener 라우트 + 임시 placeholder 2026-05-12 14:13:26 +09:00
42ebd5a87c feat(stock): screener API 헬퍼 7개 2026-05-12 14:11:51 +09:00
3b66a47316 docs(plan): 데이터 소스 pykrx → FDR + 네이버 스크래핑 (Task 0.1/0.3)
실측 결과 pykrx의 시장 전체 함수 (get_market_ticker_list,
get_market_cap, get_market_ohlcv_by_ticker)가 모두 KRX 인증
요구로 깨짐. Task 0.1 의존성을 finance-datareader + bs4 + lxml
로 교체하고 Task 0.3 snapshot.py는 FDR + 네이버 frgn 스크래핑
방식으로 재작성 (implementer dispatch 시 인라인 안내).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 04:03:31 +09:00
f7323a5b72 docs(plan): Stock Screener Board MVP 구현 plan
6 Phase × 35 task. Phase 0(백엔드 기반)·Phase 1(노드 8개 TDD)·
Phase 2(엔진/사이저/텔레그램)·Phase 3(라우터)·Phase 4(프론트)·
Phase 5(agent-office 통합)·Phase 6(백필·검증·배포).
모든 task에 TDD step + 코드 + 명령 명시. 로컬 venv 기반
실행으로 메모리 규약(로컬 docker 금지) 준수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:48:20 +09:00
ccf6d4e551 docs(spec): Stock Screener Board MVP 설계 문서
KRX 강세주 발굴 노드 기반 분석 보드의 첫 슬라이스 설계.
pykrx 일봉·수급 캐시 + 위생 게이트 1 + 점수 노드 7
(외국인 누적 매수·거래량 급증·20일 모멘텀·52주 신고가 근접도·
RS Rating·이평선 정배열·VCP-lite) + 가중합 + ATR 포지션 사이징.
평일 16:30 KST agent-office 자동 잡으로 텔레그램 전송.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:33:35 +09:00
a20315ce34 feat(stock): 포트폴리오 현재가에 NXT 시간외 거래 뱃지
백엔드 응답의 price_session에 따라 NXT 프리마켓/애프터마켓 거래 중인
종목에 작은 'NXT' / 'NXT 프리' 뱃지를 표시. 툴팁에 거래 시각 노출.
정규장 마감 후에도 평가금액이 자연스럽게 이어지는 흐름을 시각적으로 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:32:26 +09:00
3fa4dbda3c Merge feature/lotto-curator-evolution: Lotto Curator Evolution (frontend)
7 commits for Phase F + G:
- api.js: getLatestReview / getReviewHistory / bulkPurchase 헬퍼
- useReview 훅 + useBriefing 4계층 normalize
- DecisionCard + RetrospectiveBox + TierModeToggle + TierSection + PickCard + BulkPurchaseButton
- BriefingTab 단일 화면 재구성
- 분석탭 → 자료실 라벨 + 9개 패널 details 접힘
- PurchasePanel 자동 채점 일치수 배지 + 4등↑ 플래그
- 4주 추세 차트(너 vs 큐레이터 평균 일치)

자세한 컨셉/계획: docs/superpowers/{specs,plans}/2026-05-11-*.md
2026-05-11 09:39:08 +09:00
baf34dd7aa feat(lotto): 구매탭 4주 추세 차트(너 vs 큐레이터 평균 일치) 2026-05-11 09:06:54 +09:00
4ef76f6cce feat(lotto): 구매탭에 자동 채점 일치수 배지 + 4등↑ 플래그 2026-05-11 09:05:22 +09:00
0bf1233e96 feat(lotto): 분석탭 → 자료실 라벨 + 첫 진입 모든 패널 접힘 2026-05-11 09:03:10 +09:00
ff7ac48c6b feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성 2026-05-11 09:00:59 +09:00
329141c732 feat(lotto): DecisionCard 하위 컴포넌트(Pick/Tier/Toggle/Retro) + 스타일 2026-05-11 08:59:00 +09:00
cd3c538eb7 feat(lotto): useReview 훅 + useBriefing 4계층 정규화 2026-05-11 08:57:14 +09:00
9d2dfad512 feat(api): review + bulkPurchase 헬퍼 2026-05-11 08:56:10 +09:00
42073a5bf3 docs(plan): Lotto Curator Evolution 구현 plan
23 task로 분해 (TDD 사이클 + 빈번한 commit):
- Phase A (1-2): weekly_review 테이블 + 4계층 마이그레이션
- Phase B (3-5): 채점 보조 함수 + 통합 잡 + cron
- Phase C (6-8): review/bulk/briefing 라우터
- Phase D (9-12): 큐레이터 4계층 스키마 + 회고 + pipeline
- Phase E (13-15): 텔레그램 알림 + webhook + cron 변경
- Phase F (16-19): api 헬퍼 + 훅 + DecisionCard
- Phase G (20-22): 자료실 강등 + 자동채점 표시 + 추세 차트
- Phase H (23): 1주차 운영 점검

스펙→코드베이스 보정 사항(테이블명/기존 컬럼/기존 자동채점) plan 상단에 명시
2026-05-11 04:26:00 +09:00
6b2fcda2af docs(spec): Lotto Curator Evolution 설계 문서
매주 같은 시간에 큐레이터가 한 번 더 똑똑해지는 컨셉으로
- 회고 컨텍스트(weekly_review + 자동 채점 잡)
- 4계층 위계(코어/보너스/확장/풀, 5~20세트)
- 결정 카드 단일 화면(브리핑 탭 재구성)
- 분석 탭은 자료실로 강등
- 월요일 09:00 큐레이션 + 텔레그램 푸시
2026-05-11 03:19:58 +09:00
acac2cd20e chore: ignore .superpowers/ (visual companion mockup files) 2026-05-11 03:19:57 +09:00
95edc9d232 feat(web-ui): 배치 장르 목록 동적 fetch (POOLS 추가 시 자동 반영) 2026-05-10 23:53:49 +09:00
ec22321d56 fix(deploy): NAS_FRONTEND_DEST_WIN env로 Z: 매핑 변경 대응 2026-05-10 19:02:49 +09:00
a80b869878 feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress 폴링 2026-05-10 19:00:42 +09:00
93d5f49cdb feat(web-ui): PipelineStartModal '원하는 이미지 분위기' 메인 필드로 노출 2026-05-10 16:17:36 +09:00
3f5cd32c77 feat(web-ui): SetupTab visual_defaults 6옵션 확장 2026-05-09 13:36:26 +09:00
120c39a3ef feat(web-ui): PipelineDetailModal + 카드 mini 미리보기 2026-05-09 13:34:54 +09:00
08fce2d4f6 feat(web-ui): PipelineStartModal Mix 입력 라디오 + 고급 옵션 2026-05-09 13:32:23 +09:00
9c12de4593 feat(web-ui): CompileTab '영상 만들기' 버튼 + createPipeline payload 시그니처 2026-05-09 13:30:31 +09:00
53e9938903 fix(web-ui): PipelineStartModal에 initialTrackId 전달 2026-05-07 17:44:00 +09:00
522b7695aa feat(web-ui): YouTube 6 서브탭 + Library 영상 파이프라인 트리거 2026-05-07 17:31:37 +09:00
9ffd7889e7 feat(web-ui): PipelineTab — 진행 중 파이프라인 카드 보드 2026-05-07 17:28:14 +09:00
5bba880c23 feat(web-ui): SetupTab — YouTube 자동화 구성 허브 2026-05-07 17:25:53 +09:00
4498124514 feat(web-ui): pipeline/setup/youtube API 헬퍼 2026-05-07 17:23:51 +09:00
b6748ecd27 chore: 하네스 settings.json + CLAUDE.md 깨진 spec 참조 제거
- .claude/settings.json: git/npm/npx/node/ls allowlist + 민감파일 deny.
- CLAUDE.md: 삭제된 realestate-targeting-enhancement-design.md 참조 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:06:15 +09:00
397257cf3b docs: STATUS.md — 구현 현황 + 향후 계획 정리
페이지·서비스별 완료/예정 항목 인덱스. CLAUDE.md를 보완.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:12 +09:00
d38ee553c3 fix(stock): 포트폴리오 카드 모바일 금액 줄바꿈 방지
천만원 단위 이상에서 '원'이 다음 줄로 넘어가던 문제 해결.
값 길이별 폰트 단계 축소(is-fit-sm/xs) + nowrap 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 03:54:20 +09:00
4acdc451c0 feat(music): YouTube 탭 + 컴파일 기능 통합
- YouTube 탭 (영상 제작, 수익 추적, 시장 트렌드, 컴파일) 연결
- Create 탭 트랙 제목 직접 입력
- TrendsTab 히스토리 상세 + 메타데이터 수정
- 다중 트랙 FFmpeg concat 컴파일 서브탭 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:01:12 +09:00
f3b0b2c109 feat(music): YouTube 탭 컴파일 서브탭 추가 (다중 트랙 FFmpeg concat)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:58:40 +09:00
4281c1873f feat(music): Create 탭 트랙 제목 직접 입력 추가 2026-05-01 15:49:56 +09:00
8a7b5e8a38 fix(music): setTimeout 정리 + useCallback 폴링 deps
- TrendsTab: useRef로 타이머 ID 추적 후 언마운트 시 clearTimeout 호출 (stale setState 방지)
- VideoProjectsTab: loadProjects를 useCallback으로 감싸고 폴링 useEffect deps에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:13:04 +09:00
08981a292a feat(youtube-tab): MusicStudio YouTube 탭 연결 + CSS + Library 버튼
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:08:15 +09:00
ed95f6678f fix(music): TrendsTab 리포트 이력 메타데이터 장르/추천수 표시
리포트 목록 행의 메타 정보를 insights 미리보기에서 장르/추천 개수로 교체.
이제 list 응답에 top_genres·recommended_styles가 포함되므로 클릭 시
장르 차트와 Suno 프롬프트가 정상 표시됨.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:03:34 +09:00
1847771ad2 fix(music): TrendsTab 로딩상태·에러피드백·메타데이터 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:01:38 +09:00
0f0ca8610d fix(music): TrendsTab 리포트 selected_styles 표시 + created_at 시간 포맷
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:56:47 +09:00
3f2fdb095c feat(youtube-tab): TrendsTab 시장 트렌드 서브탭 2026-05-01 14:51:10 +09:00
3e54b2c98d feat(youtube-tab): RevenueTab 수익 추적 서브탭 2026-05-01 14:48:47 +09:00
16b8cc59ae feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭 2026-05-01 14:46:27 +09:00
a89de57b79 feat(youtube-tab): YoutubeTab 서브탭 shell 컴포넌트 + 스텁 탭 추가 2026-05-01 14:44:21 +09:00
413dccb655 feat(api): video-project / revenue / market-trends API 함수 추가 2026-05-01 14:42:15 +09:00
d1526af32c feat(subscription): 청약 일정 캘린더 뷰 추가
공고 목록 탭에 📅 캘린더 토글 버튼 추가.
캘린더 모드: 월간 그리드, 접수 시작일 기준 도트 표시 (상태별 색상).
날짜 클릭 시 해당일 공고 목록 패널 표시, 항목 클릭 시 상세 뷰로 전환.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:35:44 +09:00
abd8762b5c feat(subscription): 프로필 완성도 힌트 배너 추가
소득·면적·예산·자치구 티어 중 미입력 항목이 있으면
프로필 패널 상단에 입력 권장 안내 배너 표시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:31:37 +09:00
8514232775 feat(subscription): 소득 기준 힌트 표시 + input 범위 제한
income_level 입력 필드에 특별공급별 소득 기준(%)'을 안내 텍스트로 표시.
미입력 시 검증 생략됨을 명시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:12:34 +09:00
6c1f19e690 fix(deploy): rsync 대신 tar|ssh 방식으로 전환 (Synology rsync --server 차단 우회)
Synology가 rsync 바이너리를 패치하여 --server 모드도 데몬 인증을 요구함.
SSH는 정상 동작하므로 tar czf | ssh로 대체하여 배포 가능하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:48:37 +09:00
35ce362d20 fix(deploy): -e 인자 단따옴표 변경 + 키파일 존재 검증
- -e 'ssh ...' 단따옴표 사용으로 -i 경로의 따옴표 충돌 방지
- 키 파일 없을 때 명확한 에러 메시지 출력 후 종료

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:39:27 +09:00
11e4f00ae6 fix(deploy): rsync SSH에 -i 키파일 명시 (macOS Keychain 우회)
macOS에서 rsync 서브프로세스는 Keychain 키를 자동 로드하지 못해
비밀번호 프롬프트로 fallback됨. -i ~/.ssh/id_rsa 명시로 해결.
- BatchMode=yes: 비밀번호 프롬프트 차단 (명확한 에러 반환)
- StrictHostKeyChecking=accept-new: 최초 연결 host key 자동 수락
- NAS_SSH_KEY 환경변수로 다른 키 파일 지정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:35:56 +09:00
b11d1c421d fix(deploy): SSH env값 제어문자 sanitize + 포트 검증
- NAS_SSH_TARGET/PORT/PATH에서 \r\n\t 제거 (잘못된 export·copy-paste 대비)
- NAS_SSH_PORT는 숫자만 허용, 잘못된 값이면 명확한 오류 메시지 출력
- SSH deploy 실행 시 cleanTarget/sshCmd 값 콘솔에 출력 (디버그용)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:27:11 +09:00
f6d95264c3 fix(deploy): Mac SSH 배포 지원 + .env.local 자동 로드
- .env.local 파일에서 NAS_SSH_TARGET 등 SSH 설정 자동 로드
- NAS_SSH_TARGET 설정 시 SMB 마운트보다 SSH 우선 사용
- SMB 쓰기 실패(EIO) 시 스택트레이스 대신 SSH 설정 안내 메시지 출력
- .env.local을 .gitignore에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:21:32 +09:00
7cbdbe6e8b feat(subscription): 5축 점수 breakdown 시각화 + 알림 대상 카운트
- AnnouncementDetail: 5축(지역/유형/면적/가격/자격) progress bar 추가
- MatchesTab: 카드마다 미니 5축 비례 바 추가 (색상 구분)
- ProfileTab: 마운트 시 dashboard도 함께 fetch → pass_count 취득
- NotificationSettings: passCount prop → "현재 N건 대상" 인라인 표시
- Subscription.css: .ns-pass-count 스타일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:56:36 +09:00
573c0364bb style(subscription): 5티어/드래그/토글/슬라이더 다크 네온 테마 정렬
- 5티어 뱃지: light pastel → 페이지 accent 팔레트 + neon glow
  S=rose / A=orange / B=mint / C=cyan / D=purple (모두 반투명 bg + bright text + glow)
- DistrictTierEditor: surface-card glass + rose dragOver glow
- 자치구 칩: surface-raised + rose hover lift/glow
- sub-toggle: 다크 호환 + rose 활성 glow
- ns-slider: custom thumb (rose + glow + scale on hover)
- 매칭 분석: surface-card + rose 사이드 그라데이션 + 점수 text-shadow
- 모든 텍스트는 --text/--text-bright/--text-dim/--text-muted 토큰
- font-family: --font-display(--font-body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:36:01 +09:00
7f42ff3594 Merge branch 'feat/realestate-frontend-targeting'
청약 페이지 자치구 5티어 + 알림 설정 UI — 9 task TDD 구현
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only
- NotificationSettings: 임계값 슬라이더 + 알림 토글
- AnnouncementCard / MatchesTab: district + 5티어 뱃지
- AnnouncementDetail: 매칭 분석 섹션 (점수 + 사유 + 자격)
- 백엔드 스펙: web-backend 2026-04-28-realestate-targeting-enhancement
- 빌드 clean, 린트 baseline 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:37:10 +09:00
1c331f209a fix(subscription): CLAUDE.md districts shape + dragLeave 정확도
preferred_districts 문서 형태를 백엔드 실제 구조(tier-keyed Dict[str, List[str]])로 수정.
onDragLeave가 자식 요소로 커서 이동 시 flicker 발생하던 문제 수정(relatedTarget 체크).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:31:24 +09:00
c87e764063 docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:26:19 +09:00
80fcb07fc0 feat(subscription): MatchesTab 카드에 district + 5티어 뱃지
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:26:15 +09:00
a9a6808005 feat(subscription): AnnouncementDetail에 매칭 분석 섹션
match_score가 있는 공고에 한해 매칭 분석 섹션을 상세 패널 하단에 노출.
점수·매칭 사유·신청 자격 타입을 조건부 렌더링.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:08:10 +09:00
0a0ab05e41 feat(subscription): AnnouncementCard에 district + 5티어 뱃지 2026-04-28 11:06:32 +09:00
f6e78ac0ca feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:04:13 +09:00
60f17ff3e0 feat(subscription): ProfileTab에 5티어/알림 설정 통합
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:01:41 +09:00
344caace3a feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글 2026-04-28 10:58:45 +09:00
9e5521d784 feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:55:54 +09:00
3b3e4a1ee1 feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼 2026-04-28 10:51:45 +09:00
a9d9540f61 fix(portfolio): 기술 스택 로고를 정적 4줄 레이아웃으로 변경
LogoLoop 무한 캐러셀이 항목 수가 적은 카테고리에서 반복돼 시각적으로 산만한
문제. 카테고리별로 단순 flex-wrap 줄로 정적 표시. SkillLogoNode와 fallback
로직은 유지. LogoLoop 컴포넌트 자체는 다른 페이지에서 재사용 여지를 위해 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:49:00 +09:00
c68cee502a feat(portfolio): 기술 스택을 SimpleIcons 로고 무한 캐러셀로 표시
LogoLoop 컴포넌트를 추가해 카테고리별(언어/프레임워크/인프라/도구) 4줄
가로 스크롤 캐러셀로 skill 로고를 표시. simpleicons CDN을 사용하며
매칭 안 되는 항목(APScheduler, KIS Open API)은 텍스트 칩으로 자동 fallback.
편집 모드에서는 기존 칩 UI를 유지해 편집·삭제 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:43:57 +09:00
1bd680e47f chore(nav): 사이드바 메뉴 순서 재배치
Home → Portfolio → Blog → Travel → Lotto → Stock → Music → Realestate
→ Blog Lab → Todo → Agent Office → Lab 순으로 navLinks 재정렬.
BottomNav도 동일 source를 사용해 모바일 더보기 패널까지 함께 반영됨.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:28:16 +09:00
60655f8ba9 fix(portfolio): apiFetch에서 Content-Type 헤더가 options.headers에 덮여 사라지는 문제 수정
PUT /api/profile/profile 등 인증 헤더를 함께 보내는 요청에서 Content-Type이
빠져 FastAPI가 body를 JSON으로 파싱하지 못해 422를 반환하던 문제. spread 순서를
뒤집어 options 펼친 뒤 headers를 마지막에 머지하도록 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:01:14 +09:00
a50c6c8be2 docs: CLAUDE.md 서비스 네이밍 변경 + personal 서비스 반영
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:29:23 +09:00
b88ae331d7 fix(portfolio): 모바일에서 편집/PDF 툴바 플로팅 버튼으로 표시
768px 이하에서 display:none이던 toolbar를 우하단 FAB 스타일로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 15:21:14 +09:00
a56923a6b3 refactor(home): Profile 섹션 portfolio API 연동
- /api/profile/public에서 프로필·기술스택 동적 로드
- 서비스 미가동 시 하드코딩 폴백 유지
- "프로필 수정" → "포트폴리오 보기" Link로 교체
- 타임라인 섹션 제거 (포트폴리오 페이지에서 관리)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:38:18 +09:00
a6dd2ef747 feat(portfolio): 포트폴리오 페이지 전체 구현
- 3탭 구조: 프로필&경력, 프로젝트, 자기소개
- 비밀번호 인증 → 편집 모드
- 클립보드 복사, PDF 내보내기 (window.print)
- 사이버펑크 테마 CSS, 모바일 반응형

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:37:25 +09:00
bebd55874c fix(todo): 모바일 최적화 — 터치 타겟 44px, 라벨 버튼, 확인 시트, 탭 인디케이터
- 카드 액션 버튼 36px→44px + 아이콘+텍스트 라벨 (모바일)
- 날짜 필터/입력 터치 타겟 36px min-height로 확대
- 빈 상태 메시지 모바일 적절하게 변경 ("드래그하여 이동"→"아직 항목이 없습니다")
- 완료 비우기 MobileSheet 확인 다이얼로그 (모바일)
- 완료 탭 내 "비우기" 버튼 추가
- SwipeableView 활성 탭 하단 인디케이터 + 44px 높이
- 폼 라벨 14px, 입력 16px (iOS 줌 방지)
- 모바일 컬럼/패널 배경·보더 제거로 공간 절약

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:39:09 +09:00
6cbdf95596 fix(agent-office): critical bug fixes from code review — wall pathfinding, drag/click, DPR, culling
- Pathfinder.setBlocked: remove blocked.clear() to preserve wall tiles set by setWalls()
- Pathfinder.findPath: fix dead-code goal exception — remove redundant isBlocked check, keep goal-tile exception in single guard
- OfficeRenderer: track mouseDownPos/_wasDragging; expose wasDragging() method for click-after-drag suppression
- OfficeRenderer._render: track _lastDpr to detect monitor DPR changes; use setTransform instead of scale to avoid accumulation
- TileMap.render: use clientWidth/clientHeight for viewport culling (CSS space, not buffer pixels)
- TaskTab: wrap JSON.parse in try/catch to prevent crash on malformed result_data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:40:08 +09:00
3e4f2e0934 chore(agent-office): remove legacy dashboard components replaced by v2 UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:18 +09:00
31fc2dfb0d refactor(agent-office): rewrite CSS for full-screen canvas layout with mobile bottom sheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:15 +09:00
403046c4d0 refactor(agent-office): rewrite AgentOffice with full-screen canvas and side panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:11 +09:00
b03f438935 refactor(agent-office): rewrite useOfficeCanvas hook for new renderer API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:07 +09:00
22a37cf6d9 refactor(agent-office): extend useAgentManager with lotto agent and refresh triggers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:38:04 +09:00
6bd6cbd635 feat(agent-office): add SidePanel container with 4-tab layout 2026-04-27 08:35:00 +09:00
4c930c2cf8 feat(agent-office): add LogTab with auto-scroll and level coloring 2026-04-27 08:34:56 +09:00
efeecadbef feat(agent-office): add TokenTab with usage stats and cache hit rate 2026-04-27 08:34:53 +09:00
a712a2f43b feat(agent-office): add TaskTab component with expandable task history 2026-04-27 08:34:50 +09:00
ce245609f9 feat(agent-office): add CommandTab with quick actions, params, and approval UI 2026-04-27 08:34:48 +09:00
43904d033a feat(agent-office): add TopBar component with theme and zoom controls 2026-04-27 08:34:45 +09:00
379ad41e32 feat(agent-office): add overlay renderer with labels, badges, and speech bubbles 2026-04-27 08:33:36 +09:00
f3de315272 refactor(agent-office): wire real AgentSprite import, remove Phase 1 stub 2026-04-27 08:32:22 +09:00
71fe91cc85 feat(agent-office): add SpriteLoader with procedural fallback for Phase 2 2026-04-27 08:32:19 +09:00
7dd2cc9793 refactor(agent-office): rewrite AgentSprite with BFS movement and idle wandering 2026-04-27 08:32:16 +09:00
f01a432329 feat(agent-office): add 16x32 procedural sprite with 5 states and 4 directions 2026-04-27 08:32:13 +09:00
d4279f2e3b refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting 2026-04-27 08:29:02 +09:00
8207205418 feat(agent-office): add procedural furniture renderer with theme support 2026-04-27 08:28:59 +09:00
95b3f2b37c refactor(agent-office): rewrite TileMap with theme support and viewport culling 2026-04-27 08:28:56 +09:00
eab8ef295b feat(agent-office): add BFS pathfinder for agent movement 2026-04-27 08:28:53 +09:00
f11f9c529e feat(agent-office): expand office map to 32x20 with 5 agents and break room 2026-04-27 08:28:49 +09:00
d24c04f9fa feat(agent-office): add theme data definitions (modern/retro/minimal) 2026-04-27 08:28:46 +09:00
b7ee9fe3fd docs: CLAUDE.md·README.md 최신 상태 반영 2026-04-27 07:35:16 +09:00
b8eb290e4d feat(travel): 좌표 없는 커스텀 지역에 항상 "위치 지정" 버튼 표시
지역 변경 직후뿐 아니라, 앨범의 지역이 좌표 미지정 커스텀
지역이면 헤더에 핀 버튼을 상시 노출. 기존 좌표가 있으면
RegionPinPicker에 초기값으로 전달.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:20:00 +09:00
fba101500e feat(travel): 지도 핀 마커 + 위치 지정 모달 (Phase 2)
MiniMap에 Point geometry 핀 마커 렌더링, 앨범 지역 변경 후
"위치 지정" 버튼으로 RegionPinPicker 모달을 열어 좌표 저장.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:12:17 +09:00
9b8daeffa4 feat(travel): 앨범 지역 편집 UI — 텍스트 입력 + 자동완성
- AlbumDetail 헤더의 지역 라벨 클릭 → 인라인 편집 모드
- 기존 지역 목록 자동완성 제안 + 새 지역명 직접 입력 가능
- Enter/클릭으로 저장, Esc/✕로 취소
- PUT /api/travel/albums/{album}/region 호출 → 앨범 목록 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:52:10 +09:00
59bb05ba22 fix(travel): 앨범 커버 지정이 반영되지 않던 문제 수정
- useTravelData: 앨범 목록을 GET /api/travel/albums API로 빌드 (커버 정보 포함)
- 커버 지정 성공 시 refreshAlbums → 앨범 카드 즉시 갱신
- onCoverChange 콜백 체인: Travel → AlbumDetail → HeroLightbox

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:32:46 +09:00
093ca6635a feat(travel): 사진 그리드 안정화 + 앨범 커버 지정 버튼 + 동기화 결과 개선
- MasonryGrid: CSS columns → CSS Grid로 전환 (스크롤 시 정렬 위치 변동 방지)
- HeroLightbox: "커버로 지정" 버튼 추가 (PUT /api/travel/albums/{album}/cover 호출)
- Travel: 동기화 토스트에 신규 폴더 발견 수 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:13:14 +09:00
047e15cad3 fix(travel): AlbumDetail 스크롤 안 되는 문제 수정 — SwipeableView 높이 체인 + PAGE_SIZE 40
SwipeableView가 fixed overlay 안에서 flex 높이를 채우지 못해 스크롤 불가 + IntersectionObserver 미동작.
scoped CSS로 높이 체인 복원, PAGE_SIZE 20→40으로 증가.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 04:42:59 +09:00
d6ace70bff feat(travel): 사진 동기화 버튼 추가 — POST /api/travel/sync 호출 + 결과 토스트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 01:17:10 +09:00
27dca3df69 refactor(travel): Travel.jsx 리팩토링 — 컴포넌트 분리 + 앨범 카드 기반 UI
모놀리식 Travel.jsx(1024줄)를 정리하여 useTravelData, MiniMap,
AlbumCard, AlbumDetail 등 추출된 컴포넌트를 조합하는 깔끔한
메인 컨테이너로 교체. Travel.css에서 photo-mosaic, photo-card,
lightbox, filmstrip 등 개별 컴포넌트 CSS로 이동된 스타일 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:28:27 +09:00
439844cd14 feat(travel): AlbumDetail 오버레이 — 사진/영상 탭 + 진입/이탈 애니메이션
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:25:30 +09:00
085481e104 feat(travel): HeroLightbox — shared element transition + 스와이프 탐색
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:22:49 +09:00
f9495f0c30 feat(travel): VideoTab 플레이스홀더 — 영상 탭 UI 셸
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:45 +09:00
4655e9ab3b feat(travel): MasonryGrid 컴포넌트 — CSS columns Masonry + 무한스크롤
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:41 +09:00
5efb9525d5 feat(travel): AlbumCard 컴포넌트 — 대표사진 + 그라디언트 + 메타정보
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:37 +09:00
201601dc95 feat(travel): MiniMap 컴포넌트 — 접기/펼치기 + 전체보기
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:19:33 +09:00
1072a5eb21 fix(travel): useTravelData AbortController 및 에러 핸들링 보완
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 01:14:54 +09:00
c9df3e0e88 feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 01:13:11 +09:00
6ef687378d fix(components): CSS 변수명 수정 + dead code 제거
- --border-line → --line (5개 컴포넌트 8곳)
- --gradient-accent → --grad-accent (FAB)
- --text-default → --text (MobileSheet)
- useSwipe.js 삭제 (미사용 dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:15:55 +09:00
ca9929faac fix(a11y): 글로벌 prefers-reduced-motion 추가 + Blog 버튼 위치 수정
- App.css: 글로벌 reduced-motion 블록 (모든 animation/transition 비활성화)
- index.css: scroll-behavior: smooth → auto (reduced-motion)
- BlogMarketing.css: 스피너 reduced-motion 처리
- Blog.css: 플로팅 토글 버튼 bottom-nav 위로 재배치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:12:24 +09:00
0198fec43c refactor(responsive): Phase 3 코드 품질 개선
- Blog/BlogMarketing/Subscription/MusicStudio: 미사용 useIsMobile 제거
- Subscription: 미사용 Link import 제거
- Blog.css: 중복 display:block 제거
- BlogMarketing: dead prop onGenerate 제거
- Todo: 카드 버튼 터치 타겟 26→36px 확대

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:06:56 +09:00
901cfd7e1b fix(responsive): Phase 3 spec compliance 수정
- Blog: 태그 필터 칩 바 모바일 가로 스크롤 추가
- BlogMarketing: FAB 전 탭에서 표시 + 대시보드 480px 1컬럼
- Subscription: PullToRefresh refreshKey 패턴 적용, FAB→공고 목록 탭 이동
- Todo: FAB 라벨 "할일 추가"로 spec 일치

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:02:12 +09:00
c7cad9da61 feat(effect-lab): 모바일 반응형 — SwordStream 터치 대응
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:50 +09:00
28a80b5bd7 feat(agent-office): 모바일 반응형 — 바텀시트 에이전트 상세
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:40 +09:00
00f8e00436 feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:55:05 +09:00
326d54c73f feat(music): 모바일 반응형 — FAB + 풀다운 리프레시 + 1컬럼 라이브러리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:54:04 +09:00
5c10952e39 feat(subscription): 모바일 반응형 — 바텀시트 필터 + FAB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:53:12 +09:00
2b826ed700 feat(blog): 모바일 반응형 — FAB + 풀다운 리프레시 + 칩 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:52:36 +09:00
d5ef77ad17 fix(lotto): 모바일 볼 크기 36px→32px 수정 2026-04-23 14:49:06 +09:00
033b89f87d feat(travel): 모바일 반응형 — 풀다운 리프레시 + 풀스크린 라이트박스
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:47:01 +09:00
e7427ff1d5 feat(stock): 모바일 반응형 — 캐러셀 지표 + 스와이프 탭 + FAB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:58 +09:00
fd13f65faa feat(lotto): 모바일 반응형 — 스와이프 탭 전환
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:54 +09:00
2c2011659a feat(home): 모바일 반응형 — 스와이프 TODO + 풀다운 리프레시
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:51 +09:00
0922261c74 feat: 앱 셸 모바일 레이아웃 — BottomNav 통합 + 사이드바 조건부 렌더링
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:38:49 +09:00
d53108f1c9 feat: MobileSheet 바텀시트 모달 컴포넌트 2026-04-23 14:36:43 +09:00
80921563be feat: FAB 플로팅 액션 버튼 컴포넌트 2026-04-23 14:36:38 +09:00
6875a28e92 feat: SwipeableView 스와이프 탭 전환 컴포넌트 2026-04-23 14:36:35 +09:00
2db0c1b3eb feat: PullToRefresh 풀다운 새로고침 컴포넌트 2026-04-23 14:36:32 +09:00
bce5ae9fac feat: BottomNav 모바일 하단 네비게이션 컴포넌트 2026-04-23 14:34:32 +09:00
a053cf2d71 feat: react-swipeable 설치 + useIsMobile/useSwipe 훅 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:33:15 +09:00
08efaa722a style(responsive): standardize RealEstate breakpoints
- RealEstate.css: 1100px → 1024px; merge 900px into 768px block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:30:19 +09:00
2cdecd918e style(responsive): standardize Subscription, MusicStudio, BlogMarketing breakpoints
- Subscription.css: 1100px → 1024px; merge 900px into 768px block
- MusicStudio.css: 960px → 1024px; both 640px blocks → 480px
- BlogMarketing.css: 640px → 480px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:30:07 +09:00
1e60524cfc style(responsive): standardize breakpoints for Home, Lotto, Travel, Blog
- Home.css: 960px → 1024px
- Lotto.css: merge 900px into 768px block; both 640px blocks → 480px
- Travel.css: merge 900px into 768px block; both 640px blocks → 480px
- Blog.css: merge 900px into 768px block (preserving all styles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:29:34 +09:00
75d1558508 style(responsive): add viewport-fit=cover and safe area CSS variables
Add viewport-fit=cover to meta tag for notched devices.
Add --bottom-nav-h / --safe-area-bottom tokens and body padding-bottom
for mobile bottom navigation safe area support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:28:22 +09:00
188a714372 docs: 로또 페이지 3탭 구조 + 브리핑 API 반영
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:36:08 +09:00
064c983ca1 feat(lotto): 3탭 구조 재배치(브리핑/분석/구매) 2026-04-15 08:33:08 +09:00
bf1c23e66a feat(lotto): 브리핑 컴포넌트 + CSS 2026-04-15 08:31:35 +09:00
a922dd12c0 feat(lotto): useBriefing·useCuratorUsage 훅 2026-04-15 08:30:45 +09:00
1344967118 feat(lotto): 브리핑·큐레이터 API 헬퍼 2026-04-15 08:30:33 +09:00
2840ad7df6 feat(stock): 증권사별 보유 현황에 총 매입 금액 추가 표기
종목수 · 총 매입 · 평가 · 손익 · 예수금 순으로 노출.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 02:17:32 +09:00
ad0a123d0f fix(stock): 브로커 총 매입 금액을 매입가 단순 합계로 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 02:10:17 +09:00
18d2cd5a51 feat(stock): 포트폴리오 매입가/평균단가 분리 + 총 매입 금액 반영
- 기존 카드의 "매입가" → "평균단가" (avg_price) 로 라벨 변경
- 신규 "매입가" (purchase_price) 컬럼 추가. 추가/수정 폼에 입력 필드 노출
  (미입력 시 평균단가 값으로 자동 설정)
- 브로커별 총 매입 금액은 purchase_price × quantity 합계 기준
- 손익/수익률은 평균단가(avg_price) 기준 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 01:58:10 +09:00
104a34912f feat(agent-office): 모바일 반응형 세로 스택 + 작업 시간 표기 개선
- 768px 이하에서 대시보드 세로 스택 + 에이전트 카드 아코디언 토글
- waiting/알림 있을 때 자동 펼침 및 좌측 강조 바
- 픽셀 오피스 캔버스 모바일 높이 140px로 축소 후 상단 배치
- 최근 작업 시간: completed_at 우선 + 오늘/어제/MM-DD HH:MM 포맷

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:19:32 +09:00
be46da0a1f feat(subscription): 종료 청약 일괄 삭제 버튼 추가
AnnouncementsTab 툴바에 '🗑 종료 청약 삭제' 버튼 추가.
확인 다이얼로그 → DELETE /api/realestate/announcements/closed 호출 →
삭제 건수 알림 후 목록 새로고침.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 04:14:50 +09:00
6728b2269e feat(agent-office): Blog + Realestate 에이전트 UI 추가
- AGENT_META/IDS에 blog/realestate 추가 (4 컬럼 대시보드)
- SpriteSheet: 블로그(노트북 액센트)/청약(서류가방 액센트) 픽셀 캐릭터
- office-map: 사무실 책상 4개로 확장, blog_desk/realestate_desk waypoint 추가
- AgentColumn/ChatPanel: 에이전트별 퀵 명령 버튼 (키워드 리서치, 매칭 리포트 등)
- CommandColumn: 타겟 선택지 4명, 빠른 명령 6개, 아이콘 맵핑
- DocumentPanel: 에이전트별 탭 4개

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 03:06:19 +09:00
cfc45fc43f feat(agent-office): AI 토큰 사용량 뱃지 표시
- api.js: getAgentTokenUsage 헬퍼 추가
- AgentColumn: 헤더에 오늘 토큰 사용량 뱃지 (🧮 N,NNN)
- 30초 폴링 + state 변경 시 즉시 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:44:37 +09:00
a165d6271f refactor(agent-office): dashboard layout with agent columns + CEO command panel
- Restructure layout: dashboard (top, 3 columns) + office canvas (bottom, 280px)
- AgentColumn: per-agent status, quick commands, approval UI, task history
- CommandColumn: CEO command input with agent selector, quick shortcuts, history
- Remove overlay panels (ChatPanel/DocumentPanel) - integrated into dashboard
- Office canvas shrunk to compact strip at bottom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:32:07 +09:00
deb285695a feat(agent-office): notification badges + CEO desk document panel + telegram test
- Add notification state management with badge counts in useAgentManager
- Render exclamation badge on agent sprites (separate from status icons)
- Add CEO desk document icon with click-to-open activity panel
- Create DocumentPanel with unified activity feed + per-agent detail tabs
- Add telegram test button to stock agent ChatPanel
- Remove TaskHistory + bottom toolbar (replaced by DocumentPanel)
- Add getActivityFeed API helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:19:14 +09:00
25715a2198 feat: Agent Office — AI 에이전트 가상 오피스 (#2)
## Summary
- Canvas 2D 픽셀아트 오피스 렌더링 (SpriteSheet + TileMap + AgentSprite)
- WebSocket 실시간 에이전트 상태 동기화 (useAgentManager)
- ChatPanel (명령/승인) + TaskHistory (작업 이력) UI
- 다크 테마 + glassmorphism 패널

## Changes (7 commits)
- API helpers + route + Lab entry
- Canvas engine: SpriteSheet, TileMap, AgentSprite, OfficeRenderer
- React hooks: useAgentManager, useOfficeCanvas
- Components: ChatPanel, TaskHistory
- Main page + CSS
- Code review fixes: claude agent 참조 제거, rejected 배지 추가

Reviewed-on: https://gitea.gahusb.synology.me/gahusb/web-page/pulls/2
2026-04-11 13:35:35 +09:00
7fc2d3aaf7 feat(music-lab): Suno API 전체 기능 확장 — Phase 1~3 UI 2026-04-09 07:34:21 +09:00
b215a93c89 fix(music-lab): RemixTab default_param_flag 로직 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:19:48 +09:00
1f00866694 feat(music-lab): Phase 3 UI — RemixTab + 뮤직비디오 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:14:18 +09:00
0849c70644 feat(music-lab): Phase 2 UI — StemModal, SyncedLyricsPlayer, Style Boost, WAV 변환
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 09:05:07 +09:00
7a591bb0f1 feat(music-lab): Phase 1 UI — 보컬 성별, 제외 스타일, weight 슬라이더, 더보기 메뉴, CoverArtModal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:53:47 +09:00
312677e624 refactor(music-lab): 컴포넌트 분할 — AudioPlayer, LyricsTab, CreditsBadge 추출
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 08:48:35 +09:00
6786f8c883 feat(realestate): 청약 가점 현황 카드 + 매칭 가점 비교
- 내 프로필 탭: 가점 현황 카드 (무주택/부양가족/통장 프로그레스 바)
- 매칭 결과 탭: 상단에 내 가점 뱃지, 각 카드에 가점 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:27:57 +09:00
45b74e672a feat(realestate): 공고 카드 매칭 점수 + 매칭 결과 탭 강화
- 공고 카드에 매칭 점수 뱃지 표시 (70+녹색, 40+주황, 기본회색)
- 상세 패널 헤더에 매칭 점수 + 자격 유형 태그 표시
- 매칭 결과 카드에 D-day + 접수일정 + 매칭 사유 표시 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:51:09 +09:00
bf5c7ba54e feat(realestate): 즐겨찾기 + D-day 오차 수정 + 가격 표시 + 필드명 수정
- D-day 계산 로컬 타임존 통일 (UTC 파싱 → 로컬 Date 파싱, 1일 오차 해결)
- 즐겨찾기 토글 (카드 ☆/★ + 상세 패널 버튼 + 즐겨찾기 필터)
- 대시보드에 즐겨찾기 섹션 + 가격 표시
- 모델 필드명 수정: supply_price→top_amount, exclusive_area→supply_area
- 카드에 가격 범위 표시 (억/만원 자동 포맷)
- 매칭 결과 필드명 수정: score→match_score, status→ann_status, matched_at→created_at

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:39:02 +09:00
8af2824c12 fix: 필수 표시를 텍스트 * 로 변경 — span 요소가 레이아웃 깨트리는 문제 수정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:44:58 +09:00
ff0ee3757c fix: 프로필 preferred_regions/types 배열 변환 + 필수 입력 표시
- 쉼표 구분 문자열 → List[str] 변환 (백엔드 422 에러 수정)
- API 응답 배열 → 표시용 문자열 변환
- 매칭 필수 필드에 * 표시 (무주택, 세대주, 납입기간, 가족수, 선호지역)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:43:15 +09:00
0eb55fe731 realestate: 프론트 청약 페이지를 realestate-lab API로 전면 리디자인
- Subscription.jsx: /api/subscription/* → /api/realestate/* 전환
- 4탭 구성: 대시보드, 공고 목록, 매칭 결과, 내 프로필
- 대시보드: 수집 상태/실행, 진행중 공고, 신규 매칭 통계
- 공고 목록: 자동 수집 공고 카드 그리드 + 필터 + 상세 패널
- 매칭 결과: 프로필 기반 추천 점수순 목록
- 내 프로필: 자격 조건 + 선호 조건 폼
- routes.jsx: /realestate/property 라우트 제거 (RealEstate.jsx 미사용)
- 구 API 경로(/api/subscription/*, /api/realestate/complexes) 완전 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 04:35:30 +09:00
5dadd4bf2c fix(blog-marketing): 본문 복사 시 HTML 서식 유지
writeText 대신 clipboard.write로 text/html MIME 타입 복사하여
네이버 블로그 에디터에 붙여넣기 시 서식이 유지되도록 개선.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:32:54 +09:00
5cf60e7ee6 feat(blog-marketing): 브랜드커넥트 링크 UI + 버그 수정
- 삭제 버튼 한글 깨짐 수정 (삭�� → 삭제)
- 리뷰 점수 표시 /50 → /60 (6기준 60점 체계 반영)
- 브랜드커넥트 링크 관리 UI 추가 (추가/삭제/목록)
- 마케터 실행 버튼 추가 (draft → marketed 전환)
- Marketed 필터 추가 (PostsTab)
- api.js에 링크 CRUD + 마케터 API 함수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 02:09:54 +09:00
74f043bf29 Blog Lab 페이지 추가 (블로그 마케팅 수익화)
4탭 구성: Dashboard, Research, Write, Posts
- BlogMarketing.jsx/css: 키워드 분석, AI 글 생성, 품질 리뷰, 발행 관리
- api.js: blog-marketing API 함수 15개 추가
- routes.jsx + Icons.jsx: Blog Lab 네비게이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:59:28 +09:00
e8e45391ae Music Lyrics: 가사 저장/수정/삭제 기능 추가
- AI 생성 가사 즉시 DB 저장 (세션 휘발 → 영구 보관)
- 저장된 가사 목록 자동 로드 (탭 진입 시)
- 인라인 수정: 제목 + 가사 텍스트 편집 후 저장/취소
- 개별 삭제 버튼
- api.js: getSavedLyrics, saveLyrics, updateLyrics, deleteLyrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:11:47 +09:00
c9e29bdad9 Music: AI 작사(Lyrics) 전용 탭 추가
- Create ↔ Library 사이에 Lyrics 탭 신설
- 프롬프트 입력 (200자) → Suno AI 가사 생성
- 결과 카드: 제목, 가사 텍스트, 프롬프트 표시
- 클립보드 복사 / "Create에서 사용" 버튼 (가사 자동 세팅 후 Create 탭 전환)
- 로딩 shimmer, 에러 배너, 빈 상태 UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 19:02:05 +09:00
c4f67e7d34 Music 서비스 전면 개편: Lab→독립 메뉴 승격 + Suno API 고도화
- 사이드바에 Music 독립 메뉴 추가 (/lab/music → /music)
- Lab 허브에서 Sonic Forge 카드 제거
- LibraryCard: 제목 최대 표시, 파일명 축소, duration 실제값 표시
- 모델 선택 UI (V4/V4_5/V5), 크레딧 잔액 표시
- 곡 연장(Extend), 보컬 분리(Vocal Split) 버튼 추가
- api.js: getMusicModels, getMusicCredits, extendMusicTrack, removeVocals
- 라이브러리 로딩 스켈레톤, Provider 에러 배너
- 모바일 반응형 개선 (모델바, 크레딧, 프로바이더, 카드 액션)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:52:46 +09:00
a727bbf153 MusicStudio: Suno/MusicGen 듀얼 프로바이더 UI 추가
- Provider 선택 바 (Suno 🎙️ / MusicGen 🤖)
- Suno 전용: 보컬/인스트루멘탈 토글, 가사 입력, AI 가사 생성
- 라이브러리·결과 카드에 provider 뱃지 표시
- TrackResult에 가사 접기/펼치기 추가
- api.js: getMusicProviders, generateMusicLyrics 함수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:26:49 +09:00
299ce636ff Functions.jsx 컴포넌트 분할: 1,583→460줄 (3훅+8컴포넌트+유틸)
- lottoUtils.jsx: 공통 유틸·상수 추출 (Ball, NumberRow, 통계 헬퍼 등)
- hooks/useLottoData.js: 핵심 데이터 로드 (최신회차, 통계, 시뮬레이션, 리포트)
- hooks/usePurchases.js: 구매 기록 CRUD
- hooks/useManualRecommend.js: 수동 추천 + 히스토리
- components/: MetricBlock, FrequencyChart, PerformanceBanner,
  ConfidenceRing, CombinedRecommendPanel, ReportPanel,
  PersonalAnalysisPanel, PurchasePanel 분리
- getReport import 누락 버그 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:53:25 +09:00
2b463682d5 StockTrade 탭 컴포넌트 분리 (Phase 5+6): 1,932→210줄
5개 탭/드로어 컴포넌트를 components/ 디렉토리로 추출:
- PortfolioTab: 포트폴리오 관리, 예수금, 자산추이 차트
- AiTradeTab: AI 모의투자 잔고, 수동주문, KIS 모달
- ReportTab: 차트, 리스크 분석, 수익률 랭킹, AI 코치
- AdvisorTab: 프롬프트 빌더, 클립보드 복사
- SellHistoryDrawer: 실현손익 드로어, 필터, 폼

StockTrade.jsx는 210줄 오케스트레이터로 축소
(hooks 호출 + lazy load + 헤더 + 탭 바 + 탭 렌더)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:37:30 +09:00
1b16b40251 StockTrade 컴포넌트 훅 분리 (Phase 4): 2,788→1,932줄
8개 커스텀 훅으로 state/handler 로직 추출:
- usePortfolio: 포트폴리오 CRUD, 예수금, 브로커 그룹
- useSellHistory: 매도 내역 CRUD, 드로어/폼 상태
- useAiCoach: AI 코치 분석 + 캐시
- useAssetHistory: 자산 추이 차트 데이터
- useMarketContext: VIX/F&G/국채/WTI 시장 데이터
- useAiBalance: AI 모의투자 잔고, 수동 주문
- useReportData: 리포트 정렬, 차트, 집중도 분석
- useAdvisor: 어드바이저 프롬프트 빌더

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:31:10 +09:00
314702cb66 AI Coach: 클라이언트 API 키 제거, 백엔드 프록시로 전환
- Anthropic API 직접 호출 → /api/stock/ai-coach 백엔드 프록시로 변경
- API 키 입력 UI 제거 (서버에서 관리)
- aiApiKey 상태 변수 및 localStorage 저장 로직 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:12:37 +09:00
8fcfb6b000 로또 종합 추론 번호 추천 기능 추가
5가지 통계 기법(빈도Z-score·조합지문·갭분석·공동출현·다양성)을
기법별 가중치(30/25/20/15/10%)로 투표 집계하여 최적 6개 번호 도출.
- 기법별 추천 번호 시각화 (최종 번호 하이라이트)
- 투표 참여 기법 수 점 표시 (최대 5개)
- 조합 품질 점수 5차원 바 차트
- 추천 이력 히스토리 누적 저장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:40:35 +09:00
22573909ec AI 어드바이저: getAiAnalysis 제거, 유망 섹터 추천 프롬프트 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:47:24 +09:00
9bce2bfb6e fix: buildAdvisorPrompt TDZ 오류 수정 — portfolioHoldings 정의 이후로 이동
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:42:11 +09:00
f7175ad80c AI 어드바이저 탭을 프롬프트 생성/복사 방식으로 전환
Gemini API 직접 호출 대신 포트폴리오 데이터 기반 전문가 프롬프트를
자동 생성하고 클립보드에 복사하는 방식으로 변경.
- 보유 종목, 평균매입가, 현재가, 손익, 예수금, 시장 지표 포함
- Gemini/ChatGPT 바로가기 링크 제공
- 프롬프트 미리보기 영역 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 04:39:13 +09:00
d1ecf13400 stock AI 어드바이저 추가 및 UX 개선
- Gemini Pro 기반 AI 어드바이저 탭 추가 (TAB_ADVISOR)
  - 보유 종목 현재가 + 뉴스 → 종목별 매도/매수/분할매도 지침
  - 5분 캐시, 강제 새로고침 버튼
  - 경량 마크다운 렌더러 (AdvisorMarkdown)
- 실현손익 수수료 → 수수료 & 세금으로 레이블 변경
- 총 자산 추이 그래프: 0 데이터 제외 (장 미개장일 필터)
- Todo 완료 패널 하단 이동 + 날짜 필터 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:54:50 +09:00
2c4b1e2e3a TODO UX 개선 2026-03-22 12:19:02 +09:00
76447fa262 음악 제작 랩 추가 2026-03-21 10:21:11 +09:00
46e122a229 lotto 기능 고도화 2026-03-20 01:55:53 +09:00
248835fa54 stock 실현손익 보여줄 수 있게 화면 구성 추가 2026-03-19 23:36:33 +09:00
b8d6dac70a 부동산 정보 api 오류 해결 2026-03-16 08:32:42 +09:00
df54437f47 Lab 설정 변경 및 유틸 추가 2026-03-16 03:00:04 +09:00
dac06fc4eb README.md 업데이트 2026-03-16 02:30:13 +09:00
1af16dde47 부동산 정보 페이지 추가 2026-03-16 02:10:45 +09:00
c6ac849a25 주식 히스토리 API 및 블로그 작성 API 추가 2026-03-11 08:08:39 +09:00
bbc9bf36f9 home 화면 todo list 보이게 추가 2026-03-06 02:43:55 +09:00
b9aeb2ff3e 주식 각종 지표 업데이트 2026-03-05 08:14:49 +09:00
fa696b0c90 stock 지표 수정 및 자산 분석 탭 항목 추가 2026-03-05 03:12:25 +09:00
c28bd9368c 시장 주요 지표 참고 추가 2026-03-05 02:45:45 +09:00
ccc9f7c634 dashboard 형태의 UI 수정 및 고도화 2026-03-04 08:29:39 +09:00
618d5f8e6f UI 디자인 대대적으로 대시보드 형태의 전문적인 느낌으로 재구성 2026-03-04 01:39:26 +09:00
840b0a5300 주식 보유 카드 UI 수정 & 평가 금액 추가 2026-02-26 01:54:09 +09:00
3e9112c4c7 계좌 페이지 분류 2026-02-26 01:32:49 +09:00
c4abdbed3e portfolio api 오류 수정 2026-02-26 00:38:07 +09:00
9380bf331f 주식 실 계좌 정보 가져오기 추가 2026-02-26 00:17:41 +09:00
ba30de718f dev 설정 오류 수정 2026-02-26 00:17:18 +09:00
628a47b2ec lotto 추천 및 시뮬레이션 시스템 고도화 2026-02-23 22:27:21 +09:00
1d78b2c430 feat: Add Effect Lab page with an interactive Three.js sword stream animation. 2026-02-09 00:23:11 +09:00
bdb055cb32 1. 라우팅 최적화
2. API 호출 병렬 처리
3. UI개선 - 로딩 경험 개선
4. 반응형 디자인
5. API 통신 특이사항 - URL 구성 로직의 잠재적 위험 해결
2026-02-09 00:13:40 +09:00
d7e7ccdb16 주식 트레이드 핸드폰 화면 UI 개선 2026-01-29 22:51:19 +09:00
8fc7c2cb70 주식 트레이딩 페이지 고도화 2026-01-28 02:13:56 +09:00
7d01c72e58 주식 매매 api 및 화면 오류 수정 2026-01-27 03:27:01 +09:00
9ab45b64b6 주식 매매 프로그램 연동 및 페이지 개발 구체화 2026-01-27 02:03:04 +09:00
22897c3eb6 주식 잔고, 주문 창 업그레이드
- 잔고, 주문 창 분리
 - full-width 섹션으로 쌓게 변경
2026-01-26 22:47:18 +09:00
5f4742085c 주식 즉시 스크래핑 api 오류 수정 2026-01-26 03:58:00 +09:00
5dab3d99c1 주식 즉시 스크래핑 추가 2026-01-26 03:31:55 +09:00
b559eeda58 주식 주요 지수 가져오기 추가 2026-01-26 03:14:16 +09:00
07b43c48c1 stock lab 기능 구현
- 주가지수 API 연결 (KOSPI/KOSDAQ/NASDAQ 등)
 - 뉴스 카드에 키워드 하이라이트/태그 자동 추출
 - 아침 8시 스크랩” 기준 타이머/카운트다운 표시
2026-01-26 03:05:50 +09:00
9d8af6b03b 여행 기록 UI/UX 오류 수정 2026-01-26 03:05:38 +09:00
247 changed files with 71054 additions and 2400 deletions

34
.claude/settings.json Normal file
View File

@@ -0,0 +1,34 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git stash list:*)",
"Bash(git remote -v)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(npm install:*)",
"Bash(npm ci:*)",
"Bash(npm list:*)",
"Bash(npm view:*)",
"Bash(npm outdated:*)",
"Bash(npx eslint:*)",
"Bash(npx vite:*)",
"Bash(node -v)",
"Bash(ls:*)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/credentials*)",
"Read(**/secrets*)",
"Read(**/*.pem)",
"Read(**/*.key)"
]
}
}

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)"
]
}
}

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=https://gahusb.synology.me

4
.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env.local
# Superpowers visual companion (mockup files)
.superpowers/

374
CLAUDE.md Normal file
View File

@@ -0,0 +1,374 @@
# web-ui — CLAUDE.md
개인 웹페이지 프론트엔드 프로젝트에 대한 컨텍스트 문서입니다.
## 프로젝트 개요
- **스택**: React 18 + Vite + react-router-dom v6
- **목적**: 개인 블로그, 로또 실험, 주식 뉴스/트레이딩, 여행 기록을 한 곳에 모은 개인 웹 UI
- **배포 대상**: Synology NAS (`gahusb.synology.me`) Docker 컨테이너 내 nginx
## 페이지 구조
| 경로 | 컴포넌트 | 설명 |
|------|----------|------|
| `/` | `Home` | 메인 허브 |
| `/blog` | `Blog` | 마크다운 기반 블로그 |
| `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
| `/todo` | `Todo` | 태스크 보드 |
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
---
## API 설정
### 핵심 원칙
- **항상 상대 경로 사용**: 프로덕션에서 프론트와 백엔드는 nginx 리버스 프록시로 동일 도메인에서 서비스됨
- **절대 URL 사용 금지**: `https://` 절대 URL을 fetch에 직접 사용하면 Mixed Content 오류 발생
- `VITE_API_BASE` 환경변수는 사용하지 않음
### API 헬퍼 (`src/api.js`)
```js
// 모든 API 호출은 이 헬퍼를 통해 사용
import { apiGet, apiPost, apiPut, apiDelete } from './api';
// 예시
apiGet('/api/lotto/latest')
apiPost('/api/portfolio', { ... })
```
제공 함수: `apiGet`, `apiPost`, `apiPut`, `apiDelete`
### 개발 서버 프록시 (`vite.config.js`)
```js
proxy: {
'/api': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
'/media': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
// /ext/* — Yahoo Finance, CNN Fear&Greed 등 외부 API 프록시
}
```
- `/api/*` → NAS 백엔드 (nginx가 서비스별 라우팅: lotto, personal, stock, music-lab 등)
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
- 개발 서버 포트: **3007**
### API 엔드포인트 목록
| 분류 | 메서드 | 경로 |
|------|--------|------|
| 로또 기본 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
| 로또 기본 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
| 로또 기본 | POST | `/api/admin/simulate` |
| 로또 고도화 | GET | `/api/lotto/stats/performance` |
| 로또 고도화 | GET | `/api/lotto/report/latest`, `/api/lotto/report/:drw_no`, `/api/lotto/report/history?limit=N` |
| 로또 고도화 | GET | `/api/lotto/analysis/personal` |
| 로또 구매 | GET | `/api/lotto/purchase?draw_no=N&days=N`, `/api/lotto/purchase/stats` |
| 로또 구매 | POST/PUT/DELETE | `/api/lotto/purchase`, `/api/lotto/purchase/:id` |
| 히스토리 | GET | `/api/history` |
| 히스토리 | DELETE | `/api/history/:id` |
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
| 트레이딩 | GET | `/api/trade/balance` |
| 트레이딩 | POST | `/api/trade/order` |
| 스크리너 | GET | `/api/stock/screener/nodes` |
| 스크리너 | GET/PUT | `/api/stock/screener/settings` |
| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` |
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
| 포트폴리오 | GET/POST | `/api/portfolio` |
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
| 예수금 | DELETE | `/api/portfolio/cash/:broker` |
| 자산 스냅샷 | POST | `/api/portfolio/snapshot` — body: `{ total_assets }` 또는 body 없이 서버 계산 |
| 자산 스냅샷 | GET | `/api/portfolio/snapshot/history?days=N` — response: `{ history: [{date, total_assets}] }` |
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
| TODO | GET/POST | `/api/todos` — personal 서비스 |
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` — personal 서비스 |
| 블로그 | GET/POST | `/api/blog/posts` — personal 서비스 |
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` — personal 서비스 |
| AI 음악 | POST | `/api/music/generate` — body: `{ title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt }``{ task_id }` |
| AI 음악 | GET | `/api/music/status/:task_id``{ status, progress, message, audio_url?, error?, track? }` |
| AI 음악 라이브러리 | GET/POST | `/api/music/library` — response: `{ tracks: [...] }` |
| AI 음악 라이브러리 | DELETE | `/api/music/library/:id` |
| 여행 | GET | `/api/travel/regions`, `/api/travel/albums`, `/api/travel/photos` |
| 여행 | POST | `/api/travel/sync` |
| 여행 | PUT | `/api/travel/albums/:album/cover`, `/api/travel/albums/:album/region` |
| 여행 | PUT | `/api/travel/regions/:id` |
| 인스타 | GET | `/api/insta/status`, `/api/insta/news/articles`, `/api/insta/keywords`, `/api/insta/slates`, `/api/insta/slates/:id` |
| 인스타 | POST | `/api/insta/news/collect`, `/api/insta/keywords/extract`, `/api/insta/slates`, `/api/insta/slates/:id/render` |
| 인스타 | 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` |
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
| 에이전트 | WS | `/api/agent-office/ws` |
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
---
## NAS 배포 설정
### 배포 경로
| 환경 | 경로 |
|------|------|
| Windows | `Z:\docker\webpage\frontend\` (NAS 네트워크 드라이브 마운트) |
| macOS (SMB) | `/Volumes/gahusb.synology.me/docker/webpage/frontend/` |
| macOS (SSH) | `/volume1/docker/webpage/frontend/` |
### 배포 명령어
```bash
# 빌드 + 배포 (권장)
npm run release:nas
# 빌드만
npm run build
# 배포만 (dist 폴더가 이미 있을 때)
npm run deploy:nas
```
### Windows 배포
NAS가 `Z:` 드라이브로 마운트되어 있어야 함. `robocopy``/MIR` 동기화하며 로그는 `robocopy.log`에 저장됨.
### macOS 배포 — SSH 방식 (권장)
```bash
# 환경변수 설정 후 배포
NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
```
`NAS_SSH_TARGET`이 설정되면 `rsync`로 SSH 배포. SMB 마운트 방식보다 안정적.
### macOS 배포 — SMB 마운트 방식
SMB 마운트 후 `ditto`로 복사. `NAS_CLEAN=1` 설정 시 배포 전 기존 파일 전체 삭제.
```bash
NAS_CLEAN=1 npm run release:nas
```
### 배포 스크립트 파일
`scripts/deploy-nas.cjs` — Node.js CJS 모듈, 플랫폼 자동 감지
---
## 개발 환경
```bash
npm install
npm run dev # localhost:3007 에서 개발 서버 실행
npm run build # dist/ 로 프로덕션 빌드
npm run lint # ESLint 검사
npm run preview # 빌드 결과물 미리보기
```
## 주요 파일 위치
| 파일 | 역할 |
|------|------|
| `src/api.js` | API 헬퍼 함수 모음 |
| `src/routes.jsx` | 라우트 및 네비게이션 링크 정의 |
| `src/Router.jsx` | BrowserRouter 설정 |
| `vite.config.js` | 개발 서버 및 프록시 설정 |
| `scripts/deploy-nas.cjs` | NAS 배포 스크립트 |
| `src/content/blog/` | 블로그 마크다운 파일 |
| `public/` | 정적 파일 (로고, API 스펙 등) |
---
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
### 파일 구조
| 파일 | 역할 |
|------|------|
| `src/pages/music/MusicStudio.jsx` | 메인 컴포넌트 |
| `src/pages/music/MusicStudio.css` | 스타일 (Bebas Neue · Syne · Courier Prime) |
### 주요 컴포넌트
- **SonicRadar** — 헤더 우측 비주얼. SVG 링·크로스헤어·스윕 라인 + 48개 CSS 방사형 바. `isGenerating` / `accentColor` prop으로 상태 전환
- **WaveformCanvas** — 스테이지 우측 캔버스 오실로스코프 (헤더와 별도)
- **AudioPlayer** — 실제 `<audio>` 엘리먼트 기반. `audio_url` 없으면 타이머 폴백
- **Library** — 저장된 트랙 카드 그리드 + 삭제/재생
- **GenerationProgress** — 진행률 바 + 단계 메시지
### 생성 플로우
```
handleGenerate()
→ POST /api/music/generate (payload에 title 포함)
→ task_id 반환 시: setInterval 3초 폴링 (getMusicStatus)
succeeded → setTrack(status.track 우선, 없으면 로컬 조립) + loadLibrary()
failed → genError 표시
→ API 실패 시: 6단계 시뮬레이션 폴백 (오프라인 모드)
```
### 백엔드 연동 규칙
- `audio_url`은 반드시 **상대경로** `/media/music/{task_id}.mp3` 형식 (절대 URL 금지)
- `status` 응답 shape: `{ status, progress, message, audio_url?, error?, track? }`
- `track` 객체: `{ id, title, genre, moods[], instruments[], duration_sec, bpm, key, scale, audio_url, created_at }`
- 백엔드가 `succeeded` 시 library 자동 등록 → 프론트는 "Save" 버튼 없음, `loadLibrary()` 자동 호출
- generate payload에 `title` 포함 → 백엔드에서 payload title 우선 사용 권장
### CSS 설계 특이사항
- 설명 토글: `.ms-desc-wrap` + `grid-template-rows: 0fr → 1fr` 트랜지션으로 높이 애니메이션
- 완전히 닫힐 때 노출 방지: `.ms-desc-wrap { overflow: hidden }` + `.ms-desc-wrap > * { min-height: 0 }`
- 장르 선택 시 `--ms-accent` / `--radar-accent` / `--g-color` CSS 변수로 전체 컬러 테마 동기화
---
## Lotto 고도화 (`/lotto`)
`src/pages/lotto/Functions.jsx`는 3탭 구조 (`브리핑 / 분석·통계 / 구매·성과`)로 리팩토링되었습니다.
| 탭 | 파일 | 설명 |
|----|------|------|
| 이번 주 브리핑 | `tabs/BriefingTab.jsx` | AI 큐레이터 브리핑 표시 (`components/briefing/` 하위 컴포넌트) |
| 분석·통계 | `tabs/AnalysisTab.jsx` | 시뮬레이션 추천·통계·ReportPanel·수동 추천 |
| 구매·성과 | `tabs/PurchaseTab.jsx` | 구매 내역 CRUD + 성과 통계 |
### 브리핑 전용 컴포넌트 (`components/briefing/`)
| 컴포넌트 | 설명 |
|----------|------|
| `BriefingTab.jsx` | 탭 루트, 브리핑 로드 + 트리거 |
| `BriefingHeader.jsx` | 회차·생성일시 헤더 |
| `BriefingSummary.jsx` | 내러티브 요약 표시 |
| `PickSetCard.jsx` | 번호 세트 1장 카드 |
| `BriefingEmpty.jsx` | 브리핑 없을 때 빈 상태 |
| `CuratorUsageFooter.jsx` | 토큰·비용 집계 푸터 |
### 신규 api.js 헬퍼
- `getLatestBriefing()``GET /api/lotto/briefing/latest`
- `getCuratorUsage(days)``GET /api/lotto/curator/usage?days=N`
- `triggerLottoCurate()``POST /api/agent-office/command` (lotto_agent curate 명령)
### 기존 섹션 (AnalysisTab 내)
| 섹션 | API | 설명 |
|------|-----|------|
| PerformanceBanner | `/api/lotto/stats/performance` | 수익률·당첨 통계 상단 띠 |
| ReportPanel | `/api/lotto/report/*` | 주간 리포트 + 전략 카드 + ConfidenceRing |
| PersonalAnalysisPanel | `/api/lotto/analysis/personal` | 개인 번호 성향 분석 |
| PurchasePanel | `/api/lotto/purchase/*` | 구매 내역 CRUD |
---
## Travel 갤러리 (`/travel`)
테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
### 파일 구조
| 파일 | 역할 |
|------|------|
| `src/pages/travel/Travel.jsx` | 메인 페이지 — 앨범 카드 목록 + MiniMap |
| `src/pages/travel/AlbumCard.jsx` | 앨범 썸네일 카드 (커버 이미지 + 사진 수) |
| `src/pages/travel/AlbumDetail.jsx` | 앨범 상세 오버레이 — 사진/영상 탭 + 지역 편집 |
| `src/pages/travel/MasonryGrid.jsx` | CSS columns 기반 Masonry 레이아웃 + 무한 스크롤 |
| `src/pages/travel/HeroLightbox.jsx` | 전체화면 사진 뷰어 — 스와이프/키보드 네비게이션 |
| `src/pages/travel/MiniMap.jsx` | 접이식 Leaflet 지도 — GeoJSON 지역 + 핀 마커 |
| `src/pages/travel/RegionPinPicker.jsx` | 지도 핀 위치 지정 모달 (Leaflet 클릭 → 좌표 저장) |
| `src/pages/travel/VideoTab.jsx` | 영상 탭 (준비 중) |
### 핵심 기능
- **지역 관리**: GeoJSON 기반 지역 선택 → 앨범 필터링 + 지역 변경 + 핀 좌표 지정
- **앨범 카드**: 커버 사진, 지역 라벨, 사진 수 표시, 접근성 accent 색상
- **Masonry 그리드**: 40장 단위 청크 로딩, IntersectionObserver 기반 무한 스크롤
- **Lightbox**: 앨범 커버 지정, 스와이프/키보드 네비게이션, 추가 로딩 지원
- **MiniMap**: Polygon(기존 지역) + CircleMarker(커스텀 핀) 이중 렌더링
- **지역 편집**: AlbumDetail에서 인라인 편집 + 자동완성 + "위치 지정" 버튼
### API 연동
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/travel/regions` | GeoJSON (커스텀 지역 포함) |
| GET | `/api/travel/photos?region=X&page=N&size=40` | 사진 페이지네이션 |
| GET | `/api/travel/albums` | 앨범 목록 + cover + region |
| POST | `/api/travel/sync` | 폴더 동기화 |
| PUT | `/api/travel/albums/{album}/cover` | 커버 지정 |
| PUT | `/api/travel/albums/{album}/region` | 지역 변경 |
| PUT | `/api/travel/regions/{id}` | 핀 좌표 저장 |
### 미디어 URL
- 사진: `/media/travel/{album}/{filename}`
- 썸네일: `/media/travel/.thumb/{album}/{filename}`
- `vite.config.js` `/media` 프록시로 처리, 프로덕션 nginx에서 직접 서빙
---
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
### 구성 파일
| 파일 | 역할 |
|------|------|
| `server.py` | FastAPI 서버 (generate/status/audio 엔드포인트) |
| `model.py` | MusicGen 래퍼 + 프롬프트 빌더 (genre/mood/instruments→텍스트) |
| `.env` | MODEL_NAME, OUTPUT_DIR, SERVER_PORT 등 |
| `setup.bat` | venv 생성 + PyTorch CUDA 12.4 + audiocraft 설치 |
| `start.bat` | 서버 시작 |
### 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/health` | 서버·GPU 상태 확인 |
| POST | `/generate` | 음악 생성 → `task_id` 즉시 반환 |
| GET | `/status/{task_id}` | 생성 진행 폴링 |
| GET | `/audio/{task_id}.mp3` | 완성 오디오 파일 |
### 모델
- 기본: `facebook/musicgen-stereo-large` (16GB VRAM, 스테레오 고품질)
- RTX 5070 Ti(16GB)로 실행 가능
### NAS 연동 흐름
```
web-ui → POST /api/music/generate (NAS music-lab)
→ music-lab이 Windows PC :8765/generate 호출
→ Windows PC가 MusicGen 추론 → WAV → MP3 변환
→ music-lab이 /status 폴링 → audio_url 다운로드
→ /media/music/{task_id}.mp3 저장 → DB 등록
→ 프론트 폴링 성공 → Library 자동 갱신
```
Windows 방화벽에서 포트 8765 인바운드 허용 필요.

234
README.md
View File

@@ -1,27 +1,223 @@
# Web UI
블로그, 로또 추천 실험, 여행 기록을 한 곳에서 모아 보는 개인 웹 UI입니다.
개인 대시보드 — 블로그, 로또, 주식, 부동산, 여행, AI 음악, AI 에이전트, 할 일 등을 한 곳에서 관리하는 개인 웹 UI입니다.
## 블로그
## 기술 스택
- 마크다운 기반 글 작성 및 자동 목록화 (`src/content/blog`)
- 태그 기반 카테고리 분류와 카테고리별 목록 뷰
- 목록/본문 분리 UI, 페이지네이션 지원
- 인라인 스타일(링크/강조/코드/이미지) 렌더링 지원
| 분류 | 사용 기술 |
|------|-----------|
| 프레임워크 | React 18 + Vite |
| 라우팅 | react-router-dom v6 |
| 지도 | react-leaflet + Leaflet |
| 차트 | Recharts |
| 3D | Three.js |
| 제스처 | react-swipeable |
| 스타일 | 커스텀 CSS (CSS 변수 기반 사이버펑크 다크 테마) |
| 배포 | Synology NAS (Docker + nginx 리버스 프록시) |
## Lotto Lab
---
- 최신 로또 결과 조회
- 추천 번호 생성 (가중치/최근 회차/회피 수 등 파라미터 반영)
- 프리셋 파라미터로 빠른 추천 생성
- 추천 히스토리 목록 확인 및 삭제
- 번호 복사 기능
- API 스펙 다운로드 링크 제공 (`public/lotto-api.md`)
## 페이지 구성 (13개 라우트)
## 여행 기록 (Travel Archive)
### Home (`/`)
- 지도 기반 지역 선택 (GeoJSON)
- 선택한 지역의 사진 목록 로딩 및 캐시
- 스크롤 기반 사진 추가 로딩 (chunked lazy load)
- 썸네일/모달 뷰, 키보드/스와이프 네비게이션
- 앨범/파일 메타 정보 표시
개인 아카이브 허브.
- 전체 페이지 네비게이션 카드 그리드
- 최근 업데이트 및 할 일 목록 미리보기
- 사이트 소개 및 최신 활동 요약
---
### Blog (`/blog`)
마크다운 기반 개인 블로그.
- `src/content/blog/` 폴더의 마크다운 파일 자동 목록화
- 태그 기반 카테고리 분류 및 필터링
- 목록 / 본문 분리 UI, 페이지네이션 지원
- 인라인 스타일(링크, 강조, 코드, 이미지) 렌더링
---
### Lotto (`/lotto`) — 14 컴포넌트
로또 번호 추천 및 통계 실험실.
- **3탭 구조**: 이번 주 브리핑 / 분석·통계 / 구매·성과
- AI 큐레이터 브리핑 (5세트 + 내러티브 + 토큰·비용 집계)
- 가중치·최근 회차·회피 수 파라미터 기반 번호 추천
- 몬테카를로 시뮬레이션 최적 번호 표시
- 전략 진화 (EMA+Softmax) 기반 메타 추천
- 주간 리포트 + ConfidenceRing 시각화
- 구매 이력 CRUD + 성과 통계 (수익률·당첨 현황)
- 프리셋으로 빠른 추천 생성, 번호 원클릭 복사
---
### Stock (`/stock`)
주식 시장 모니터링 대시보드.
- KOSPI, KOSDAQ, 나스닥, S&P500 등 주요 지수 현황
- Fear & Greed 지수 및 VIX 변동성 지표 시각화
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
### Stock Trade (`/stock/trade`) — 7 컴포넌트
포트폴리오 관리 및 트레이딩 데스크.
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
- 매도 히스토리 드로어 (실현손익 추적)
---
### Realestate (`/realestate`) — 2 섹션
부동산 청약 통합 관리.
#### 청약 대시보드 (`/realestate`)
- 관심 청약 카드 목록 + 상세 패널 (요건/일정/자금 섹션)
- 전체 청약 일정 타임라인 (청약 → 계약 → 중도금 → 잔금)
- 가점 계산 엔진 (무주택 32점 + 부양가족 35점 + 통장 17점 = 84점 만점)
- 청약 유형 분류: 줍줍 / 특공 / 일반
#### 부동산 정보 (`/realestate/property`)
- 관심 아파트 단지 카드 그리드 + Leaflet 지도 통합 뷰
- D-day 카운트다운, 평당가 비교 바 차트 (Recharts)
- 모달 기반 단지 추가/편집, 네이버 부동산 바로가기 연동
---
### Travel (`/travel`) — 8 컴포넌트
여행 사진 갤러리 (Dark Room 테마).
- **MiniMap**: GeoJSON 기반 접이식 세계 지도 — Polygon(기존 지역) + CircleMarker(핀)
- **AlbumCard**: 앨범 썸네일 카드 그리드 (커버 이미지 + 지역 라벨 + 사진 수)
- **AlbumDetail**: 앨범 상세 오버레이 — 사진/영상 탭 + 지역 인라인 편집
- **MasonryGrid**: CSS columns Masonry 레이아웃 + IntersectionObserver 무한 스크롤
- **HeroLightbox**: 전체화면 사진 뷰어 — 스와이프/키보드 네비 + 앨범 커버 지정
- **RegionPinPicker**: 커스텀 지역 좌표 지정 모달 (Leaflet 클릭 → 핀 저장)
- 40장 단위 청크 로딩, PullToRefresh 지원
---
### Music — Sonic Forge (`/lab/music`) — 8 컴포넌트
AI 음악 생성 스튜디오.
- 듀얼 프로바이더: Suno (보컬/가사) + 로컬 MusicGen (인스트루멘탈)
- 장르/무드/악기/BPM/키/스케일 설정, 스타일 부스트
- 생성 진행 폴링 (3초 간격), 라이브러리 자동 등록
- 가사 관리 + 타임스탬프 동기 재생 (가라오케)
- 커버 이미지 생성, WAV 변환, 12스템 분리
- SonicRadar 시각 효과 + WaveformCanvas 오실로스코프
---
### Blog Marketing (`/blog-lab`)
AI 블로그 마케팅 자동화 대시보드.
- 키워드 리서치 (네이버 검색 + 상위 블로그 크롤링)
- AI 글 생성 → 마케팅 강화 → 품질 리뷰 (6기준 x 10점)
- 발행 관리 + 브랜드커넥트 링크 + 수익 추적
- 비동기 작업 상태 폴링
---
### Agent Office (`/agent-office`) — 5 컴포넌트
AI 에이전트 가상 오피스.
- 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업 수행
- WebSocket 실시간 상태 동기화 (에이전트 FSM: idle → working → reporting)
- 에이전트별 명령 전송 + 작업 승인/거부
- 채팅 패널 + 문서 패널
---
### Lab (`/lab`) — 3 컴포넌트
실험적 UI/UX 효과 테스트 공간.
- **SwordStream**: Three.js 1,500개 파티클 3D 애니메이션 (호버/오빗 모드)
- **DayCalc**: 날짜 계산 유틸리티
---
### Todo (`/todo`)
태스크 관리 보드.
- 칸반 레이아웃: 할 일 → 진행 중 → 완료
- 드래그 앤 드롭으로 상태 변경
- 태스크 추가/삭제, 완료 항목 일괄 정리
---
## 공통 컴포넌트 (`src/components/`)
| 컴포넌트 | 설명 |
|----------|------|
| `Navbar` | 상단 네비게이션 바 |
| `BottomNav` | 모바일 하단 네비게이션 |
| `PageHeader` | 페이지 헤더 + 브레드크럼 |
| `SwipeableView` | 스와이프 탭 컨테이너 |
| `PullToRefresh` | 풀투리프레시 제스처 |
| `MobileSheet` | 모바일 바텀시트 모달 |
| `FAB` | 플로팅 액션 버튼 |
| `FearGreedGauge` | 공포·탐욕 게이지 |
| `Loading` | 로딩 스피너 |
| `Icons` | SVG 아이콘 라이브러리 |
---
## 개발 환경
```bash
npm install
npm run dev # localhost:3007
npm run build # dist/ 빌드
npm run lint # ESLint
npm run preview # 빌드 결과 미리보기
```
## NAS 배포
```bash
# 빌드 + NAS 배포 (Windows, Z: 드라이브 마운트 필요)
npm run release:nas
# SSH 배포 (macOS)
NAS_SSH_TARGET=user@gahusb.synology.me NAS_SSH_PORT=22 npm run release:nas
```
## API 설정
모든 API 호출은 상대 경로(`/api/...`)를 사용합니다. 개발 서버에서는 `vite.config.js`의 프록시 설정으로 NAS 백엔드(`gahusb.synology.me`)로 자동 전달됩니다.
```js
import { apiGet, apiPost, apiPut, apiDelete } from './api';
apiGet('/api/stock/indices');
apiPost('/api/travel/sync');
```
## 프로젝트 통계
| 항목 | 값 |
|------|-----|
| 페이지 라우트 | 13개 |
| JSX 컴포넌트 | 62+ |
| 공통 컴포넌트 | 10개 |
| API 헬퍼 함수 | 65+ |
| 외부 라이브러리 | React, Router, Leaflet, Recharts, Three.js, react-swipeable |

126
STATUS.md Normal file
View File

@@ -0,0 +1,126 @@
# web-ui — 구현 현황 & 로드맵
> 최종 갱신: 2026-05-07
> 자세한 페이지·API 표는 [CLAUDE.md](./CLAUDE.md) 참조.
---
## 1. 구현 완료
### 1-1. 메인 페이지
| 경로 | 상태 | 비고 |
|------|------|------|
| `/` Home | ✅ | 메인 허브 |
| `/blog` Blog | ✅ | 마크다운 기반 |
| `/portfolio` Portfolio | ✅ | 프로필·경력·프로젝트·자기소개 |
| `/todo` Todo | ✅ | 태스크 보드 |
### 1-2. 로또 (`/lotto`)
| 영역 | 상태 |
|------|------|
| 3탭 구조 (브리핑 / 분석·통계 / 구매·성과) | ✅ |
| AI 큐레이터 브리핑 탭 | ✅ |
| 성과 배너 + ReportPanel + ConfidenceRing | ✅ |
| 개인 분석 패널 | ✅ |
| 구매 내역 CRUD + 성과 통계 | ✅ |
### 1-3. 주식 (`/stock`, `/stock/trade`)
| 영역 | 상태 |
|------|------|
| 뉴스·지수 | ✅ |
| 트레이딩 + 잔고 | ✅ |
| 포트폴리오 (수동 입력 종목 + 예수금 + 자산 추이) | ✅ |
| 자산 스냅샷 + 7/30/90일 차트 | ✅ |
| 실현손익(매도이력) Drawer | ✅ |
| 포트폴리오 카드 모바일 금액 줄바꿈 대응 | ✅ (2026-05-06) |
### 1-4. 청약 (`/realestate`, `/realestate/property`)
| 영역 | 상태 |
|------|------|
| 자치구 5티어 (S/A/B/C/D) 드래그&드롭 + 슬라이더 + 토글 | ✅ |
| 카드/매칭 결과에 district 뱃지 + 5티어 뱃지 | ✅ |
| AnnouncementDetail 매칭 분석 섹션 | ✅ |
| 5축 점수 breakdown 시각화 + 알림 대상 카운트 | ✅ |
| 청약 일정 캘린더 뷰 | ✅ |
| 프로필 완성도 힌트 배너 + 소득 기준 힌트 | ✅ |
### 1-5. 여행 (`/travel`)
| 영역 | 상태 |
|------|------|
| Dark Room 테마 갤러리 | ✅ |
| 앨범 카드 + Masonry + Lightbox + MiniMap | ✅ |
| 지역 변경 + 핀 좌표 지정 | ✅ |
| 영상(VideoTab) | 🚧 준비 중 |
### 1-6. 음악 스튜디오 (`/lab/music` — Sonic Forge)
| 영역 | 상태 |
|------|------|
| Create 탭 (장르/무드/악기/BPM/Key) + 트랙 제목 직접 입력 | ✅ |
| Library 탭 + 트랙 카드 + 삭제/재생 | ✅ |
| YouTube 탭 (서브탭 4개: VideoProjects / Trends / Revenue / Compile) | ✅ (2026-05-01~05-06) |
| 다중 트랙 컴파일 (FFmpeg concat → MP4) | ✅ |
| 시장 트렌드 리포트 (장르/추천수/이력) | ✅ |
### 1-7. 기타 Lab
| 경로 | 상태 |
|------|------|
| `/lab/sword-stream` Three.js 파티클 | ✅ |
| `/lab/day-calc` 날짜 계산기 | ✅ |
| `/agent-office` 에이전트 가상 오피스 (WebSocket) | ✅ |
| `/blog-lab` 블로그 마케팅 수익화 대시보드 | ✅ |
### 1-8. 인프라 / DX
| 항목 | 상태 |
|------|------|
| Vite 개발 서버 프록시 (`/api`, `/media`, `/ext`) | ✅ |
| Windows robocopy + macOS SSH/SMB 배포 (`scripts/deploy-nas.cjs`) | ✅ |
| Mac SSH 배포 + tar\|ssh 전환 (Synology rsync 우회) | ✅ |
| 반응형 웹 디자인 패스 | ✅ |
---
## 2. 진행 중 / 향후 계획
### 2-1. Travel 영상 탭 완성
- 현재 "준비 중" 플레이스홀더 → 실제 영상 업로드/재생 UI 구현
- 백엔드 `travel-proxy`에 영상 메타·썸네일 API 필요
### 2-2. 로또 프리미엄 구독 UI (백엔드 Phase 3 연동)
- 회원 가입/로그인 UI (JWT)
- 구독 플랜 선택 + Toss/Stripe 결제 플로우
- 구독자 전용 리포트·알림 영역
- 백엔드 로드맵: `web-backend/docs/lotto-premium-roadmap.md`
### 2-3. Music YouTube 탭 후속
- VideoProjects 실제 렌더링 진행률 시각화 강화
- Compile 탭에 트랙 트림/페이드 옵션
- Revenue 대시보드 차트 강화
### 2-4. 청약 후속
- 알림 dry-run 미리보기 UI (어떤 공고가 매칭됐을지 사전 확인)
- 모바일 5티어 편집 모드 (현재 PC 전용)
### 2-5. 포트폴리오/주식 후속
- 종목별 평균 매입가 분할 입력 UI
- 매도 시뮬레이터 (수익률 시나리오 비교)
### 2-6. 일반
- 다크/라이트 테마 토글 (현재 다크 단일)
- PWA 설치 + 홈화면 단축 (모바일 사용 빈도 증가)
---
## 3. 참고 문서
- 페이지·라우트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
- 워크스페이스 통합 가이드: `../CLAUDE.md`
- 백엔드 상태: `../web-backend/STATUS.md`
- 백엔드 Spec/Plan 디렉토리: `../web-backend/docs/superpowers/`

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,999 @@
# AI News Phase 1 — articles Source 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:** ai_news 파이프라인의 데이터 소스를 Naver 스크래퍼에서 기존 `articles` 테이블로 교체. 종목명 substring 매핑으로 시총 상위 100 ticker 의 뉴스 sentiment 산출. `news_sentiment.source` 컬럼 추가로 Phase 2 비교 baseline 확보.
**Architecture:** 신규 `articles_source.py` 모듈이 `articles` 테이블 + `krx_master.name` substring 매핑으로 ticker별 뉴스 dict 반환. `pipeline.py`는 scraper 호출 대신 articles_source 사용. `analyzer.py` 가 LLM prompt 에 `summary` 포함. 텔레그램 메시지에 매핑 hit-rate 라인 추가. legacy `scraper.py` 는 deprecate 주석만 추가하고 보존.
**Tech Stack:** Python 3.11 / SQLite (WAL + busy_timeout) / anthropic AsyncClient / FastAPI / pytest + pytest-asyncio.
**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-14-ai-news-articles-source-design.md`
---
## 파일 구조
신규 파일 (backend):
```
web-backend/stock-lab/app/screener/ai_news/
articles_source.py ← DB articles 조회 + 종목명 매핑
web-backend/stock-lab/tests/
test_ai_news_articles_source.py ← 6 tests
```
수정 파일 (backend):
```
web-backend/stock-lab/app/screener/
schema.py ← news_sentiment.source 컬럼 + migration
ai_news/pipeline.py ← articles_source 사용, _make_http 제거
ai_news/analyzer.py ← prompt에 summary/pub_date 포함
ai_news/telegram.py ← build_message 에 mapping 라인
ai_news/scraper.py ← deprecate 주석만 추가
router.py ← post_refresh_news_sentiment 에 mapping 전달
web-backend/stock-lab/tests/
test_ai_news_pipeline.py ← articles_source mock 으로 갱신
test_ai_news_analyzer.py ← summary 케이스 추가
test_ai_news_telegram.py ← mapping 인자 케이스 추가
test_ai_news_router.py ← mapping 응답 필드 검증
```
---
### Task 1: schema.py — `news_sentiment.source` 컬럼 + migration
**Files:**
- Modify: `web-backend/stock-lab/app/screener/schema.py`
- [ ] **Step 1: DDL 본문에 `source` 컬럼 정의 추가**
`schema.py``DDL` 문자열 안 `news_sentiment` 테이블 정의에 `source` 컬럼을 `model` 컬럼 다음에 추가:
```sql
CREATE TABLE IF NOT EXISTS news_sentiment (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
score_raw REAL NOT NULL,
reason TEXT NOT NULL DEFAULT '',
news_count INTEGER NOT NULL DEFAULT 0,
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
source TEXT NOT NULL DEFAULT 'articles',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
PRIMARY KEY (ticker, date)
);
```
- [ ] **Step 2: `ensure_screener_schema()` 함수에 1회성 migration 블록 추가**
기존 ai_news weight migration 블록 (라인 ~142-156 근처) 직전 또는 직후에 다음을 추가:
```python
# news_sentiment.source 컬럼 1회 추가 (기존 운영 환경)
cols = {r[1] for r in conn.execute(
"PRAGMA table_info(news_sentiment)"
).fetchall()}
if "source" not in cols:
conn.execute(
"ALTER TABLE news_sentiment "
"ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
)
```
위치는 `executescript(DDL)` 직후, 기존 ai_news weight migration block 안이 자연스러움.
- [ ] **Step 3: 기존 schema 테스트 회귀**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest app/test_screener_schema.py -v
```
Expected: PASS — 3 tests passed (migration 추가에도 idempotency 유지).
- [ ] **Step 4: Commit**
```bash
git add app/screener/schema.py
git commit -m "feat(ai_news): add news_sentiment.source column with migration"
```
---
### Task 2: `articles_source.py` — DB 매핑 모듈 + 6 tests
**Files:**
- Create: `web-backend/stock-lab/app/screener/ai_news/articles_source.py`
- Test: `web-backend/stock-lab/tests/test_ai_news_articles_source.py`
- [ ] **Step 1: 실패하는 테스트 작성**
`tests/test_ai_news_articles_source.py`:
```python
import datetime as dt
import sqlite3
import pytest
from app.screener.ai_news import articles_source
from app.screener.schema import ensure_screener_schema
@pytest.fixture
def conn():
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
ensure_screener_schema(c)
# krx_master + articles 시드 helper 는 각 테스트에서 진행
yield c
c.close()
def _seed_master(conn, ticker, name):
conn.execute(
"INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
"VALUES (?, ?, 'KOSPI', 1_000_000_000, datetime('now'))",
(ticker, name),
)
def _seed_article(conn, title, summary="", crawled_at="2026-05-14T07:30:00"):
import hashlib
h = hashlib.md5(f"{title}|x".encode()).hexdigest()
conn.execute(
"INSERT INTO articles (hash, title, summary, link, press, pub_date, crawled_at) "
"VALUES (?, ?, ?, '', '', '2026-05-14', ?)",
(h, title, summary, crawled_at),
)
ASOF = dt.date(2026, 5, 14)
def test_single_ticker_match_in_title(conn):
_seed_master(conn, "005930", "삼성전자")
_seed_article(conn, "삼성전자, HBM 양산 가시화")
conn.commit()
out, stats = articles_source.gather_articles_for_tickers(
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
)
assert len(out["005930"]) == 1
assert out["005930"][0]["title"] == "삼성전자, HBM 양산 가시화"
assert stats["matched_pairs"] == 1
assert stats["hit_tickers"] == 1
def test_single_ticker_match_in_summary(conn):
_seed_master(conn, "005930", "삼성전자")
_seed_article(conn, "메모리 시장 회복세", summary="삼성전자가 1분기 어닝 서프라이즈")
conn.commit()
out, _ = articles_source.gather_articles_for_tickers(
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
)
assert len(out["005930"]) == 1
def test_multi_ticker_match(conn):
_seed_master(conn, "005930", "삼성전자")
_seed_master(conn, "000660", "SK하이닉스")
_seed_article(conn, "삼성전자와 SK하이닉스, 메모리 양산 경쟁")
conn.commit()
out, stats = articles_source.gather_articles_for_tickers(
conn, ["005930", "000660"], ASOF, window_days=1, max_per_ticker=5,
)
assert len(out["005930"]) == 1
assert len(out["000660"]) == 1
assert stats["matched_pairs"] == 2
assert stats["hit_tickers"] == 2
def test_no_match_returns_empty_list(conn):
_seed_master(conn, "005930", "삼성전자")
_seed_article(conn, "엔비디아 실적 발표", summary="AI 칩 수요 견조")
conn.commit()
out, stats = articles_source.gather_articles_for_tickers(
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
)
assert out["005930"] == []
assert stats["matched_pairs"] == 0
assert stats["hit_tickers"] == 0
def test_max_per_ticker_caps_results(conn):
_seed_master(conn, "005930", "삼성전자")
for i in range(6):
_seed_article(conn, f"삼성전자 뉴스 #{i}", crawled_at=f"2026-05-14T0{i}:00:00")
conn.commit()
out, _ = articles_source.gather_articles_for_tickers(
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
)
assert len(out["005930"]) == 5
def test_window_days_filters_old_articles(conn):
_seed_master(conn, "005930", "삼성전자")
_seed_article(conn, "삼성전자 최신 뉴스", crawled_at="2026-05-14T07:00:00")
_seed_article(conn, "삼성전자 오래된 뉴스", crawled_at="2026-05-01T07:00:00")
conn.commit()
out, _ = articles_source.gather_articles_for_tickers(
conn, ["005930"], ASOF, window_days=1, max_per_ticker=5,
)
assert len(out["005930"]) == 1
assert "최신" in out["005930"][0]["title"]
```
- [ ] **Step 2: 테스트 실패 확인**
```bash
python -m pytest tests/test_ai_news_articles_source.py -v
```
Expected: FAIL — "No module named 'app.screener.ai_news.articles_source'".
- [ ] **Step 3: `articles_source.py` 구현** — 정확히:
```python
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
from __future__ import annotations
import datetime as dt
import logging
import sqlite3
from typing import Any, Dict, List, Tuple
log = logging.getLogger(__name__)
def gather_articles_for_tickers(
conn: sqlite3.Connection,
tickers: List[str],
asof: dt.date,
*,
window_days: int = 1,
max_per_ticker: int = 5,
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
"""articles 에서 ticker.name substring 매칭으로 종목별 뉴스 dict 반환.
Returns:
(
{ticker: [{"title": str, "summary": str, "press": str, "pub_date": str}, ...]},
{"total_articles": int, "matched_pairs": int, "hit_tickers": int},
)
"""
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
stats = {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
if not tickers:
return out, stats
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
placeholders = ",".join("?" * len(tickers))
name_rows = conn.execute(
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
tickers,
).fetchall()
# 2글자 미만 회사명은 false positive 위험으로 제외
name_map = {r[0]: r[1] for r in name_rows if r[1] and len(r[1]) >= 2}
articles = conn.execute(
"SELECT title, summary, press, pub_date, crawled_at "
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
(cutoff,),
).fetchall()
stats["total_articles"] = len(articles)
for a in articles:
title = (a[0] or "").strip()
summary = (a[1] or "").strip()
haystack = title + " " + summary
for ticker, name in name_map.items():
if name not in haystack:
continue
if len(out[ticker]) >= max_per_ticker:
continue
out[ticker].append({
"title": title,
"summary": summary,
"press": a[2] or "",
"pub_date": a[3] or "",
})
stats["matched_pairs"] += 1
stats["hit_tickers"] = sum(1 for arts in out.values() if arts)
return out, stats
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
python -m pytest tests/test_ai_news_articles_source.py -v
```
Expected: PASS — 6 tests passed.
- [ ] **Step 5: Commit**
```bash
git add app/screener/ai_news/articles_source.py tests/test_ai_news_articles_source.py
git commit -m "feat(ai_news): articles_source module (substring ticker matching)"
```
---
### Task 3: `analyzer.py` — prompt 에 summary/pub_date 포함
**Files:**
- Modify: `web-backend/stock-lab/app/screener/ai_news/analyzer.py`
- Modify: `web-backend/stock-lab/tests/test_ai_news_analyzer.py`
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
`tests/test_ai_news_analyzer.py``NEWS` 상수와 `test_score_sentiment_success_parses_json` 테스트를 다음으로 교체/보강:
```python
NEWS = [
{"title": "삼성전자, HBM 양산", "summary": "1분기 영업이익 사상 최대", "pub_date": "2026-05-14"},
{"title": "메모리 가격 반등", "summary": "", "pub_date": "2026-05-14"},
]
@pytest.mark.asyncio
async def test_score_sentiment_includes_summary_in_prompt():
"""summary 가 있으면 prompt 에 포함, 없으면 title 만."""
llm = _mk_llm(json.dumps({"score": 5.0, "reason": "ok"}))
await analyzer.score_sentiment(llm, "005930", NEWS, name="삼성전자")
# mock 의 messages.create 호출 인자 확인
call = llm.messages.create.call_args
user_msg = call.kwargs["messages"][0]["content"]
assert "1분기 영업이익 사상 최대" in user_msg # summary 포함
assert "삼성전자, HBM 양산" in user_msg # title 포함
assert "2026-05-14" in user_msg # pub_date 포함
```
- [ ] **Step 2: 테스트 실행으로 실패 확인**
```bash
python -m pytest tests/test_ai_news_analyzer.py::test_score_sentiment_includes_summary_in_prompt -v
```
Expected: FAIL — `1분기 영업이익 사상 최대` 가 prompt 에 없음.
- [ ] **Step 3: `analyzer.py` 의 news_block 빌더 분리 + summary 포함**
기존 prompt 빌드 부분 수정. `score_sentiment` 함수의 prompt build 직전에 helper 함수 추가:
```python
def _format_news_block(news: List[Dict[str, Any]]) -> str:
"""news dict 리스트 → prompt 에 들어가는 텍스트 블록.
summary 가 있으면 title 다음 줄에 indent 해서 포함 (최대 200자).
pub_date 가 있으면 title 앞에 표시.
"""
lines: List[str] = []
for n in news:
date = (n.get("pub_date") or "").strip()
title = (n.get("title") or "").strip()
summary = (n.get("summary") or "").strip()
prefix = f"[{date}] " if date else ""
if summary:
lines.append(f"- {prefix}{title}\n {summary[:200]}")
else:
lines.append(f"- {prefix}{title}")
return "\n".join(lines)
```
그리고 `score_sentiment``news_block` 계산 라인을 다음으로 교체:
```python
news_block = _format_news_block(news)
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
python -m pytest tests/test_ai_news_analyzer.py -v
```
Expected: PASS — 5 tests (기존 4 + 신규 1) 모두 통과.
- [ ] **Step 5: Commit**
```bash
git add app/screener/ai_news/analyzer.py tests/test_ai_news_analyzer.py
git commit -m "feat(ai_news): include summary + pub_date in LLM prompt"
```
---
### Task 4: `pipeline.py` — articles_source 사용으로 교체
**Files:**
- Modify: `web-backend/stock-lab/app/screener/ai_news/pipeline.py`
- Modify: `web-backend/stock-lab/tests/test_ai_news_pipeline.py`
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
`tests/test_ai_news_pipeline.py``test_refresh_daily_happy_path` 를 다음으로 교체:
```python
@pytest.mark.asyncio
async def test_refresh_daily_happy_path(conn):
"""3종목 mini integration — articles_source mock + analyzer mock.
각 종목에 매핑되는 articles 1개씩 있다고 가정.
"""
asof = dt.date(2026, 5, 13)
fake_articles_by_ticker = {
"005930": [{"title": "삼성 뉴스", "summary": "", "press": "", "pub_date": ""}],
"000660": [{"title": "SK 뉴스", "summary": "", "press": "", "pub_date": ""}],
"373220": [{"title": "LG 뉴스", "summary": "", "press": "", "pub_date": ""}],
}
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
scores_by_ticker = {
"005930": 7.5, "000660": 4.0, "373220": -6.0,
}
async def fake_score(llm, ticker, news, *, name=None, model="m"):
return {
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
"reason": f"r{ticker}", "news_count": 1,
"tokens_input": 100, "tokens_output": 20, "model": model,
}
with patch.object(pipeline, "articles_source") as mas, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml:
mas.gather_articles_for_tickers = MagicMock(
return_value=(fake_articles_by_ticker, fake_stats)
)
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
assert result["asof"] == "2026-05-13"
assert result["updated"] == 3
assert result["failures"] == []
assert result["top_pos"][0]["ticker"] == "005930"
assert result["top_neg"][0]["ticker"] == "373220"
assert result["mapping"] == fake_stats
rows = conn.execute("SELECT ticker, score_raw, source FROM news_sentiment "
"WHERE date=?", ("2026-05-13",)).fetchall()
assert len(rows) == 3
assert all(r["source"] == "articles" for r in rows)
@pytest.mark.asyncio
async def test_refresh_daily_no_match_ticker_skipped(conn):
"""매핑 0인 ticker 는 LLM 호출 skip + news_sentiment 행 미생성."""
asof = dt.date(2026, 5, 13)
fake_articles_by_ticker = {
"005930": [{"title": "삼성", "summary": "", "press": "", "pub_date": ""}],
"000660": [], # 매핑 없음
"373220": [], # 매핑 없음
}
fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1}
async def fake_score(llm, ticker, news, *, name=None, model="m"):
return {
"ticker": ticker, "score_raw": 5.0, "reason": "r",
"news_count": 1, "tokens_input": 100, "tokens_output": 20,
"model": model,
}
with patch.object(pipeline, "articles_source") as mas, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml:
mas.gather_articles_for_tickers = MagicMock(
return_value=(fake_articles_by_ticker, fake_stats)
)
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
assert result["updated"] == 1
rows = conn.execute("SELECT ticker FROM news_sentiment "
"WHERE date=?", ("2026-05-13",)).fetchall()
assert {r["ticker"] for r in rows} == {"005930"}
```
기존 `test_refresh_daily_failures_isolated` 는 articles_source 매핑 데이터를 추가해야 함:
```python
@pytest.mark.asyncio
async def test_refresh_daily_failures_isolated(conn):
asof = dt.date(2026, 5, 13)
fake_articles_by_ticker = {
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
}
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
async def fake_score(llm, ticker, news, *, name=None, model="m"):
if ticker == "000660":
raise RuntimeError("llm exploded")
return {
"ticker": ticker, "score_raw": 5.0, "reason": "r", "news_count": 1,
"tokens_input": 100, "tokens_output": 20, "model": model,
}
with patch.object(pipeline, "articles_source") as mas, \
patch.object(pipeline, "_analyzer") as ma, \
patch.object(pipeline, "_make_llm") as ml:
mas.gather_articles_for_tickers = MagicMock(
return_value=(fake_articles_by_ticker, fake_stats)
)
ma.score_sentiment = fake_score
ml.return_value.__aenter__.return_value = AsyncMock()
ml.return_value.__aexit__.return_value = None
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
assert result["updated"] == 2
assert len(result["failures"]) == 1
```
상단 import 에 `MagicMock` 추가 확인:
```python
from unittest.mock import AsyncMock, MagicMock, patch
```
- [ ] **Step 2: 테스트 실패 확인**
```bash
python -m pytest tests/test_ai_news_pipeline.py -v
```
Expected: FAIL — pipeline 이 articles_source 를 아직 사용 안 함.
- [ ] **Step 3: `pipeline.py` 본문 교체**
`pipeline.py` 의 다음을 변경:
(1) 상단 import 에 articles_source 추가:
```python
from . import scraper as _scraper # legacy, kept for backward import
from . import analyzer as _analyzer
from . import articles_source # 신규
```
(2) `_make_http()` 함수와 `DEFAULT_RATE_LIMIT_SEC` 상수는 제거 (또는 deprecate). 더 이상 사용 안 함.
(3) `_process_one()` 함수를 다음으로 교체:
```python
async def _process_one(
ticker: str, name: str, articles: List[Dict[str, Any]],
sem: asyncio.Semaphore, llm, model: str,
) -> Dict[str, Any]:
async with sem:
return await _analyzer.score_sentiment(
llm, ticker, articles, name=name, model=model,
)
```
(4) `refresh_daily()` 시그니처 + 본문 교체:
```python
async def refresh_daily(
conn: sqlite3.Connection,
asof: dt.date,
*,
top_n: int = DEFAULT_TOP_N,
concurrency: int = DEFAULT_CONCURRENCY,
max_news_per_ticker: int = DEFAULT_NEWS_PER_TICKER,
window_days: int = 1,
model: str = _analyzer.DEFAULT_MODEL,
) -> Dict[str, Any]:
started = time.time()
tickers = _top_market_cap_tickers(conn, n=top_n)
name_map = {
r[0]: r[1] for r in conn.execute(
f"SELECT ticker, name FROM krx_master WHERE ticker IN "
f"({','.join('?' * len(tickers))})", tickers,
).fetchall()
} if tickers else {}
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
conn, tickers, asof,
window_days=window_days,
max_per_ticker=max_news_per_ticker,
)
sem = asyncio.Semaphore(concurrency)
async with _make_llm() as llm:
tasks = []
for t in tickers:
arts = articles_by_ticker.get(t, [])
if not arts:
continue # 매핑 0 — score 미생성
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model))
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
successes: List[Dict[str, Any]] = []
failures: List[str] = []
for r in raw_results:
if isinstance(r, BaseException):
failures.append(repr(r))
elif isinstance(r, dict):
successes.append(r)
if successes:
_upsert_news_sentiment(conn, asof, successes, source="articles")
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
return {
"asof": asof.isoformat(),
"updated": len(successes),
"failures": failures,
"duration_sec": round(time.time() - started, 2),
"tokens_input": sum(r["tokens_input"] for r in successes),
"tokens_output": sum(r["tokens_output"] for r in successes),
"top_pos": top_pos,
"top_neg": top_neg,
"model": model,
"mapping": mapping_stats,
}
```
(5) `_upsert_news_sentiment()` 함수에 `source` 인자 추가 + INSERT 에 컬럼 포함:
```python
def _upsert_news_sentiment(
conn: sqlite3.Connection, asof: dt.date,
rows: List[Dict[str, Any]], *, source: str = "articles",
) -> None:
iso = asof.isoformat()
data = [
(
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
r["tokens_input"], r["tokens_output"], r["model"], source,
)
for r in rows
]
conn.executemany(
"""INSERT INTO news_sentiment
(ticker, date, score_raw, reason, news_count,
tokens_input, tokens_output, model, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker, date) DO UPDATE SET
score_raw=excluded.score_raw,
reason=excluded.reason,
news_count=excluded.news_count,
tokens_input=excluded.tokens_input,
tokens_output=excluded.tokens_output,
model=excluded.model,
source=excluded.source
""",
data,
)
conn.commit()
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
python -m pytest tests/test_ai_news_pipeline.py -v
```
Expected: PASS — `test_refresh_daily_happy_path`, `test_refresh_daily_failures_isolated`, `test_refresh_daily_no_match_ticker_skipped`, `test_top_market_cap_tickers` 모두 통과 (4 tests).
- [ ] **Step 5: Commit**
```bash
git add app/screener/ai_news/pipeline.py tests/test_ai_news_pipeline.py
git commit -m "feat(ai_news): pipeline uses articles_source (replaces Naver scraper)"
```
---
### Task 5: `telegram.py` — 매핑 라인 추가
**Files:**
- Modify: `web-backend/stock-lab/app/screener/ai_news/telegram.py`
- Modify: `web-backend/stock-lab/tests/test_ai_news_telegram.py`
- [ ] **Step 1: 테스트 갱신 (실패 유도)**
`tests/test_ai_news_telegram.py` 끝에 새 테스트 추가:
```python
def test_build_message_includes_mapping_line():
msg = tg.build_message(
asof="2026-05-14",
top_pos=[_row("005930", 8.5, "HBM 호재")],
top_neg=[],
tokens_input=1000, tokens_output=200,
mapping={"total_articles": 35, "matched_pairs": 50, "hit_tickers": 42},
)
assert "매핑" in msg
assert "42" in msg
assert "50" in msg
assert "35" in msg
def test_build_message_without_mapping_omits_line():
msg = tg.build_message(
asof="2026-05-14",
top_pos=[],
top_neg=[],
tokens_input=1000, tokens_output=200,
)
assert "매핑" not in msg
```
- [ ] **Step 2: 테스트 실패 확인**
```bash
python -m pytest tests/test_ai_news_telegram.py -v
```
Expected: FAIL — `mapping` 인자 미지원.
- [ ] **Step 3: `telegram.py` 의 `build_message` 시그니처 + footer 갱신**
```python
def build_message(
*,
asof: str,
top_pos: List[Dict[str, Any]],
top_neg: List[Dict[str, Any]],
tokens_input: int,
tokens_output: int,
mapping: Dict[str, int] | None = None,
) -> str:
lines: List[str] = [
f"🌅 *AI 뉴스 분석* \\({_escape(asof)} 08:00\\)",
"",
"📈 *호재 Top 5*",
]
if top_pos:
for i, r in enumerate(top_pos, 1):
lines.append(_row_line(i, r))
else:
lines.append(_escape("- (없음)"))
lines += ["", "📉 *악재 Top 5*"]
if top_neg:
for i, r in enumerate(top_neg, 1):
lines.append(_row_line(i, r))
else:
lines.append(_escape("- (없음)"))
cost = _cost_won(tokens_input, tokens_output)
mapping_part = ""
if mapping:
mapping_part = (
f"매핑 {mapping['hit_tickers']}/100 ticker "
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}\\) · "
)
lines += [
"",
f"_분석: 시총 상위 100종목 · {mapping_part}"
f"토큰 {tokens_input:,} in / {tokens_output:,} out · "
f"약 ₩{cost:,}_",
]
return "\n".join(lines)
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
python -m pytest tests/test_ai_news_telegram.py -v
```
Expected: PASS — 6 tests (기존 4 + 신규 2) 모두 통과.
- [ ] **Step 5: Commit**
```bash
git add app/screener/ai_news/telegram.py tests/test_ai_news_telegram.py
git commit -m "feat(ai_news): telegram includes article mapping stats line"
```
---
### Task 6: `router.py` — mapping 응답 필드 전달
**Files:**
- Modify: `web-backend/stock-lab/app/screener/router.py`
- Modify: `web-backend/stock-lab/tests/test_ai_news_router.py`
- [ ] **Step 1: 테스트 갱신**
`tests/test_ai_news_router.py``test_refresh_news_sentiment_weekday_invokes_pipeline` 보강:
```python
def test_refresh_news_sentiment_weekday_invokes_pipeline():
fake_summary = {
"asof": "2026-05-13", "updated": 3, "failures": [],
"duration_sec": 1.0, "tokens_input": 100, "tokens_output": 20,
"top_pos": [], "top_neg": [], "model": "m",
"mapping": {"total_articles": 5, "matched_pairs": 8, "hit_tickers": 3},
}
with patch("app.screener.router._ai_pipeline") as mp, \
patch("app.screener.router._ai_telegram") as mt:
mp.refresh_daily = AsyncMock(return_value=fake_summary)
mt.build_message = lambda **kw: f"TEXT_with_mapping={kw.get('mapping')}"
client = TestClient(app)
resp = client.post(
"/api/stock/screener/snapshot/refresh-news-sentiment?asof=2026-05-13"
)
assert resp.status_code == 200
body = resp.json()
assert body["mapping"]["hit_tickers"] == 3
assert "mapping=" in body["telegram_text"]
```
- [ ] **Step 2: 테스트 실패 확인**
```bash
python -m pytest tests/test_ai_news_router.py -v
```
Expected: FAIL — `mapping` 이 build_message 호출에 전달되지 않음.
- [ ] **Step 3: `router.py` 의 `post_refresh_news_sentiment` 의 telegram_text 빌드 갱신**
기존:
```python
summary["telegram_text"] = _ai_telegram.build_message(
asof=summary["asof"],
top_pos=summary["top_pos"], top_neg=summary["top_neg"],
tokens_input=summary["tokens_input"],
tokens_output=summary["tokens_output"],
)
```
다음으로 교체:
```python
summary["telegram_text"] = _ai_telegram.build_message(
asof=summary["asof"],
top_pos=summary["top_pos"], top_neg=summary["top_neg"],
tokens_input=summary["tokens_input"],
tokens_output=summary["tokens_output"],
mapping=summary.get("mapping"),
)
```
- [ ] **Step 4: 테스트 통과 확인**
```bash
python -m pytest tests/test_ai_news_router.py -v
```
Expected: PASS — 2 tests.
- [ ] **Step 5: Commit**
```bash
git add app/screener/router.py tests/test_ai_news_router.py
git commit -m "feat(ai_news): router forwards mapping stats to telegram"
```
---
### Task 7: 전체 회귀 + scraper deprecate 주석
**Files:**
- Modify: `web-backend/stock-lab/app/screener/ai_news/scraper.py` (주석만)
- [ ] **Step 1: scraper.py 상단에 deprecate 주석 추가**
기존 docstring 을 다음으로 교체:
```python
"""[DEPRECATED] 네이버 finance 종목 뉴스 스크래핑.
본 모듈은 ai_news Phase 1 (2026-05-14, `cdfa31b` spec) 에서 더 이상
파이프라인에서 사용되지 않음. 데이터 소스는 stock-lab 의 articles 테이블
(`ai_news/articles_source.py`) 로 전환됨.
삭제 시점: Phase 2 (DART 도입) 결정 후. IC 검증 4주 누적 후 노드 활성화
여부에 따라 본 모듈을 (a) 완전 삭제 또는 (b) DART 와 함께 ensemble
fallback 으로 재활용.
"""
```
다른 라인은 유지 (테스트가 여전히 import 함).
- [ ] **Step 2: 전체 stock-lab 테스트 실행**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend\stock-lab
python -m pytest --ignore=app/test_scraper.py -q
```
Expected: 신규 6 + 갱신 테스트 포함 **82 tests passed** (이전 76 + ai_news_articles_source 6 - 변동 없음).
- [ ] **Step 3: Commit**
```bash
git add app/screener/ai_news/scraper.py
git commit -m "docs(ai_news): mark scraper.py deprecated (Phase 1 transition)"
```
---
### Task 8: 운영 검증 + 배포
**Files:** (실행만, 수동 점검)
- [ ] **Step 1: backend push**
```bash
cd C:\Users\jaeoh\Desktop\workspace\web-backend
git push origin main
```
실패 시: 사용자에게 Gitea 자격증명 입력 요청.
- [ ] **Step 2: deployer 반영 확인 (~1분)**
```bash
docker logs stock-lab --tail 20 2>&1 | grep -i "starting\|started"
docker logs agent-office --tail 20 2>&1 | grep -i "starting\|started"
```
두 컨테이너 모두 새 startup 시각 확인.
- [ ] **Step 3: 운영 DB 마이그레이션 자동 적용 확인**
```bash
docker exec stock-lab python -c "
import sqlite3
c = sqlite3.connect('/app/data/stock.db')
cols = [r[1] for r in c.execute('PRAGMA table_info(news_sentiment)').fetchall()]
print('news_sentiment columns:', cols)
print('has source:', 'source' in cols)
"
```
Expected: `has source: True`.
- [ ] **Step 4: 수동 트리거**
```bash
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
-H "Content-Type: application/json" \
-d '{"agent":"stock","action":"run_ai_news"}'
```
응답 `{"ok": true}` 받으면 30-60초 후 텔레그램에 메시지 도착.
- [ ] **Step 5: 텔레그램 메시지 검증**
수신 메시지에 다음 패턴 모두 포함되는지 확인:
- `🌅 AI 뉴스 분석 (YYYY-MM-DD 08:00)` 헤더
- `📈 호재 Top 5` / `📉 악재 Top 5` 섹션
- 종목명 + 티커 형태 (예: `삼성전자 (005930)`)
- `매핑 N/100 ticker (M쌍 / articles K건)` 라인 (신규)
- 토큰/비용 라인
매핑 hit_tickers 가 합리적 범위 (예: 20~60) 인지 확인.
- [ ] **Step 6: DB 검증**
```bash
docker exec stock-lab python -c "
import sqlite3
c = sqlite3.connect('/app/data/stock.db')
rows = c.execute('SELECT COUNT(*), SUM(news_count), SUM(tokens_input) FROM news_sentiment WHERE date = date(\"now\") AND source = \"articles\"').fetchone()
print('articles rows / total_news / tokens:', rows)
# Naver 데이터와 비교
naver = c.execute('SELECT COUNT(*) FROM news_sentiment WHERE source = \"articles\"').fetchone()
print('all articles-source rows:', naver[0])
"
```
Expected: `articles rows >= 10` (매핑 hit 종목 수), `source='articles'`.
- [ ] **Step 7: 메모리 업데이트**
`C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-web-ui\memory\project_stock_screener.md` 의 hotfix 이력에 본 슬라이스 commits 추가:
- Phase 1 (`cdfa31b` spec + 본 plan 의 task commit SHA들)
- 매핑 hit-rate 측정 결과 (예: "첫 실행 매핑 42/100, articles 35건, LLM cost ₩42")
- 다음 단계: 4주 후 IC 측정 결과 보고 Phase 2 (DART) 또는 노드 삭제 결정
---
## 완료 후 검증 체크리스트
본 plan 완료 시:
- [ ] stock-lab `news_sentiment` 테이블에 `source` 컬럼 존재
- [ ] 운영 트리거 시 source='articles' 행 생성, news_count > 0
- [ ] 텔레그램 메시지에 매핑 N/100 라인 표시
- [ ] 외부 HTTP 호출 (Naver) 0건
- [ ] LLM cost 텔레그램 ₩ 라인이 이전(~₩60)보다 작거나 비슷 (~₩40-80)
- [ ] 단위 테스트 신규 6 + 갱신 4 모두 통과, 기존 회귀 없음
- [ ] `news_sentiment.source` 컬럼이 idempotent 하게 추가 (재기동 시 재추가 시도 없음)
- [ ] legacy `scraper.py` 에 deprecate 주석 (코드 보존)
## 후속 슬라이스 (이번 plan 완료 후)
본 spec §15 명시:
- **Phase 1.5** — 매핑 hit-rate < 30% 면 alias dict 추가
- **Phase 2** — 4주 IC ≥ 0.05 시 DART OpenAPI 추가
- **Phase X** — IC < 0.05 시 노드 deprecate

View 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.

View File

@@ -0,0 +1,506 @@
# stock-lab → stock 리네이밍 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:** `stock-lab` 컨테이너/디렉토리/환경변수를 `stock` 으로 graduation. lab 네이밍 정책 정리 + V2 Phase 1 작업 시작 전 선행.
**Architecture:** Atomic refactor — web-backend repo 안의 모든 stock-lab 참조를 한 commit으로 갱신 (git mv + docker-compose + agent-office + nginx + 문서). web-ui/workspace CLAUDE.md 별도 commit. 메모리는 controller 직접 갱신. Python `app.*` import 경로 + API URL `/api/stock/...` + DB 파일 그대로 유지.
**Tech Stack:** Git (mv with history), Docker Compose, nginx upstream, Python FastAPI / httpx.
**선행 spec**: `web-ui/docs/superpowers/specs/2026-05-15-stock-lab-rename-to-stock.md`
---
## 사전 가정
- web-backend repo 와 web-ui repo 는 별도 git 저장소
- `workspace/CLAUDE.md` 는 git 관리 외 파일 (단순 편집)
- `stock-lab/.venv/` 디렉토리는 `.gitignore` 되어 있음 (Windows 로컬 가상환경, 변경 영향 무관)
- Gitea webhook 자동 배포: web-backend push → deployer rsync + docker compose up
---
## 파일 변경 매트릭스 요약 (Task 별로 상세)
```
[Task 1] grep 사전 검토 (코드 변경 0)
[Task 2] web-backend atomic commit
- git mv stock-lab → stock (수십 파일)
- docker-compose.yml (서비스 키 + container_name + build.context + depends_on + agent-office env)
- agent-office/app/config.py (STOCK_LAB_URL → STOCK_URL)
- agent-office/app/service_proxy.py (import + 5 함수)
- agent-office/app/agents/stock.py (있다면)
- agent-office/tests/test_stock_screener_job.py
- nginx/default.conf (upstream + proxy_pass)
- CLAUDE.md, README.md, STATUS.md
- scripts/deploy-nas.sh, deploy.sh
[Task 3] web-ui commit
- web-ui/CLAUDE.md
[Task 4] workspace 편집 (git 없음 가능)
- workspace/CLAUDE.md
[Task 5] 메모리 갱신 (controller, 별도 git 외)
- project_workspace.md / project_scale.md / project_stock_screener.md / nas_infra.md
- feedback_lab_naming.md (graduation 사례)
[Task 6] 배포 + 검증
- 사용자 push (Gitea 자격증명) + NAS 검증
```
---
### Task 1: 사전 검토 — 모든 stock-lab 참조 위치 확인
**Files:** (검증만, 변경 없음)
- [ ] **Step 1: web-backend stock-lab 참조 전체 grep (docs / .venv / __pycache__ 제외)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend
grep -rln "stock-lab\|STOCK_LAB" . \
--exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git --exclude-dir=docs \
2>&1 | sort
```
Expected output (예상): 다음 파일들이 등장해야 함:
- `./agent-office/app/agents/stock.py`
- `./agent-office/app/config.py`
- `./agent-office/app/service_proxy.py`
- `./agent-office/tests/test_stock_screener_job.py`
- `./CLAUDE.md`
- `./docker-compose.yml`
- `./nginx/default.conf`
- `./README.md`
- `./scripts/deploy-nas.sh`
- `./scripts/deploy.sh`
- `./STATUS.md`
- `./stock-lab/...` (stock-lab 내부 파일들 — `app/main.py`, 테스트 등 내부 참조는 디렉토리 rename 으로 자연 해소)
- [ ] **Step 2: web-ui stock-lab 참조 grep**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ui
grep -rln "stock-lab" . \
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs \
2>&1 | sort
```
Expected: `./CLAUDE.md` 만.
- [ ] **Step 3: nginx/default.conf 정확한 변경 라인 식별**
```bash
grep -nE "stock-lab|upstream stock" /c/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf
```
Expected: `upstream stock-lab { ... }` 블록 정의 + `proxy_pass http://stock-lab` 호출 라인 (1-3 곳).
- [ ] **Step 4: web-backend stock-lab 내부의 자기 참조 확인 (디렉토리 rename 후 영향)**
```bash
grep -rln "stock-lab" /c/Users/jaeoh/Desktop/workspace/web-backend/stock-lab/ \
--exclude-dir=.venv --exclude-dir=__pycache__ 2>&1 | sort
```
Expected: `app/main.py` 의 헬스체크 메시지 + 일부 CLAUDE.md/README.md 문구. Python `app.*` import 는 stock-lab 문자열 없으므로 0건. 발견된 매칭은 Task 2 의 7단계 (디렉토리 내부 문서) 에서 처리.
- [ ] **Step 5: 사용자에게 `.venv` 삭제 요청 (선택사항이지만 git mv 안전성 향상)**
사용자에게 다음 메시지:
> "git mv stock-lab → stock 직전에 `web-backend/stock-lab/.venv/` 디렉토리 삭제 권장 (Windows local 가상환경, .gitignore 되어있어 영향 없음. 사용 시 재생성 필요). 삭제 완료 후 Task 2 진행."
Step 5 는 사용자 직접 실행:
```bash
rm -rf /c/Users/jaeoh/Desktop/workspace/web-backend/stock-lab/.venv
```
- [ ] **Step 6: Step 1-4 결과 기록 (commit 없음, Task 2 의 cross-check 자료)**
기록할 항목:
- 변경 대상 파일 N개 (Step 1 출력)
- nginx config 의 정확한 변경 라인 (예: 라인 12, 18, 25 등)
- 사용자가 `.venv` 삭제 완료했는지
---
### Task 2: web-backend repo atomic commit
**Files:** (web-backend repo)
- Rename: `stock-lab/``stock/`
- Modify: `docker-compose.yml`
- Modify: `agent-office/app/config.py`
- Modify: `agent-office/app/service_proxy.py`
- Modify: `agent-office/app/agents/stock.py` (해당 시)
- Modify: `agent-office/tests/test_stock_screener_job.py`
- Modify: `nginx/default.conf`
- Modify: `CLAUDE.md`
- Modify: `README.md`
- Modify: `STATUS.md`
- Modify: `scripts/deploy-nas.sh`
- Modify: `scripts/deploy.sh`
- [ ] **Step 1: git mv 디렉토리 rename**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git mv stock-lab stock
git status --short | head -10
```
Expected: git status 에 `R stock-lab/... -> stock/...` 라인 다수. .venv 가 사용자에 의해 사전 삭제되었다면 무관, 살아있어도 .gitignore 로 untracked.
- [ ] **Step 2: docker-compose.yml 갱신**
`docker-compose.yml` 안 4 곳 변경:
1. `services:` 아래 `stock-lab:` 키 → `stock:`
2. `container_name: stock-lab``container_name: stock`
3. `build:``context: ./stock-lab``context: ./stock`
4. `frontend:``depends_on:` 항목 중 `- stock-lab``- stock`
5. `agent-office:``environment:``STOCK_LAB_URL=http://stock-lab:8000``STOCK_URL=http://stock:8000`
수정 명령 (Edit tool 로 안전하게):
- `stock-lab:` 단일 occurrence → `stock:`
- `container_name: stock-lab``container_name: stock`
- `context: ./stock-lab``context: ./stock`
- `- stock-lab` (frontend.depends_on 항목) → `- stock`
- `STOCK_LAB_URL=http://stock-lab:8000``STOCK_URL=http://stock:8000`
- [ ] **Step 3: agent-office/app/config.py 갱신**
`STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://stock-lab:8000")` 형태의 라인을:
```python
STOCK_URL = os.getenv("STOCK_URL", "http://stock:8000")
```
으로 교체. 다른 lab URL (MUSIC_LAB_URL 등) 은 그대로 유지.
- [ ] **Step 4: agent-office/app/service_proxy.py 갱신**
상단 import:
```python
from .config import STOCK_LAB_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
```
을:
```python
from .config import STOCK_URL, MUSIC_LAB_URL, BLOG_LAB_URL, REALESTATE_LAB_URL
```
으로 변경.
함수 본문의 `STOCK_LAB_URL` 사용 5개 (fetch_stock_news / fetch_stock_indices / summarize_stock_news / refresh_screener_snapshot / run_stock_screener) 모두 `STOCK_URL` 로 변경. 또한 본 spec 이후 추가된 `refresh_ai_news_sentiment` 함수도 STOCK_URL 사용.
가장 단순한 방법: 파일 안 모든 `STOCK_LAB_URL``STOCK_URL` 치환 (replace_all).
- [ ] **Step 5: agent-office/app/agents/stock.py 갱신**
다음 패턴 grep:
```bash
grep -n "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office/app/agents/stock.py
```
매칭이 있으면 (`stock-lab` 호스트 URL 또는 환경변수명 직접 참조) 갱신. 없으면 skip.
- [ ] **Step 6: agent-office/tests/test_stock_screener_job.py 갱신**
```bash
grep -n "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office/tests/test_stock_screener_job.py
```
mock URL 또는 환경변수 참조 갱신. `STOCK_LAB_URL``STOCK_URL`, `http://stock-lab:``http://stock:`.
- [ ] **Step 7: nginx/default.conf 갱신**
Task 1 Step 3 에서 식별된 라인 모두 변경:
- `upstream stock-lab``upstream stock`
- `server stock-lab:8000;``server stock:8000;`
- `proxy_pass http://stock-lab``proxy_pass http://stock`
- [ ] **Step 8: 운영 문서 갱신 (CLAUDE.md / README.md / STATUS.md / scripts/)**
각 파일 grep 후 모든 stock-lab 언급을 stock 으로 교체:
- `web-backend/CLAUDE.md` — 디렉토리 표 + 서비스 표 + 환경변수 표
- `web-backend/README.md` — 동일
- `web-backend/STATUS.md` — 동일
- `web-backend/scripts/deploy-nas.sh` — stock-lab 호출/경로 갱신
- `web-backend/scripts/deploy.sh` — 동일
수정 방법: 각 파일에 대해 grep → Edit tool replace_all (단, 의도적 보존 항목 — 예: 과거 변경 이력 등 — 있는지 검토).
- [ ] **Step 9: stock 디렉토리 내부 문서 갱신**
Task 1 Step 4 에서 발견된 stock-lab 내부 자기 참조 (예: `stock/CLAUDE.md`, `stock/app/main.py` 헬스체크 문구) 모두 갱신.
- [ ] **Step 10: agent-office 테스트 회귀 검증**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office
python -m pytest tests/test_stock_screener_job.py -v
```
Expected: PASS — `STOCK_LAB_URL` 참조 없이 새 `STOCK_URL` 환경변수 기반으로 mock 통과.
- [ ] **Step 11: stock pytest 회귀**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend/stock
python -m pytest --ignore=app/test_scraper.py -q 2>&1 | tail -5
```
Expected: 80+ tests passed (이전 76 + Phase 1 작업 전 검증). 디렉토리 이름만 변경, 코드 무변. 회귀 0건.
- [ ] **Step 12: 최종 grep 검증 — stock-lab 잔여 0건**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend
grep -rln "stock-lab\|STOCK_LAB" . \
--exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git --exclude-dir=docs \
2>&1 | sort
```
Expected: **0 lines** (의도적 보존된 docs/ 제외).
만약 0건이 아니면 빠진 위치 찾아서 추가 갱신 후 재검증.
- [ ] **Step 13: web-backend atomic commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add -A
git status --short | head -20
git commit -m "refactor: rename stock-lab → stock (graduation)
- git mv stock-lab/ → stock/
- docker-compose.yml: 서비스 키 + container_name + build.context +
frontend.depends_on + agent-office STOCK_LAB_URL → STOCK_URL
- agent-office/app: config.py, service_proxy.py STOCK_LAB_URL → STOCK_URL
- nginx/default.conf: upstream + proxy_pass 갱신
- CLAUDE.md / README.md / STATUS.md / scripts/ 문구 갱신
lab 네이밍 정책 (feedback_lab_naming.md) 에 따라 정식 graduation.
API URL / Python import / DB 파일명은 변경 없음."
```
---
### Task 3: web-ui CLAUDE.md 갱신
**Files:**
- Modify: `web-ui/CLAUDE.md` (web-ui repo)
- [ ] **Step 1: stock-lab 언급 grep**
```bash
grep -n "stock-lab" /c/Users/jaeoh/Desktop/workspace/web-ui/CLAUDE.md
```
Expected: 디렉토리 경로 / 라우팅 설명 / API 표 등에서 다수 매칭.
- [ ] **Step 2: 모두 stock 으로 교체**
Edit tool 의 `replace_all=true``stock-lab``stock` 일괄 치환. 단, "stock screener" 같은 단어는 영향 없음 (정확한 `stock-lab` 문자열만 매칭).
- [ ] **Step 3: commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ui
git add CLAUDE.md
git commit -m "docs: rename stock-lab → stock in CLAUDE.md"
```
---
### Task 4: workspace/CLAUDE.md 갱신 (git 외 가능)
**Files:**
- Modify: `/c/Users/jaeoh/Desktop/workspace/CLAUDE.md`
- [ ] **Step 1: git 관리 여부 확인**
```bash
ls /c/Users/jaeoh/Desktop/workspace/.git 2>&1
```
Expected: `No such file or directory` — workspace 자체는 git repo 아님. 단순 파일 편집.
(만약 git 관리 중이라면 별도 commit 진행)
- [ ] **Step 2: stock-lab 언급 grep + 교체**
```bash
grep -n "stock-lab" /c/Users/jaeoh/Desktop/workspace/CLAUDE.md
```
Edit tool 로 `stock-lab``stock` 일괄 치환.
- [ ] **Step 3: 변경 사항 사용자에게 알림 (commit 없음, 단순 파일)**
workspace/CLAUDE.md 는 단순 파일 — 자동 syncing 없음. 사용자에게 다음 메시지 전달:
> "workspace/CLAUDE.md 갱신 완료. git 관리 외 파일이라 commit 없음. 다음 세션부터 자동 적용."
---
### Task 5: 메모리 갱신 (controller 직접)
**Files:**
- Modify: `C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-web-ui\memory\project_workspace.md`
- Modify: `...\memory\project_scale.md`
- Modify: `...\memory\project_stock_screener.md`
- Modify: `...\memory\nas_infra.md`
- Modify: `...\memory\feedback_lab_naming.md` (graduation 사례 추가)
- [ ] **Step 1: 메모리 폴더 grep**
```bash
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/ 2>&1
```
매칭 파일 모두 확인.
- [ ] **Step 2: 각 메모리에서 stock-lab → stock 갱신**
다음 표를 보고 각 파일에서 Edit:
| 파일 | 주요 갱신 |
|------|----------|
| `project_workspace.md` | "stock-lab/" → "stock/" (디렉토리 경로) |
| `project_scale.md` | 백엔드 서비스 표의 stock-lab 행 → stock |
| `project_stock_screener.md` | 백엔드 위치 / 컨테이너 이름 모두 |
| `nas_infra.md` | Docker 서비스 포트 표 + nginx 라우팅 |
- [ ] **Step 3: feedback_lab_naming.md 에 graduation 사례 등재**
기존 메모리 본문 끝에 다음 추가:
```markdown
## Graduation 이력
- **2026-05-15**: `stock-lab``stock` graduation. 8 노드 screener + 캔버스 UI + AI 뉴스 Phase 1 + V2 시그널 파이프라인 중심 = 정식 서비스 단계. 디렉토리/컨테이너/환경변수 (`STOCK_LAB_URL``STOCK_URL`) 갱신. API URL `/api/stock/*` + Python import / DB 파일명 그대로.
```
- [ ] **Step 4: MEMORY.md 인덱스의 stock_screener 행에 영향 있나 확인**
```bash
grep -n "stock-lab" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md
```
매칭 있으면 갱신, 없으면 skip.
- [ ] **Step 5: 메모리 폴더 잔여 grep 검증 (0건)**
```bash
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/ 2>&1
```
Expected: 0 lines (feedback_lab_naming.md의 graduation 본문 안에 의도적으로 "stock-lab" 언급은 가능 — 정책 사례 명시).
---
### Task 6: 배포 + 운영 검증
**Files:** (실행만, 변경 없음)
- [ ] **Step 1: web-backend push (사용자 수동, Gitea 자격증명 필요)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git push origin main
```
자격증명 prompt 시 사용자가 입력. push 성공 시 Gitea webhook → deployer rsync + docker compose up 자동.
- [ ] **Step 2: web-ui push (사용자 수동)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ui
git push origin main
```
자격증명 prompt. 본 push 는 CLAUDE.md 한 줄 변경만이라 deployer 영향 없음.
- [ ] **Step 3: NAS 컨테이너 상태 확인 (사용자가 NAS SSH)**
```bash
docker ps --format "{{.Names}}: {{.Status}}" | grep -E "stock|agent-office"
```
Expected:
- `stock: Up (healthy)` 라인 존재 (옛 stock-lab 컨테이너는 사라짐)
- `agent-office: Up (healthy)`
- [ ] **Step 4: stock 컨테이너 로그 확인**
```bash
docker logs stock --tail 30
```
Expected: FastAPI startup 로그, init_db 완료, 어떤 stock-lab 잔여 참조나 에러 없음.
- [ ] **Step 5: agent-office 환경변수 확인**
```bash
docker exec agent-office env | grep -E "STOCK|stock"
```
Expected:
- `STOCK_URL=http://stock:8000` (새 변수)
- (옛 `STOCK_LAB_URL` 잔여가 없어야 — `.env` 파일에 남아있으면 사용자가 수동 삭제)
- [ ] **Step 6: API curl 검증**
```bash
curl -s https://gahusb.synology.me/api/stock/news | python -m json.tool | head -10
curl -s https://gahusb.synology.me/api/stock/screener/runs | python -m json.tool | head -10
```
Expected: 200 응답, JSON 파싱 정상.
- [ ] **Step 7: agent-office 수동 트리거 (테스트)**
```bash
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
-H "Content-Type: application/json" \
-d '{"agent":"stock","action":"run_ai_news"}'
```
Expected: `{"ok": true, "message": "AI 뉴스 분석 트리거 완료"}`. 30-60초 후 텔레그램 메시지 도착 = stock 호스트 라우팅 정상.
- [ ] **Step 8: web-ui 페이지 회귀 (브라우저)**
`https://gahusb.synology.me/stock/screener` 접속:
- 캔버스 모드 진입 정상
- 슬라이더 조작 → settings PUT 정상 (X-WebAI-Key 미사용 상태에서도 통과 — 인증은 Phase 1 작업)
- 노드 변경 즉시 반영
`https://gahusb.synology.me/portfolio` 접속:
- portfolio 페이지 정상 (current_price/PnL 표시 — Phase 1 작업 전이므로 raw 값만 표시)
- [ ] **Step 9: 운영 .env 파일 정리 안내 (사용자 수동)**
NAS의 `/volume1/docker/webpage/.env` 파일에서:
- `STOCK_LAB_URL=...` 라인 삭제 (또는 `STOCK_URL=...` 로 갱신)
- agent-office 컨테이너 재기동 필요 시: `docker restart agent-office`
사용자에게 알림:
> "NAS의 .env 파일에서 옛 STOCK_LAB_URL 라인 제거 권장. agent-office 의 default fallback (`http://stock:8000`) 으로 동작 가능하지만, 명시적 STOCK_URL 등재가 깔끔."
---
## 완료 후 검증 체크리스트
- [ ] `web-backend/stock-lab/` 사라지고 `stock/` 존재 (`ls web-backend/stock` 확인)
- [ ] `grep -rln "stock-lab\|STOCK_LAB" web-backend --exclude-dir=docs --exclude-dir=.venv --exclude-dir=__pycache__ --exclude-dir=.git` → 0 lines
- [ ] web-ui/CLAUDE.md stock-lab 0건
- [ ] workspace/CLAUDE.md stock-lab 0건
- [ ] 메모리 폴더 stock-lab 0건 (feedback_lab_naming.md graduation 본문 외)
- [ ] docker ps 에 `stock` 컨테이너 healthy
- [ ] curl `/api/stock/news` 200
- [ ] agent-office `run_ai_news` 수동 트리거 + 텔레그램 도착
- [ ] stock pytest 76+ tests passed (회귀 0)
- [ ] agent-office tests 통과
- [ ] web-ui 페이지 (portfolio + screener) 정상
## 본 plan 완료 후 다음 단계
- **Confidence Signal Pipeline V2 Phase 1 brainstorming 시작** (이전 발표 디자인 그대로, 새 이름 `stock` 기준)
- spec → plan → 실행 (1주 작업 예상)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,358 @@
# Lotto Curator Evolution — Design Spec
- 일자: 2026-05-11
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
## 1. 동기와 문제
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
## 2. 컨셉
다음 두 축으로 강화한다.
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
## 3. 주간 사이클
```
토 20:35 추첨
일 03:00 추첨결과 sync (기존)
채점 잡 (신규) → weekly_review INSERT
lotto_purchase auto_graded UPDATE
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
├─ build_retrospective(target_draw)
├─ collect_candidates(N=30)
├─ build_context (+retrospective)
├─ Claude 호출 (회고+계층 규칙)
└─ briefings INSERT (4계층 picks)
월 09:05 텔레그램 헤드라인 푸시
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
토 20:35 추첨 → 다음 사이클
```
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
## 4. 결정 카드 (브리핑 탭 메인)
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
### 4계층 위계
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|---|---|---|---|
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage``lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
### 분석 탭은 "Deep Dive" 자료실로 강등
- 라벨 변경: `📊 분석·통계``📚 자료실 / Deep Dive`
- 첫 진입 시 모든 패널 접힘
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
## 5. 데이터 모델
### 신규 테이블 — `weekly_review`
```sql
CREATE TABLE weekly_review (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER NOT NULL UNIQUE,
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
curator_avg_match REAL,
curator_best_tier TEXT, -- 안정 | 균형 | 공격
curator_best_match INTEGER,
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
user_avg_match REAL,
user_best_match INTEGER,
user_5plus_prizes INTEGER,
-- 패턴 갭 (서사 재료)
user_pattern_summary TEXT,
draw_pattern_summary TEXT,
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### `lotto_purchase` 컬럼 추가
```sql
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
```
### `briefings.picks` 구조 변경
JSON 컬럼을 4계층 구조로 마이그레이션:
```json
{
"core": [/* 5 */],
"bonus": [/* 5 */],
"extended": [/* 5 */],
"pool": [/* 5 */]
}
```
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
## 6. 큐레이터 변경
### 출력 스키마 (`agent-office/curator/schema.py`)
```python
class CuratorOutput(BaseModel):
core_picks: List[Pick] = Field(min_length=5, max_length=5)
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
narrative: Narrative # retrospective(60자 이내) 필드 추가
confidence: int # 0~100
```
### SYSTEM_PROMPT 추가 규칙
```
회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
계층별 큐레이션 규칙:
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
```
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
```python
def build_retrospective(target_draw_no: int) -> dict | None:
last = lotto_get_review(target_draw_no - 1)
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
if not last:
return None
return {
"last_draw": {
"draw_no": last["draw_no"],
"curator_avg": last["curator_avg_match"],
"curator_best_tier": last["curator_best_tier"],
"user_avg": last["user_avg_match"],
"user_5plus": last["user_5plus_prizes"],
"pattern_delta": last["pattern_delta"],
},
"trend_4w": {
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
}
}
```
### 후보 풀 N=30
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
```
실행: 매주 일요일 03:00 KST (cron)
입력: 가장 최근 sync된 추첨 회차
처리:
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
6) weekly_review UPSERT (draw_no 유니크)
7) lotto_purchase 채점:
- 일치 3개 → prize=5000, auto_graded=1
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
호출하여 텔레그램 별도 알림 트리거
- numbers NULL 인 행은 스킵
```
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
```
🎟 1154회 · 큐레이션 떴음
"이번 주는 안정 +1, 콜드 누적 보강."
신뢰도 72 · 분배 안정 3·균형 1·공격 1
▸ 회고: 너 2.0 / 나 1.8
너 저번호 편향 → 보너스 고번호 보강
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
```
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
## 9. 프론트 변경
### 파일 변경 맵
| 파일 | 종류 | 내용 |
|------|------|------|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
### 컴포넌트 격리 원칙
- `DecisionCard``briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
- `TierSection``tier`, `picks`, `rationale` 만 받아 4번 재사용.
- `BulkPurchaseButton``draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
## 10. 백엔드 변경
### `web-backend/lotto/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
### `web-backend/agent-office/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/curator/retrospective.py` | 신규 | build_retrospective |
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
## 11. API 추가·변경
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
기존 엔드포인트는 그대로 유지(스키마 호환).
## 12. 에러 처리 / 격리
| 단계 | 실패 | 처리 |
|------|------|------|
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
## 13. 테스트
### 백엔드 (`lotto/`)
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
### 큐레이터 (`agent-office/curator/`)
- `build_retrospective`: review 1건 / 4건 / 0건
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
- `telegram_lotto.format`: retrospective 있음·없음
### 프론트
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
- `TierModeToggle` 단위: onChange 콜백 정확
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
- 자료실 탭 수동: 첫 진입 모두 접힘
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
## 14. 운영 점검 (배포 후 1주차)
수동으로 확인:
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
4. 결정 카드 렌더링 정상(모바일 + PC)
5. 원클릭 구매 정확 N건 INSERT
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
## 15. Out of Scope
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)

View File

@@ -0,0 +1,822 @@
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
- **상태**: 설계 (Draft)
- **작성일**: 2026-05-12
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
---
## 1. 배경 & 목표
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
### 비전 (장기)
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
### 본 슬라이스 (MVP)
| 요소 | 범위 |
|------|------|
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
### 비목표 (후속 슬라이스에 명시 예약)
1. AI 뉴스 호재/악재 노드
2. 노드 캔버스 UI (react-flow)
3. 주간 자가학습 (가중치 자동 조정 제안)
4. DART 공시·재무제표 노드
5. 분봉 기반 노드 (한투 API)
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
7. 멀티 프리셋 ("공격형"/"안정형")
8. 백테스트 화면
9. KRX 호가단위 적용
10. 메트릭/대시보드 (Prometheus 등)
---
## 2. 전체 아키텍처
```
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
│ │
▼ ▼
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
│ │
└──────────► Screener.run() ◄──────────────────┘
ScreenContext.load(asof)
(KRX 마스터·일봉·수급 SQLite 캐시)
HygieneGate.filter() ← Survivors ~500-800종
[ScoreNode.compute() × 7 활성 노드]
combine + rank Top N
position_sizer (entry/stop/target)
┌─────────────┴───────────────┐
▼ ▼
screener_runs + screener_results 응답 JSON (results, telegram_payload)
(mode='auto'·'manual_save') │
agent-office가 telegram_payload 전송
(mode='auto')
```
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
---
## 3. 백엔드 컴포넌트 구조 (stock-lab)
### 3.1 디렉토리
```
web-backend/stock-lab/app/
├─ main.py # router.include_router(screener_router) 1줄 추가
├─ db.py
├─ price_fetcher.py
├─ scraper.py
├─ ai_summarizer.py
├─ holidays.json
├─ test_*.py # 기존
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
└─ screener/ # ← NEW
├─ __init__.py
├─ router.py # FastAPI: /api/stock/screener/*
├─ schemas.py # Pydantic 요청/응답
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
├─ snapshot.py # pykrx 일봉·수급 갱신
├─ position_sizer.py # ATR 기반 진입/손절/익절
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
└─ nodes/
├─ __init__.py
├─ base.py # ScoreNode, GateNode 추상
├─ hygiene.py
├─ foreign_buy.py
├─ volume_surge.py
├─ momentum.py
├─ high52w.py
├─ rs_rating.py
├─ ma_alignment.py
└─ vcp_lite.py
```
### 3.2 핵심 추상
```python
# nodes/base.py
class ScoreNode(ABC):
name: ClassVar[str] # "foreign_buy"
label: ClassVar[str] # "외국인 누적 순매수"
default_params: ClassVar[dict]
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
@abstractmethod
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
"""index=ticker, dtype=float, range 0..100."""
class GateNode(ABC):
name: ClassVar[str]
label: ClassVar[str]
default_params: ClassVar[dict]
param_schema: ClassVar[dict]
@abstractmethod
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
"""returns surviving tickers."""
# engine.py
@dataclass(frozen=True)
class ScreenContext:
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
kospi: pd.Series # date → close (시장 비교용)
asof: datetime.date
@classmethod
def load(cls, asof: datetime.date) -> "ScreenContext": ...
def restrict(self, tickers) -> "ScreenContext": ...
class Screener:
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
node_params: dict[str, dict], gate_params: dict, top_n: int,
sizer_params: dict):
...
def run(self, ctx: ScreenContext) -> "ScreenerResult":
survivors = self.gate.filter(ctx, self.gate_params)
scoped = ctx.restrict(survivors)
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
total = combine(scores, self.weights)
ranked = total.sort_values(ascending=False).head(self.top_n)
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
survivors_count=len(survivors), warnings=[...])
```
### 3.3 registry
```python
# registry.py
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
rs_rating, ma_alignment, vcp_lite, hygiene)
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
"foreign_buy": foreign_buy.ForeignBuy,
"volume_surge": volume_surge.VolumeSurge,
"momentum": momentum.Momentum20,
"high52w": high52w.High52WProximity,
"rs_rating": rs_rating.RsRating,
"ma_alignment": ma_alignment.MaAlignment,
"vcp_lite": vcp_lite.VcpLite,
}
GATE_REGISTRY: dict[str, type[GateNode]] = {
"hygiene": hygiene.HygieneGate,
}
```
---
## 4. 데이터 모델 (stock.db 신규 7테이블)
### 4.1 KRX 캐시 (3테이블)
```sql
CREATE TABLE IF NOT EXISTS krx_master (
ticker TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
is_managed INTEGER NOT NULL DEFAULT 0,
is_preferred INTEGER NOT NULL DEFAULT 0,
is_spac INTEGER NOT NULL DEFAULT 0,
listed_date TEXT, -- 'YYYY-MM-DD'
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS krx_daily_prices (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
volume INTEGER,
value INTEGER, -- 거래대금(원)
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
CREATE TABLE IF NOT EXISTS krx_flow (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
foreign_net INTEGER, -- 원
institution_net INTEGER,
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
```
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py``backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
### 4.2 사용자 설정 (싱글톤 1테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
top_n INTEGER NOT NULL DEFAULT 20,
rr_ratio REAL NOT NULL DEFAULT 2.0,
atr_window INTEGER NOT NULL DEFAULT 14,
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
updated_at TEXT NOT NULL
);
```
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
### 4.3 실행 스냅샷 (2테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asof TEXT NOT NULL,
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
weights_json TEXT NOT NULL,
node_params_json TEXT NOT NULL,
gate_params_json TEXT NOT NULL,
top_n INTEGER NOT NULL,
survivors_count INTEGER,
telegram_sent INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
CREATE TABLE IF NOT EXISTS screener_results (
run_id INTEGER NOT NULL,
rank INTEGER NOT NULL,
ticker TEXT NOT NULL,
name TEXT NOT NULL,
total_score REAL NOT NULL,
scores_json TEXT NOT NULL,
close INTEGER,
market_cap INTEGER,
entry_price INTEGER,
stop_price INTEGER,
target_price INTEGER,
atr14 REAL,
PRIMARY KEY (run_id, ticker),
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
```
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
### 4.4 마이그레이션 방식
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
---
## 5. 노드 8개 알고리즘
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
```text
params:
min_market_cap_won = 50_000_000_000 # 500억 이상
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
min_listed_days = 60 # 신규 상장 60일 미만 제외
skip_managed = true
skip_preferred = true
skip_spac = true
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
출력: 통과 종목 Index (보통 500~800종)
```
### 5.2 외국인 누적 순매수 — `ForeignBuy`
```text
params: window_days = 5
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
score = percentile_rank(raw, 통과군) × 100
debug: foreign_net_sum, market_cap, raw_ratio_pct
```
### 5.3 거래량 급증 — `VolumeSurge`
```text
params: baseline_days = 20, eval_days = 3
baseline = mean(volume[-23:-3])
recent = mean(volume[-3:])
raw = log1p(recent / baseline) # 극값 평탄화
score = percentile_rank(raw, 통과군) × 100
debug: baseline, recent, ratio
```
### 5.4 20일 모멘텀 — `Momentum20`
```text
params: window_days = 20
raw = close[today] / close[today - 20] - 1
score = percentile_rank(raw, 통과군) × 100
debug: return_20d_pct
```
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
```text
params: window_days = 252
high_52w = max(high[-252:])
proximity = close / high_52w # 0..1
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
# 70% 미만 = 0, 100% 도달 = 100, 선형
debug: high_52w, proximity_pct
```
### 5.6 RS Rating — `RsRating`
```text
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
for k in [63, 126, 189, 252] 거래일:
r_stock = close[t]/close[t-k] - 1
r_kospi = kospi[t]/kospi[t-k] - 1
excess_k = r_stock - r_kospi
raw = Σ w_k × excess_k
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
debug: excess_1y, excess_3m, raw
```
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
```text
params: ma_periods = [50, 150, 200]
5개 조건의 만족 개수 / 5 × 100:
① close > MA50
② MA50 > MA150
③ MA150 > MA200
④ close > MA200
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
debug: 각 조건 boolean
```
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
```text
params: short_window = 40, long_window = 252 # 8주 / 52주
daily_range_pct = (high - low) / close
short_vol = mean(daily_range_pct[-40:])
long_vol = mean(daily_range_pct[-252:])
raw = 1 - (short_vol / long_vol) # 양수면 수축
score = percentile_rank(raw, 통과군) × 100
debug: short_vol, long_vol, contraction_ratio
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
```
### 5.9 결합 (`engine.combine`)
```python
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
```
### 5.10 디폴트 가중치
| 노드 | w | 근거 |
|------|----|------|
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
| volume_surge | 1.0 | 표준 |
| momentum | 1.0 | 표준 |
| high52w | **1.2** | 미너비니 SEPA 핵심 |
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
| ma_alignment | 1.0 | Stage 2 확인용 |
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
### 5.11 포지션 사이징 — `position_sizer.py`
```text
params (settings):
atr_window = 14
atr_stop_mult = 2.0 # 2 × ATR 손절
rr_ratio = 2.0 # 익절 = 진입가 + 2R
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
stop = round_won(close - 2.0 × atr14)
target = round_won(entry + 2.0 × (entry - stop))
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
```
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
### 5.12 정규화 시 주의점
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
---
## 6. API 명세 (prefix `/api/stock/screener/*`)
### 6.1 엔드포인트 표
| 메서드 | 경로 | 호출 주체 | 책임 |
|--------|------|----------|------|
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
| GET | `/settings` | 프론트 | 현재 설정 조회 |
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
### 6.2 `/run` 시맨틱
```jsonc
// REQUEST
POST /api/stock/screener/run
{
"mode": "preview" | "manual_save" | "auto",
"asof": "2026-05-12", // 생략 시 직전 거래일
"weights": { ... }, // optional override
"node_params": { ... }, // optional override
"gate_params": { ... }, // optional override
"top_n": 20 // optional override
}
// RESPONSE
{
"asof": "2026-05-12",
"mode": "preview",
"status": "success",
"run_id": null, // manual_save·auto만
"survivors_count": 612,
"weights": { ... }, // 실제 사용된 값
"top_n": 20,
"results": [
{
"rank": 1,
"ticker": "005930",
"name": "삼성전자",
"total_score": 84.3,
"scores": {
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
},
"close": 74500,
"market_cap": 444800000000000,
"entry_price": 74872,
"stop_price": 71200,
"target_price": 82216,
"atr14": 1835.5,
"r_pct": 4.9
}
],
"telegram_payload": null, // auto · manual_save만
"warnings": []
}
```
### 6.3 mode 매트릭스
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|------|------------------|---------|----------------------|----------------|
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
`telegram_payload``status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
### 6.4 `asof` 처리
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
### 6.5 에러 응답
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
| HTTP | body.status | 발생 |
|------|-------------|------|
| 200 | `success` | 정상 분석 완료 |
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
---
## 7. 프론트엔드 구조 (web-ui)
### 7.1 라우팅 & 내비게이션
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
- `src/Router.jsx`: 라우트 추가
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
- 홈(`/`) 허브 카드에 항목 추가
### 7.2 디렉토리
```
src/pages/stock/screener/
├─ Screener.jsx # 페이지 루트
├─ Screener.css
├─ components/
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
│ ├─ GatePanel.jsx # 위생 게이트 1개
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
│ ├─ ResultTable.jsx
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
│ ├─ RunHistoryList.jsx
│ └─ TelegramPreview.jsx
└─ hooks/
├─ useScreenerMeta.js
├─ useScreenerSettings.js
├─ useScreenerRun.js
└─ useScreenerHistory.js
```
### 7.3 `src/api.js` 신규 헬퍼
```js
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
```
### 7.4 레이아웃
```
PC (≥1024px)
┌─────────────────────────────────────────────────────────────┐
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
├──────────────┬──────────────────────────────┬───────────────┤
│ NodePanel │ ResultTable │ RunHistoryList │
│ + GlobalControls │ TelegramPreview │ │
│ [지금 실행] │ │ │
└──────────────┴──────────────────────────────┴───────────────┘
모바일 (<768px) — 세로 적층
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
→ [TelegramPreview 접기] → [RunHistoryList]
```
### 7.5 상태 관리 패턴
- `useScreenerMeta`: 마운트 시 1회, 정적
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
---
## 8. 텔레그램 메시지 포맷
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
```
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
통과 612종 / Top 20 / 본문 1-10
1. *삼성전자* `005930` ⭐ 84.3
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
2. *NAVER* `035420` ⭐ 81.7
👤외 ⚡거 🆙고 💪RS 📈MA
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
⋯ (3-10)
🔗 전체 결과·11~20위:
https://gahusb.synology.me/stock/screener?run_id=42
```
### 노드 아이콘 (점수 ≥70인 노드만 표시)
| 노드 | 아이콘 |
|------|--------|
| foreign_buy | 👤외 |
| volume_surge | ⚡거 |
| momentum | 🚀모 |
| high52w | 🆙고 |
| rs_rating | 💪RS |
| ma_alignment | 📈MA |
| vcp_lite | 🌀VCP |
빌더(`screener/telegram.py`)는 payload만 반환:
```jsonc
{
"chat_target": "default",
"parse_mode": "MarkdownV2",
"text": "..." // 위 메시지
}
```
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
---
## 9. agent-office 통합
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
```text
Trigger: 평일 16:30 KST (Asia/Seoul)
Steps:
1. POST /api/stock/screener/snapshot/refresh
실패해도 다음 단계 진행 (이전 캐시로 분석)
2. POST /api/stock/screener/run { "mode": "auto" }
3. 응답에서 status 확인:
- status == 'skipped_holiday': 종료, 텔레그램 미발신
- status == 'success': telegram_payload 추출 → 발신
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
4. 텔레그램 발신은 agent-office의 기존 채널 사용
```
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
---
## 10. 에러 처리
| 발생 지점 | 정책 |
|----------|------|
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
| 모든 가중치 0 | 422 + message "no active score nodes" |
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
`/run``warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
---
## 11. 테스트 전략
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
### 11.1 단위 테스트 (노드별)
```
app/test_screener_nodes_foreign_buy.py
app/test_screener_nodes_volume_surge.py
app/test_screener_nodes_momentum.py
app/test_screener_nodes_high52w.py
app/test_screener_nodes_rs_rating.py
app/test_screener_nodes_ma_alignment.py
app/test_screener_nodes_vcp_lite.py
app/test_screener_nodes_hygiene.py
app/test_screener_position_sizer.py
```
**공통 케이스**:
1. 알려진 입력 → 알려진 출력 (회귀 방지)
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
### 11.2 통합 테스트
```
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
app/test_screener_telegram.py # 메시지 텍스트 생성
```
### 11.3 픽스쳐
`app/screener/_test_fixtures.py`:
- 5종목 × 60거래일 합성 DataFrame 빌더
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
### 11.4 수동 검증 (verification-before-completion)
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
---
## 12. 운영
- 로그: stock-lab stdout (Docker logs)
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
---
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
- [ ] 백엔드: `app/main.py`에 router include
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
- [ ] 백엔드: `requirements.txt``pykrx` 추가
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx``/stock/screener` 등록
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
- [ ] 배포: `scripts/deploy.bat` 또는 개별
---
## 14. 후속 슬라이스 예약
| # | 슬라이스 | 의존 |
|---|---------|------|
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
| 8 | 멀티 프리셋 | settings 테이블 확장 |
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
---
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
```jsonc
{
"score_nodes": [
{
"name": "foreign_buy",
"label": "외국인 누적 순매수",
"default_params": { "window_days": 5 },
"param_schema": {
"type": "object",
"properties": {
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
}
}
}
// … 7개
],
"gate_nodes": [
{
"name": "hygiene",
"label": "위생 게이트",
"default_params": {
"min_market_cap_won": 50000000000,
"min_avg_value_won": 500000000,
"min_listed_days": 60,
"skip_managed": true,
"skip_preferred": true,
"skip_spac": true,
"skip_halted_days": 3
},
"param_schema": { ... }
}
]
}
```
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.

View File

@@ -0,0 +1,558 @@
# AI News Sentiment Node — Design
**작성일**: 2026-05-13
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — AI 뉴스 호재/악재 노드 후속 슬라이스)
---
## 1. 목표
스크리너의 8번째 점수 노드 `AiNewsSentiment` 를 추가한다. 평일 **08:00 KST** 에 시총 상위 100종목의 네이버 종목 뉴스를 스크래핑하고 Claude Haiku로 호재/악재를 정량화하여, 그날의 sentiment를 (a) 텔레그램으로 호재/악재 Top 5 알림으로 전달하고, (b) 16:30 스크리너 자동 잡의 가중합에 percentile_rank 형태로 기여한다.
**Why**: 기존 7개 점수 노드는 모두 수치 기반(가격/거래량/외국인 수급)이며, 시장 정서(뉴스 호재/악재)는 반영되지 않는다. 트레이더 의사결정에 큰 영향을 주는 호재/악재 시그널을 LLM으로 정량화하면 정량 노드와 정성 노드를 한 점수 체계로 통합할 수 있다. 장 시작 전 알림으로 즉시 가치 전달.
---
## 2. 범위
**포함 (이번 슬라이스)**:
- 평일 08:00 KST agent-office cron → stock-lab `/snapshot/refresh-news-sentiment` 호출
- 시총 상위 100종목 × 네이버 종목 뉴스 (`/item/news_news.naver?code=XXX`) 스크래핑
- 종목당 Claude Haiku 1콜 (총 100콜 asyncio.gather 병렬, 동시성 10)
- `news_sentiment(ticker, date, score_raw, reason, news_count, tokens_input, tokens_output, model, created_at)` 테이블
- 8번째 ScoreNode `AiNewsSentiment` 등록 (registry, schema, ScreenContext, 가중합 통합)
- 호재 Top 5 + 악재 Top 5 텔레그램 메시지 (장 시작 전 발송)
- 프론트 캔버스 모드에 8번째 노드 추가 (`SCORE_KEYS` 한 줄 + `INITIAL_NODE_POSITIONS` 좌표 한 줄)
**범위 외 (NOT)**:
- 뉴스 URL 단위 캐싱 (비용이 충분히 낮음)
- 16:00 추가 cron (MVP 일 1회)
- 시장 전체 뉴스 종목 매핑 LLM (시총 상위 100 명시적 매핑)
- 백테스트 (sentiment 점수가 실수익에 미친 영향) — 별도 후속 슬라이스
- 가중치 자동 조정 — spec §14 별도 슬라이스
- 종목별 sentiment 트렌드 차트 — 데이터 누적 후 후속 슬라이스
- 종목 5-10위 외 sentiment 가시화 — Top 5 알림 외 별도 대시보드 없음
---
## 3. 아키텍처 개요
```
┌─────────────────────────────┐
[08:00 KST 평일] │ agent-office cron │
│ on_ai_news_schedule() │
└──────────────┬──────────────┘
│ HTTP POST
┌──────────────────────────────────────────────────────┐
│ stock-lab: /api/stock/screener/snapshot/ │
│ refresh-news-sentiment │
│ │
│ ai_news/pipeline.refresh_daily(asof): │
│ 1. krx_master 시총 상위 100 ticker 조회 │
│ 2. asyncio.gather(Semaphore=10) 100 종목 병렬: │
│ a. scraper.fetch_news(ticker) │
│ b. analyzer.score_sentiment(ticker, news[]) │
│ c. → {score: float, reason: str, ...} │
│ 3. news_sentiment 일괄 upsert │
│ 4. Top 5 호재/악재 추출 → 텔레그램 페이로드 빌드 │
│ 5. agent-office /telegram/send 호출 │
└──────────────────────────────────────────────────────┘
[16:30 KST 평일] ┌─────────────────────────────┐
│ agent-office on_screener_ │
│ schedule (변경 없음) │
└──────────────┬──────────────┘
│ HTTP POST
┌──────────────────────────────────────────────────────┐
│ stock-lab: /api/stock/screener/run mode=auto │
│ │
│ Screener.run(ctx): │
│ ctx.news_sentiment = SELECT * FROM news_sentiment │
│ WHERE date = asof │
│ AiNewsSentiment.compute(ctx, params) │
│ → percentile_rank(score_raw) for 100 tickers │
│ → 가중합에 ai_news weight × percentile 점수 기여 │
└──────────────────────────────────────────────────────┘
```
**의존성 추가**: `anthropic` Python SDK (stock-lab requirements.txt). `ANTHROPIC_API_KEY` 는 docker-compose.yml에 이미 stock-lab 환경변수로 존재.
---
## 4. 컴포넌트 분해 (신규 파일)
### 4.1 stock-lab
```
web-backend/stock-lab/app/
screener/
ai_news/ ← 신규 모듈
__init__.py
scraper.py ← 네이버 finance 종목 뉴스 스크래핑
analyzer.py ← Claude Haiku 호재/악재 분석
pipeline.py ← refresh_daily() 메인 (스크래핑+병렬 LLM+DB upsert)
telegram.py ← Top 5/Top 5 메시지 빌더
nodes/
ai_news.py ← 8번째 ScoreNode 클래스
schema.py ← (수정) news_sentiment 테이블 DDL 추가
registry.py ← (수정) NODE_REGISTRY["ai_news"] 등록
engine.py ← (수정) ScreenContext에 news_sentiment 로딩
router.py ← (수정) POST /snapshot/refresh-news-sentiment 라우트 추가
requirements.txt ← (수정) anthropic 추가
tests/
test_ai_news_scraper.py ← 네이버 HTML mock 파싱
test_ai_news_analyzer.py ← anthropic mock 응답
test_ai_news_pipeline.py ← 5종목 mini integration
test_ai_news_node.py ← percentile_rank + min_news_count 필터
```
### 4.2 agent-office
```
web-backend/agent-office/app/
agents/stock.py ← (수정) on_ai_news_schedule 메서드 추가
scheduler.py ← (수정) cron mon-fri 08:00 등록
service_proxy.py ← (수정) refresh_ai_news_sentiment() helper 추가
```
### 4.3 frontend
```
web-ui/src/pages/stock/screener/
components/canvas/constants/
canvasLayout.js ← (수정) AI 노드 추가 (NODE_IDS / NAME_MAP / LABEL / POSITIONS / SCORE_KEYS)
canvasLayout.test.js ← (수정) 카운트 8 점수 노드, 18 엣지로 갱신
```
---
## 5. DB 스키마 (1개 신규 테이블)
```sql
CREATE TABLE IF NOT EXISTS news_sentiment (
ticker TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
score_raw REAL NOT NULL, -- LLM 원점수 -10 ~ +10
reason TEXT NOT NULL DEFAULT '', -- LLM 한 줄 근거
news_count INTEGER NOT NULL DEFAULT 0, -- 분석에 사용된 뉴스 수
tokens_input INTEGER NOT NULL DEFAULT 0, -- 비용 모니터링
tokens_output INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC);
```
`schema.py``ensure_screener_schema(conn)` 함수에 이 DDL 추가. WAL + busy_timeout 패턴은 stock-lab `_conn()` 표준화로 이미 적용됨.
**기본 가중치 시드**: `DEFAULT_WEIGHTS["ai_news"] = 0.5` 추가 (다른 7노드의 default와 동일). 기존 settings 행이 있는 환경에서는 마이그레이션 1회 — `ensure_screener_schema()` 가 settings의 weights_json에 ai_news 키 누락 시 0.5로 보충하는 1회성 patch 적용.
---
## 6. ScoreNode 구현
```python
# stock-lab/app/screener/nodes/ai_news.py
import pandas as pd
from .base import ScoreNode, percentile_rank
class AiNewsSentiment(ScoreNode):
name = "ai_news"
label = "AI 뉴스 호재/악재"
default_params = {"min_news_count": 1}
param_schema = {
"type": "object",
"properties": {
"min_news_count": {
"type": "integer", "default": 1, "minimum": 0,
"description": "최소 분석 뉴스 수. 미만이면 NaN.",
},
},
}
def compute(self, ctx, params):
df = getattr(ctx, "news_sentiment", None)
if df is None or df.empty:
return pd.Series(dtype=float)
df = df[df["news_count"] >= params["min_news_count"]]
if df.empty:
return pd.Series(dtype=float)
return percentile_rank(df.set_index("ticker")["score_raw"])
```
`ScreenContext` dataclass에 `news_sentiment: Optional[pd.DataFrame] = None` 필드 추가 (default None 으로 기존 호출자 호환성 유지). `ScreenContext.load(conn, asof)` 에 로딩 한 줄 추가:
```python
news_sentiment = pd.read_sql_query(
"SELECT ticker, score_raw, news_count FROM news_sentiment WHERE date = ?",
conn, params=(asof.isoformat(),),
)
return ScreenContext(..., news_sentiment=news_sentiment)
```
기존 테스트 fixture에서 `ScreenContext(...)` 를 직접 생성하는 케이스는 default=None 으로 자동 호환. AiNewsSentiment.compute() 는 `getattr(ctx, "news_sentiment", None)` 로 안전 fallback.
---
## 7. 파이프라인 (`ai_news/pipeline.py`)
```python
async def refresh_daily(conn, asof, *, tickers=None, model=DEFAULT_MODEL,
concurrency=10, news_per_ticker=5):
"""
Returns:
{"asof": ..., "updated": N, "failures": [...], "duration_sec": ...,
"tokens_input": ..., "tokens_output": ..., "top_pos": [...], "top_neg": [...]}
"""
if tickers is None:
tickers = _top_market_cap_tickers(conn, n=100)
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(...) as http_client, AsyncAnthropic(...) as llm:
tasks = [_process_ticker(t, sem, http_client, llm, news_per_ticker, model)
for t in tickers]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = [r for r in results if isinstance(r, dict)]
failures = [r for r in results if isinstance(r, BaseException)]
_upsert_news_sentiment(conn, asof, successes)
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
return {
"asof": asof.isoformat(),
"updated": len(successes),
"failures": [str(e) for e in failures],
"duration_sec": ...,
"tokens_input": sum(r["tokens_input"] for r in successes),
"tokens_output": sum(r["tokens_output"] for r in successes),
"top_pos": top_pos,
"top_neg": top_neg,
}
async def _process_ticker(ticker, sem, http_client, llm, news_per_ticker, model):
async with sem:
await asyncio.sleep(0.2) # rate limit
news = await scraper.fetch_news(http_client, ticker, n=news_per_ticker)
if not news:
return {"ticker": ticker, "score_raw": 0.0,
"reason": "no news", "news_count": 0,
"tokens_input": 0, "tokens_output": 0}
return await analyzer.score_sentiment(llm, ticker, news, model=model)
```
---
## 8. Scraper (`ai_news/scraper.py`)
```python
NAVER_NEWS_URL = "https://finance.naver.com/item/news_news.naver"
async def fetch_news(client, ticker, n=5):
r = await client.get(NAVER_NEWS_URL, params={"code": ticker, "page": 1})
if r.status_code != 200:
return []
soup = BeautifulSoup(r.text, "lxml")
rows = soup.select("table.type5 tbody tr")[:n]
out = []
for row in rows:
title_el = row.select_one("td.title a")
date_el = row.select_one("td.date")
if not title_el or not date_el:
continue
out.append({
"title": title_el.get_text(strip=True),
"date": date_el.get_text(strip=True),
})
return out
```
Rate limit: pipeline 의 `Semaphore(10) + 0.2초 sleep` 으로 보호.
---
## 9. Analyzer (`ai_news/analyzer.py`)
```python
DEFAULT_MODEL = os.getenv("AI_NEWS_MODEL", "claude-haiku-4-5-20251001")
PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {n}개의 헤드라인입니다.
{news_block}
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
reason: 30자 이내 한 줄 근거.
JSON으로만 응답하세요:
{{"score": <float>, "reason": "<string>"}}"""
async def score_sentiment(llm, ticker, news, *, model=DEFAULT_MODEL, name=None):
news_block = "\n".join(f"- {n['title']}" for n in news)
prompt = PROMPT_TEMPLATE.format(
name=name or ticker, ticker=ticker,
n=len(news), news_block=news_block,
)
resp = await llm.messages.create(
model=model, max_tokens=200,
messages=[{"role": "user", "content": prompt}],
)
try:
text = resp.content[0].text
data = json.loads(text)
return {
"ticker": ticker,
"score_raw": float(data["score"]),
"reason": str(data["reason"])[:200],
"news_count": len(news),
"tokens_input": resp.usage.input_tokens,
"tokens_output": resp.usage.output_tokens,
}
except (json.JSONDecodeError, KeyError, ValueError) as e:
log.warning("ai_news parse fail for %s: %s", ticker, e)
return {
"ticker": ticker, "score_raw": 0.0,
"reason": f"parse fail: {e!s}",
"news_count": len(news),
"tokens_input": resp.usage.input_tokens,
"tokens_output": resp.usage.output_tokens,
}
```
---
## 10. 텔레그램 메시지 (`ai_news/telegram.py`)
```python
def build_telegram_payload(*, asof, top_pos, top_neg,
tokens_input, tokens_output, model):
cost_won = int(tokens_input * 0.0013 + tokens_output * 0.0065) # ₩ 환산
lines = [
f"🌅 *AI 뉴스 분석* ({asof} 08:00)",
"",
"📈 *호재 Top 5*",
]
for i, r in enumerate(top_pos, 1):
lines.append(
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
f"{_escape(r['reason'])}"
)
lines += ["", "📉 *악재 Top 5*"]
for i, r in enumerate(top_neg, 1):
lines.append(
f"{i}\\. {_escape(r['ticker'])} \\({r['score_raw']:+.1f}\\) — "
f"{_escape(r['reason'])}"
)
lines += [
"",
f"_분석: 시총 상위 100종목 · 토큰 {tokens_input:,} in / {tokens_output:,} out · "
f"약 ₩{cost_won:,}_",
]
return "\n".join(lines)
```
agent-office 가 텔레그램 발송 책임: stock-lab `/refresh-news-sentiment` 응답을 받아 `messaging.send_raw(text, parse_mode="MarkdownV2")` 호출.
---
## 11. agent-office 통합
### 11.1 `agents/stock.py`
```python
async def on_ai_news_schedule(self):
"""평일 08:00 KST cron."""
try:
result = await service_proxy.refresh_ai_news_sentiment()
except httpx.HTTPError as e:
await self.telegram.send_raw(f"⚠️ AI 뉴스 분석 실패: {e!s}")
return
if result.get("updated", 0) == 0:
await self.telegram.send_raw("⚠️ AI 뉴스: 0종목 분석됨 (스크래핑/LLM 전체 실패)")
return
failure_rate = len(result.get("failures", [])) / 100
if failure_rate > 0.3:
await self.telegram.send_raw(
f"⚠️ AI 뉴스 실패율 {failure_rate:.0%} — 어제 데이터 사용 가능성"
)
payload = build_telegram_payload(
asof=result["asof"],
top_pos=result["top_pos"], top_neg=result["top_neg"],
tokens_input=result["tokens_input"],
tokens_output=result["tokens_output"],
model=DEFAULT_MODEL,
)
await self.telegram.send_raw(payload, parse_mode="MarkdownV2")
```
### 11.2 `scheduler.py`
```python
scheduler.add_job(
stock_agent.on_ai_news_schedule,
"cron", day_of_week="mon-fri", hour=8, minute=0,
id="stock_ai_news_sentiment",
timezone="Asia/Seoul",
)
```
---
## 12. 에러 처리
| 상황 | 처리 |
|------|------|
| 네이버 뉴스 페이지 404/타임아웃 | 해당 종목 score_raw=0 + reason="no news", failures 별도 카운트 |
| BeautifulSoup 파싱 실패 (HTML 구조 변경) | 동일 처리 (failures 카운트) |
| LLM JSON 파싱 실패 | score_raw=0 + reason="parse fail", tokens는 그래도 누적 (실제 호출됨) |
| anthropic API 5xx | 자동 retry 1회 (SDK 기본), 실패 시 failures 카운트 |
| 전체 cron 실패 (네트워크 등) | agent-office 에러 텔레그램 + 16:30 잡은 어제 sentiment 데이터 사용 (date 비교로 자동) |
| 실패율 > 30% | 텔레그램 경고 알림. 단 부분 데이터는 그대로 DB 반영 |
| 16:30 시점 news_sentiment 비어 있음 | AiNewsSentiment.compute() 가 빈 Series 반환 → 가중합에서 이 노드 자동 제외 |
| LLM이 -10/+10 범위 벗어난 값 응답 | clamp `max(-10, min(10, score))` 적용 |
---
## 13. 동시성 & rate limit
- `asyncio.Semaphore(10)` — 동시 10종목 처리 (네이버 차단 회피)
- 종목 처리 사이 0.2초 sleep (semaphore 안에서)
- 100종목 ÷ 10 동시 × 평균 3초/종목 = **~30-60초 총 소요**
- agent-office httpx timeout = 180초 (충분한 여유)
- stock-lab _conn() 의 WAL + busy_timeout=120s 로 16:30 잡과 동시 실행 시 lock 보호
---
## 14. 비용 모니터링
- 종목당 평균: input ~500 tokens, output ~50 tokens
- 일 비용: 50K input × $1/M + 5K output × $5/M = **$0.075/일**
- 월 비용: **~$1.6** (텔레그램 메시지 하단에 매일 ₩72 형태로 표시)
- `news_sentiment.tokens_input/output` 컬럼으로 누적 추적 가능
- 환산: 1 USD ≈ ₩1,300, input $0.0013/K, output $0.0065/K (장기 평균)
---
## 15. 프론트엔드 변경
캔버스 모드에 8번째 점수 노드 추가. 아래 한 파일만 수정:
```js
// canvasLayout.js
export const NODE_IDS = {
...,
AI_NEWS: 'score-ai-news', // 신규
...,
};
export const NODE_KIND_MAP = { ..., [NODE_IDS.AI_NEWS]: 'score', ... };
export const SCORE_NODE_NAME_MAP = { ..., [NODE_IDS.AI_NEWS]: 'ai_news' };
export const SCORE_NODE_LABEL = {
...,
[NODE_IDS.AI_NEWS]: { icon: '🤖', title: 'AI 뉴스' },
};
export const INITIAL_NODE_POSITIONS = {
...,
// 기존 7개 score y: 0,90,180,270,360,450,540 → 8개 y: 0,90,...,630
[NODE_IDS.AI_NEWS]: { x: 480, y: 630 },
};
const SCORE_KEYS = [..., 'AI_NEWS']; // 한 줄 추가
```
폼 모드 `NodePanel` 은 백엔드 `/api/stock/screener/nodes` 응답 기반이라 백엔드 등록만으로 자동 반영.
테스트 갱신:
- `canvasLayout.test.js`: 8 score 노드, 18 엣지 (1+8+8+1), Object.keys(SCORE_NODE_NAME_MAP) === 8
---
## 16. 테스트 전략
### 16.1 backend 단위 테스트
| 파일 | 검증 |
|------|------|
| `test_ai_news_scraper.py` | 네이버 HTML mock 파싱 (3종목 fixture, 빈 HTML, 404 응답) |
| `test_ai_news_analyzer.py` | anthropic mock — success / JSON 파싱 실패 / score 범위 클램프 |
| `test_ai_news_pipeline.py` | 5종목 mini integration (scraper/analyzer monkeypatch) — top_pos/top_neg 정렬 검증, failures 격리 검증 |
| `test_ai_news_node.py` | AiNewsSentiment.compute() — percentile_rank 결과, min_news_count 필터, 빈 컨텍스트 |
| `test_screener_schema.py` | news_sentiment DDL 생성 확인 (기존 테스트 보강) |
| `test_screener_router.py` | POST /snapshot/refresh-news-sentiment 라우팅 검증 (mock pipeline) |
### 16.2 frontend 회귀 테스트
| 파일 | 검증 |
|------|------|
| `canvasLayout.test.js` (수정) | SCORE_NODE_NAME_MAP 8 entries, EDGES 18, AI_NEWS가 gate→score→combine 경로 가짐 |
### 16.3 수동 검증 체크리스트
배포 전 NAS에서:
- [ ] 08:00 cron 트리거 (수동 `agent-office.on_ai_news_schedule()`)
- [ ] news_sentiment 테이블에 100종목 행 생성 확인
- [ ] 텔레그램 메시지 호재/악재 Top 5 + 비용 라인 정상 표시
- [ ] 16:30 스크리너 잡이 ai_news 점수 가중합에 반영 (스크리너 결과의 scores.ai_news 컬럼 확인)
- [ ] 캔버스 모드에 🤖 AI 뉴스 노드 표시, 활성/비활성 토글 동작
- [ ] LLM 실패 시뮬레이션 (ANTHROPIC_API_KEY 잘못 설정 후 cron) → fail-soft 동작
---
## 17. 배포
- **백엔드**: stock-lab + agent-office 동시 변경 → git push → Gitea webhook → 자동 deployer rsync + docker compose build
- **DB 마이그레이션**: `ensure_screener_schema(conn)``CREATE TABLE IF NOT EXISTS` 로 자동 (기존 패턴)
- **환경변수**: stock-lab docker-compose.yml 에 `AI_NEWS_MODEL` (옵션) 추가 가능. 기본값 `claude-haiku-4-5-20251001`
- **프론트**: web-ui에서 `npm run release:nas` (캔버스 노드 1개 추가는 작은 변경)
---
## 18. 후속 슬라이스 후보 (이번 슬라이스 NOT)
본 슬라이스 완료 후 자연스럽게 이어질 작업:
1. **URL 단위 캐싱** — 뉴스 분석 비용 ~70% 절감
2. **장중 16:00 추가 sentiment cron** — 16:30 스크리너에 더 신선한 데이터 공급
3. **종목별 sentiment 트렌드 차트** — 데이터 1-2주 누적 후 시각화
4. **시총 200~500 확장** — 중소형주 sentiment 커버리지
5. **백테스트** — sentiment 점수가 실수익에 미친 영향 회귀
6. **다국어/거시 뉴스 통합** — 글로벌 시장 영향 변수 추가
7. **알림 토글** — 운영 중 텔레그램 알림 일시 정지 옵션
8. **종목별 sentiment 페이지** — 상세 뉴스 + 점수 + LLM 근거 가시화
---
## 19. 리스크와 완화
| 리스크 | 완화 |
|--------|------|
| 네이버 finance HTML 구조 변경 | 단위 테스트로 빠른 감지. fail-soft (해당 종목 skip). 운영 알림 (실패율 > 30%) |
| LLM 응답이 JSON 깨짐 | 종목당 1콜 + JSON-mode prompt + 파싱 실패 시 단일 종목만 skip. lotto curator에서 검증된 패턴 |
| 네이버 차단 (429) | Semaphore(10) + 0.2초 sleep + httpx User-Agent. 향후 429 응답 시 exponential backoff 추가 |
| anthropic API 비용 폭증 | 일 1회 100종목 = $0.075 상한. 토큰 모니터링 컬럼 + 텔레그램 표시로 즉시 감지 |
| 08:00 cron이 16:30 잡과 lock 충돌 | _conn() WAL + busy_timeout=120s 로 흡수. 두 cron 시간 8.5시간 차이로 실질 충돌 없음 |
| 16:30 시점 news_sentiment 비어 있음 (cron 실패) | AiNewsSentiment.compute() 가 빈 Series → 가중합에서 자동 제외. 다른 7노드 점수만 사용 |
---
## 20. 완료 조건 (Definition of Done)
- [ ] 평일 08:00 KST agent-office cron 등록, 수동 트리거로 실행 검증
- [ ] news_sentiment 테이블에 100종목 데이터 일별 생성
- [ ] 텔레그램에 호재/악재 Top 5 + 비용 라인 표시
- [ ] 16:30 스크리너 잡에서 ai_news 점수가 가중합에 반영 (scores.ai_news 노출)
- [ ] 캔버스 모드에 8번째 노드 🤖 AI 뉴스 표시, 가중치/활성/파라미터 편집 동작
- [ ] 폼 모드 NodePanel에 AI 뉴스 자동 노출 (백엔드 메타 기반)
- [ ] 16.1 단위 테스트 모두 통과
- [ ] 16.3 수동 검증 체크리스트 모두 통과
- [ ] LLM 실패 시 fail-soft 동작 (전체 잡은 성공으로 끝나고, 실패 종목만 누락)

View File

@@ -0,0 +1,505 @@
# Stock Screener — Node Canvas Mode Design
**작성일**: 2026-05-13
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**: `2026-05-12-stock-screener-board-design.md` (§14 — react-flow 노드 캔버스 후속 슬라이스)
---
## 1. 목표
`/stock/screener` 페이지에 **n8n 스타일 노드 캔버스 모드**를 추가한다. 폼 모드와 토글로 전환하며, 같은 settings state를 공유한다. 백엔드는 변경하지 않는다 — 캔버스는 시각화 + 편집 UI일 뿐, 결과적으로는 동일한 `weights / node_params / gate_params``/api/stock/screener/run` 에 전송한다.
**Why**: 사용자가 슬라이더만 들여다보는 폼 모드는 "어떤 노드가 어떤 단계에서 무엇을 하는지"의 파이프라인 감각이 약하다. n8n/Figma류 캔버스 시각화는 데이터 흐름을 한눈에 보여줘 강세주 분석 모델의 구조적 이해를 돕는다.
---
## 2. 범위
**포함 (이번 슬라이스)**:
- 헤더 토글 (`폼 ↔ 캔버스`) — 데스크탑 전용
- 11개 노드의 미니 파이프라인 시각화 (고정 토폴로지)
- 점수 노드 카드 위 가중치/활성/핵심 파라미터 인라인 편집 + 설명 표시
- floating 미니 툴바 (실행 / 저장 실행 / 설정 영구 저장 / 레이아웃 리셋)
- 노드 위치 localStorage 저장 + 초기화 버튼
- 모바일에서는 캔버스 토글 숨김, 폼 강제
**범위 외 (NOT)**:
- 노드 추가/삭제 UI (토폴로지 고정)
- 노드 간 연결선 사용자 편집
- 자유 그래프 모드 (별도 후속 슬라이스)
- 캔버스 안 결과 노드에 결과 표시 (외부 테이블에만 표시)
- 노드 캔버스 화면 자체에서의 대화형 백테스트
- dagre 등 자동 레이아웃 알고리즘
---
## 3. 아키텍처 개요
```
┌─────────────────────────────┐
│ Screener.jsx (entrypoint) │
│ - useScreenerMode (form|canvas) │
│ - useIsMobile() → 강제 form │
└────────────┬────────────────┘
┌────────────────┼────────────────┐
│ │ │
form mode canvas mode shared result area
(기존 그대로) (신규) (기존 그대로)
│ │ │
┌──────────┴──┐ ┌─────────┴──────┐ ┌────┴──────┐
│ GatePanel │ │ ScreenerCanvas │ │ ResultTable
│ NodePanel │ │ + CanvasToolbar│ │ TelegramPreview
│ GlobalControls│ │ + Node cards │ │ RunHistoryList
└──────────────┘ └─────────────────┘ └───────────┘
↑ ↑ ↑
└────────────────┴────────────────┘
공유 state: useScreenerSettings,
useScreenerRun, useScreenerHistory
```
**의존성 추가**: `@xyflow/react` (구 react-flow, MIT, ~50KB gzipped).
**백엔드 변경 없음**. 캔버스는 settings를 동일한 형태로 만들고, 동일한 `/run` 엔드포인트를 호출한다.
---
## 4. 화면 레이아웃
### 4.1 데스크탑 — 캔버스 모드
```
┌───────────────────────────────────────────────────────────┐
│ Header: 스크리너 [폼] [캔버스] │
│ 최근 자동잡: 2026-05-13 · 분석 기준일: 2026-05-13│
├───────────────────────────────────────────────────────────┤
│ ╔═════════════════════════════════════════════════════╗ │
│ ║ ┌─ floating toolbar ──────────────────────────┐ ║ │
│ ║ │ ▶ 실행 💾 저장 실행 📌 설정 저장 🔄 ⛶ │ ║ │
│ ║ └──────────────────────────────────────────────┘ ║ │
│ ║ ║ │
│ ║ ┌─────┐ ┌──────┐ ┌───────┐ ║ │
│ ║ │📥KRX│→ │🛡️위생│ ┬→│외국인 │ ┐ ║ │
│ ║ │data │ │gate │ ├→│거래량 │ │ ┌─────────────┐ ║ │
│ ║ └─────┘ └──────┘ ├→│모멘텀 │ ┼→ │⚙️가중합+TopN │→ │📊│║│
│ ║ ├→│52w고가│ │ │ +ATR 사이저 │ ║ │
│ ║ ├→│RS │ │ └─────────────┘ ║ │
│ ║ ├→│이평선│ ┤ ║ │
│ ║ └→│VCP │ ┘ ║ │
│ ║ ║ │
│ ║ (캔버스 영역: 화면 높이의 약 60-65%) ║ │
│ ╚═══════════════════════════════════════════════════════╝ │
├───────────────────────────────────────────────────────────┤
│ ResultTable (기존 그대로) — 비교 모드 그대로 │
│ TelegramPreview (기존 그대로) │
│ RunHistoryList (기존 그대로 — 우측 사이드) │
└───────────────────────────────────────────────────────────┘
```
**그리드 구성 (캔버스 모드)**:
- Row 1 — 헤더 (높이 자동)
- Row 2 — 캔버스 영역 (`min-height: 60vh`, `max-height: 70vh`)
- Row 3 — 2-column: 좌측 `ResultTable + TelegramPreview` (flex 1), 우측 `RunHistoryList` (width 300px)
폼 모드의 3-column 그리드(좌 사이드/센터/우 사이드)와 달리, 캔버스 모드는 캔버스가 가로 전체를 쓰고 결과 영역만 2-column으로 분리. `RunHistoryList` 의 위치는 두 모드 모두 "우측 결과 사이드"로 일관.
### 4.2 데스크탑 — 폼 모드
기존 layout 그대로. 헤더에 토글 [폼] [캔버스]만 추가.
### 4.3 모바일 (<768px)
기존 모바일 카드 layout 그대로. 헤더 토글 자체를 렌더하지 않음. localStorage에 `mode='canvas'`로 저장돼 있어도 무시.
---
## 5. 노드 종류
총 11개 노드, 4개 카테고리.
| 카테고리 | 노드 | 편집 | 색상 | 표시 정보 |
|----------|------|------|------|-----------|
| **데이터** | `📥 KRX 데이터` | 불가 | 회색 | "~2,800종목 · FDR" |
| **게이트** | `🛡️ 위생 게이트` | 가능 | 노랑 | 파라미터 (min_market_cap 등) + 활성/비활성 |
| **점수** | `📈 외국인` | 가능 | 컬러 | 가중치 + 핵심 파라미터 + 설명 |
| **점수** | `📊 거래량 급증` | 가능 | 컬러 | 동일 |
| **점수** | `🚀 모멘텀` | 가능 | 컬러 | 동일 |
| **점수** | `🔝 52w 고가` | 가능 | 컬러 | 동일 |
| **점수** | `💪 RS Rating` | 가능 | 컬러 | 동일 |
| **점수** | `📉 이평선 정렬` | 가능 | 컬러 | 동일 |
| **점수** | `🌀 VCP-lite` | 가능 | 컬러 | 동일 |
| **결합** | `⚙️ 가중합+TopN+ATR` | 불가 | 회색 | "TopN=10 · ATR×2" 등 현재 settings 요약 |
| **결과** | `📊 결과` | 불가 | 회색 | "마지막 실행: 2026-05-13 · 8종목 통과" |
점수 노드의 컬러는 기존 `NODE_META` 의 accent color 시스템과 동기화 — 폼 모드에서 쓰던 색상이 캔버스에서도 동일하게 적용.
---
## 6. 노드 카드 디자인
### 6.1 점수 노드 카드 (편집 가능)
```
┌──────────────────────────────────┐
│ 📈 거래량 급증 ⓘ │ ← 호버 시 풀 설명 툴팁
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ "20일 평균 대비 2배 이상" │ ← 항상 표시되는 한 줄 요약
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ 가중치 [█████░░░░░] 0.5 │ ← 슬라이더 (0~1, step 0.05)
│ ☑ 활성 │ ← 체크박스. uncheck = weight 0
│ │
│ ▾ 파라미터 (펼치면) │
│ lookback_days: [ 20 ] 일 │
│ multiplier: [2.0 ] │
└──────────────────────────────────┘
```
- 한 줄 요약: 기존 `NODE_META[name].summary` (없으면 `description` 첫 줄)
- 풀 설명 (호버 툴팁): 기존 `NODE_META[name].description`
- 파라미터 폼: `param_schema` 기반 자동 생성 (기존 `NodeCard.jsx` 와 동일 로직 재사용)
### 6.2 게이트 노드 카드 (편집 가능, 노랑)
```
┌──────────────────────────────────┐
│ 🛡️ 위생 게이트 ⓘ │
│ "통과해야 점수 단계 진입" │
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ ☑ 활성 │
│ ▾ 파라미터 │
│ min_market_cap: [50] 억원 │
│ exclude_spac: ☑ │
│ ... │
└──────────────────────────────────┘
```
### 6.3 고정 노드 카드 (정보 표시만, 회색)
```
┌────────────────────┐
│ 📥 KRX 데이터 │
│ ~2,800종목 · FDR │
└────────────────────┘
```
결합 노드는 동적으로 현재 settings를 요약 표시:
```
┌────────────────────────────┐
│ ⚙️ 가중합 + TopN + ATR │
│ Top 10 · RR 2.0 · ATR×2 │ ← settings에서 계산해서 표시
└────────────────────────────┘
```
결과 노드도 동적:
```
┌──────────────────────────┐
│ 📊 결과 │
│ 마지막 실행: 14:32 │
│ 8 / 12 종목 통과 │
└──────────────────────────┘
```
---
## 7. 캔버스 인터랙션
| 동작 | 결과 |
|------|------|
| 노드 드래그 | 위치 변경 → 드래그 종료 시 `screener-canvas-layout-v1` localStorage에 저장 |
| 슬라이더 변경 | `useScreenerSettings.setLocal({...settings, weights: {...}})``dirty=true` |
| 체크박스 (활성) | weight 토글: uncheck 시 weight=0 저장, check 시 이전 값 복원 (default = 0.5) |
| 파라미터 ▾ 펼치기 | 카드 높이 동적 확장 |
| 마우스 휠 | 줌 (React Flow 기본) |
| 드래그 (빈 공간) | 팬 (React Flow 기본) |
| ⛶ fitView 버튼 | 전체 노드 화면 맞춤 |
| 🔄 레이아웃 리셋 | `INITIAL_NODE_POSITIONS` 로 복귀, localStorage 키 삭제 |
| ▶ 실행 | 기존 `runPreview(settings)` → 결과는 하단 ResultTable |
| 💾 저장 실행 | 기존 `runSave(settings)` → DB 영구화 |
| 📌 설정 저장 | 기존 `save()` (settings 영구화) |
엣지 연결선은 사용자가 편집할 수 없음 (고정). React Flow 인스턴스 prop `nodesConnectable={false}`, `edgesUpdatable={false}`.
---
## 8. 컴포넌트 분해 (신규 파일)
```
src/pages/stock/screener/
Screener.jsx ← 모드 토글 추가, canvas 모드 분기 렌더
hooks/
useScreenerMode.js ← 신규: 'form' | 'canvas' state + localStorage
useCanvasLayout.js ← 신규: 노드 위치 read/write/reset
(기존 hooks 그대로)
components/
ModeToggle.jsx ← 신규: [폼][캔버스] 세그먼트 컨트롤 (헤더용)
canvas/
CanvasLayout.jsx ← 신규: 캔버스 + 결과 영역 그리드 (4.1 그리드 구성)
ScreenerCanvas.jsx ← React Flow 루트 컨테이너
CanvasToolbar.jsx ← floating Panel (실행/저장/리셋/fitView)
nodes/
ScoreNodeCard.jsx ← 점수 노드 카드 (편집)
GateNodeCard.jsx ← 게이트 노드 카드 (편집)
FixedNodeCard.jsx ← 데이터/결합/결과 카드 (정보만)
constants/
canvasLayout.js ← INITIAL_NODE_POSITIONS / EDGES / NODE_KIND_MAP
(기존 components 그대로 — 폼 모드에서 계속 사용)
```
기존 컴포넌트(`GatePanel`, `NodePanel`, `GlobalControls`, `ResultTable`, `TelegramPreview`, `RunHistoryList`)는 **변경 없음**. 결과 영역은 모드와 무관하게 동일.
### 8.1 `Screener.jsx` 변경점
```jsx
const { mode, setMode } = useScreenerMode();
const isMobile = useIsMobile();
const effectiveMode = isMobile ? 'form' : mode;
return (
<div className="screener-page">
<header className="screener-header">
<h1>스크리너</h1>
{!isMobile && (
<ModeToggle value={mode} onChange={setMode} />
)}
</header>
{effectiveMode === 'form' ? (
<FormLayout {...sharedProps} /> /* 기존 grid layout */
) : (
<CanvasLayout {...sharedProps} /> /* 신규 — 캔버스 + 동일 결과 영역 */
)}
</div>
);
```
---
## 9. 데이터 / state 설계
### 9.1 localStorage 키
| 키 | shape | 설명 |
|----|-------|------|
| `screener-mode-v1` | `'form' \| 'canvas'` | 마지막 사용 모드 |
| `screener-canvas-layout-v1` | `{ [nodeId: string]: { x: number, y: number } }` | 노드별 좌표 |
### 9.2 `useScreenerMode`
```js
export function useScreenerMode() {
const [mode, setModeState] = useState(() => {
try {
return localStorage.getItem('screener-mode-v1') || 'form';
} catch { return 'form'; }
});
const setMode = (m) => {
setModeState(m);
try { localStorage.setItem('screener-mode-v1', m); } catch {}
};
return { mode, setMode };
}
```
### 9.3 `useCanvasLayout`
```js
export function useCanvasLayout(initialPositions) {
const STORAGE_KEY = 'screener-canvas-layout-v1';
const [positions, setPositions] = useState(() => readOrInit(initialPositions));
const updateNodePosition = (nodeId, pos) => {
setPositions((prev) => {
const next = { ...prev, [nodeId]: pos };
writeSafe(next);
return next;
});
};
const reset = () => {
setPositions(initialPositions);
try { localStorage.removeItem(STORAGE_KEY); } catch {}
};
return { positions, updateNodePosition, reset };
}
```
`readOrInit` 은 JSON.parse 실패하거나 노드 ID가 누락된 경우 누락된 ID에 대해서만 `initialPositions` 값을 보충.
### 9.4 `canvasLayout.js` 상수
```js
export const NODE_IDS = {
DATA: 'data',
GATE: 'gate-hygiene',
FOREIGN: 'score-foreign-buy',
VOLUME: 'score-volume-surge',
MOMENTUM: 'score-momentum',
HIGH52W: 'score-high52w',
RS: 'score-rs-rating',
MA: 'score-ma-alignment',
VCP: 'score-vcp-lite',
COMBINE: 'combine',
RESULT: 'result',
};
export const INITIAL_NODE_POSITIONS = {
[NODE_IDS.DATA]: { x: 40, y: 280 },
[NODE_IDS.GATE]: { x: 240, y: 280 },
[NODE_IDS.FOREIGN]: { x: 480, y: 0 },
[NODE_IDS.VOLUME]: { x: 480, y: 90 },
[NODE_IDS.MOMENTUM]: { x: 480, y: 180 },
[NODE_IDS.HIGH52W]: { x: 480, y: 270 },
[NODE_IDS.RS]: { x: 480, y: 360 },
[NODE_IDS.MA]: { x: 480, y: 450 },
[NODE_IDS.VCP]: { x: 480, y: 540 },
[NODE_IDS.COMBINE]: { x: 800, y: 280 },
[NODE_IDS.RESULT]: { x: 1080, y: 280 },
};
export const EDGES = [
{ id: 'e-data-gate', source: NODE_IDS.DATA, target: NODE_IDS.GATE },
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
id: `e-gate-${k.toLowerCase()}`, source: NODE_IDS.GATE, target: NODE_IDS[k],
})),
...['FOREIGN','VOLUME','MOMENTUM','HIGH52W','RS','MA','VCP'].map((k) => ({
id: `e-${k.toLowerCase()}-combine`, source: NODE_IDS[k], target: NODE_IDS.COMBINE,
})),
{ id: 'e-combine-result', source: NODE_IDS.COMBINE, target: NODE_IDS.RESULT },
];
```
총 엣지 수: 1(data→gate) + 7(gate→점수) + 7(점수→combine) + 1(combine→result) = **16개**.
---
## 10. 시각 디자인 디테일
| 요소 | 스타일 |
|------|--------|
| 캔버스 배경 | `bg-screener-canvas` (다크 그리드, 점선 `#1f2937`) |
| 고정 노드 카드 | 배경 `#1f2937`, 텍스트 `#9ca3af`, 200×64 |
| 게이트 카드 | accent `#facc15` (노랑) 좌측 4px stripe, 220×auto |
| 점수 카드 | accent = 기존 `NODE_META[name].color`, 240×auto |
| 비활성 점수 카드 | opacity 0.45 + grayscale 0.6 |
| 엣지 (active) | `#fbbf24` 1.5px, 약한 그라데이션 |
| 엣지 (해당 점수 노드 weight=0) | `#374151` 1px, 점선 |
| 미니맵 | **사용하지 않음** (캔버스 크기가 작아 불필요) |
| Controls (줌/리셋) | React Flow `<Controls />` 좌하단, 미니멀 |
| floating toolbar | 좌상단, `position: absolute`, `backdrop-filter: blur(8px)`, 반투명 |
---
## 11. 모바일/엣지 케이스
| 케이스 | 처리 |
|--------|------|
| 모바일 진입 (≤768px) | 토글 미렌더, `effectiveMode = 'form'` 강제 |
| 데스크탑 → 모바일 리사이즈 중 | `useIsMobile` 가 자동 감지 → 폼으로 폴백 |
| localStorage 파싱 실패 | catch + reset → 초기 위치/모드로 복귀 |
| 노드 ID 누락 (마이그레이션) | 누락 노드만 `INITIAL_NODE_POSITIONS` 값 사용, 나머지는 저장값 유지 |
| 노드 ID 신규 추가 (후속) | 같은 누락 처리 로직으로 자동 흡수 |
| React Flow 초기 렌더 깜빡임 | `fitView` 초기 옵션 + `defaultViewport` 명시로 흡수 |
---
## 12. 테스트 전략
캔버스는 시각화 위주라 E2E 테스트 비용이 크므로 **단위 테스트 중심**으로 간다.
### 12.1 단위 테스트 (web-ui)
| 파일 | 검증 |
|------|------|
| `useScreenerMode.test.js` | 초기값 'form', set 후 localStorage 반영, 손상 시 fallback |
| `useCanvasLayout.test.js` | 초기 positions 반환, updateNodePosition 후 localStorage 반영, reset 후 storage 삭제, 손상 시 initial 반환, 누락 ID 시 initial 보충 |
| `canvasLayout.test.js` | EDGES 정합성: 모든 점수 노드가 gate 입력과 combine 출력을 가짐, source/target ID가 NODE_IDS 안에 존재 |
| `ScoreNodeCard.test.jsx` | 슬라이더 onChange 호출, 비활성 체크박스 시 weight=0, 활성 복원 시 default 0.5 |
### 12.2 통합 (가볍게)
- `Screener.test.jsx` 회귀: 폼 모드 기본 렌더 후 토글로 캔버스 진입, 다시 폼으로 — settings state 유지 확인
### 12.3 수동 검증 체크리스트
배포 전 데스크탑 브라우저:
- [ ] 토글 폼↔캔버스 전환 시 가중치 동기화
- [ ] 캔버스에서 슬라이더 → `dirty` 표시 정상
- [ ] `▶ 실행` → 하단 ResultTable 갱신
- [ ] 노드 드래그 → 새로고침 후 위치 복원
- [ ] `🔄` 리셋 → 초기 위치로 복귀
- [ ] 모바일 (DevTools 360×640) → 토글 미표시, 폼 강제
---
## 13. 성능
| 항목 | 평가 |
|------|------|
| 번들 사이즈 | `@xyflow/react` ~50KB gzipped + 노드 카드 컴포넌트 ~5KB. 전체 web-ui 번들 영향 미미 |
| 렌더 비용 | 11개 노드, 16개 엣지 — React Flow 권장 한계 대비 매우 작음 |
| localStorage I/O | 노드 드래그 종료(`onNodeDragStop`) 시점에만 write, 드래그 중 빈번한 write 없음 |
| 모바일 폴백 | useIsMobile 분기로 캔버스 컴포넌트 자체를 mount하지 않음 → 모바일 번들 부담 없음 (lazy import 검토 가치 있음) |
`@xyflow/react` 는 데스크탑 진입 시에만 필요하므로 **`React.lazy` + `Suspense` 로 분리 import** 권장 (Plan에서 task로 명시).
---
## 14. 후속 슬라이스 후보 (이번 슬라이스 NOT)
이번 캔버스 슬라이스가 완료된 이후 자연스럽게 이어질 수 있는 작업들:
1. **노드 추가/삭제 UI** — 캔버스 우클릭 메뉴로 점수 노드 추가/제거 (백엔드 registry 동적 등록 필요)
2. **자유 그래프 모드** — 토폴로지 자체를 사용자가 구성 (엔진 재설계 동반)
3. **캔버스 안 결과 노드 펼치기** — 결과 노드 클릭 시 in-canvas 결과 표
4. **캔버스 백테스트 시각화** — 노드별 기여도 히트맵 (후속 백테스트 슬라이스와 연동)
5. **노드 그룹화** — 점수 노드 7개를 묶어 접기/펼치기
6. **키보드 단축키** — Space=실행, Cmd+S=저장, R=리셋
---
## 15. 리스크와 완화
| 리스크 | 완화 |
|--------|------|
| `@xyflow/react` API 변경 (v11 → v12 transition 중) | spec 작성 시점 안정 버전(`12.x`) 고정, package.json에 명시 |
| 캔버스 모드에서 폼 모드 settings와 동기화 깨짐 | 같은 hook 인스턴스 공유 + Screener.jsx 한 컴포넌트가 두 layout 분기 렌더 → 동일 state 자동 공유 |
| 노드 카드가 너무 커서 캔버스 빽빽 | spec 6장의 카드 폭(220~240px), 점수 노드 세로 90px 간격으로 사전 검증된 좌표 사용 |
| localStorage 무한 누적 | 키는 정해진 1개씩만 사용, 마이그레이션 시 키 명에 -v1 suffix |
| 모바일 사용자 혼란 | 토글 자체를 렌더하지 않음 → 캔버스 모드 존재 자체를 알지 못함 → 학습 부담 0 |
---
## 16. API/백엔드 영향
**없음**. 본 슬라이스는 프론트엔드 전용. 기존 API:
- `GET /api/stock/screener/nodes`
- `GET/PUT /api/stock/screener/settings`
- `POST /api/stock/screener/run`
를 그대로 사용한다. settings의 shape도 변경 없음.
---
## 17. 배포
- 프론트만 변경 → `npm run release:nas` 또는 `scripts\deploy.bat --frontend`
- 백엔드 배포 불필요
- 마이그레이션 불필요 (DB 변경 없음, localStorage는 점진적 적용)
---
## 18. 완료 조건 (Definition of Done)
- [ ] 데스크탑에서 헤더 [폼][캔버스] 토글이 보이고 정상 전환
- [ ] 캔버스 모드에 11개 노드, 16개 엣지가 사전 정의된 위치로 표시
- [ ] 점수 노드 카드에서 가중치 슬라이더/활성 체크박스/핵심 파라미터 편집 동작
- [ ] 카드 ⓘ 호버 시 설명 툴팁 표시, 한 줄 요약 항상 표시
- [ ] floating 툴바 4개 버튼 (실행/저장 실행/설정 저장/레이아웃 리셋) 모두 동작
- [ ] 노드 드래그 → localStorage 저장 → 새로고침 후 복원
- [ ] 🔄 리셋 → 초기 좌표 복귀 + localStorage 삭제
- [ ] 모바일 (≤768px)에서 토글 미렌더, 폼 강제
- [ ] 폼/캔버스 모드 전환해도 settings, 미리보기 히스토리, 결과 유지
- [ ] 12.1의 단위 테스트 모두 통과
- [ ] 12.3의 수동 검증 체크리스트 통과

View File

@@ -0,0 +1,422 @@
# AI News Phase 1 — `articles` Source Integration Design
**작성일**: 2026-05-14
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**: `2026-05-13-ai-news-sentiment-node-design.md`
**선행 review**: adversarial review (Claude general-purpose, codex CLI ENOENT fallback)
---
## 1. 목표
`ai_news` 파이프라인의 데이터 소스를 **Naver 종목 뉴스 스크래핑 → 기존 `articles` 테이블 재사용** 으로 교체한다. 인프라 중복 제거(이미 매일 cron으로 수집 중) + Naver 차단 회피 + LLM 입력 풍부화(summary 포함).
본 슬라이스는 **Phase 1** 전략의 일부. 4주 IC 측정 결과를 보고 (a) IC < 0.05 → 노드 폐기, (b) IC ≥ 0.05 → Phase 2 (DART OpenAPI 추가) 결정.
**Why**: adversarial review에서 가장 강한 비판이 **"이미 매일 수집 중인 `articles` 테이블을 무시하고 Naver를 100번 더 긁는 중복 인프라"**였음. weight=0 차단(이전 슬라이스 `943f676`)과 짝을 이루어 본 슬라이스로 인프라 중복 해소.
---
## 2. 범위
**포함 (Phase 1)**:
- 신규 모듈 `ai_news/articles_source.py` — 기존 articles 테이블 조회 + 종목명 substring 매핑
- `news_sentiment` 테이블에 `source TEXT NOT NULL DEFAULT 'articles'` 컬럼 추가
- `pipeline.py` 가 articles_source 사용 (Naver scraper 호출 제거)
- `analyzer.py` 가 LLM 입력에 `summary` 추가 (제목 + 요약)
- 텔레그램 메시지에 매핑 hit-rate 표시 (e.g., "matched 42/100")
- 단위 테스트 — articles_source 6개, pipeline 통합 회귀
**범위 외 (NOT)**:
- DART OpenAPI 통합 (Phase 2, IC 검증 후)
- alias dict / LLM ticker 추출 (Phase 1.5, hit-rate 낮을 시)
- failure taxonomy (별도 슬라이스)
- legacy `scraper.py` 삭제 (Phase 2 결정 후)
- 환경변수로 source 토글 fallback (YAGNI)
- weight 변경 (여전히 0.0 유지)
- 매핑 정확도 자동 alarm/threshold
---
## 3. 아키텍처
```
┌──────────────────────────────┐
[08:00 KST 평일] │ agent-office on_ai_news_ │
│ schedule (변경 없음) │
└──────────┬───────────────────┘
│ HTTP POST
┌────────────────────────────────────────────────────────┐
│ stock-lab /snapshot/refresh-news-sentiment (변경 없음) │
│ │
│ ai_news/pipeline.refresh_daily(asof): │
│ 1. top-100 tickers by market_cap (그대로) │
│ 2. articles_source.gather_articles_for_tickers(...) │
│ - SELECT * FROM articles WHERE crawled_at >= asof-1d│
│ - 각 article (title+summary) ∋ ticker.name 매칭 │
│ - {ticker: [article_dict, ...]} 반환 │
│ 3. asyncio.gather (매핑된 ticker만): │
│ a. analyzer.score_sentiment(llm, ticker, articles) │
│ (Naver scraper 호출 없음 — articles 그대로 전달) │
│ 4. news_sentiment upsert with source='articles' │
│ 5. 텔레그램 페이로드: matched_count / total_count 추가 │
└────────────────────────────────────────────────────────┘
```
**의존성 변경 없음**: anthropic SDK 유지, httpx/BeautifulSoup 제거하지 않음 (legacy scraper에서 import 유지).
---
## 4. 파일 변경
### 4.1 신규
```
web-backend/stock-lab/app/screener/ai_news/
articles_source.py ← DB articles 조회 + 종목 매핑
web-backend/stock-lab/tests/
test_ai_news_articles_source.py ← 6 tests
```
### 4.2 수정
```
web-backend/stock-lab/app/screener/
schema.py ← news_sentiment.source 컬럼 + migration
ai_news/pipeline.py ← scraper 호출 제거, articles_source 사용
ai_news/analyzer.py ← summary 활용
```
### 4.3 변경 없음
- `ai_news/scraper.py` (deprecate 주석만, 다음 슬라이스에서 삭제 결정)
- `ai_news/telegram.py` (매핑 통계는 router 에서 처리하거나 telegram 빌더에 인자 추가)
- `ai_news/validation.py` (IC 측정은 데이터 소스 무관)
- `nodes/ai_news.py`
- `engine.py`
- `router.py` (응답 구조는 동일, 새 통계 필드만 추가)
- agent-office 전체
- 프론트엔드
---
## 5. DB 스키마 변경
```sql
ALTER TABLE news_sentiment ADD COLUMN source TEXT NOT NULL DEFAULT 'articles';
```
`schema.py``ensure_screener_schema(conn)` 에 migration block:
```python
cols = {r[1] for r in conn.execute("PRAGMA table_info(news_sentiment)").fetchall()}
if "source" not in cols:
conn.execute(
"ALTER TABLE news_sentiment ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'"
)
```
기존 운영 row (Naver 출처)는 default `'articles'` 로 채워짐 — 이는 의미적으로 부정확하지만 다음 cron부터 실제 articles 출처로 upsert되어 덮어쓰여짐. 24시간 내 정확화. Phase 2 비교 시점(4주 후)에는 충분히 cleared.
---
## 6. `articles_source.py` 구현
```python
"""기존 articles 테이블에서 종목별 뉴스 매핑."""
from __future__ import annotations
import datetime as dt
import logging
import sqlite3
from typing import Any, Dict, List, Tuple
log = logging.getLogger(__name__)
def gather_articles_for_tickers(
conn: sqlite3.Connection,
tickers: List[str],
asof: dt.date,
*,
window_days: int = 1,
max_per_ticker: int = 5,
) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, int]]:
"""Returns ({ticker: [article, ...]}, stats)."""
cutoff = (asof - dt.timedelta(days=window_days)).isoformat()
# 1. tickers 의 회사명 조회
if not tickers:
return {}, {"total_articles": 0, "matched_pairs": 0, "hit_tickers": 0}
placeholders = ",".join("?" * len(tickers))
name_rows = conn.execute(
f"SELECT ticker, name FROM krx_master WHERE ticker IN ({placeholders})",
tickers,
).fetchall()
name_map = {r[0]: r[1] for r in name_rows if r[1]}
# 2. 최근 articles 조회
articles = conn.execute(
"SELECT title, summary, press, pub_date, crawled_at "
"FROM articles WHERE crawled_at >= ? ORDER BY crawled_at DESC",
(cutoff,),
).fetchall()
# 3. 매핑
out: Dict[str, List[Dict[str, Any]]] = {t: [] for t in tickers}
matched_pairs = 0
for a in articles:
title = (a[0] or "").strip()
summary = (a[1] or "").strip()
haystack = title + " " + summary
for ticker, name in name_map.items():
if not name or len(name) < 2:
continue
if name in haystack:
if len(out[ticker]) >= max_per_ticker:
continue
out[ticker].append({
"title": title,
"summary": summary,
"press": a[2] or "",
"pub_date": a[3] or "",
})
matched_pairs += 1
hit_tickers = sum(1 for arts in out.values() if arts)
stats = {
"total_articles": len(articles),
"matched_pairs": matched_pairs,
"hit_tickers": hit_tickers,
}
return out, stats
```
---
## 7. `pipeline.py` 변경
`refresh_daily()``_make_http()` / `asyncio.Semaphore(rate_limit)` / scraper 호출 부분 교체:
```python
async def refresh_daily(conn, asof, *, top_n=100, concurrency=10,
max_news_per_ticker=5, model=_analyzer.DEFAULT_MODEL):
started = time.time()
tickers = _top_market_cap_tickers(conn, n=top_n)
name_map = {...} # 기존 그대로
# 새: articles 매핑
articles_by_ticker, mapping_stats = articles_source.gather_articles_for_tickers(
conn, tickers, asof, window_days=1, max_per_ticker=max_news_per_ticker,
)
sem = asyncio.Semaphore(concurrency)
async with _make_llm() as llm:
tasks = []
for t in tickers:
articles = articles_by_ticker.get(t, [])
if not articles:
continue # 매핑 0 — score 미생성
tasks.append(_process_one_articles(
t, name_map.get(t, t), articles, sem, llm, model
))
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
successes, failures = _split_results(raw_results)
if successes:
_upsert_news_sentiment(conn, asof, successes, source="articles")
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
return {
"asof": asof.isoformat(),
"updated": len(successes),
"failures": [str(f) for f in failures],
"duration_sec": round(time.time() - started, 2),
"tokens_input": sum(r["tokens_input"] for r in successes),
"tokens_output": sum(r["tokens_output"] for r in successes),
"top_pos": top_pos, "top_neg": top_neg, "model": model,
"mapping": mapping_stats, # 신규
}
async def _process_one_articles(ticker, name, articles, sem, llm, model):
async with sem:
return await _analyzer.score_sentiment(llm, ticker, articles, name=name, model=model)
```
`_make_http()` 제거. legacy scraper 의존 없음.
`_upsert_news_sentiment``source` 인자 추가:
```python
def _upsert_news_sentiment(conn, asof, rows, *, source="articles"):
iso = asof.isoformat()
data = [(
r["ticker"], iso, r["score_raw"], r["reason"], r["news_count"],
r["tokens_input"], r["tokens_output"], r["model"], source,
) for r in rows]
conn.executemany(
"""INSERT INTO news_sentiment
(ticker, date, score_raw, reason, news_count,
tokens_input, tokens_output, model, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker, date) DO UPDATE SET
score_raw=excluded.score_raw, reason=excluded.reason,
news_count=excluded.news_count, tokens_input=excluded.tokens_input,
tokens_output=excluded.tokens_output, model=excluded.model,
source=excluded.source
""", data,
)
conn.commit()
```
---
## 8. `analyzer.py` 변경 (미세)
`news_block` 빌더만:
```python
def _format_news_block(news: List[Dict[str, Any]]) -> str:
lines = []
for n in news:
date = n.get("pub_date", "")
title = n["title"]
summary = (n.get("summary") or "").strip()
if summary:
lines.append(f"- [{date}] {title}\n {summary[:200]}")
else:
lines.append(f"- [{date}] {title}")
return "\n".join(lines)
```
`score_sentiment()` 의 prompt 빌드 부분:
```python
news_block = _format_news_block(news)
```
LLM 입력 토큰 ~2-3배 (summary 200자 cap). 매핑 수가 감소(예상 100 → 30-60)하므로 총 토큰 비용은 비슷하거나 약간 감소.
---
## 9. 텔레그램 매핑 통계 표시
`telegram.build_message()``mapping` 인자 추가:
```python
def build_message(*, asof, top_pos, top_neg, tokens_input, tokens_output,
mapping=None):
...
cost = _cost_won(tokens_input, tokens_output)
mapping_line = ""
if mapping:
mapping_line = (
f"매핑: {mapping['hit_tickers']}/100 ticker "
f"\\({mapping['matched_pairs']}쌍 / articles {mapping['total_articles']}\\) · "
)
lines += [
"",
f"_분석: 시총 상위 100종목 · {mapping_line}"
f"토큰 {tokens_input:,} in / {tokens_output:,} out · 약 ₩{cost:,}_",
]
return "\n".join(lines)
```
`router.py` 에서 `mapping=summary.get('mapping')` 전달.
---
## 10. 테스트 전략
### 10.1 신규 `test_ai_news_articles_source.py` (6 tests)
1. **single_ticker_match_in_title** — title 에 회사명 → 매핑 hit
2. **single_ticker_match_in_summary** — summary 에 회사명 → 매핑 hit
3. **multi_ticker_match** — 한 article 이 두 회사명 포함 → 두 ticker 모두 매핑
4. **no_match_returns_empty_list** — 회사명 미포함 article → 빈 리스트
5. **max_per_ticker_caps_results** — 6개 매핑 가능한 articles 중 max=5
6. **window_days_filters_old_articles** — crawled_at < cutoff 인 article 제외
### 10.2 갱신 `test_ai_news_pipeline.py`
기존 `patch.object(pipeline, "_scraper")` 패턴을 `patch.object(pipeline, "articles_source")` 로 교체. 시나리오:
- happy path: 3 ticker × 1 article each
- failures isolated: 한 ticker LLM error
- 매핑 0 ticker (skip 검증)
### 10.3 갱신 `test_ai_news_analyzer.py`
- `news` 입력에 `summary` 가 있을 때 prompt 에 포함되는지
- summary 없을 때 title 만 사용
- pub_date 표시
### 10.4 갱신 `test_ai_news_telegram.py`
- `mapping` 인자 있을 때 매핑 라인 포함
- `mapping=None` 일 때 기존 동작
### 10.5 갱신 `test_ai_news_router.py`
- response 에 `mapping` 필드 포함
### 10.6 갱신 `test_screener_schema.py`
- migration 시 `source` 컬럼 생성
- 기존 row 의 source default 검증
---
## 11. 운영 가정 + 모니터링
| 가정 | 모니터링 |
|------|----------|
| 기존 `stock_news` cron (7:30 KST)이 articles 매일 수집 | 그게 깨지면 ai_news 도 0 결과 — articles 일별 count 별도 모니터링 권장 (이번 슬라이스 외) |
| 시장 뉴스에 시총 상위 100종목 회사명이 자주 등장 | hit-rate 텔레그램 라인으로 일별 확인. <30% 면 alias dict 추가 검토 |
| 회사명 substring match가 false positive 적음 | 4주 IC 결과로 검증 (positive면 매핑 정확도 OK 추정) |
---
## 12. 에러 처리
| 상황 | 처리 |
|------|------|
| articles 테이블 비어 있음 | gather() 반환 = `{}`, stats `total=0`. 모든 ticker skip, news_sentiment 0 row 추가, telegram에 "매핑 0/100" 표시 |
| 시총 상위 ticker 모두 매핑 0 | `updated=0` → on_ai_news_schedule 의 운영자 알림 분기 (기존 그대로) |
| krx_master 비어 있음 | gather() 가 빈 결과, 위와 동일 |
| LLM 실패 (특정 ticker) | 기존 fail-soft 그대로. failures 리스트에 추가, 다른 ticker 영향 없음 |
| migration 실행 실패 (예: 이미 컬럼 존재) | PRAGMA table_info 체크로 idempotent. ALTER 안 실행 |
---
## 13. 비용 / 성능 비교
| 항목 | 현재 (Naver) | Phase 1 (articles) |
|------|--------------|-------------------|
| 외부 HTTP | 100건/일 (Naver) | 0건 |
| 실패율 | 30%+ (Naver 차단) | 0% (DB 조회) |
| LLM calls | 100 | hit_tickers 수 (예상 30-60) |
| LLM input tokens | ~25K | ~30-50K (summary 포함) |
| 일 비용 | ~$0.075 | ~$0.05-0.10 (실측 후) |
| 처리 시간 | 30-60초 | 5-15초 (DB + LLM) |
---
## 14. Rollback
- 데이터: `news_sentiment.source` 컬럼으로 Phase 1 데이터와 이전 Naver 데이터 구분 가능
- 코드: `git revert` 만으로 가능. legacy `scraper.py` 유지로 코드 회복 즉시
- 환경변수 토글: **미포함** (YAGNI)
---
## 15. 후속 슬라이스 (Phase 1 이후 결정)
- **Phase 1.5** — 매핑 hit-rate < 30% 면 alias dict 추가 (50-100개)
- **Phase 2** — 4주 IC ≥ 0.05 시 DART OpenAPI 추가 (하이브리드 점수)
- **Phase X** — IC < 0.05 시 노드 deprecate 후 삭제 (scraper + analyzer + pipeline + node + DB cleanup)
---
## 16. 완료 조건 (Definition of Done)
- [ ] `articles_source.py` + 6개 단위 테스트
- [ ] `news_sentiment.source` 컬럼 추가 + migration
- [ ] `pipeline.py` 가 articles_source 사용 (scraper 호출 없음)
- [ ] `analyzer.py` 가 summary 포함 prompt
- [ ] `telegram.py` 에 매핑 통계 라인
- [ ] `router.py` 응답에 `mapping` 필드
- [ ] 기존 76 단위 테스트 + 갱신/신규 테스트 모두 통과
- [ ] 운영 환경 트리거 시 텔레그램에 "매핑 N/100" 표시 + news_sentiment 행에 source='articles'
- [ ] LLM 비용이 일 ~$0.05-0.10 범위로 감소 (텔레그램 ₩ 라인으로 확인)
- [ ] 첫 실행 후 매핑 hit-rate 메모리 기록 (1.5/2 결정 baseline)

View File

@@ -0,0 +1,420 @@
# Confidence Signal Pipeline V2 — Architecture & Contract (Phase 0)
**작성일**: 2026-05-15
**작성자**: gahusb
**상태**: Approved for implementation (Phase 0 = architecture decisions, 코드 변경 없음)
**Amended 2026-05-15**: Chronos-2 채택 (LSTM 폐기) + Qwen3 14B 채택 (Claude Haiku 폐기). 모델 결정 11개 보정.
**선행 컨텍스트**:
- adversarial review (2026-05-13) — 신호 검증 인프라 필요성
- Stock Screener V1 (post-close 16:30 Top-N) — 가치 발굴 완성
- AI News Phase 1 (`articles` source, weight=0 검증 대기) — sentiment 신호
- web-ai (Windows GPU, RTX 5070 Ti) — LSTM + KIS API + Telegram Bot 기존 자산
---
## 1. 비전
**"주식을 쉽게 잘하기"** — 다층 신뢰도 시스템으로 사용자 + 아내 모두에게 확신 있는 매매 신호 전달.
V1 screener는 종가 기반 일별 Top-N 만 산출. V2는:
- **가치 발굴 (stock-lab 종가 기반)** ×
- **시점 분석 (web-ai 장중 Chronos-2 + 분봉)** ×
- **2차 검증 (agent-office → web-ai Qwen3 14B Ollama)** ×
- **이중 텔레그램 (본인 = 기술 풀 / 아내 = 간소화)**
= **확신의 신호**
**역할 분리 — 두 AI 모델**:
- **Chronos-2** (Amazon, 120M params, FP16 ~1GB) = 시계열 예측 엔진 (수치 → quantile 분포)
- **Qwen3 14B Q4** (Ollama, ~8.3GB) = 분석가/개발자 보조 두뇌 (자연어 메시지 + 전략 해석 + 코드 자동화)
---
## 2. Phase 0 산출물
**본 spec 1 문서**. 코드 변경 0. 후속 Phase 1-7 의 모든 구현이 본 spec 의 결정을 따른다.
핵심 결정 8개 (amend 시점):
1. 데이터 채널 — `web-ai pull from stock-lab` (web-ai 가 polling)
2. 데이터 소스 — KIS API 직접 (web-ai) + stock-lab API (settings/screener/portfolio)
3. **시점 예측 모델 — Chronos-2 (Amazon, 120M, zero-shot, quantile 분포)**
4. **2차 검증 모델 — Qwen3 14B Q4 (Ollama on web-ai, ~8.3GB, 응답 ~13초)**
5. 2차 검증 방식 — context augmentation (메시지 직접 작성 + 양방향 게이트)
6. 트리거 — 매수 (screener Top-20) + 매도 (portfolio 보유). 관심종목은 백로그
7. 이중 텔레그램 — 본인 풀버전 + 아내 간소화. LLM 단일 콜에서 양쪽 생성
8. 운영 — 시간대별 폴링 주기 (장전 5분 / 장중 1분 / 장후 5분 / 야간 없음 — Chronos-2 zero-shot)
---
## 3. 시스템 아키텍처
```
┌─────────────────────────────────────┐ ┌──────────────────────────────────┐
│ NAS (Synology Docker) │ │ Windows PC (RTX 5070 Ti) │
│ │ │ │
│ ┌────────────────────────────────┐ │ │ ┌─────────────────────────────┐ │
│ │ stock-lab :18500 │ │ │ │ web-ai :8001 │ │
│ │ • /screener/settings │◄─┼──────┼─►│ ① Pull Worker │ │
│ │ • /screener/run │ │ HTTP │ │ (시간대별 폴링) │ │
│ │ • /portfolio │ │ pull │ │ │ │
│ │ • /news-sentiment (옵션) │ │ │ │ ② KIS Client │ │
│ └────────────────────────────────┘ │ │ │ (WebSocket 분봉/호가) │ │
│ │ │ │ │ │
│ ┌────────────────────────────────┐ │ │ │ ③ Chronos-2 Predictor │ │
│ │ agent-office :18900 │◄─┼──────┼──┤ (Chronos-2 120M zero-shot)│ │
│ │ • /signal (Ollama 라우팅) │ │ HTTP │ │ 60일 → quantile 분포 │ │
│ │ • Telegram dispatcher (이중) │ │ push │ │ │ │
│ │ → web-ai Ollama HTTP 호출 │ │ │ │ ④ Timing Analyzer │ │
│ └─────────┬──────────────────────┘ │ trig │ │ (분봉 모멘텀) │ │
│ │ │ │ │ │ │
└────────────┼──────────────────────────┘ │ │ ⑤ Signal Generator │ │
│ │ │ (매수/매도 룰) │ │
▼ │ │ │ │
┌─────────────────┐ │ │ ⑥ Rate Limiter │ │
│ Telegram │ │ │ (24h 중복 차단) │ │
│ - 본인 (full) │ │ └─────────────┬───────────────┘ │
│ - 아내 (lite) │ │ │
└─────────────────┘ └───────────────────────────────────┘
```
**책임 분리**:
- **stock-lab**: 가치 발굴 (8 노드 + 위생 게이트 + ATR), 사용자 설정 저장, portfolio 단일 진실원
- **web-ai**: 시점 분석 (Chronos-2 + 분봉), 시그널 생성, rate limit, **Ollama LLM 호스팅 (Qwen3 14B Q4)**
- **agent-office**: 신호 라우팅 (web-ai Ollama HTTP 호출), 텔레그램 발송 (본인 + 아내)
- **web-ui**: stock-lab settings 편집 (캔버스 UI). 신호 수신/표시는 V2 NOT.
**VRAM 분배 (RTX 5070 Ti 16GB, usable 15.5GB)**:
- Chronos-2: ~1GB
- Qwen3 14B Q4: ~8.3GB
- 합: ~9.3GB
- 여유: ~6GB (안전 마진)
---
## 4. 데이터 소스 분담
| 데이터 | 출처 | 갱신 주기 | 저장소 |
|--------|------|----------|-------|
| KRX 일봉 60일 (Chronos-2 입력) | KIS API (web-ai 직접) | 시작 시 + 종가 후 갱신 | web-ai 로컬 |
| 정규장 분봉/실시간 호가 | KIS API WebSocket (web-ai 직접) | 실시간 | web-ai 메모리 |
| NXT 가격 스냅샷 (장전/장후) | KIS API + 네이버 모바일 백업 | 30초~1분 폴링 | web-ai 로컬 |
| screener settings (가중치) | stock-lab API (web-ai pull) | 1-5분 | NAS `stock.db` |
| screener 점수 (Top-20) | stock-lab `/run` 호출 결과 | 1-5분 | NAS (preview 모드, 미저장) |
| portfolio (보유 종목 + 평단) | stock-lab API (web-ai pull) | 1-5분 | NAS `stock.db` |
| 외인/기관 수급 | stock-lab (네이버 frgn) | 종가 후 16:30 | NAS `stock.db` |
| AI 뉴스 sentiment | stock-lab (articles 기반 Claude) | 평일 08:00 | NAS `stock.db` |
| 사용자 텔레그램 chat IDs | agent-office 환경변수 | 정적 | docker-compose env |
**원칙**:
- web-ai는 NAS DB 직접 접근 안 함 — 모든 데이터는 stock-lab API 경유
- KIS API 데이터는 web-ai 로컬에만 — NAS push 안 함 (실시간성 + 용량)
- 본인+아내 chat ID 는 agent-office 단독 보관 — web-ai 는 ticker/action 만 push
---
## 5. API 계약
### 5.1 stock-lab → web-ai (pull 응답)
**기존 endpoint (변경 없음)**:
- `GET /api/stock/screener/settings` — 현재 가중치/임계값
- `POST /api/stock/screener/run {mode:"preview"}` — 8 노드 점수 + Top-N (DB 미저장)
- `GET /api/portfolio` — 보유 종목 리스트
**신규 endpoint (Phase 1)**:
- `GET /api/stock/screener/news-sentiment?days=1` — 종목별 sentiment 점수 (옵션, Phase 1 에 추가)
### 5.2 web-ai → agent-office (push)
**신규 endpoint** (Phase 5):
```
POST /api/agent-office/signal
Content-Type: application/json
```
Request body:
```json
{
"ticker": "005930",
"name": "삼성전자",
"action": "buy" | "sell",
"confidence_webai": 0.82,
"current_price": 78500,
"avg_price": 75000, // sell 시에만
"pnl_pct": 0.047, // sell 시에만
"context": {
"lstm_pred_1d": 0.023,
"lstm_pred_conf": 0.82,
"screener_rank": 3,
"screener_scores": {"foreign_buy": 88, "volume_surge": 75, "momentum": 60, ...},
"minute_momentum": "strong_up" | "weak_up" | "neutral" | "weak_down" | "strong_down",
"kospi_change": 0.004,
"news_sentiment": 6.2,
"news_top": ["HBM 양산 가시화", "1분기 어닝 서프라이즈"]
}
}
```
Response (agent-office → web-ai):
```json
{
"ok": true,
"decision": "send" | "hold",
"final_confidence": 0.745,
"telegram_self_sent": true,
"telegram_wife_sent": true
}
```
### 5.3 web-ai Ollama 응답 (agent-office → Ollama HTTP)
agent-office 가 web-ai 의 Ollama (Qwen3 14B Q4) 에 보내는 prompt 의 응답 schema:
```json
{
"decision": "send" | "hold",
"confidence_llm": 0.91,
"reason": "외인+거래량+호재 일관성 강함",
"warnings": ["KOSPI 약세 가능성"],
"message_self": "🔔 매수 신호: 삼성전자 (005930)\n💡 신뢰도 ...",
"message_wife": "📈 추천: 삼성전자 매수 검토\n사유: ..."
}
```
`final_confidence = confidence_webai × confidence_llm`. 임계값 (default 0.7) 미만 또는 `decision="hold"` 면 silent (텔레그램 발송 안 함).
**프롬프트 엔지니어링 (Qwen3 14B JSON 강제)** — ai_news 슬라이스의 Claude JSON 강제 패턴 적용:
- system: "너는 한국 주식 분석가다. JSON 객체 하나만 반환한다."
- assistant prefill `"{"` 로 응답 시작 강제
- temperature=0
- 응답 파싱 실패 시 `decision="hold"` 폴백 (silent block)
---
## 6. 시그널 룰
### 6.1 매수 신호 (screener Top-20 종목 대상)
조건 (전부 충족):
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`:
- 5분봉 5개 연속 양봉
- 거래량 > 평균 1.5배
3. KIS 호가 매수세 ≥ 60%
종합 confidence:
```
confidence_webai = chronos_conf × 0.5 + minute_score × 0.3 + screener_norm × 0.2
```
- `chronos_conf` ∈ [0, 1] — Chronos-2 분포 폭에서 변환 (좁을수록 1에 가까움)
- `minute_score` ∈ [0, 1] (5분봉 강도 + 거래량 multiplier 정규화)
- `screener_norm` = 1 - (rank - 1) / 20 (rank 1 = 1.0, rank 20 = 0.05)
**임계값**: `confidence_webai > 0.7` → agent-office 전송. 아니면 silent.
### 6.2 매도 신호 (portfolio 보유 종목 대상)
**손절선** (사용자 조정 가능, default -7%):
- `pnl_pct < -0.07` 시 즉시 매도 시그널 (Chronos-2/분봉 무관)
- 메시지: "손절선 도달, 매도 검토"
**익절선** (default +15%):
- `pnl_pct > 0.15` 시 검토 알림 (강제 매도 아님)
- 메시지: "익절선 도달, 부분 매도 또는 추세 추종 검토"
**이상 신호** (보유 중 급격한 약세):
- Chronos-2 1-day quantile (median) 예측 < -1% + 분포 폭 좁음 (chronos_conf > 0.7)
- 분봉 모멘텀 = `strong_down`
- KIS 호가 매도세 ≥ 60%
- `confidence_webai > 0.7` 동일 임계값으로 전송
### 6.3 Rate limit
- **같은 종목 + 같은 action**: 24h 내 재알림 금지
- **장 마감 후 재실행**: 손절선/익절선 알림은 1일 1회 maximum
- Rate limit state: web-ai 로컬 SQLite 또는 메모리 dict (재기동 시 reset = 운영상 허용)
---
## 7. 텔레그램 메시지 형식
### 7.1 본인 (기술 풀)
```
🔔 매수 신호: 삼성전자 (005930)
💡 신뢰도 87/100 (web-ai 82 × Qwen3 91)
📊 분석 근거:
• Chronos-2 예측: 다음날 +2.3% (분포 폭 좁음, conf 0.82)
• Screener Top-3: 외인+거래량 강세
• AI 뉴스: +6.2 (HBM 양산 가시화)
• 분봉 모멘텀: 강세 (5분봉 5연속 양봉)
• KOSPI: +0.4% (약강세)
⚠️ 주의:
• 코스피 약세 구간 진입 가능성
• 분할 매수 권고
현재가: 78,500원
```
### 7.2 아내 (간소화)
```
📈 추천: 삼성전자 매수 검토
사유: 외국인 매수 강세 + 호재 뉴스
추천 강도: ★★★★☆ (높음)
현재가: 78,500원
```
추천 강도 표시: `final_confidence` 기준
- ★★★★★ (0.85+)
- ★★★★☆ (0.7-0.85)
- ★★★☆☆ (0.55-0.7) — 텔레그램 발송은 0.7 임계값이라 도달 안 함
### 7.3 매도 메시지 (본인/아내 양쪽)
본인:
```
🚨 매도 신호: SK하이닉스 (000660)
💡 신뢰도 78/100
📊 사유:
• 평단 대비 -7.2% (손절선 도달)
• Chronos-2 다음날 -1.5% 예측 (conf 0.75)
• 분봉 강한 매도세
매도 검토 권고. 평단 152,000원 → 현재 141,100원
```
아내:
```
⚠️ 매도 검토: SK하이닉스
사유: 손절선 도달, 약세 신호
손익: -7.2%
```
---
## 8. 운영 모드
| 시간대 | web-ai 동작 | 폴링 주기 | 비용 |
|--------|------------|----------|------|
| **장전 (07:00-09:00)** | settings + screener pull + NXT 가격 + sentiment | 5분 | 0 |
| **장중 (09:00-15:30)** | KIS 분봉 + 호가 + Chronos-2 추론 + 시그널 + Qwen3 검증 | 1분 | 0 (LLM 로컬) |
| **장후 (15:30-20:00)** | NXT 가격 + 보유 종목 PnL 추적 + 손절/익절 알림 | 5분 | 0 |
| **야간 (20:00-07:00)** | (재학습 cron 없음 — Chronos-2 zero-shot) | — | 0 |
**예상 LLM 비용**:
- **월 LLM API 비용 = 0** (Qwen3 14B Q4 로컬 호스팅)
- 전기료만 (Windows PC 상시 가동, RTX 5070 Ti 평균 idle ~30W + 추론 spike ~200W)
- 일 신호 3-5건 × ~13초 추론 = 일 GPU full load ~1분 정도, 무시 가능
- **Chronos-2 추론은 GPU 로컬, 비용 0**
---
## 9. Phase 1-7 분해
```
Phase 1: stock-lab API 보강 (1주)
- /api/portfolio 외부 노출 (현재 web-ui 내부용)
- /api/stock/screener/news-sentiment endpoint 추가
- /api/stock/screener/run preview 옵션 검증
Phase 2: web-ai Pull Worker + Signal API Client (2주)
- 기존 main_server.py + bot.py 분리
- stock-lab API client (httpx + retry + cache)
- 시간대별 폴링 스케줄러
- rate limit DB
Phase 3: KIS WebSocket + 분봉 + Chronos-2 추론 (2주, ↓ 1주)
- KIS WebSocket client (정규장 분봉 + 호가)
- NXT 폴링 client (스냅샷 + 네이버 백업)
- Chronos-2 zero-shot 추론 파이프라인 (HuggingFace 모델 로드 + 배치 추론)
- 분봉 모멘텀 분류기
- (재학습 인프라 X — Chronos-2 zero-shot)
Phase 4: Signal Generator (1주)
- 매수 룰 (Chronos-2 quantile + 분봉 + 호가 + screener)
- 매도 룰 (손절/익절/이상)
- confidence 계산 + 임계값
Phase 5: agent-office /signal + Ollama Qwen3 검증 + 이중 텔레그램 (2주)
- POST /signal 라우터 (agent-office)
- web-ai 에 Ollama 서버 + Qwen3 14B Q4 설치
- agent-office → web-ai Ollama HTTP client (Anthropic SDK 대체)
- Qwen3 prompt (system + user + assistant prefill JSON)
- 본인/아내 dispatcher
- **A/B 테스트 1주 — 본인 chat 에 Qwen3/Claude Haiku 메시지 동시 발송 후 한 쪽 채택**
Phase 6: web-ai 기존 trading bot 정리 (1주)
- 자체 watchlist_manager 삭제
- 자체 뉴스 크롤링 (Ollama) 삭제
- 기존 자동 매매 (KIS 실주문) 비활성화 또는 별도 모드 분리
Phase 7: 운영 모니터링 + 4주 IC 검증 (1주 + 4주)
- 신호 hit-rate 추적 (forward return correlation)
- false positive rate
- 임계값 점진 조정
- Phase 8 (자동 매매) 검토
```
총 10-12주 (개인 페이스). 각 Phase 마다 자체 spec + plan + 검증 사이클.
---
## 10. Backlog (V2 본 spec NOT)
미래 슬라이스로 분리:
- **관심종목 (watchlist) 모니터링** — Top-N + portfolio 외, 사용자 관심종목의 변동성 spike / 거래량 급증 알람
- **자동 매매 (KIS 실주문)** — Phase 8 검토. 4주 신호 hit-rate ≥ 60% 후 단계적
- **DART 공시 통합** — LLM 검증 컨텍스트에 공시 추가
- **백테스트 화면** — 과거 신호 정확도 시각화
- **신호 hit-rate 대시보드** — web-ui 신규 페이지
- **분할 매수/매도 전략 추천** — Phase 7 이후
- **옵션/선물/해외 주식** — V3 검토
- **Qwen3 14B "개발자 보조" 별도 endpoint** — 전략 해석/코드 자동화/디버그 도구. V2 흐름 외 사용자 챗봇 형태 (텔레그램 또는 web-ui chat). 같은 Ollama 인스턴스 재활용
- **Claude API 폴백** — web-ai/Ollama 장애 시 anthropic 으로 자동 전환 (가용성 보강)
- **Kimi K2.6 API 옵션** — Qwen3 응답 품질 부족 시 ~80% 저비용 외부 API 대안
---
## 11. 위험 및 완화
| 위험 | 완화 |
|------|------|
| Windows PC 다운 시 신호 zero | stock-lab은 정상. web-ai down 시 헬스체크 → 텔레그램 운영자 알림. Ollama도 함께 다운 (같은 머신) → Claude API 폴백은 백로그 |
| KIS API 장애 | NXT는 네이버 모바일 API 폴백. 분봉은 단기 재시도 + 일정 시간 후 alert |
| **Qwen3 14B 한국어 메시지 품질 부족** | **Phase 5 A/B 테스트 1주 — Qwen3 vs Claude Haiku 메시지 동시 발송 후 우월한 쪽 채택. Qwen3 부족 시 Claude Haiku 로 폴백** |
| False positive 다수 | 4주 IC + Phase 7 모니터링. 임계값 점진 상향 |
| Chronos-2 분포 drift | 주간 ablation (forward return correlation 추적). drift 시 다른 foundation 모델 (Moirai-2.0) 으로 교체 검토 |
| 메시지 본인-아내 drift | LLM 단일 콜에서 양쪽 동시 생성 (drift 회피, 같은 reasoning) |
| 매도 신호 지연 | 분봉 1분 폴링. 손절선은 보유 종목 단순 비교 (Chronos-2 무관 즉시 트리거) |
| stock-lab API 응답 지연 | web-ai 측 timeout 10s + 캐시 (마지막 성공 응답 ttl 5분) |
| 종목 갱신 race condition | screener Top-20 변동 시 rate limit 키 = (ticker, action, date) |
| **Qwen3 응답 13초로 분봉 1분 안에 한 사이클 끝낼 수 없을 위험** | 신호 발생 빈도 일 3-5건이라 동시 처리 거의 없음. 큐 직렬 처리로 충분. 대량 신호 시 backpressure → Phase 7 모니터링 |
| **VRAM 빡빡 (Chronos-2 + Qwen3 = 9.3GB / 15.5GB)** | 여유 6GB 안전. 동시 로딩 시점 분리 (Chronos-2 추론 → 결과 메모리 보관 → Qwen3 호출). swap 발생 시 Phase 7 에서 Qwen3 8B 로 다운그레이드 검토 |
---
## 12. 명시적 NOT 범위 (Phase 0)
- **자동 매매 (실주문)**: V2 는 신호만. 사용자가 수동 매매. Phase 8 별도 검토
- **종목 매수 가격/수량 추천**: 사용자 결정. 신호는 "검토 권고" 수준
- **분할 매수/매도 전략**: Phase 7 이후 별도 슬라이스
- **옵션/선물/해외 주식**: KRX 정규장 + NXT 한정
- **관심종목 모니터링**: 백로그 (§10)
- **신호 hit-rate 시각화 UI**: 백로그
---
## 13. 완료 조건 (Phase 0 DoD)
본 spec 완료 = 다음 조건 모두 충족:
- [x] 사용자가 spec 검토 + 승인 (2026-05-15)
- [x] git commit (`docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
- [x] 8 핵심 결정 명시적 (데이터 채널, 데이터 소스, Chronos-2 예측, Qwen3 검증, context augmentation, 매수+매도, 이중 텔레그램, 운영 모드)
- [x] 4개 API 계약 (3 stock-lab pull + 1 agent-office push) 모두 schema 정의
- [x] Phase 1-7 분해 + 각 Phase 추정 기간 (Phase 3 -1주, Phase 5 +0주 → 총 10-11주)
- [x] backlog + 위험/완화 매트릭스 + NOT 범위
- [x] **Amend (2026-05-15): Chronos-2 + Qwen3 14B Q4 채택 + 11 보정**
Phase 0 자체에는 코드 변경 0. 본 spec 승인 후 Phase 1 brainstorming 으로 자연스럽게 이어진다.

View 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 자동화 — 일정 운영 안정화 후

View File

@@ -0,0 +1,214 @@
# stock-lab → stock 리네이밍 Design
**작성일**: 2026-05-15
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**: Confidence Signal Pipeline V2 Phase 0 (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
---
## 1. 목표
`stock-lab` 컨테이너/디렉토리/환경변수의 `-lab` 접미사를 제거해 **stock** 으로 graduation. lab 네이밍 규칙 (`feedback_lab_naming.md`) 에 따라 정식 서비스로 명확화.
본 리네이밍은 **Confidence Signal Pipeline V2 Phase 1** 작업 시작 전 선행. 이름이 stock-lab인 채로 Phase 1 spec/plan/code 가 작성되면 다시 갱신하는 비용 회피.
**Why**: 메모리 `feedback_lab_naming.md` 정책 — "-lab은 개발/연구 단계에만, 정식 서비스에는 미사용". stock 서비스는 (a) 8 노드 screener 완성, (b) 캔버스 UI, (c) AI 뉴스 Phase 1, (d) V2 시그널 파이프라인의 중심 = 정식 graduation 단계.
---
## 2. 범위
**포함**:
- web-backend 디렉토리 `git mv stock-lab stock`
- `docker-compose.yml` 4 곳 갱신
- agent-office 환경변수 `STOCK_LAB_URL``STOCK_URL` 코드 + 컴포즈
- nginx config (`nginx/default.conf` in web-backend repo) `upstream stock-lab``stock`
- 운영 문서 (`web-backend/CLAUDE.md`, `README.md`, `STATUS.md`, scripts)
- workspace `CLAUDE.md` + web-ui `CLAUDE.md`
- 메모리 4개 (`project_workspace.md`, `project_scale.md`, `project_stock_screener.md`, `nas_infra.md`)
- 메모리 정책 추가 (`feedback_lab_naming.md` 에 stock graduation 케이스 등재)
**범위 외 (NOT)**:
- API URL 경로 (`/api/stock/...` 그대로)
- Python `app.*` import 경로
- DB 파일명 (`stock.db` 그대로)
- frontend 라우트 (`/stock/*` 그대로)
- 다른 lab 의 이름 (lotto/music-lab/blog-lab/realestate-lab/packs-lab/travel-proxy 모두 그대로)
- 과거 spec/plan 문서 (`docs/superpowers/specs|plans/2026-05-*.md`) — 역사적 기록 유지
- `.venv` 디렉토리 — gitignore, 사용자 로컬에서 재생성
---
## 3. 변경 매트릭스
### 3.1 web-backend 코드 (필수)
| 파일 | 변경 |
|------|------|
| `stock-lab/``stock/` | `git mv` |
| `docker-compose.yml` | service key `stock-lab``stock` (1) / container_name `stock-lab``stock` (1) / build.context `./stock-lab``./stock` (1) / frontend.depends_on의 `stock-lab``stock` (1) |
| `agent-office/app/config.py` | `STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", ...)``STOCK_URL = os.getenv("STOCK_URL", ...)` |
| `agent-office/app/service_proxy.py` | `from .config import STOCK_LAB_URL``STOCK_URL`. 함수 본문의 `STOCK_LAB_URL` 사용처 5개 (fetch_stock_news / fetch_stock_indices / summarize_stock_news / refresh_screener_snapshot / run_stock_screener) → `STOCK_URL` |
| `agent-office/app/agents/stock.py` | `STOCK_LAB_URL` 직접 참조 시 갱신 (만약 있다면) |
| `agent-office/tests/test_stock_screener_job.py` | mock URL 또는 env var 참조 갱신 |
| `agent-office docker-compose.yml 부분` | `STOCK_LAB_URL=http://stock-lab:8000``STOCK_URL=http://stock:8000` |
| `nginx/default.conf` | `upstream stock-lab { server stock-lab:8000; }``upstream stock { server stock:8000; }` + `proxy_pass http://stock-lab``http://stock` |
| `web-backend/CLAUDE.md` | stock-lab 언급 모두 stock 으로 |
| `web-backend/README.md` | 동일 |
| `web-backend/STATUS.md` | 동일 |
| `web-backend/scripts/deploy-nas.sh`, `deploy.sh` | stock-lab 호출/경로 갱신 |
### 3.2 web-ui (문서만)
| 파일 | 변경 |
|------|------|
| `web-ui/CLAUDE.md` | stock-lab 언급을 stock 으로 (디렉토리 경로 표 포함) |
**과거 spec/plan 문서들** (`web-ui/docs/superpowers/specs|plans/2026-05-*.md`): 역사적 기록 유지 — **변경 없음**.
### 3.3 workspace 최상위
| 파일 | 변경 |
|------|------|
| `workspace/CLAUDE.md` | "stock-lab" 컨테이너 이름 표 + 디렉토리 경로 갱신 |
### 3.4 메모리 (controller 직접 적용)
| 메모리 | 변경 |
|--------|------|
| `project_workspace.md` | stock-lab → stock |
| `project_scale.md` | 백엔드 서비스 표의 stock-lab 행 갱신, `stock-lab/` 디렉토리 → `stock/` |
| `project_stock_screener.md` | 다수 언급 (백엔드 위치) 갱신 |
| `nas_infra.md` | Docker 서비스 포트 표 + nginx 라우팅 |
| `feedback_lab_naming.md` | stock graduation 사례 추가 (2026-05-15) |
---
## 4. 작업 순서
```
1. 사전 검토 (10분)
- 본 spec 의 3장 매트릭스 모든 파일이 grep 결과와 일치하는지 cross-check
- `.venv` / `__pycache__` 제외 확인
- nginx default.conf 의 정확한 변경 줄 식별
2. web-backend 디렉토리 + 컴포즈 + agent-office 코드 (한 commit)
- git mv stock-lab stock
- docker-compose.yml 4 곳
- agent-office config.py, service_proxy.py, agents/stock.py, tests/
- nginx/default.conf
- web-backend의 CLAUDE.md, README.md, STATUS.md, scripts/
3. workspace + web-ui CLAUDE.md (별도 commit, 각 repo)
- workspace/CLAUDE.md
- web-ui/CLAUDE.md
4. 메모리 갱신 (controller 직접)
- 4개 메모리 파일 + feedback_lab_naming.md graduation 케이스
5. 배포 검증
- web-backend push → Gitea webhook → deployer rsync + docker compose up
- docker logs stock --tail 30
- docker ps 에서 stock 컨테이너 healthy
- curl https://gahusb.synology.me/api/stock/news (200)
- curl https://gahusb.synology.me/api/stock/screener/runs (200)
- agent-office 다음 16:30 cron 결과 (텔레그램) 정상 도착 확인 또는 수동 트리거
```
---
## 5. 위험 및 완화
| 위험 | 완화 |
|------|------|
| nginx config 가 옛 호스트 `stock-lab` 으로 라우팅 → 502 | nginx config 도 같은 commit 에 포함. deployer rsync 가 web-backend repo 의 nginx 폴더를 NAS runtime 에 동기화 |
| agent-office 가 옛 환경변수 `STOCK_LAB_URL` 사용 → connection refused | 컴포즈의 환경변수 항목 동시 변경. agent-office 재기동 후 새 변수 적용 |
| `.env` 파일에 `STOCK_LAB_URL=...` 남아 있으면 새 변수 빈 값 → 기본값 `http://stock:8000` fallback | service_proxy 의 `os.getenv("STOCK_URL", "http://stock:8000")` default 확인. 운영 .env 갱신은 사용자 1회 작업 |
| 다른 lab 의 stock-lab 호출 누락 | grep `STOCK_LAB_URL` 결과 5개 파일 모두 commit 에 포함. 추가 누락 시 다음 cron 실패로 즉시 발견 |
| 컨테이너 교체 다운타임 | 약 10초 (docker compose up 의 stop+start). 1인 운영 + 비치명적, 허용 |
| Python `app.*` import 경로 회귀 | 디렉토리 이름만 변경. 빌드 컨텍스트 변경으로 도커 이미지 안의 app 패키지 그대로. 회귀 없음 (76 + 신규 테스트 전부 통과 검증) |
| 메모리 갱신 누락 | grep "stock-lab" / "STOCK_LAB" 메모리 폴더 0건 검증 |
| 과거 spec/plan 문서의 stock-lab 언급 | 역사적 기록 — 의도적 보존. 미래 spec 부터 stock 사용 |
---
## 6. 테스트 / 검증
### 6.1 자동 (코드 검증)
```bash
# stock-lab 잔여 참조 0건 (의도적 보존 spec/plan 제외)
grep -rln "stock-lab\|STOCK_LAB" /c/Users/jaeoh/Desktop/workspace/web-backend/ \
| grep -v "\.venv" | grep -v "__pycache__" | grep -v "/docs/" | grep -v "\.git"
# Expected: 0 lines
# agent-office 테스트
cd /c/Users/jaeoh/Desktop/workspace/web-backend/agent-office
python -m pytest tests/test_stock_screener_job.py -v
# Expected: PASS
# stock pytest
cd /c/Users/jaeoh/Desktop/workspace/web-backend/stock
python -m pytest --ignore=app/test_scraper.py -q
# Expected: 76+ tests passed
```
### 6.2 수동 (배포 검증)
배포 후 NAS:
```bash
docker logs stock --tail 30
docker logs agent-office --tail 20
docker ps --format "{{.Names}}: {{.Status}}" | grep stock
```
브라우저 / curl:
- `https://gahusb.synology.me/api/stock/news` → 200
- `https://gahusb.synology.me/api/stock/screener/runs` → 200
- `https://gahusb.synology.me/stock/screener` (web-ui) → 캔버스 모드 진입 정상
agent-office 수동 트리거 (다음 cron 기다리지 않고):
```bash
curl -X POST "https://gahusb.synology.me/api/agent-office/command" \
-H "Content-Type: application/json" \
-d '{"agent":"stock","action":"run_ai_news"}'
```
응답 `{"ok": true}` + 텔레그램 도착 → stock 호스트 라우팅 정상.
---
## 7. 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | ~10초 (컨테이너 교체) |
| 사용자 영향 | 없음 (API URL/UI 경로 그대로) |
| .env 파일 갱신 | 사용자 1회 (STOCK_LAB_URL 줄 삭제 또는 STOCK_URL 추가) |
| frontend 재배포 | 불필요 (web-ui 는 문서만 변경) |
| 다른 lab 영향 | agent-office 만 영향 (환경변수). 나머지 lab 무영향 |
---
## 8. Phase 1 와의 관계
본 리네이밍 완료 후 즉시 **Confidence Signal Pipeline V2 Phase 1** spec 작성 (이전 발표 디자인 그대로, 새 이름 `stock` 기준). 의존성:
```
[본 리네이밍 spec/plan/실행] → [Phase 1 spec → plan → 실행]
1-2시간 1주
```
---
## 9. 완료 조건 (DoD)
- [ ] `web-backend/stock-lab/` 디렉토리 사라지고 `stock/` 존재 (git history 보존)
- [ ] `docker-compose.yml` 의 4 곳 갱신
- [ ] agent-office env 변수 `STOCK_LAB_URL` 코드/컴포즈/문서에서 0건
- [ ] nginx config `upstream stock-lab` 0건, `upstream stock` 존재
- [ ] grep "stock-lab" 결과: 의도적 보존 (`docs/superpowers/*`) 외 0건
- [ ] stock pytest 76+ tests passed
- [ ] 배포 후 `docker ps``stock` 컨테이너 healthy
- [ ] curl `/api/stock/news`, `/api/stock/screener/runs` 200
- [ ] agent-office `run_ai_news` 수동 트리거 텔레그램 도착
- [ ] 메모리 4 파일 갱신 + `feedback_lab_naming.md` graduation 케이스 등재

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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분 (남은 섹션)
```

View File

@@ -0,0 +1,345 @@
# Agent Office 그리드 재설계 — Design Spec
**Date:** 2026-05-17
**Author:** CEO (with Claude)
**Target:** `web-ui` `/agent-office` 페이지
---
## 1. 배경 & 목적
현재 `/agent-office` 페이지는 픽셀 사무실 Canvas 위에서 5명의 에이전트 캐릭터가 무의미하게 걸어다니는 형태다. 시각적 즐거움은 있으나 정보 밀도가 낮고, 각 에이전트가 무슨 일을 하는지 한눈에 파악하기 어렵다.
이를 **3x3 그리드** 기반의 정보 중심 UI로 재설계한다. 왼편에 9개의 에이전트 이미지 카드를 배치하고, 카드 클릭 시 오른편 패널에서 해당 에이전트의 명령·태스크·토큰·로그를 확인한다.
---
## 2. 범위 (Scope)
### In scope
- `src/pages/agent-office/AgentOffice.jsx` 전면 재작성 (Canvas → Grid)
- 그리드 카드 컴포넌트 신규 작성
- `SidePanel.jsx` 헤더 부분 수정 (emoji → 이미지)
- `SidePanel.jsx``AGENT_META`에서 `blog` 제거, `insta` 추가
- TopBar 단순화 (theme/zoom 컨트롤 제거)
- Canvas 관련 파일/디렉토리 전체 삭제
- 이미지 에셋 디렉토리 신설
### Out of scope
- 백엔드 변경 (현재 백엔드의 `insta` 에이전트는 이미 등록 완료, 추가 작업 불필요)
- 새 에이전트 추가 (4개 placeholder는 "준비 중" 표시만)
- 4탭 컨텐츠 (Commands/Tasks/Tokens/Logs) 로직 수정
---
## 3. 에이전트 구성
### 실제 작동 5명
| ID | 표시명 | 색상 | 역할 요약 |
|----|--------|------|-----------|
| `stock` | 주식 트레이더 | `#4488cc` | 주식 매매·뉴스 분석·포트폴리오 |
| `music` | 음악 프로듀서 | `#44aa88` | AI 음악 생성 |
| `insta` | 인스타 큐레이터 | `#d97706` | 매일 09:30 뉴스 수집 → 키워드 추출 → AI 카드 10장 생성 → 텔레그램 푸시 |
| `realestate` | 청약 애널리스트 | `#c026d3` | 부동산 청약 매칭·자치구 5티어 분석 |
| `lotto` | 로또 큐레이터 | `#ef4444` | 로또 번호 추천·브리핑 |
> `blog`는 `insta`로 대체됨. 기존 `SidePanel.jsx`의 `AGENT_META`에서 `blog` 항목 삭제 + `insta` 추가.
### Placeholder 4개
- ID 없음 (그리드 슬롯 인덱스 6/7/8/9로만 식별)
- 모두 동일하게 `agent_undetermined.png` + "준비 중" 라벨
- 클릭 시 정적 안내 패널 노출
---
## 4. 디렉토리 & 파일 구조
### 신설 디렉토리
```
src/pages/agent-office/assets/agents/
├── agent_stock.png (사용자 제공)
├── agent_music.png (사용자 제공)
├── agent_insta.png (사용자 제공)
├── agent_realestate.png (사용자 제공)
├── agent_lotto.png (사용자 제공)
└── agent_undetermined.png (사용자 제공, 4 placeholder 공유)
```
### 파일명 규칙
`agent_{id}.png` 형식. `{id}`는 백엔드의 agent_id와 일치 (소문자, underscore).
### 권장 이미지 사양
- 정사각형 (예: 512x512)
- PNG (투명 배경 허용)
- 카드 표시 시 `object-fit: cover`로 정사각 크롭
### 삭제 대상
```
src/pages/agent-office/
├── canvas/ ← 전체 삭제
│ ├── themes.js
│ ├── FurnitureRenderer.js
│ ├── ProceduralSprite.js
│ ├── AgentSprite.js
│ ├── SpriteLoader.js
│ ├── OverlayRenderer.js
│ ├── Pathfinder.js
│ ├── OfficeRenderer.js
│ └── TileMap.js
├── hooks/
│ └── useOfficeCanvas.js ← 삭제
└── assets/
└── office-map.json ← 삭제
```
### 유지 대상
```
src/pages/agent-office/
├── AgentOffice.jsx ← 재작성
├── AgentOffice.css ← 재작성
├── hooks/
│ └── useAgentManager.js ← 그대로 (WebSocket 로직)
└── components/
├── TopBar.jsx ← 단순화 (theme/zoom 제거)
├── SidePanel.jsx ← 헤더 수정 + AGENT_META 갱신
├── CommandTab.jsx ← 그대로
├── TaskTab.jsx ← 그대로
├── TokenTab.jsx ← 그대로
└── LogTab.jsx ← 그대로
```
### 신규 컴포넌트
```
src/pages/agent-office/components/
├── AgentGrid.jsx ← 3x3 그리드 래퍼
├── AgentCard.jsx ← 카드 1개 (image + state dot + badge + name)
├── PlaceholderCard.jsx ← "준비 중" 카드
└── EmptyDetailPanel.jsx ← 초기 안내 / placeholder 클릭 시 안내
```
---
## 5. 레이아웃
### 전체 화면 구조
```
┌─────────────────────────────────────────────────────────────┐
│ TopBar (connected status only) │
├──────────────────────────────────┬──────────────────────────┤
│ │ │
│ AgentGrid (3x3) │ Right Panel │
│ ┌──────┬──────┬──────┐ │ │
│ │stock │music │insta │ │ ┌─ active 선택 시 ─┐ │
│ ├──────┼──────┼──────┤ │ │ SidePanel │ │
│ │realE │lotto │ ?? │ │ │ - 헤더(이미지+이름)│ │
│ ├──────┼──────┼──────┤ │ │ - 4 tabs │ │
│ │ ?? │ ?? │ ?? │ │ └──────────────────┘ │
│ └──────┴──────┴──────┘ │ │
│ │ ┌─ placeholder 선택 ─┐ │
│ │ │ "준비 중인 에이전트"│ │
│ │ └────────────────────┘ │
│ │ │
│ │ ┌─ 초기(미선택) ──────┐ │
│ │ │ "에이전트를 선택…" │ │
│ │ └────────────────────┘ │
└──────────────────────────────────┴──────────────────────────┘
```
### 그리드 슬롯 순서 (좌→우, 위→아래)
| Index | Slot |
|-------|------|
| 1 (행1·열1) | `stock` |
| 2 (행1·열2) | `music` |
| 3 (행1·열3) | `insta` |
| 4 (행2·열1) | `realestate` |
| 5 (행2·열2) | `lotto` |
| 6 (행2·열3) | placeholder |
| 7 (행3·열1) | placeholder |
| 8 (행3·열2) | placeholder |
| 9 (행3·열3) | placeholder |
### AgentCard 시각 구조
```
┌─────────────────────┐
│ ● state [③] │ ← 상태 dot(좌상, image 약간 위) + 알림 뱃지(우상)
│ ┌───────────────┐ │
│ │ │ │
│ │ agent_xxx │ │ ← 정사각 이미지 (object-fit: cover)
│ │ .png │ │
│ │ │ │
│ └───────────────┘ │
│ 주식 트레이더 │ ← display_name
└─────────────────────┘
```
#### 상태 dot
| state | color | 동작 |
|-------|-------|------|
| `idle` | `#6b7280` (회색) | 정적 |
| `working` | `#22c55e` (초록) | pulse 애니메이션 |
| `error` | `#ef4444` (빨강) | 정적 |
| `waiting_approval` | `#f59e0b` (주황) | pulse |
| `break` | `#94a3b8` (밝은 회색) | 정적 |
상태 dot은 카드의 좌상단, 이미지보다 약간 위쪽에 위치 (이미지 영역 바깥 또는 모서리 살짝 걸침).
#### 알림 뱃지
- `notifications[agentId] > 0`일 때만 우상단에 표시
- 빨강 배경에 흰 숫자 (count > 9면 "9+")
- 카드 클릭 시 자동으로 0으로 리셋 (`clearNotifications` 호출 — 기존 로직 재사용)
---
## 6. 데이터 플로우
```
useAgentManager (그대로 유지)
├── WebSocket /api/agent-office/ws
├── agents: { [id]: { state, detail, task_id } }
├── notifications: { [id]: count }
├── pendingTasks: [...]
├── connected: bool
└── refreshTrigger: number
AgentOffice.jsx
├── agents → AgentGrid에 전달 → 각 AgentCard가 state로 dot 색상 결정
├── notifications → 각 AgentCard가 badge 표시
├── selectedAgent (local state): string | null | "placeholder"
└── 카드 클릭 시 setSelectedAgent + clearNotifications
Right Panel 분기
├── selectedAgent === null → EmptyDetailPanel (초기 안내)
├── selectedAgent === "placeholder"→ EmptyDetailPanel ("준비 중" 메시지)
└── selectedAgent ∈ active 5명 → SidePanel (4탭, 기존 로직)
```
---
## 7. SidePanel 수정 사항
### AGENT_META 갱신
```js
// src/pages/agent-office/components/SidePanel.jsx
import stockImg from '../assets/agents/agent_stock.png';
import musicImg from '../assets/agents/agent_music.png';
import instaImg from '../assets/agents/agent_insta.png';
import realestateImg from '../assets/agents/agent_realestate.png';
import lottoImg from '../assets/agents/agent_lotto.png';
const AGENT_META = {
stock: { displayName: '주식 트레이더', image: stockImg, color: '#4488cc' },
music: { displayName: '음악 프로듀서', image: musicImg, color: '#44aa88' },
insta: { displayName: '인스타 큐레이터', image: instaImg, color: '#d97706' },
realestate: { displayName: '청약 애널리스트', image: realestateImg, color: '#c026d3' },
lotto: { displayName: '로또 큐레이터', image: lottoImg, color: '#ef4444' }
};
// blog 항목 삭제
```
### 헤더 시각 변경
```jsx
// 변경 전: emoji icon
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
{meta.emoji}
</div>
// 변경 후: 이미지
<div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
<img src={meta.image} alt={meta.displayName} />
</div>
```
4탭(Commands/Tasks/Tokens/Logs) 본체 로직은 손대지 않음.
---
## 8. CSS 토큰 (제안)
```css
:root {
--ao-bg: #0f172a;
--ao-card-bg: #1e293b;
--ao-card-border: #334155;
--ao-card-border-active: #60a5fa;
--ao-text: #e2e8f0;
--ao-text-muted: #94a3b8;
--ao-grid-gap: 16px;
--ao-card-radius: 12px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--ao-grid-gap);
}
.ao-card {
aspect-ratio: 1 / 1.15; /* 이미지 정사각 + 이름줄 */
background: var(--ao-card-bg);
border: 1px solid var(--ao-card-border);
border-radius: var(--ao-card-radius);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.ao-card:hover { transform: translateY(-2px); }
.ao-card.active { border-color: var(--ao-card-border-active); }
.ao-card.placeholder { opacity: 0.55; cursor: pointer; }
```
반응형: 모바일에서는 `grid-template-columns: repeat(2, 1fr)` 또는 `repeat(1, 1fr)`로 축소.
---
## 9. 에러 처리 / Edge Cases
| 상황 | 동작 |
|------|------|
| 이미지 로드 실패 | `<img onError>`로 단색 배경 + 첫 글자 fallback |
| WebSocket 끊김 | TopBar에 disconnected 표시. 카드는 마지막 상태 유지 (회색 처리 안 함 — 기존 동작 유지) |
| `agents[id]` 미존재 | dot 회색(`idle`), 정상 표시 |
| placeholder 클릭 | 우측 패널만 변경, WebSocket 호출/clearNotifications 호출 없음 |
---
## 10. 테스트 계획
- [ ] 6개 이미지 파일이 디렉토리에 존재할 때 그리드 정상 렌더링
- [ ] 이미지 누락 시 fallback 표시
- [ ] WebSocket으로 `agent_state` 수신 시 dot 색상 변경
- [ ] `notification` 수신 시 뱃지 표시, 카드 클릭 시 0으로 리셋
- [ ] active 5명 클릭 → SidePanel 4탭 표시 (기존 동작 유지)
- [ ] placeholder 4슬롯 클릭 → "준비 중" 패널
- [ ] TopBar의 connected/disconnected 표시 정상
- [ ] Canvas 잔재(파일 import 누락 등) 없음 — `npm run build` 통과
- [ ] 모바일 뷰(<768px) 그리드 축소 정상
---
## 11. 이행 절차 (사용자 작업 포함)
1. **사용자**: `src/pages/agent-office/assets/agents/` 디렉토리에 6개 PNG 파일 배치
2. **Claude (구현 단계)**: writing-plans 스킬로 단계별 작업 계획 작성
3. 구현·삭제·테스트 후 commit
4. NAS 배포는 별도 (`npm run release:nas`)
---
## 12. 향후 확장
- 9번째 active 에이전트 채용 시: 이미지 추가 + `AGENT_META` 갱신 + 슬롯 인덱스 매핑 변경
- 그리드 자동 정렬(상태별/우선순위별 sort) — 현재는 정적 배치
- 카드 hover 시 미니 프리뷰 (최근 활동 1줄 요약) — 추후 검토

View File

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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>가후습 개인기록</title>
</head>
<body>

3010
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,17 +10,26 @@
"deploy:nas": "node scripts/deploy-nas.cjs",
"release:nas": "npm run build && npm run deploy:nas",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@xyflow/react": "^12.10.2",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.3",
"react-swipeable": "^7.0.2",
"recharts": "^3.7.0",
"three": "^0.182.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^5.1.1",
@@ -28,7 +37,9 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^25.0.1",
"rimraf": "^6.1.2",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^2.1.9"
}
}

View File

@@ -1,58 +1,121 @@
const { execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Load .env.local from project root if present (persists NAS_SSH_TARGET etc.)
const envLocalPath = path.join(__dirname, "..", ".env.local");
if (fs.existsSync(envLocalPath)) {
for (const line of fs.readFileSync(envLocalPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx < 0) continue;
const k = trimmed.slice(0, idx).trim();
const v = trimmed.slice(idx + 1).trim();
if (!(k in process.env)) process.env[k] = v;
}
}
const isWin = process.platform === "win32";
const isMac = process.platform === "darwin";
const src = "dist";
const dstWin = "Z:\\docker\\webpage\\frontend\\";
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
// Windows 배포 경로 — Z: 매핑이 NAS 루트(/volume1/)인 경우 docker\webpage\frontend,
// /volume1/docker/만 매핑된 경우 webpage\frontend, /volume1/docker/webpage 매핑이면 frontend.
// NAS_FRONTEND_DEST_WIN env로 override (예: "Z:\\webpage\\frontend\\")
const dstWin = process.env.NAS_FRONTEND_DEST_WIN || "Z:\\docker\\webpage\\frontend\\";
const dstMac = process.env.NAS_FRONTEND_DEST_MAC || "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
const dst = isWin ? dstWin : dstMac;
if (!fs.existsSync(src)) {
console.error("dist not found. Run build first.");
process.exit(1);
}
if (!fs.existsSync(dst)) {
console.error("NAS path not found. Check mount: " + dst);
process.exit(1);
}
if (isWin) {
// PowerShell single-quote literal로 path 전달 — backslash over-escape 회피
const cmd =
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $src='dist'; $dst='${dstWin}'; if(!(Test-Path $src)){ throw 'dist not found. Run build first.' }; if(!(Test-Path $dst)){ throw ('NAS 경로를 찾을 수 없음: ' + $dst + ' — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인') }; $log = Join-Path (Get-Location) 'robocopy.log'; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host ('robocopy failed with code ' + $rc + '. See ' + $log); exit $rc } else { exit 0 }"`;
execSync(cmd, { stdio: "inherit" });
} else if (isMac) {
const sshTarget = process.env.NAS_SSH_TARGET;
const sshPath =
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
const sshPort = process.env.NAS_SSH_PORT;
// SSH 경로: NAS_SSH_TARGET이 설정된 경우 항상 우선
if (sshTarget) {
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
// 제어문자·줄바꿈 제거 (잘못된 export/copy-paste 대비)
const cleanTarget = sshTarget.replace(/[\r\n\t]/g, "").trim();
const cleanPath = sshPath.replace(/[\r\n\t]/g, "").trim();
const cleanPort = sshPort ? sshPort.replace(/\D/g, "").trim() : "";
if (!cleanTarget) {
console.error("NAS_SSH_TARGET 값이 비어있습니다. .env.local 또는 환경변수를 확인하세요.");
printSshHint();
process.exit(1);
}
if (cleanPort && !/^\d{1,5}$/.test(cleanPort)) {
console.error(`NAS_SSH_PORT 값이 잘못됐습니다: "${sshPort}" → 숫자만 입력하세요.`);
process.exit(1);
}
// macOS Keychain은 서브프로세스(rsync)에서 SSH 키를 자동 로드하지 못함 → -i 명시
const keyFile = (process.env.NAS_SSH_KEY || path.join(os.homedir(), ".ssh", "id_rsa"))
.replace(/[\r\n]/g, "").trim();
if (!fs.existsSync(keyFile)) {
console.error(`SSH 키 파일을 찾을 수 없습니다: ${keyFile}`);
console.error("NAS_SSH_KEY 환경변수를 올바른 키 경로로 설정하거나, ~/.ssh/id_rsa 가 있는지 확인하세요.");
process.exit(1);
}
const portOpt = cleanPort ? `-p ${cleanPort}` : "";
// Synology는 rsync --server 모드를 별도 인증으로 막음 → tar | ssh 방식 사용
const sshBase = `ssh ${portOpt} -i ${keyFile} -o StrictHostKeyChecking=accept-new -o PreferredAuthentications=publickey`
.replace(/\s+/g, " ").trim();
console.log(`Deploying via tar|ssh → ${cleanTarget}:${cleanPath}`);
// 1단계: 원격 디렉토리 초기화
execSync(
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
`${sshBase} ${cleanTarget} "rm -rf '${cleanPath}'/* 2>/dev/null; mkdir -p '${cleanPath}'"`,
{ stdio: "inherit" }
);
// 2단계: 빌드 산출물 tar로 전송 → 원격에서 압축 해제
execSync(
`cd ${src} && tar czf - . | ${sshBase} ${cleanTarget} "cd '${cleanPath}' && tar xzf -"`,
{ stdio: "inherit" }
);
console.log("Deploy complete.");
process.exit(0);
}
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
// SMB 마운트 경로 fallback
if (!fs.existsSync(dst)) {
console.error("NAS path not found: " + dst);
printSshHint();
process.exit(1);
}
if (!dst.includes("docker/webpage/frontend")) {
console.error("Safety check failed: unexpected dst path: " + dst);
process.exit(1);
}
try {
const testPath = `${dst}.deploy-write-test`;
fs.writeFileSync(testPath, "ok");
fs.unlinkSync(testPath);
} catch (err) {
console.error("NAS write test failed (EIO / permission error).");
console.error(
"NAS write test failed. Files may be locked or permissions are read-only."
"macOS SMB → Synology 쓰기 실패는 흔한 이슈입니다. SSH 배포를 사용하세요.\n"
);
console.error(
"Try stopping services using the folder, remounting the share with write access,",
"or set NAS_SSH_TARGET to deploy over SSH instead."
);
throw err;
printSshHint();
process.exit(1);
}
const sleep = (ms) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
const retry = (fn, attempts = 6) => {
@@ -96,3 +159,15 @@ if (isWin) {
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
execSync(cmd, { stdio: "inherit" });
}
function printSshHint() {
console.error("──────────────────────────────────────────────────");
console.error("SSH 배포 설정 방법:");
console.error(" 프로젝트 루트에 .env.local 파일을 만들고 아래 내용을 입력하세요:");
console.error("");
console.error(" NAS_SSH_TARGET=<NAS_유저명>@gahusb.synology.me");
console.error(" NAS_SSH_PORT=<SSH_포트> # 기본 22, DSM에서 확인");
console.error("");
console.error(" 이후 npm run release:nas 를 다시 실행하면 rsync over SSH로 배포됩니다.");
console.error("──────────────────────────────────────────────────");
}

View File

@@ -1,35 +1,90 @@
:root {
--bg: #0f0d12;
--surface: rgba(26, 23, 32, 0.88);
--text: #f4efe9;
--muted: #b6b1a9;
--line: rgba(255, 255, 255, 0.12);
--accent: #f7a8a5;
--accent-strong: #fdd4b1;
--font-display: "DM Serif Display", "Noto Serif KR", serif;
--font-body: "Manrope", "Noto Sans KR", sans-serif;
}
/* ═══════════════════════════════════════════════════════════════════════
App.css — Dashboard Layout & Design System
Cyberpunk / Futuristic Dashboard UI
═══════════════════════════════════════════════════════════════════════ */
/* ── Layout: App Shell ───────────────────────────────────────────────── */
.app-shell {
min-height: 100vh;
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
position: relative;
}
/* ── Layout: Content Area ────────────────────────────────────────────── */
.app-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
position: relative;
margin-left: var(--sidebar-w);
}
/* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */
.app-topbar {
display: none;
height: var(--topbar-h);
align-items: center;
padding: 0 16px;
background: rgba(7, 11, 25, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
}
@media (max-width: 768px) {
.app-topbar {
display: flex;
}
}
/* ── Layout: Main Content ────────────────────────────────────────────── */
.site-main {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px 80px;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 28px 32px;
background: transparent;
position: relative;
}
@media (max-width: 768px) {
.site-main {
padding: 20px 16px 60px;
padding: 16px;
padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
}
}
@keyframes fadeUp {
from {
/* ── Loading State ───────────────────────────────────────────────────── */
.suspend-loading {
display: grid;
place-items: center;
min-height: 50vh;
color: var(--text-dim);
font-size: 13px;
letter-spacing: 0.1em;
}
/* ═══════════════════════════════════════════════════════════════════════
Animations
═══════════════════════════════════════════════════════════════════════ */
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(16px);
transform: translateY(12px);
}
to {
opacity: 1;
@@ -37,34 +92,415 @@
}
}
.site-main > * {
animation: fadeUp 0.6s ease both;
@keyframes glowPulse {
0%, 100% {
box-shadow: var(--glow-cyan);
}
50% {
box-shadow: var(--glow-purple);
}
}
@keyframes scanLine {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
@keyframes neonFlicker {
0%, 95%, 100% { opacity: 1; }
96% { opacity: 0.85; }
97% { opacity: 1; }
98% { opacity: 0.9; }
}
@keyframes borderGlow {
0% { border-color: var(--neon-cyan-dim); }
50% { border-color: var(--neon-purple-dim); }
100% { border-color: var(--neon-cyan-dim); }
}
.page-enter {
animation: fadeIn 0.4s var(--ease-out) both;
}
/* ═══════════════════════════════════════════════════════════════════════
Button System
═══════════════════════════════════════════════════════════════════════ */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid var(--line);
padding: 10px 18px;
border-radius: 999px;
text-decoration: none;
background: var(--surface-card);
color: var(--text);
font-size: 14px;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.06);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.05em;
padding: 9px 20px;
border-radius: 999px;
cursor: pointer;
user-select: none;
white-space: nowrap;
text-decoration: none;
transition:
border-color 0.2s var(--ease-out),
color 0.2s var(--ease-out),
background 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out),
filter 0.2s var(--ease-out),
transform 0.15s var(--ease-spring);
position: relative;
overflow: hidden;
}
.button:hover {
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
border-color: var(--line-bright);
color: var(--neon-cyan);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
}
.button:active {
transform: scale(0.97);
}
/* Primary */
.button.primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #1a1414;
background: var(--grad-accent);
color: #fff;
border: none;
font-weight: 600;
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.2);
}
.button.primary:hover {
box-shadow: var(--glow-cyan);
filter: brightness(1.1);
color: #fff;
}
/* Ghost */
.button.ghost {
background: transparent;
border-color: transparent;
}
.button.ghost {
background: transparent;
.button.ghost:hover {
background: rgba(255, 255, 255, 0.05);
border-color: var(--line);
color: var(--text-bright);
box-shadow: none;
}
/* Small */
.button.small {
padding: 6px 14px;
font-size: 12px;
}
/* Danger */
.button.danger {
border-color: rgba(239, 68, 68, 0.4);
color: rgba(248, 113, 113, 1);
background: rgba(239, 68, 68, 0.08);
}
.button.danger:hover {
border-color: rgba(239, 68, 68, 0.7);
background: rgba(239, 68, 68, 0.15);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
color: rgba(252, 165, 165, 1);
}
/* Disabled */
.button:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════════════
Dashboard Card / Panel System
═══════════════════════════════════════════════════════════════════════ */
.dash-card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
transition:
border-color 0.25s var(--ease-out),
box-shadow 0.25s var(--ease-out);
}
/* Top accent line */
.dash-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0.3;
pointer-events: none;
}
.dash-card:hover {
border-color: rgba(0, 212, 255, 0.15);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.05);
}
/* Elevated variant */
.dash-card.raised {
background: var(--surface-raised);
border-color: rgba(255, 255, 255, 0.1);
}
/* Glow variant */
.dash-card.glow {
animation: glowPulse 4s ease-in-out infinite;
}
/* ── Legacy card alias ───────────────────────────────────────────────── */
.card {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-card);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.card:hover {
border-color: rgba(0, 212, 255, 0.15);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(0, 212, 255, 0.05);
}
/* ═══════════════════════════════════════════════════════════════════════
Typography Utilities
═══════════════════════════════════════════════════════════════════════ */
/* Eyebrow / Section label */
.eyebrow {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.26em;
color: var(--neon-cyan);
margin: 0 0 8px;
font-family: var(--font-display);
font-weight: 500;
}
/* Panel title */
.panel-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.01em;
margin: 0 0 4px;
}
/* Section heading */
.section-heading {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: var(--text-bright);
letter-spacing: -0.03em;
line-height: 1.2;
}
/* ═══════════════════════════════════════════════════════════════════════
Badge / Chip System
═══════════════════════════════════════════════════════════════════════ */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.badge.cyan {
background: var(--neon-cyan-muted);
color: var(--neon-cyan);
border: 1px solid rgba(0, 212, 255, 0.2);
}
.badge.purple {
background: var(--neon-purple-muted);
color: var(--neon-purple);
border: 1px solid rgba(139, 92, 246, 0.2);
}
.badge.green {
background: rgba(52, 211, 153, 0.12);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.2);
}
.badge.red {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
letter-spacing: 0.1em;
border: 1px solid var(--line);
color: var(--text-dim);
background: var(--surface-card);
transition: border-color 0.15s ease, color 0.15s ease;
}
.chip:hover {
border-color: var(--line-bright);
color: var(--neon-cyan);
}
/* ═══════════════════════════════════════════════════════════════════════
Data Display Utilities
═══════════════════════════════════════════════════════════════════════ */
/* Metric / stat number */
.metric-value {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
letter-spacing: -0.04em;
line-height: 1;
}
.metric-label {
font-size: 12px;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 6px;
}
/* Positive / negative indicators */
.pos {
color: #34d399;
}
.neg {
color: #f87171;
}
/* ── Separator / Divider ─────────────────────────────────────────────── */
.divider {
height: 1px;
background: var(--line);
margin: 16px 0;
}
/* ── Gradient text utility ───────────────────────────────────────────── */
.gradient-text {
background: var(--grad-accent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ═══════════════════════════════════════════════════════════════════════
Grid Utilities
═══════════════════════════════════════════════════════════════════════ */
.dash-grid {
display: grid;
gap: 16px;
}
.dash-grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.dash-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.dash-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 1024px) {
.dash-grid-4 { grid-template-columns: repeat(2, 1fr); }
.dash-grid-3 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.dash-grid-2,
.dash-grid-3,
.dash-grid-4 {
grid-template-columns: 1fr;
}
}
/* ═══════════════════════════════════════════════════════════════════════
Responsive Mobile
═══════════════════════════════════════════════════════════════════════ */
@media (max-width: 768px) {
body {
overflow: auto;
}
.app-shell {
flex-direction: column;
height: auto;
min-height: 100vh;
}
.app-content {
margin-left: 0;
height: auto;
overflow: visible;
}
.site-main {
overflow: visible;
flex: none;
}
}
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -1,15 +1,27 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Navbar from './components/Navbar';
import BottomNav from './components/BottomNav';
import PageHeader from './components/PageHeader';
import Loading from './components/Loading';
import { useIsMobile } from './hooks/useIsMobile';
import './App.css';
function App() {
const isMobile = useIsMobile();
return (
<div className="app-shell">
<Navbar />
<main className="site-main">
<Outlet />
</main>
<div className="app-content">
<main className="site-main">
<PageHeader />
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet />
</React.Suspense>
</main>
</div>
{isMobile && <BottomNav />}
</div>
);
}

View File

@@ -1,6 +1,12 @@
// src/api.js
// 프론트와 API가 동일 도메인(nginx 프록시)이므로 항상 상대 경로 사용.
// 절대 URL(VITE_API_BASE)은 Mixed Content를 유발하므로 사용하지 않음.
const toApiUrl = (path) => path;
export async function apiGet(path) {
const res = await fetch(path, { headers: { "Accept": "application/json" } });
const res = await fetch(toApiUrl(path), {
headers: { "Accept": "application/json" },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
@@ -9,7 +15,39 @@ export async function apiGet(path) {
}
export async function apiDelete(path) {
const res = await fetch(path, { method: "DELETE" });
const res = await fetch(toApiUrl(path), { method: "DELETE" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export async function apiPost(path, body) {
const res = await fetch(toApiUrl(path), {
method: "POST",
headers: {
"Accept": "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export async function apiPut(path, body) {
const res = await fetch(toApiUrl(path), {
method: "PUT",
headers: {
"Accept": "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
@@ -41,3 +79,647 @@ export function getHistory(limit = 30, offset = 0) {
export function deleteHistory(id) {
return apiDelete(`/api/history/${id}`);
}
// ── 시뮬레이션 관련 API ──────────────────────────────────────────────────────
export function getBestPicks(limit = 20) {
return apiGet(`/api/lotto/best?limit=${limit}`);
}
export function getAnalysis() {
return apiGet('/api/lotto/analysis');
}
export function triggerSimulate(nCandidates = 20000, topK = 100, bestN = 20) {
const qs = new URLSearchParams({
n_candidates: String(nCandidates),
top_k: String(topK),
best_n: String(bestN),
});
return apiPost(`/api/admin/simulate?${qs.toString()}`);
}
export function getStockNews(limit = 20, category) {
const qs = new URLSearchParams({ limit: String(limit) });
if (category) {
qs.set("category", category);
}
return apiGet(`/api/stock/news?${qs.toString()}`);
}
export function getStockIndices() {
return apiGet("/api/stock/indices");
}
export function getTradeBalance() {
return apiGet("/api/trade/balance");
}
export function createTradeOrder(payload) {
return apiPost("/api/trade/order", payload);
}
// ── 포트폴리오 (수동 입력) API ──────────────────────────────────────────────
export function getPortfolio() {
return apiGet("/api/portfolio");
}
export function addPortfolio(item) {
return apiPost("/api/portfolio", item);
}
export function updatePortfolio(id, fields) {
return apiPut(`/api/portfolio/${id}`, fields);
}
export function deletePortfolio(id) {
return apiDelete(`/api/portfolio/${id}`);
}
// ── 자산 스냅샷 API ──────────────────────────────────────────────────────────
// 장 마감 시점 총 자산을 기록하고, 기간별 추이를 조회합니다.
// GET /api/portfolio/snapshot/history?days=N
// response: { history: [{ date: "2026-03-07", total_assets: 12345678 }, ...] }
export function getAssetHistory(days = 30) {
const qs = days ? `?days=${days}` : '';
return apiGet(`/api/portfolio/snapshot/history${qs}`);
}
// POST /api/portfolio/snapshot (body 없이 호출 — 서버가 현재 total_assets 계산해서 저장)
// 또는 body: { total_assets: number } 로 직접 지정 가능
export function saveAssetSnapshot(total_assets) {
return apiPost('/api/portfolio/snapshot', total_assets != null ? { total_assets } : undefined);
}
// ── 예수금 API ───────────────────────────────────────────────────────────────
export function upsertCash(broker, cash) {
return apiPut('/api/portfolio/cash', { broker, cash });
}
export function deleteCash(broker) {
return apiDelete(`/api/portfolio/cash/${encodeURIComponent(broker)}`);
}
// ── 시장 심리 지표 API ────────────────────────────────────────────────────────
// CNN Fear & Greed Index (개발: vite proxy /ext/feargreed, 프로덕션: nginx proxy 필요)
export async function getFearAndGreed() {
const res = await fetch('/ext/feargreed', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Yahoo Finance chart API 공통 파서
async function fetchYahooPrice(extPath) {
const res = await fetch(extPath, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const meta = data?.chart?.result?.[0]?.meta;
const price = meta?.regularMarketPrice;
const prevClose = meta?.previousClose ?? meta?.chartPreviousClose;
if (price == null) throw new Error('데이터 없음');
const rounded = Math.round(price * 100) / 100;
const change = prevClose != null ? Math.round((price - prevClose) * 100) / 100 : null;
const changePercent = prevClose ? Math.round(((price - prevClose) / prevClose) * 10000) / 100 : null;
return { value: rounded, change, changePercent };
}
// VIX 지수 (Yahoo Finance 공개 API)
export function getVix() { return fetchYahooPrice('/ext/vix'); }
// 미국 10년물 국채 금리 (^TNX)
export function getTreasury10Y() { return fetchYahooPrice('/ext/treasury'); }
// WTI 원유 선물 (CL=F)
export function getWTI() { return fetchYahooPrice('/ext/wti'); }
// Brent 원유 선물 (BZ=F)
export function getBrent() { return fetchYahooPrice('/ext/brent'); }
// ── TODO API ─────────────────────────────────────────────────────────────────
export function getTodos() {
return apiGet('/api/todos');
}
export function addTodo(data) {
return apiPost('/api/todos', data);
}
export function updateTodo(id, data) {
return apiPut(`/api/todos/${id}`, data);
}
export function deleteTodo(id) {
return apiDelete(`/api/todos/${id}`);
}
export function clearTodos() {
return apiDelete('/api/todos/done');
}
// ── 실현손익 내역 API ─────────────────────────────────────────────────────────
// GET /api/portfolio/sell-history?broker=X&days=N → { records: [...] }
// POST /api/portfolio/sell-history → 저장된 레코드 반환
// DELETE /api/portfolio/sell-history/:id → { ok: true }
export function getSellHistory({ broker, days } = {}) {
const qs = new URLSearchParams();
if (broker && broker !== 'ALL') qs.set('broker', broker);
if (days) qs.set('days', String(days));
const q = qs.toString();
return apiGet(`/api/portfolio/sell-history${q ? '?' + q : ''}`);
}
export function addSellHistory(record) {
return apiPost('/api/portfolio/sell-history', record);
}
export function updateSellHistory(id, record) {
return apiPut(`/api/portfolio/sell-history/${id}`, record);
}
export function deleteSellHistory(id) {
return apiDelete(`/api/portfolio/sell-history/${id}`);
}
// ── AI 음악 생성 API ──────────────────────────────────────────────────────────
// GET /api/music/providers → { providers: [{ id, name, description, features }] }
export function getMusicProviders() {
return apiGet('/api/music/providers');
}
// POST /api/music/generate
// body: { provider, genre, moods, instruments, duration_sec, bpm, key, scale, prompt, lyrics, instrumental }
// → { task_id: string, provider: string }
export function generateMusic(payload) {
return apiPost('/api/music/generate', payload);
}
// GET /api/music/status/:task_id
// → { status, progress, message, audio_url?, error?, provider?, track? }
export function getMusicStatus(taskId) {
return apiGet(`/api/music/status/${encodeURIComponent(taskId)}`);
}
// POST /api/music/lyrics body: { prompt }
// → { id, status, text } (Suno 가사 생성)
export function generateMusicLyrics(prompt) {
return apiPost('/api/music/lyrics', { prompt });
}
// GET /api/music/library
// → { tracks: [{ id, title, genre, ..., provider, lyrics, image_url, suno_id }] }
export function getMusicLibrary() {
return apiGet('/api/music/library');
}
// POST /api/music/library body: track object
// → saved track with id
export function saveMusicTrack(data) {
return apiPost('/api/music/library', data);
}
// DELETE /api/music/library/:id
// → { ok: true }
export function deleteMusicTrack(id) {
return apiDelete(`/api/music/library/${id}`);
}
// GET /api/music/models → { models: [{ id, name, max_duration, description }] }
export function getMusicModels() {
return apiGet('/api/music/models');
}
// GET /api/music/credits → { remaining, total, ... }
export function getMusicCredits() {
return apiGet('/api/music/credits');
}
// POST /api/music/extend body: { suno_id, continue_at, prompt, style, title, model }
// → { task_id, provider }
export function extendMusicTrack(payload) {
return apiPost('/api/music/extend', payload);
}
// POST /api/music/vocal-removal body: { suno_id, title }
// → { task_id, provider }
export function removeVocals(payload) {
return apiPost('/api/music/vocal-removal', payload);
}
// ── 저장된 가사 CRUD ─────────────────────────────────────────────────────────
// GET /api/music/lyrics/library → { lyrics: [{ id, title, text, prompt, created_at, updated_at }] }
export function getSavedLyrics() {
return apiGet('/api/music/lyrics/library');
}
// POST /api/music/lyrics/library body: { title, text, prompt }
export function saveLyrics(data) {
return apiPost('/api/music/lyrics/library', data);
}
// PUT /api/music/lyrics/library/:id body: { title?, text?, prompt? }
export function updateLyrics(id, data) {
return apiPut(`/api/music/lyrics/library/${id}`, data);
}
// DELETE /api/music/lyrics/library/:id
export function deleteLyrics(id) {
return apiDelete(`/api/music/lyrics/library/${id}`);
}
// ── Phase 1: 커버 이미지 ────────────────────────────────────────────────────
// POST /api/music/cover-image body: { suno_task_id, track_id }
export function generateCoverImage(payload) {
return apiPost('/api/music/cover-image', payload);
}
// ── Phase 2 API ─────────────────────────────────────────────────────────────
// POST /api/music/wav body: { suno_task_id, suno_id, track_id }
export function convertToWav(payload) {
return apiPost('/api/music/wav', payload);
}
// POST /api/music/stem-split body: { suno_task_id, suno_id, track_id }
export function splitStems(payload) {
return apiPost('/api/music/stem-split', payload);
}
// GET /api/music/timestamped-lyrics?task_id=...&suno_id=...
export function getTimestampedLyrics(taskId, sunoId) {
return apiGet(`/api/music/timestamped-lyrics?task_id=${encodeURIComponent(taskId)}&suno_id=${encodeURIComponent(sunoId)}`);
}
// POST /api/music/style-boost body: { content }
export function generateStyleBoost(content) {
return apiPost('/api/music/style-boost', { content });
}
// ── Phase 3 API ─────────────────────────────────────────────────────────────
// POST /api/music/upload-cover
export function uploadAndCover(payload) {
return apiPost('/api/music/upload-cover', payload);
}
// POST /api/music/upload-extend
export function uploadAndExtend(payload) {
return apiPost('/api/music/upload-extend', payload);
}
// POST /api/music/add-vocals
export function addVocals(payload) {
return apiPost('/api/music/add-vocals', payload);
}
// POST /api/music/add-instrumental
export function addInstrumental(payload) {
return apiPost('/api/music/add-instrumental', payload);
}
// POST /api/music/video
export function generateVideo(payload) {
return apiPost('/api/music/video', payload);
}
// ── 로또 고도화 API ────────────────────────────────────────────────────────────
// GET /api/lotto/stats/performance
export function getPerformanceStats() {
return apiGet('/api/lotto/stats/performance');
}
// GET /api/lotto/report/latest
export function getLatestReport() {
return apiGet('/api/lotto/report/latest');
}
// GET /api/lotto/report/:drw_no
export function getReport(drwNo) {
return apiGet(`/api/lotto/report/${drwNo}`);
}
// GET /api/lotto/report/history?limit=N
export function getReportHistory(limit = 10) {
return apiGet(`/api/lotto/report/history?limit=${limit}`);
}
// GET /api/lotto/analysis/personal
export function getPersonalAnalysis() {
return apiGet('/api/lotto/analysis/personal');
}
// ── 종합 추론 추천 ──────────────────────────────────────────────────────────
// GET /api/lotto/recommend/combined
export function getCombinedRecommend() {
return apiGet('/api/lotto/recommend/combined');
}
// GET /api/lotto/recommend/combined/history
export function getCombinedHistory(limit = 30) {
return apiGet(`/api/lotto/recommend/combined/history?limit=${limit}`);
}
// GET /api/lotto/purchase?draw_no=N&days=N
export function getPurchases({ draw_no, days } = {}) {
const qs = new URLSearchParams();
if (draw_no) qs.set('draw_no', String(draw_no));
if (days) qs.set('days', String(days));
const q = qs.toString();
return apiGet(`/api/lotto/purchase${q ? '?' + q : ''}`);
}
// GET /api/lotto/purchase/stats
export function getPurchaseStats() {
return apiGet('/api/lotto/purchase/stats');
}
// POST /api/lotto/purchase
export function addPurchase(data) {
return apiPost('/api/lotto/purchase', data);
}
// PUT /api/lotto/purchase/:id
export function updatePurchase(id, data) {
return apiPut(`/api/lotto/purchase/${id}`, data);
}
// DELETE /api/lotto/purchase/:id
export function deletePurchase(id) {
return apiDelete(`/api/lotto/purchase/${id}`);
}
// ── 블로그 API ────────────────────────────────────────────────────────────────
// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] }
// POST /api/blog/posts → 새 글 생성
// PUT /api/blog/posts/:id → 글 수정
// DELETE /api/blog/posts/:id → 글 삭제
export function getBlogPostsApi() {
return apiGet('/api/blog/posts');
}
export function createBlogPost(data) {
return apiPost('/api/blog/posts', data);
}
export function updateBlogPost(id, data) {
return apiPut(`/api/blog/posts/${id}`, data);
}
export function deleteBlogPost(id) {
return apiDelete(`/api/blog/posts/${id}`);
}
// ── insta-lab ────────────────────────────────────────────────────────────────
export function getInstaStatus() {
return apiGet('/api/insta/status');
}
export function instaCollectNews(categories) {
return apiPost('/api/insta/news/collect', categories ? { categories } : {});
}
export function getInstaArticles({ category, days = 7 } = {}) {
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 instaExtractKeywords(categories) {
return apiPost('/api/insta/keywords/extract', categories ? { categories } : {});
}
export function getInstaKeywords({ category, used } = {}) {
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 createInstaSlate({ keyword, category, keyword_id }) {
return apiPost('/api/insta/slates', { keyword, category, keyword_id });
}
export function getInstaSlates(limit = 50) {
return apiGet(`/api/insta/slates?limit=${limit}`);
}
export function getInstaSlate(id) {
return apiGet(`/api/insta/slates/${id}`);
}
export function renderInstaSlate(id) {
return apiPost(`/api/insta/slates/${id}/render`);
}
export function deleteInstaSlate(id) {
return apiDelete(`/api/insta/slates/${id}`);
}
export function getInstaAssetUrl(slateId, page) {
return `/api/insta/slates/${slateId}/assets/${page}`;
}
export function getInstaTask(taskId) {
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
}
export function getInstaPrompt(name) {
return apiGet(`/api/insta/templates/prompts/${encodeURIComponent(name)}`);
}
export function putInstaPrompt(name, template, description = '') {
return apiPut(`/api/insta/templates/prompts/${encodeURIComponent(name)}`, { template, description });
}
// ── insta-lab trends ──
export function getInstaTrends({ source, category, days = 1 } = {}) {
const q = new URLSearchParams();
if (source) q.set('source', source);
if (category) q.set('category', category);
q.set('days', String(days));
return apiGet(`/api/insta/trends?${q.toString()}`);
}
export function instaCollectTrends(categories) {
return apiPost('/api/insta/trends/collect', categories ? { categories } : {});
}
export function getInstaPreferences() {
return apiGet('/api/insta/preferences');
}
export function putInstaPreferences(categories) {
return apiPut('/api/insta/preferences', { categories });
}
// ── Agent Office ──────────────────────────────────
export const getAgents = () => apiGet('/api/agent-office/agents');
export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`);
export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body);
export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending');
export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
export const getAgentStates = () => apiGet('/api/agent-office/states');
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
// --- Lotto Briefing ---
export async function getLatestBriefing() {
const r = await fetch('/api/lotto/briefing/latest');
if (r.status === 404) return null;
if (!r.ok) throw new Error(`briefing fetch failed: ${r.status}`);
return r.json();
}
export async function getCuratorUsage(days = 30) {
const r = await fetch(`/api/lotto/curator/usage?days=${days}`);
if (!r.ok) throw new Error(`usage fetch failed: ${r.status}`);
return r.json();
}
export async function triggerLottoCurate() {
const r = await fetch('/api/agent-office/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: 'lotto', action: 'curate_now', params: {} }),
});
if (!r.ok) throw new Error(`curate trigger failed: ${r.status}`);
return r.json();
}
// ── Music Lab — Video Projects ────────────────────
export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
export const getVideoProjects = () => apiGet('/api/music/video-projects');
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
// ── Music Lab — Revenue ───────────────────────────
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
export const getRevenueRecords = () => apiGet('/api/music/revenue');
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
// ── Music Lab — Market Trends ─────────────────────
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
export const getTrendReports = () => apiGet('/api/music/market/report');
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
// ── Music Lab — Compile ──────────────────────────────────
export const createCompileJob = (data) => apiPost('/api/music/compile', data);
export const getCompileJobs = () => apiGet('/api/music/compiles');
export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`);
export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`);
export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`);
// --- Music Pipeline ---
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
export const createPipeline = (payload) => {
// 옛 호출 호환: createPipeline(13) → { track_id: 13 }
if (typeof payload === 'number') payload = { track_id: payload };
return apiPost('/api/music/pipeline', payload);
};
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
// --- Music Setup ---
export const getMusicSetup = () => apiGet('/api/music/setup');
export const updateMusicSetup = (payload) => apiPut('/api/music/setup', payload);
// --- YouTube OAuth ---
export const getYoutubeAuthUrl = () => apiGet('/api/music/youtube/auth-url');
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
// === Batch generation ===
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
export const listGenres = () => apiGet('/api/music/genres');
// === 주간 회고 (weekly_review) ===
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
throw e;
});
export const getReviewHistory = (limit = 4) =>
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
// === 큐레이터 4계층 원클릭 구매 ===
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
// ---- Stock Screener ----
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
// --- Lotto Weight Evolver ---
export async function fetchEvolverStatus() {
const r = await fetch('/api/lotto/evolver/status');
if (!r.ok) throw new Error(`evolver/status ${r.status}`);
return r.json();
}
export async function fetchEvolverHistory(weeks = 12) {
const r = await fetch(`/api/lotto/evolver/history?weeks=${weeks}`);
if (!r.ok) throw new Error(`evolver/history ${r.status}`);
return r.json();
}
export async function fetchLottoTasks({ days = 7, taskType = null } = {}) {
const params = new URLSearchParams({ days: String(days), limit: '100' });
if (taskType) params.set('task_type', taskType);
const r = await fetch(`/api/agent-office/agents/lotto/tasks?${params}`);
if (!r.ok) throw new Error(`agent-office tasks ${r.status}`);
return r.json();
}
export async function fetchLottoLogs({ days = 7 } = {}) {
const r = await fetch(`/api/agent-office/agents/lotto/logs?limit=200`);
if (!r.ok) throw new Error(`agent-office logs ${r.status}`);
const data = await r.json();
if (!days) return data;
const cutoff = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString();
return { items: (data.items || data.logs || []).filter(l => (l.created_at || '') >= cutoff) };
}
export async function triggerEvolverGenerate() {
const r = await fetch('/api/lotto/evolver/generate-now', { method: 'POST' });
if (!r.ok) throw new Error(`generate-now ${r.status}`);
return r.json();
}
export async function triggerEvolverEvaluate() {
const r = await fetch('/api/lotto/evolver/evaluate-now', { method: 'POST' });
if (!r.ok) throw new Error(`evaluate-now ${r.status}`);
return r.json();
}

View File

@@ -0,0 +1,167 @@
/* BottomNav — mobile bottom navigation */
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--bottom-nav-h);
padding-bottom: var(--safe-area-bottom);
background: var(--bg-secondary);
border-top: 1px solid var(--line);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
z-index: 300;
align-items: stretch;
justify-content: space-around;
}
@media (max-width: 768px) {
.bottom-nav {
display: flex;
}
}
/* Primary nav items */
.bottom-nav__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-width: 48px;
min-height: 48px;
gap: 3px;
color: var(--text-dim);
text-decoration: none;
font-family: var(--font-body);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.02em;
transition: color 0.18s var(--ease-out);
-webkit-tap-highlight-color: transparent;
outline: none;
border: none;
background: none;
cursor: pointer;
padding: 4px 2px;
}
.bottom-nav__item:hover,
.bottom-nav__item.is-active,
.bottom-nav__item--active {
color: var(--neon-cyan);
}
.bottom-nav__item:hover .bottom-nav__icon,
.bottom-nav__item.is-active .bottom-nav__icon,
.bottom-nav__item--active .bottom-nav__icon {
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
}
/* Icon wrapper */
.bottom-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
transition: filter 0.18s var(--ease-out);
}
.bottom-nav__icon svg,
.bottom-nav__icon > * {
width: 22px;
height: 22px;
}
/* Label */
.bottom-nav__label {
line-height: 1;
white-space: nowrap;
}
/* ---- More overlay backdrop ---- */
.bottom-nav__more-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 298;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s var(--ease-out);
}
.bottom-nav__more-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
/* ---- More panel ---- */
.bottom-nav__more-panel {
position: fixed;
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
left: 0;
right: 0;
z-index: 299;
padding: 16px 12px 12px;
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
transform: translateY(100%);
transition: transform 0.25s var(--ease-out);
}
.bottom-nav__more-panel.is-open {
transform: translateY(0);
}
/* More panel item */
.bottom-nav__more-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 4px;
color: var(--text-dim);
text-decoration: none;
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
transition: color 0.18s var(--ease-out), border-color 0.18s var(--ease-out);
-webkit-tap-highlight-color: transparent;
cursor: pointer;
}
.bottom-nav__more-item:hover,
.bottom-nav__more-item.is-active {
color: var(--neon-cyan);
border-color: var(--neon-cyan-dim);
}
.bottom-nav__more-item:hover .bottom-nav__icon,
.bottom-nav__more-item.is-active .bottom-nav__icon {
filter: drop-shadow(0 0 6px var(--neon-cyan-dim));
}
/* Reduce motion */
@media (prefers-reduced-motion: reduce) {
.bottom-nav__item,
.bottom-nav__icon,
.bottom-nav__more-overlay,
.bottom-nav__more-panel,
.bottom-nav__more-item {
transition: none;
}
}

View File

@@ -0,0 +1,114 @@
import { useState, useCallback } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { navLinks } from '../routes';
import './BottomNav.css';
const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
// Vertical dots (three circles) icon for "more"
function MoreDotsIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 22 22"
fill="currentColor"
aria-hidden="true"
>
<circle cx="11" cy="4.5" r="1.8" />
<circle cx="11" cy="11" r="1.8" />
<circle cx="11" cy="17.5" r="1.8" />
</svg>
);
}
const primaryLinks = navLinks.filter((link) =>
PRIMARY_PATHS.includes(link.path)
);
// Preserve the order defined in PRIMARY_PATHS
const orderedPrimaryLinks = PRIMARY_PATHS.map((p) =>
primaryLinks.find((l) => l.path === p)
).filter(Boolean);
const moreLinks = navLinks.filter(
(link) => !PRIMARY_PATHS.includes(link.path)
);
export default function BottomNav() {
const [moreOpen, setMoreOpen] = useState(false);
const location = useLocation();
const openMore = useCallback(() => setMoreOpen(true), []);
const closeMore = useCallback(() => setMoreOpen(false), []);
const toggleMore = useCallback(() => setMoreOpen((prev) => !prev), []);
// Highlight the "more" button when the current path belongs to moreLinks
const isMoreActive =
moreOpen || moreLinks.some((link) => location.pathname === link.path);
return (
<>
{/* Backdrop */}
<div
className={`bottom-nav__more-overlay${moreOpen ? ' is-open' : ''}`}
onClick={closeMore}
aria-hidden="true"
/>
{/* More panel */}
<div
className={`bottom-nav__more-panel${moreOpen ? ' is-open' : ''}`}
role="menu"
aria-label="더보기 메뉴"
>
{moreLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`bottom-nav__more-item${isActive ? ' is-active' : ''}`
}
onClick={closeMore}
role="menuitem"
>
<span className="bottom-nav__icon">{link.icon}</span>
<span className="bottom-nav__label">{link.label}</span>
</NavLink>
))}
</div>
{/* Bottom nav bar */}
<nav className="bottom-nav" aria-label="하단 내비게이션">
{orderedPrimaryLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
end={link.path === '/'}
className={({ isActive }) =>
`bottom-nav__item${isActive ? ' is-active' : ''}`
}
>
<span className="bottom-nav__icon">{link.icon}</span>
<span className="bottom-nav__label">{link.label}</span>
</NavLink>
))}
{/* More button */}
<button
type="button"
className={`bottom-nav__item${isMoreActive ? ' is-active' : ''}`}
onClick={toggleMore}
aria-expanded={moreOpen}
aria-haspopup="menu"
aria-label="더보기"
>
<span className="bottom-nav__icon">
<MoreDotsIcon />
</span>
<span className="bottom-nav__label">더보기</span>
</button>
</nav>
</>
);
}

50
src/components/FAB.css Normal file
View File

@@ -0,0 +1,50 @@
/* FAB — Floating Action Button (mobile-only) */
.fab {
display: none;
position: fixed;
right: 20px;
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px);
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--grad-accent);
border: none;
color: #000;
font-size: 24px;
z-index: 250;
box-shadow: 0 0 0 1px var(--neon-cyan-dim), 0 4px 16px rgba(0, 255, 255, 0.25);
align-items: center;
justify-content: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
}
@media (max-width: 768px) {
.fab {
display: flex;
}
}
.fab:active {
transform: scale(0.92);
}
.fab__icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
/* Variant: positioned above a music mini-player */
.fab--above-player {
bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom) + 16px + 56px);
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.fab {
transition: none;
}
}

37
src/components/FAB.jsx Normal file
View File

@@ -0,0 +1,37 @@
import { useIsMobile } from '../hooks/useIsMobile';
import './FAB.css';
const PlusIcon = () => (
<svg
className="fab__icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
export default function FAB({ onClick, icon, label = '액션', className = '' }) {
const isMobile = useIsMobile();
if (!isMobile) return null;
return (
<button
type="button"
className={`fab ${className}`}
onClick={onClick}
aria-label={label}
>
{icon ?? <PlusIcon />}
</button>
);
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
export const getFgColor = (score) => {
if (score <= 25) return '#ef4444';
if (score <= 45) return '#f97316';
if (score <= 55) return '#eab308';
if (score <= 75) return '#84cc16';
return '#22c55e';
};
export const getFgLabel = (score) => {
if (score <= 25) return '극단적 공포';
if (score <= 45) return '공포';
if (score <= 55) return '중립';
if (score <= 75) return '탐욕';
return '극단적 탐욕';
};
const FG_LEVELS = [
{
range: '0 25',
label: '극단적 공포',
color: '#ef4444',
desc: '투자자들이 극도로 불안해하는 상태. 역사적으로 매수 기회가 되기도 하나, 하락세가 이어질 수 있습니다.',
},
{
range: '26 45',
label: '공포',
color: '#f97316',
desc: '시장 심리가 위축된 상태. 불확실성이 높고, 매도 압력이 강합니다.',
},
{
range: '46 55',
label: '중립',
color: '#eab308',
desc: '공포와 탐욕이 균형을 이루는 상태. 뚜렷한 방향성 없이 관망세가 지속됩니다.',
},
{
range: '56 75',
label: '탐욕',
color: '#84cc16',
desc: '투자자들이 낙관적이고 시장에 적극 참여하는 상태. 과열 신호를 주의해야 합니다.',
},
{
range: '76 100',
label: '극단적 탐욕',
color: '#22c55e',
desc: '시장이 과열된 상태. 조정 가능성이 높아지므로 리스크 관리가 필요합니다.',
},
];
/**
* Fear & Greed 게이지 컴포넌트
* @param {{ score: number, date?: string, showLevels?: boolean }} props
*/
const FearGreedGauge = ({ score, date, showLevels = false }) => {
const color = getFgColor(score);
const label = getFgLabel(score);
return (
<div className="fg-wrap">
<div className="fg-panel">
<div className="fg-score-display">
<span className="fg-score-number" style={{ color }}>{score}</span>
<span className="fg-score-label" style={{ color }}>{label}</span>
{date && <span className="fg-score-date">{date}</span>}
</div>
<div className="fg-gauge">
<div className="fg-gauge__track">
<div
className="fg-gauge__needle"
style={{ left: `${Math.min(100, Math.max(0, score))}%` }}
/>
</div>
<div className="fg-gauge__labels">
<span>극단적 공포</span>
<span>공포</span>
<span>중립</span>
<span>탐욕</span>
<span>극단적 탐욕</span>
</div>
</div>
</div>
{showLevels && (
<div className="fg-levels">
{FG_LEVELS.map((lv) => (
<div
key={lv.label}
className={`fg-level${getFgLabel(score) === lv.label ? ' is-current' : ''}`}
>
<div className="fg-level__head">
<span className="fg-level__dot" style={{ background: lv.color }} />
<span className="fg-level__label" style={{ color: lv.color }}>{lv.label}</span>
<span className="fg-level__range">{lv.range}</span>
</div>
<p className="fg-level__desc">{lv.desc}</p>
</div>
))}
</div>
)}
</div>
);
};
export default FearGreedGauge;

136
src/components/Icons.jsx Normal file
View File

@@ -0,0 +1,136 @@
const S = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: '1.6',
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
const svg = (children) => (
<svg width="18" height="18" viewBox="0 0 24 24" {...S}>
{children}
</svg>
);
export const IconHome = () =>
svg(
<>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9,22 9,12 15,12 15,22" />
</>
);
export const IconBlog = () =>
svg(
<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</>
);
export const IconLotto = () =>
svg(
<>
<circle cx="12" cy="12" r="10" />
<circle cx="8.5" cy="9.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="15.5" cy="9.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="8.5" cy="14.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="15.5" cy="14.5" r="1.4" fill="currentColor" strokeWidth="0" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" strokeWidth="0" />
</>
);
export const IconStock = () =>
svg(
<>
<polyline points="22,7 13.5,15.5 8.5,10.5 2,17" />
<polyline points="16,7 22,7 22,13" />
</>
);
export const IconTravel = () =>
svg(<polygon points="3,11 22,2 13,21 11,13 3,11" />);
export const IconMusic = () =>
svg(
<>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</>
);
export const IconLab = () =>
svg(
<>
<line x1="9" y1="3" x2="15" y2="3" />
<path d="M10 3v6.5L5.5 17.5A2 2 0 0 0 7.3 20h9.4a2 2 0 0 0 1.8-2.5L14 9.5V3" />
<line x1="6.5" y1="15" x2="17.5" y2="15" />
</>
);
export const IconTodo = () =>
svg(
<>
<rect x="3" y="5" width="6" height="6" rx="1" />
<polyline points="9,8 11,10 15,6" />
<rect x="3" y="13" width="6" height="6" rx="1" />
<line x1="13" y1="16" x2="21" y2="16" />
<line x1="13" y1="8" x2="21" y2="8" />
<line x1="17" y1="12" x2="21" y2="12" />
</>
);
export const IconSubscription = () =>
svg(
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<polyline points="9,15 11,17 15,13" />
<line x1="9" y1="10" x2="15" y2="10" />
</>
);
export const IconBlogMarketing = () =>
svg(
<>
<path d="M4 4h16v16H4z" />
<path d="M8 8h8" />
<path d="M8 12h5" />
<circle cx="17" cy="15" r="2.5" fill="currentColor" strokeWidth="0" />
<path d="M15.5 13l3 4" />
</>
);
export const IconPortfolio = () =>
svg(
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>
);
export const IconBuilding = () =>
svg(
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 21V9" />
<rect x="6" y="6" width="3" height="3" />
<rect x="11" y="6" width="3" height="3" />
<rect x="16" y="6" width="2" height="3" />
<rect x="11" y="11" width="3" height="3" />
<rect x="16" y="11" width="2" 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" />
</>
);

View File

@@ -0,0 +1,67 @@
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
gap: 12px;
}
.loading-spinner__circle {
width: 28px;
height: 28px;
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
border-top-color: var(--accent, #f7a8a5);
animation: loading-spin 0.75s linear infinite;
}
.loading-spinner__text {
font-size: 12px;
color: var(--muted, #9b9490);
margin: 0;
letter-spacing: 0.04em;
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
/* ── Skeleton ─────────────────────────────────────────────────────── */
.loading-skeleton {
display: grid;
gap: 14px;
padding: 4px 0;
width: 100%;
}
.loading-skeleton__line {
height: 14px;
border-radius: 7px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.09) 40%,
rgba(255, 255, 255, 0.04) 80%
);
background-size: 300% 100%;
animation: loading-shimmer 1.8s ease-in-out infinite;
}
.loading-skeleton__line:nth-child(1) { width: 65%; }
.loading-skeleton__line:nth-child(2) { width: 85%; animation-delay: 0.1s; }
.loading-skeleton__line:nth-child(3) { width: 50%; animation-delay: 0.2s; }
.loading-skeleton__line:nth-child(4) { width: 75%; animation-delay: 0.15s; }
.loading-skeleton__line:nth-child(5) { width: 60%; animation-delay: 0.25s; }
@keyframes loading-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import './Loading.css';
const Loading = ({ type = 'spinner', message = '로딩 중...' }) => {
if (type === 'skeleton') {
return (
<div className="loading-skeleton">
<div className="loading-skeleton__line" style={{ width: '60%' }}></div>
<div className="loading-skeleton__line" style={{ width: '80%' }}></div>
<div className="loading-skeleton__line" style={{ width: '40%' }}></div>
</div>
);
}
return (
<div className="loading-spinner">
<div className="loading-spinner__circle"></div>
{message && <p className="loading-spinner__text">{message}</p>}
</div>
);
};
export default Loading;

147
src/components/LogoLoop.css Normal file
View File

@@ -0,0 +1,147 @@
.logoloop {
position: relative;
overflow: hidden;
}
.logoloop:not(.logoloop--vertical) {
overflow-x: hidden;
}
.logoloop--vertical {
overflow-y: hidden;
height: 100%;
display: inline-block;
}
.logoloop__track {
display: flex;
flex-direction: row;
width: max-content;
will-change: transform;
user-select: none;
position: relative;
z-index: 0;
}
.logoloop__track--vertical {
flex-direction: column;
width: 100%;
height: max-content;
}
@media (prefers-reduced-motion: reduce) {
.logoloop__track {
transform: none !important;
}
}
.logoloop__list {
display: flex;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
.logoloop__list--vertical {
flex-direction: column;
}
.logoloop__item {
flex: none;
font-size: var(--logoloop-logoHeight, 36px);
line-height: 1;
}
.logoloop__list:not(.logoloop__list--vertical) .logoloop__item {
margin-right: var(--logoloop-gap, 32px);
}
.logoloop__list--vertical .logoloop__item {
margin-bottom: var(--logoloop-gap, 32px);
}
.logoloop__item--scalable {
overflow: visible;
}
.logoloop__link {
display: inline-flex;
align-items: center;
text-decoration: none;
border-radius: 4px;
transition: opacity 0.2s linear;
color: inherit;
}
.logoloop__link:hover {
opacity: 0.8;
}
.logoloop__link:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.logoloop__node {
display: inline-flex;
align-items: center;
}
.logoloop__node--scale,
.logoloop__img--scale {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logoloop__item--scalable:hover .logoloop__node--scale,
.logoloop__item--scalable:hover .logoloop__img--scale {
transform: scale(1.18);
}
.logoloop__img {
height: var(--logoloop-logoHeight, 36px);
width: auto;
display: block;
object-fit: contain;
-webkit-user-drag: none;
pointer-events: none;
image-rendering: -webkit-optimize-contrast;
}
.logoloop__fade {
position: absolute;
pointer-events: none;
z-index: 10;
}
.logoloop__fade--left {
top: 0;
bottom: 0;
left: 0;
width: clamp(24px, 8%, 120px);
background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}
.logoloop__fade--right {
top: 0;
bottom: 0;
right: 0;
width: clamp(24px, 8%, 120px);
background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}
.logoloop__fade--top {
left: 0;
right: 0;
top: 0;
height: clamp(24px, 8%, 120px);
background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}
.logoloop__fade--bottom {
left: 0;
right: 0;
bottom: 0;
height: clamp(24px, 8%, 120px);
background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
}

322
src/components/LogoLoop.jsx Normal file
View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './LogoLoop.css';
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
};
const toCssLength = (value) =>
typeof value === 'number' ? `${value}px` : value ?? undefined;
const cx = (...parts) => parts.filter(Boolean).join(' ');
function useResizeObserver(callback, elements, deps) {
useEffect(() => {
if (!window.ResizeObserver) {
const handler = () => callback();
window.addEventListener('resize', handler);
callback();
return () => window.removeEventListener('resize', handler);
}
const observers = elements.map((ref) => {
if (!ref.current) return null;
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
return observer;
});
callback();
return () => {
observers.forEach((o) => o?.disconnect());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useImageLoader(seqRef, onLoad, deps) {
useEffect(() => {
const images = seqRef.current?.querySelectorAll('img') ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remaining = images.length;
const handleLoad = () => {
remaining -= 1;
if (remaining === 0) onLoad();
};
images.forEach((img) => {
if (img.complete) {
handleLoad();
} else {
img.addEventListener('load', handleLoad, { once: true });
img.addEventListener('error', handleLoad, { once: true });
}
});
return () => {
images.forEach((img) => {
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleLoad);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
const rafRef = useRef(null);
const lastTsRef = useRef(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const seqSize = isVertical ? seqHeight : seqWidth;
if (seqSize > 0) {
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
track.style.transform = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
}
if (prefersReduced) {
track.style.transform = 'translate3d(0, 0, 0)';
return () => {
lastTsRef.current = null;
};
}
const animate = (ts) => {
if (lastTsRef.current === null) lastTsRef.current = ts;
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easing;
if (seqSize > 0) {
let next = offsetRef.current + velocityRef.current * dt;
next = ((next % seqSize) + seqSize) % seqSize;
offsetRef.current = next;
track.style.transform = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
lastTsRef.current = null;
};
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
}
export default function LogoLoop({
logos,
speed = 60,
direction = 'left',
width = '100%',
logoHeight = 36,
gap = 32,
pauseOnHover = true,
hoverSpeed,
fadeOut = true,
fadeOutColor,
scaleOnHover = true,
ariaLabel = 'Skill logos',
className,
style,
}) {
const containerRef = useRef(null);
const trackRef = useRef(null);
const seqRef = useRef(null);
const [seqWidth, setSeqWidth] = useState(0);
const [seqHeight, setSeqHeight] = useState(0);
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
const [isHovered, setIsHovered] = useState(false);
const effectiveHoverSpeed = useMemo(() => {
if (hoverSpeed !== undefined) return hoverSpeed;
if (pauseOnHover === true) return 0;
if (pauseOnHover === false) return undefined;
return 0;
}, [hoverSpeed, pauseOnHover]);
const isVertical = direction === 'up' || direction === 'down';
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
const dirMul = isVertical
? direction === 'up'
? 1
: -1
: direction === 'left'
? 1
: -1;
const speedMul = speed < 0 ? -1 : 1;
return magnitude * dirMul * speedMul;
}, [speed, direction, isVertical]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const seqRect = seqRef.current?.getBoundingClientRect?.();
const sw = seqRect?.width ?? 0;
const sh = seqRect?.height ?? 0;
if (isVertical) {
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
if (containerRef.current && parentH > 0) {
const h = Math.ceil(parentH);
if (containerRef.current.style.height !== `${h}px`)
containerRef.current.style.height = `${h}px`;
}
if (sh > 0) {
setSeqHeight(Math.ceil(sh));
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
}
} else if (sw > 0) {
setSeqWidth(Math.ceil(sw));
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
}
}, [isVertical]);
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
const cssVars = useMemo(() => ({
'--logoloop-gap': `${gap}px`,
'--logoloop-logoHeight': `${logoHeight}px`,
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
}), [gap, logoHeight, fadeOutColor]);
const containerStyle = useMemo(() => ({
width: isVertical
? toCssLength(width) === '100%'
? undefined
: toCssLength(width)
: toCssLength(width) ?? '100%',
...cssVars,
...style,
}), [width, cssVars, style, isVertical]);
const handleEnter = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
}, [effectiveHoverSpeed]);
const handleLeave = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
}, [effectiveHoverSpeed]);
const renderItem = (item, key) => {
const isNode = 'node' in item;
const inner = isNode ? (
<span
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
aria-hidden={!!item.href && !item.ariaLabel}
>
{item.node}
</span>
) : (
<img
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
src={item.src}
srcSet={item.srcSet}
sizes={item.sizes}
width={item.width}
height={item.height}
alt={item.alt ?? ''}
title={item.title}
loading="lazy"
decoding="async"
draggable={false}
/>
);
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
const wrapper = item.href ? (
<a
className="logoloop__link"
href={item.href}
aria-label={itemAriaLabel || 'logo link'}
target="_blank"
rel="noreferrer noopener"
>
{inner}
</a>
) : (
inner
);
return (
<li
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
key={key}
role="listitem"
>
{wrapper}
</li>
);
};
const lists = useMemo(
() =>
Array.from({ length: copyCount }, (_, i) => (
<ul
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
key={`copy-${i}`}
role="list"
aria-hidden={i > 0}
ref={i === 0 ? seqRef : undefined}
>
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
</ul>
)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[copyCount, logos, isVertical, scaleOnHover],
);
return (
<div
ref={containerRef}
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
style={containerStyle}
role="region"
aria-label={ariaLabel}
>
{fadeOut && (
<>
<div
aria-hidden
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
/>
<div
aria-hidden
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
/>
</>
)}
<div
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
ref={trackRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{lists}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
/* MobileSheet — bottom sheet modal */
/* Backdrop */
.mobile-sheet__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 400;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s var(--ease-out);
}
.mobile-sheet__backdrop.is-open {
opacity: 1;
pointer-events: auto;
}
/* Sheet */
.mobile-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 90vh;
background: var(--bg-secondary);
border-top: 1px solid var(--line);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
z-index: 401;
display: flex;
flex-direction: column;
touch-action: none;
transform: translateY(100%);
transition: transform 0.3s var(--ease-out);
}
.mobile-sheet.is-open {
transform: translateY(0);
}
/* Snap variants */
.mobile-sheet.snap-half {
max-height: 50vh;
}
/* Drag handle area */
.mobile-sheet__handle {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0 8px;
cursor: grab;
flex-shrink: 0;
}
.mobile-sheet__handle:active {
cursor: grabbing;
}
.mobile-sheet__handle-bar {
display: block;
width: 36px;
height: 4px;
background: var(--text-muted);
border-radius: 2px;
}
/* Header */
.mobile-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px 12px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.mobile-sheet__title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text-bright);
}
.mobile-sheet__close {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
border-radius: var(--radius-sm);
-webkit-tap-highlight-color: transparent;
transition: color 0.18s var(--ease-out);
}
.mobile-sheet__close:hover {
color: var(--text);
}
/* Scrollable body */
.mobile-sheet__body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
padding-bottom: calc(20px + var(--safe-area-bottom));
overscroll-behavior: contain;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.mobile-sheet__backdrop,
.mobile-sheet {
transition: none;
}
.mobile-sheet__close {
transition: none;
}
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import './MobileSheet.css';
export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
const [dragY, setDragY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startYRef = useRef(null);
const sheetRef = useRef(null);
// Lock body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
// Reset drag state on close
useEffect(() => {
if (!open) {
setDragY(0);
setIsDragging(false);
}
}, [open]);
const handleHandleTouchStart = (e) => {
startYRef.current = e.touches[0].clientY;
setIsDragging(true);
};
const handleHandleTouchMove = (e) => {
if (startYRef.current === null) return;
const delta = e.touches[0].clientY - startYRef.current;
if (delta < 0) return; // no drag up
setDragY(delta);
};
const handleHandleTouchEnd = () => {
setIsDragging(false);
if (dragY > 100) {
setDragY(0);
onClose?.();
} else {
setDragY(0);
}
startYRef.current = null;
};
const sheetTransform = open
? `translateY(${isDragging ? dragY : 0}px)`
: 'translateY(100%)';
return (
<>
<div
className={`mobile-sheet__backdrop ${open ? 'is-open' : ''}`}
onClick={onClose}
aria-hidden="true"
/>
<div
ref={sheetRef}
className={`mobile-sheet snap-${snap} ${open ? 'is-open' : ''}`}
style={{
transform: sheetTransform,
transition: isDragging ? 'none' : undefined,
}}
role="dialog"
aria-modal="true"
aria-label={title}
>
{/* Drag handle */}
<div
className="mobile-sheet__handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
aria-hidden="true"
>
<span className="mobile-sheet__handle-bar" />
</div>
{/* Header */}
<div className="mobile-sheet__header">
<span className="mobile-sheet__title">{title}</span>
<button
type="button"
className="mobile-sheet__close"
onClick={onClose}
aria-label="닫기"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M3 3l12 12M15 3L3 15"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/* Body */}
<div className="mobile-sheet__body">
{children}
</div>
</div>
</>
);
}

View File

@@ -1,126 +1,339 @@
.site-nav {
position: sticky;
/* ── 사이드바 본체 ───────────────────────────────────────────────────── */
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 10;
background: rgba(16, 16, 24, 0.82);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.site-nav__inner {
max-width: 1200px;
margin: 0 auto;
padding: 18px 20px;
bottom: 0;
width: var(--sidebar-w);
z-index: 200;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-direction: column;
background: rgba(7, 12, 28, 0.92);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
border-right: 1px solid rgba(0, 212, 255, 0.08);
box-shadow: 4px 0 40px rgba(0, 0, 0, 0.5), 1px 0 0 rgba(0, 212, 255, 0.05);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
}
.site-nav__brand {
display: flex;
align-items: center;
gap: 14px;
}
/* ── 브랜드 섹션 ─────────────────────────────────────────────────────── */
.site-nav__logo-image {
width: 42px;
height: 42px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
}
.site-nav__logo {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
font-family: var(--font-display);
font-size: 20px;
color: #1b1a24;
background: linear-gradient(135deg, #fdd4b1, #f7a8a5);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
}
.site-nav__title {
margin: 0;
font-weight: 600;
letter-spacing: 0.02em;
}
.site-nav__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.site-nav__links {
.sidebar__brand {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 20px 16px;
flex-shrink: 0;
}
.site-nav__link {
.sidebar__logo {
width: 38px;
height: 38px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
box-shadow:
0 0 0 1px rgba(0, 212, 255, 0.2),
0 0 12px rgba(0, 212, 255, 0.15),
0 4px 12px rgba(0, 0, 0, 0.4);
}
.sidebar__brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar__brand-name {
margin: 0;
font-family: 'Space Grotesk', 'Manrope', sans-serif;
font-weight: 700;
font-size: 15px;
color: var(--text-bright);
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__brand-sub {
margin: 0;
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--neon-cyan);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 구분선 ──────────────────────────────────────────────────────────── */
.sidebar__divider {
height: 1px;
background: var(--line, rgba(255, 255, 255, 0.1));
margin: 8px 0;
flex-shrink: 0;
}
/* ── 네비게이션 ──────────────────────────────────────────────────────── */
.sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
/* 스크롤바 숨김 */
scrollbar-width: none;
}
.sidebar__nav::-webkit-scrollbar {
display: none;
}
.sidebar__section-label {
margin: 0;
padding: 8px 24px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--text-muted);
}
/* ── 네비게이션 아이템 ───────────────────────────────────────────────── */
.sidebar__item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-sm, 12px);
margin: 2px 10px;
text-decoration: none;
color: var(--text-dim);
font-size: 14px;
letter-spacing: 0.02em;
color: var(--text);
padding: 8px 12px;
border-radius: 999px;
font-weight: 500;
font-family: var(--font-body, 'Manrope', sans-serif);
border: 1px solid transparent;
transition: all 0.2s ease;
position: relative;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
overflow: hidden;
}
.site-nav__link:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
.sidebar__item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text, #f0ebe4);
border-color: rgba(255, 255, 255, 0.08);
}
.site-nav__link.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.16);
color: #ffe9e2;
/* 활성 아이템 */
.sidebar__item.is-active {
background: linear-gradient(90deg, rgba(0, 212, 255, 0.12) 0%, rgba(0, 212, 255, 0.04) 100%);
border-color: rgba(0, 212, 255, 0.2);
color: var(--text-bright);
}
@media (max-width: 800px) {
.site-nav__inner {
flex-direction: column;
align-items: flex-start;
}
/* 활성 아이템 좌측 네온 바 */
.sidebar__item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 20%;
bottom: 20%;
width: 2px;
background: var(--neon-cyan);
border-radius: 0 2px 2px 0;
box-shadow: 0 0 8px var(--neon-cyan), 0 0 16px rgba(0, 212, 255, 0.4);
}
/* ── 아이콘 ──────────────────────────────────────────────────────────── */
.sidebar__item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: inherit;
transition: color 0.2s ease;
}
.sidebar__item.is-active .sidebar__item-icon {
color: var(--neon-cyan);
}
/* ── 라벨 ────────────────────────────────────────────────────────────── */
.sidebar__item-label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 도트 인디케이터 ─────────────────────────────────────────────────── */
.sidebar__item-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--neon-cyan);
box-shadow: 0 0 6px var(--neon-cyan);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.sidebar__item.is-active .sidebar__item-dot {
opacity: 1;
}
/* ── 사이드바 푸터 ───────────────────────────────────────────────────── */
.sidebar__footer {
flex-shrink: 0;
margin-top: auto;
}
.sidebar__footer-content {
padding: 12px 16px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar__status {
display: flex;
align-items: center;
gap: 7px;
}
.sidebar__status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4);
flex-shrink: 0;
animation: pulse-dot 2.4s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(52, 211, 153, 0.8), 0 0 12px rgba(52, 211, 153, 0.4); }
50% { opacity: 0.7; box-shadow: 0 0 3px rgba(52, 211, 153, 0.5), 0 0 6px rgba(52, 211, 153, 0.2); }
}
.sidebar__status-text {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0.02em;
}
.sidebar__version {
margin: 0;
font-size: 10px;
color: var(--text-muted);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.05em;
opacity: 0.6;
}
/* ── 모바일 토글 버튼 ────────────────────────────────────────────────── */
.sidebar-toggle {
display: none;
position: fixed;
top: 10px;
left: 10px;
z-index: 201;
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(7, 12, 28, 0.88);
backdrop-filter: blur(12px) saturate(1.4);
-webkit-backdrop-filter: blur(12px) saturate(1.4);
cursor: pointer;
padding: 0;
align-items: center;
justify-content: center;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
.sidebar-toggle:hover {
background: rgba(0, 212, 255, 0.1);
border-color: rgba(0, 212, 255, 0.25);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.4), 0 0 8px rgba(0, 212, 255, 0.15);
}
.sidebar-toggle__icon {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 18px;
height: 18px;
}
.sidebar-toggle__icon span {
display: block;
width: 16px;
height: 1.5px;
background: var(--text-bright, #ffffff);
border-radius: 2px;
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.28s ease,
width 0.28s ease;
transform-origin: center;
}
.sidebar-toggle__icon.is-open span:nth-child(1) {
transform: translateY(6.5px) rotate(45deg);
}
.sidebar-toggle__icon.is-open span:nth-child(2) {
opacity: 0;
width: 0;
}
.sidebar-toggle__icon.is-open span:nth-child(3) {
transform: translateY(-6.5px) rotate(-45deg);
}
/* ── 오버레이 ────────────────────────────────────────────────────────── */
.sidebar__overlay {
position: fixed;
inset: 0;
z-index: 199;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.sidebar__overlay.is-visible {
opacity: 1;
pointer-events: auto;
}
/* ── 모바일 반응형 ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.site-nav__inner {
padding: 14px 16px;
gap: 12px;
}
.site-nav__brand {
gap: 10px;
}
.site-nav__logo-image {
width: 36px;
height: 36px;
}
.site-nav__title {
font-size: 14px;
}
.site-nav__subtitle {
font-size: 11px;
}
.site-nav__links {
gap: 8px;
}
.site-nav__link {
font-size: 13px;
padding: 6px 10px;
.sidebar {
display: none;
}
}

View File

@@ -1,35 +1,58 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { navLinks } from '../routes.jsx';
import { useIsMobile } from '../hooks/useIsMobile';
import mainLogo from '../assets/main_logo.png';
import './Navbar.css';
const Navbar = () => {
const isMobile = useIsMobile();
// 모바일에서는 BottomNav가 대체하므로 사이드바 미렌더링
if (isMobile) return null;
return (
<header className="site-nav">
<div className="site-nav__inner">
<div className="site-nav__brand">
<img src={mainLogo} alt="Logo" className="site-nav__logo-image" />
<div>
<p className="site-nav__title">Jaeoh Archive</p>
<p className="site-nav__subtitle">Stories, notes, and snapshots</p>
</div>
<aside className="sidebar">
<div className="sidebar__brand">
<img src={mainLogo} alt="Logo" className="sidebar__logo" />
<div className="sidebar__brand-text">
<p className="sidebar__brand-name">Jaeoh</p>
<p className="sidebar__brand-sub">MANAGEMENT ROOM</p>
</div>
<nav className="site-nav__links">
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`site-nav__link${isActive ? ' is-active' : ''}`
}
>
{link.label}
</NavLink>
))}
</nav>
</div>
</header>
<div className="sidebar__divider" />
<nav className="sidebar__nav">
<p className="sidebar__section-label">NAVIGATION</p>
{navLinks.map((link) => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`sidebar__item${isActive ? ' is-active' : ''}`
}
style={{ '--item-accent': link.accent }}
end={link.path === '/'}
>
<span className="sidebar__item-icon">{link.icon}</span>
<span className="sidebar__item-label">{link.label}</span>
<span className="sidebar__item-dot" />
</NavLink>
))}
</nav>
<div className="sidebar__footer">
<div className="sidebar__divider" />
<div className="sidebar__footer-content">
<div className="sidebar__status">
<span className="sidebar__status-dot" />
<span className="sidebar__status-text">System Online</span>
</div>
<p className="sidebar__version">v2.0.0</p>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,67 @@
/* ── PageHeader ──────────────────────────────────────────────────────── */
.page-header {
padding: 0 0 20px;
margin-bottom: 4px;
}
.page-header__inner {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-header__subtitle {
margin: 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--page-accent, var(--neon-cyan));
font-family: var(--font-display, 'Space Grotesk', sans-serif);
display: flex;
align-items: center;
gap: 10px;
}
.page-header__subtitle::before {
content: '';
display: block;
width: 20px;
height: 1.5px;
background: var(--page-accent, var(--neon-cyan));
border-radius: 2px;
box-shadow: 0 0 6px var(--page-accent, var(--neon-cyan));
flex-shrink: 0;
}
.page-header__title {
margin: 0;
font-size: clamp(22px, 3vw, 32px);
font-weight: 800;
font-family: var(--font-display, 'Space Grotesk', sans-serif);
color: var(--text-bright, #fff);
letter-spacing: -0.03em;
line-height: 1.1;
}
.page-header__line {
height: 1px;
background: linear-gradient(
90deg,
var(--page-accent, var(--neon-cyan)) 0%,
transparent 60%
);
margin-top: 14px;
opacity: 0.3;
}
@media (max-width: 768px) {
.page-header {
padding: 0 0 16px;
}
.page-header__title {
font-size: clamp(18px, 5vw, 24px);
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { navLinks } from '../routes.jsx';
import './PageHeader.css';
const PageHeader = () => {
const { pathname } = useLocation();
// Home 페이지에서는 Hero 섹션이 있으므로 숨김
if (pathname === '/') return null;
// stock/trade 같은 하위 경로도 stock로 매칭
const current = navLinks.find((link) => {
if (link.path === '/') return false;
return pathname === link.path || pathname.startsWith(link.path + '/');
});
if (!current) return null;
return (
<header className="page-header" style={{ '--page-accent': current.accent }}>
<div className="page-header__inner">
<p className="page-header__subtitle">{current.subtitle}</p>
<h1 className="page-header__title">{current.label}</h1>
</div>
<div className="page-header__line" />
</header>
);
};
export default PageHeader;

View File

@@ -0,0 +1,86 @@
/* PullToRefresh — pull-down-to-refresh wrapper */
.pull-to-refresh {
position: relative;
overscroll-behavior-y: contain;
}
/* Indicator circle */
.pull-to-refresh__indicator {
position: absolute;
top: -48px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--surface-card);
border: 1px solid var(--line);
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s var(--ease-out);
z-index: 10;
color: var(--neon-cyan);
}
.pull-to-refresh__indicator.is-visible {
opacity: 1;
}
/* Spinner */
.pull-to-refresh__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid var(--line);
border-top-color: var(--neon-cyan);
border-radius: 50%;
animation: ptr-spin 0.7s linear infinite;
}
@keyframes ptr-spin {
to { transform: rotate(360deg); }
}
/* Arrow chevron */
.pull-to-refresh__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
transition: transform 0.2s var(--ease-out);
}
.pull-to-refresh__arrow.is-ready {
transform: rotate(180deg);
}
/* Content area */
.pull-to-refresh__content {
will-change: transform;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.pull-to-refresh__spinner {
animation: none;
border-top-color: var(--neon-cyan);
}
.pull-to-refresh__arrow {
transition: none;
}
.pull-to-refresh__indicator {
transition: none;
}
.pull-to-refresh__content {
transition: none !important;
}
}

View File

@@ -0,0 +1,99 @@
import { useRef, useState, useCallback } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import './PullToRefresh.css';
const THRESHOLD = 60;
const MAX_PULL = 120;
const RESISTANCE = 0.5;
const CONTENT_SHIFT_FACTOR = 0.3;
export default function PullToRefresh({ onRefresh, children, className = '' }) {
const isMobile = useIsMobile();
const [pullY, setPullY] = useState(0);
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
const startYRef = useRef(null);
const containerRef = useRef(null);
const handleTouchStart = useCallback((e) => {
const el = containerRef.current;
if (!el) return;
if (el.scrollTop > 0) return; // only trigger at top
startYRef.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e) => {
if (startYRef.current === null) return;
const delta = e.touches[0].clientY - startYRef.current;
if (delta <= 0) {
setPullY(0);
setState('idle');
return;
}
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
setPullY(clamped);
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
}, []);
const handleTouchEnd = useCallback(async () => {
if (startYRef.current === null) return;
startYRef.current = null;
if (state === 'ready') {
setState('refreshing');
setPullY(THRESHOLD);
try {
await onRefresh?.();
} finally {
setState('idle');
setPullY(0);
}
} else {
setState('idle');
setPullY(0);
}
}, [state, onRefresh]);
if (!isMobile) {
return <div className={className}>{children}</div>;
}
const indicatorVisible = state !== 'idle';
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
return (
<div
ref={containerRef}
className={`pull-to-refresh ${className}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
style={{ transform: `translateY(${pullY}px)` }}
aria-hidden="true"
>
{state === 'refreshing' ? (
<span className="pull-to-refresh__spinner" />
) : (
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M9 3v10M4 8l5 5 5-5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
<div
className="pull-to-refresh__content"
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
/* SwipeableView — swipeable tab container */
.swipeable-view {
overflow: hidden;
position: relative;
width: 100%;
}
/* Tab bar */
.swipeable-view__tabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--surface);
border-radius: var(--radius-md);
border: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
margin-bottom: 8px;
}
.swipeable-view__tabs::-webkit-scrollbar {
display: none;
}
/* Individual tab button */
.swipeable-view__tab {
flex: 1;
min-width: fit-content;
padding: 8px 16px;
background: none;
border: none;
color: var(--text-dim);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
border-radius: calc(var(--radius-md) - 2px);
cursor: pointer;
white-space: nowrap;
transition: color 0.18s var(--ease-out), background 0.18s var(--ease-out);
-webkit-tap-highlight-color: transparent;
outline: none;
}
.swipeable-view__tab.is-active {
background: var(--surface-raised);
color: var(--neon-cyan);
position: relative;
}
.swipeable-view__tab.is-active::after {
content: '';
position: absolute;
bottom: 2px;
left: 20%;
right: 20%;
height: 2px;
background: var(--neon-cyan);
border-radius: 1px;
}
/* Sliding track */
.swipeable-view__track {
display: flex;
width: 100%;
transition: transform 0.3s var(--ease-out);
will-change: transform;
}
.swipeable-view__track.is-swiping {
transition: none;
}
/* Each panel */
.swipeable-view__panel {
flex: 0 0 100%;
min-width: 0;
overflow-y: auto;
}
/* Mobile touch targets */
@media (max-width: 768px) {
.swipeable-view__tab {
min-height: 44px;
font-size: 14px;
padding: 10px 16px;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.swipeable-view__track {
transition: none;
}
.swipeable-view__tab {
transition: none;
}
}

View File

@@ -0,0 +1,92 @@
import { useState, useRef } from 'react';
import { useSwipeable } from 'react-swipeable';
import { useIsMobile } from '../hooks/useIsMobile';
import './SwipeableView.css';
export default function SwipeableView({
tabs = [],
activeIndex: controlledIndex,
onTabChange,
showTabs = true,
}) {
const isMobile = useIsMobile();
const [internalIndex, setInternalIndex] = useState(0);
const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const trackRef = useRef(null);
const activeIndex = controlledIndex !== undefined ? controlledIndex : internalIndex;
const setIndex = (idx) => {
const clamped = Math.max(0, Math.min(tabs.length - 1, idx));
if (controlledIndex === undefined) setInternalIndex(clamped);
onTabChange?.(clamped);
};
const handlers = useSwipeable({
onSwiping: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(true);
setSwipeOffset(deltaX);
},
onSwipedLeft: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (Math.abs(deltaX) > 30) setIndex(activeIndex + 1);
},
onSwipedRight: ({ deltaX }) => {
if (!isMobile) return;
setIsSwiping(false);
setSwipeOffset(0);
if (Math.abs(deltaX) > 30) setIndex(activeIndex - 1);
},
onTouchEndOrOnMouseUp: () => {
setIsSwiping(false);
setSwipeOffset(0);
},
trackMouse: false,
trackTouch: true,
delta: 30,
preventScrollOnSwipe: false,
});
const trackTranslate = -activeIndex * 100 + (isSwiping ? (swipeOffset / (trackRef.current?.offsetWidth || 1)) * 100 : 0);
return (
<div className="swipeable-view">
{showTabs && (
<div className="swipeable-view__tabs" role="tablist">
{tabs.map((tab, i) => (
<button
key={tab.key}
role="tab"
aria-selected={i === activeIndex}
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
onClick={() => setIndex(i)}
>
{tab.label}
</button>
))}
</div>
)}
<div
{...(isMobile ? handlers : {})}
ref={trackRef}
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
style={{ transform: `translateX(${trackTranslate}%)` }}
>
{tabs.map((tab, i) => (
<div
key={tab.key}
role="tabpanel"
aria-hidden={i !== activeIndex}
className="swipeable-view__panel"
>
{tab.content}
</div>
))}
</div>
</div>
);
}

View File

@@ -83,7 +83,8 @@ const inferDateFromSlug = (slug) => {
export const getBlogPosts = () => {
const modules = import.meta.glob('/src/content/blog/**/*.md', {
as: 'raw',
query: '?raw',
import: 'default',
eager: true,
});

83
src/data/heroConfig.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* 홈 히어로 카드 월별 테마 설정
* 매달 month, theme, desc, nextUpdate 를 수정해 적용하세요.
*/
export const MONTHLY_THEMES = [
{
month: 1,
theme: '새해 목표 설정',
desc: '연초를 맞아 올해 개발·기록 목표를 구체적으로 정리하고 실행 계획을 세웁니다.',
nextUpdate: '매주 일요일',
},
{
month: 2,
theme: '코드 품질 개선',
desc: '리팩토링과 테스트 커버리지 향상에 집중합니다. 작은 개선도 꾸준히 쌓아갑니다.',
nextUpdate: '매주 토요일',
},
{
month: 3,
theme: '웹 UI 고도화',
desc: '대시보드 형태의 UI를 사이버펑크 스타일로 전면 개편하고, 새 기능을 추가합니다.',
nextUpdate: '이번 주말',
},
{
month: 4,
theme: '백엔드 성능 최적화',
desc: 'API 응답 속도와 데이터베이스 쿼리를 분석하고 병목을 개선하는 달입니다.',
nextUpdate: '이번 주말',
},
{
month: 5,
theme: '인프라 자동화',
desc: 'Docker/Kubernetes 파이프라인을 정비하고 배포 자동화를 강화합니다.',
nextUpdate: '격주 일요일',
},
{
month: 6,
theme: '여름 사이드 프로젝트',
desc: '새로운 기술 스택을 탐구하며 소규모 실험 프로젝트를 진행합니다.',
nextUpdate: '매주 금요일',
},
{
month: 7,
theme: '기록과 문서화',
desc: '그동안 미뤄뒀던 개발 노트와 블로그 글 작성에 집중합니다.',
nextUpdate: '매주 화요일',
},
{
month: 8,
theme: '보안 점검',
desc: '서비스 취약점을 점검하고 인증·인가 로직을 강화합니다.',
nextUpdate: '격주 토요일',
},
{
month: 9,
theme: '모니터링 강화',
desc: '로그 수집과 알림 파이프라인을 개선해 운영 가시성을 높입니다.',
nextUpdate: '이번 주말',
},
{
month: 10,
theme: '오픈소스 기여',
desc: '사용 중인 라이브러리에 이슈를 제보하거나 PR을 올려봅니다.',
nextUpdate: '매주 목요일',
},
{
month: 11,
theme: '연말 회고 준비',
desc: '올 한 해의 개발 성과를 정리하고 내년 로드맵 초안을 그립니다.',
nextUpdate: '매주 일요일',
},
{
month: 12,
theme: '느린 기록, 깊은 회고',
desc: '빠르게 달려온 한 해를 천천히 돌아보며 가장 의미 있었던 작업을 기록합니다.',
nextUpdate: '크리스마스 주간',
},
];
export function getCurrentTheme() {
const month = new Date().getMonth() + 1;
return MONTHLY_THEMES.find((t) => t.month === month) ?? MONTHLY_THEMES[0];
}

18
src/hooks/useIsMobile.js Normal file
View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(
() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
const handler = (e) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

View File

@@ -1,35 +1,251 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Manrope:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
* {
/* ── Reset ───────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Design Tokens ───────────────────────────────────────────────────── */
:root {
/* ── Background Surfaces ─────────────────────────────────────────── */
--bg: #070b19;
--bg-secondary: #0a0f23;
--bg-tertiary: #0d1530;
/* ── Glass Surfaces ──────────────────────────────────────────────── */
--surface: rgba(10, 18, 45, 0.8);
--surface-raised: rgba(14, 24, 58, 0.9);
--surface-card: rgba(255, 255, 255, 0.03);
/* ── Neon Cyan ───────────────────────────────────────────────────── */
--neon-cyan: #00d4ff;
--neon-cyan-dim: rgba(0, 212, 255, 0.6);
--neon-cyan-muted: rgba(0, 212, 255, 0.12);
/* ── Neon Purple ─────────────────────────────────────────────────── */
--neon-purple: #8b5cf6;
--neon-purple-dim: rgba(139, 92, 246, 0.6);
--neon-purple-muted: rgba(139, 92, 246, 0.12);
/* ── Gradients ───────────────────────────────────────────────────── */
--grad-accent: linear-gradient(135deg, #00d4ff 0%, #8b5cf6 100%);
--grad-accent-subtle: linear-gradient(135deg, rgba(0, 212, 255, 0.12) 0%, rgba(139, 92, 246, 0.12) 100%);
--grad-bg-radial: radial-gradient(ellipse 120% 80% at 20% 0%, rgba(0, 212, 255, 0.06) 0%, transparent 60%),
radial-gradient(ellipse 100% 70% at 80% 10%, rgba(139, 92, 246, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 80% 60% at 50% 80%, rgba(0, 100, 180, 0.04) 0%, transparent 70%);
/* ── Text ────────────────────────────────────────────────────────── */
--text: #ccd6f6;
--text-bright: #e8f0fe;
--text-dim: #8892b0;
--text-muted: #4a5572;
/* ── Borders ─────────────────────────────────────────────────────── */
--line: rgba(255, 255, 255, 0.07);
--line-bright: rgba(0, 212, 255, 0.25);
--line-subtle: rgba(255, 255, 255, 0.04);
/* ── Glow Effects ────────────────────────────────────────────────── */
--glow-cyan: 0 0 20px rgba(0, 212, 255, 0.25), 0 0 60px rgba(0, 212, 255, 0.08);
--glow-purple: 0 0 20px rgba(139, 92, 246, 0.25), 0 0 60px rgba(139, 92, 246, 0.08);
--glow-active: 0 0 30px rgba(0, 212, 255, 0.2), 0 2px 0 rgba(0, 212, 255, 0.4);
/* ── Shadows ─────────────────────────────────────────────────────── */
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.4);
--shadow-md: 0 8px 32px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 24px 64px rgba(0, 0, 0, 0.65);
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.45), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
/* ── Border Radii ────────────────────────────────────────────────── */
--radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
/* ── Layout ──────────────────────────────────────────────────────── */
--sidebar-w: 240px;
--topbar-h: 56px;
--bottom-nav-h: 64px;
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
/* ── Typography ──────────────────────────────────────────────────── */
--font-display: 'Space Grotesk', 'Noto Sans KR', system-ui, serif;
--font-body: 'Inter', 'Noto Sans KR', system-ui, sans-serif;
/* ── Easing ──────────────────────────────────────────────────────── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* ── Page Accent Colors ──────────────────────────────────────────── */
--accent-home: #00d4ff;
--accent-blog: #c084fc;
--accent-lotto: #34d399;
--accent-stock: #38bdf8;
--accent-realestate: #f43f5e;
--accent-subscription: #f43f5e;
--accent-todo: #f472b6;
--accent-travel: #fb923c;
--accent-lab: #fbbf24;
/* ── Convenience alias ───────────────────────────────────────────── */
--accent: var(--neon-cyan);
/* ── Legacy / Backward-compat aliases ───────────────────────────── */
--muted: var(--text-dim);
--fg: var(--text-bright);
--surface-hover: var(--surface-raised);
--line-strong: var(--line-bright);
--accent-strong: var(--neon-purple);
--shadow-inset: 0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
/* ── Base Document ───────────────────────────────────────────────────── */
html {
height: 100%;
scroll-behavior: smooth;
text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(2000px 1200px at 15% 5%, rgba(247, 168, 165, 0.25), transparent 70%),
radial-gradient(1600px 1200px at 85% 0%, rgba(253, 212, 177, 0.18), transparent 70%),
radial-gradient(1500px 800px at 50% 50%, rgba(151, 201, 170, 0.1), transparent 80%),
#0f0d12;
overflow: hidden;
background-color: var(--bg);
background-image: var(--grad-bg-radial);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (max-width: 768px) {
body {
background-attachment: scroll;
}
/* ── Scrollbar ───────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.22);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.45);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.22) transparent;
}
/* ── Selection ───────────────────────────────────────────────────────── */
::selection {
background: rgba(0, 212, 255, 0.2);
color: var(--text-bright);
}
/* ── Focus ───────────────────────────────────────────────────────────── */
:focus-visible {
outline: 1.5px solid rgba(0, 212, 255, 0.8);
outline-offset: 3px;
border-radius: var(--radius-xs);
}
/* ── Typography ──────────────────────────────────────────────────────── */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
font-weight: 600;
color: var(--text-bright);
line-height: 1.25;
letter-spacing: -0.02em;
}
p {
line-height: 1.75;
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--neon-cyan);
}
/* ── Images ──────────────────────────────────────────────────────────── */
img {
max-width: 100%;
display: block;
}
/* ── Form Elements ───────────────────────────────────────────────────── */
button {
font-family: var(--font-body);
}
input,
textarea,
select {
font-family: var(--font-body);
background: var(--surface-card);
border: 1px solid var(--line);
color: var(--text);
border-radius: var(--radius-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--neon-cyan-dim);
box-shadow: 0 0 0 3px var(--neon-cyan-muted);
outline: none;
}
select option {
background: var(--bg-secondary);
color: var(--text);
}
/* ── Responsive Mobile Override ──────────────────────────────────────── */
@media (max-width: 768px) {
body {
overflow: auto;
background-attachment: scroll;
padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
}
}

View File

@@ -0,0 +1,437 @@
/* src/pages/agent-office/AgentOffice.css */
/* ===== Root Layout ===== */
.ao-root {
display: flex;
flex-direction: column;
height: 100vh;
background: #0f172a;
color: #e2e8f0;
font-family: 'Courier New', monospace;
overflow: hidden;
}
/* ===== Top Bar ===== */
.ao-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
background: #1a1a2e;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.ao-topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.ao-topbar-title {
font-weight: bold;
font-size: 15px;
color: #8b5cf6;
}
.ao-topbar-status { font-size: 11px; }
.ao-topbar-status.connected { color: #22c55e; }
.ao-topbar-status.disconnected { color: #ef4444; }
/* ===== Main Area ===== */
.ao-main {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
/* ===== Grid Wrap ===== */
.ao-grid-wrap {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
width: 100%;
}
/* ===== Agent Card ===== */
.ao-card {
position: relative;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
cursor: pointer;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
font-family: inherit;
color: inherit;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.ao-card:hover {
transform: translateY(-2px);
border-color: var(--card-accent, #60a5fa);
}
.ao-card.active {
border-color: var(--card-accent, #60a5fa);
box-shadow: 0 0 0 2px var(--card-accent, #60a5fa);
}
.ao-card.placeholder {
opacity: 0.55;
}
.ao-card-dot {
position: absolute;
top: 8px;
left: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
box-shadow: 0 0 0 2px #0f172a;
}
.ao-card-dot.working { background: #22c55e; }
.ao-card-dot.error { background: #ef4444; }
.ao-card-dot.waiting_approval { background: #f59e0b; }
.ao-card-dot.pulse {
animation: ao-pulse 1.6s ease-in-out infinite;
}
@keyframes ao-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.2); }
}
.ao-card-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ef4444;
color: #fff;
border-radius: 9px;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.ao-card-image {
width: 100%;
aspect-ratio: 941 / 1672;
border-radius: 8px;
overflow: hidden;
background: #0f172a;
margin-bottom: 8px;
}
.ao-card-image img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.ao-card-name {
font-size: 12px;
color: #e2e8f0;
text-align: center;
}
/* ===== Side Panel ===== */
.ao-sidepanel {
width: 320px;
background: #111;
border-left: 1px solid #333;
display: flex;
flex-direction: column;
flex-shrink: 0;
animation: slideIn 0.2s ease-out;
}
.ao-sidepanel-initial {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.ao-sidepanel-header {
padding: 12px;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.ao-sidepanel-agent {
display: flex;
align-items: center;
gap: 10px;
}
.ao-sidepanel-icon {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid #444;
overflow: hidden;
flex-shrink: 0;
}
.ao-sidepanel-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ao-sidepanel-name {
font-weight: bold;
font-size: 14px;
}
.ao-sidepanel-state {
font-size: 11px;
color: #94a3b8;
}
.ao-sidepanel-actions {
display: flex;
align-items: center;
gap: 4px;
}
.ao-sidepanel-close {
background: none;
border: none;
color: #666;
font-size: 24px;
cursor: pointer;
padding: 0 4px;
}
.ao-sidepanel-close:hover { color: #fff; }
/* 전체 화면 토글 — 모바일 전용 (데스크톱에서는 숨김) */
.ao-sidepanel-expand {
display: none;
background: none;
border: none;
color: #888;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.ao-sidepanel-expand:hover { color: #fff; }
/* Tabs */
.ao-sidepanel-tabs {
display: flex;
border-bottom: 1px solid #333;
}
.ao-sidepanel-tab {
flex: 1;
padding: 8px 4px;
text-align: center;
font-size: 12px;
font-family: inherit;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #666;
cursor: pointer;
}
.ao-sidepanel-tab.active {
color: #8b5cf6;
border-bottom-color: #8b5cf6;
font-weight: bold;
}
.ao-sidepanel-tab:hover { color: #aaa; }
.ao-sidepanel-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* ===== Command Tab ===== */
.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
.ao-section { margin-bottom: 4px; }
.ao-section-label {
color: #888;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.ao-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ao-btn-quick {
background: #2a2a4e;
color: #8b5cf6;
border: 1px solid #4c1d95;
padding: 5px 12px;
border-radius: 4px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.ao-btn-quick:hover { background: #3a3a5e; }
.ao-btn-quick:disabled { opacity: 0.4; }
.ao-param-row { display: flex; gap: 6px; }
.ao-input {
flex: 1;
background: #1a1a2e;
border: 1px solid #333;
color: #fff;
padding: 7px 10px;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
}
.ao-input::placeholder { color: #555; }
.ao-btn-send {
background: #4c1d95;
color: #fff;
border: none;
padding: 7px 14px;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
}
.ao-btn-send:hover { background: #5b21b6; }
.ao-btn-send:disabled { opacity: 0.4; }
.ao-approval-card {
background: rgba(146,64,14,0.15);
border: 1px solid #92400e;
border-radius: 6px;
padding: 10px;
}
.ao-approval-title { color: #fbbf24; font-size: 12px; font-weight: bold; margin-bottom: 4px; }
.ao-approval-desc { color: #ddd; font-size: 11px; margin-bottom: 8px; word-break: break-all; }
.ao-approval-actions { display: flex; gap: 6px; }
.ao-btn-approve {
flex: 1; background: #065f46; color: #fff; border: none;
padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
.ao-btn-reject {
flex: 1; background: #7f1d1d; color: #fff; border: none;
padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
/* ===== Task Tab ===== */
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
.ao-task-item { background: #1a1a2e; border-radius: 4px; padding: 8px; cursor: pointer; }
.ao-task-item:hover { background: #222240; }
.ao-task-header { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
.ao-task-badge { padding: 1px 6px; border-radius: 3px; font-size: 10px; }
.ao-task-time { color: #666; font-size: 10px; }
.ao-task-result {
margin-top: 6px; background: #0d0d1a; padding: 6px; border-radius: 3px;
font-size: 10px; color: #aaa; max-height: 200px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
}
/* ===== Token Tab ===== */
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
.ao-token-period { display: flex; gap: 4px; }
.ao-btn-period {
flex: 1; background: #1a1a2e; color: #888; border: 1px solid #333;
padding: 5px; border-radius: 4px; font-size: 11px; font-family: inherit; cursor: pointer;
}
.ao-btn-period.active { background: #4c1d95; color: #fff; border-color: #4c1d95; }
.ao-token-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.ao-token-card { background: #1a1a2e; border-radius: 6px; padding: 10px; text-align: center; }
.ao-token-label { font-size: 10px; color: #888; text-transform: uppercase; margin-bottom: 4px; }
.ao-token-value { font-size: 18px; font-weight: bold; color: #fff; }
.ao-token-bar { margin-top: 4px; }
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
.ao-token-bar-track { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1a1a2e; }
.ao-token-bar-fill.input { background: #3b82f6; }
.ao-token-bar-fill.output { background: #8b5cf6; }
.ao-token-bar-legend { display: flex; gap: 12px; font-size: 10px; color: #888; margin-top: 4px; }
.ao-token-bar-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.ao-token-bar-legend .dot.input { background: #3b82f6; }
.ao-token-bar-legend .dot.output { background: #8b5cf6; }
.ao-token-detail { display: flex; justify-content: space-between; font-size: 10px; color: #666; }
/* ===== Log Tab ===== */
.ao-log-tab {
max-height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
}
.ao-log-item {
display: flex; gap: 6px; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1a1a2e;
}
.ao-log-time { color: #555; min-width: 60px; }
.ao-log-level { min-width: 48px; font-weight: bold; }
.ao-log-msg { color: #ccc; word-break: break-all; }
/* ===== Common ===== */
.ao-empty {
color: #94a3b8;
text-align: center;
padding: 24px;
font-size: 13px;
line-height: 1.6;
}
/* ===== Mobile (< 768px) ===== */
@media (max-width: 768px) {
.ao-grid-wrap { padding: 12px; }
.ao-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.ao-main { flex-direction: column; }
.ao-sidepanel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
width: 100%;
height: 55vh;
max-height: 55vh;
border-left: none;
border-top: 1px solid #333;
border-radius: 16px 16px 0 0;
animation: slideUp 0.25s ease-out;
z-index: 100;
transition: height 0.25s ease, max-height 0.25s ease, border-radius 0.25s ease;
}
/* 전체 화면으로 확장 */
.ao-sidepanel.expanded {
top: 0;
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
border-top: none;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.ao-sidepanel-expand { display: inline-block; }
.ao-sidepanel-header { padding: 8px 12px; }
.ao-sidepanel-header::before {
content: '';
display: block;
width: 32px;
height: 4px;
background: #555;
border-radius: 2px;
margin: 0 auto 8px;
}
.ao-sidepanel-tab { font-size: 11px; padding: 6px 2px; }
.ao-sidepanel-content {
padding: 8px 12px;
padding-bottom: env(safe-area-inset-bottom, 16px);
}
}

View File

@@ -0,0 +1,75 @@
// src/pages/agent-office/AgentOffice.jsx
import { useState, useCallback } from 'react';
import { useAgentManager } from './hooks/useAgentManager.js';
import { AGENT_META } from './constants.js';
import TopBar from './components/TopBar.jsx';
import AgentGrid from './components/AgentGrid.jsx';
import SidePanel from './components/SidePanel.jsx';
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
import './AgentOffice.css';
export default function AgentOffice() {
const {
agents, pendingTasks, notifications, connected, reconnectAttempt,
refreshTrigger, clearNotifications
} = useAgentManager();
// selectedAgent: null | active agent id | "placeholder-N"
const [selectedAgent, setSelectedAgent] = useState(null);
const handleSelectAgent = useCallback((agentId) => {
setSelectedAgent(agentId);
clearNotifications(agentId);
}, [clearNotifications]);
const handleSelectPlaceholder = useCallback((placeholderKey) => {
setSelectedAgent(placeholderKey);
}, []);
const handleClose = useCallback(() => {
setSelectedAgent(null);
}, []);
const pendingTask = selectedAgent && AGENT_META[selectedAgent]
? pendingTasks.find(t => t.agent_id === selectedAgent)
: null;
let rightPanel;
if (selectedAgent === null) {
rightPanel = <EmptyDetailPanel variant="initial" />;
} else if (selectedAgent.startsWith('placeholder-')) {
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
} else {
rightPanel = (
<SidePanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
pendingTask={pendingTask}
onClose={handleClose}
refreshTrigger={refreshTrigger}
/>
);
}
return (
<div className="ao-root">
<TopBar connected={connected} reconnectAttempt={reconnectAttempt} />
<div className="ao-main">
<div className="ao-grid-wrap">
<AgentGrid
agents={agents}
notifications={notifications}
selectedAgent={selectedAgent}
onSelectAgent={handleSelectAgent}
onSelectPlaceholder={handleSelectPlaceholder}
/>
</div>
{rightPanel}
</div>
</div>
);
}
export function Component() {
return <AgentOffice />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,30 @@
// src/pages/agent-office/components/AgentCard.jsx
import { AGENT_META, STATE_COLORS, DEFAULT_STATE_COLOR } from '../constants.js';
export default function AgentCard({ agentId, agentState, notificationCount = 0, active = false, onClick }) {
const meta = AGENT_META[agentId];
if (!meta) return null;
const state = agentState?.state || 'idle';
const stateInfo = STATE_COLORS[state] || DEFAULT_STATE_COLOR;
const dotClass = `ao-card-dot ${state}${stateInfo.pulse ? ' pulse' : ''}`;
const badgeText = notificationCount > 9 ? '9+' : String(notificationCount);
return (
<button
type="button"
className={`ao-card${active ? ' active' : ''}`}
onClick={onClick}
style={{ '--card-accent': meta.color }}
>
<span className={dotClass} title={state} />
{notificationCount > 0 && (
<span className="ao-card-badge">{badgeText}</span>
)}
<div className="ao-card-image">
<img src={meta.image} alt={meta.displayName} />
</div>
<div className="ao-card-name">{meta.displayName}</div>
</button>
);
}

View File

@@ -0,0 +1,76 @@
// src/pages/agent-office/components/AgentCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import AgentCard from './AgentCard.jsx';
describe('AgentCard', () => {
it('에이전트의 displayName을 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />);
expect(screen.getByText('주식 트레이더')).toBeInTheDocument();
});
it('working 상태일 때 dot에 working 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('working');
});
it('working 상태에서는 pulse 클래스도 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('pulse');
});
it('idle 상태에는 pulse 클래스 부여하지 않음', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).not.toHaveClass('pulse');
});
it('agentState 없으면 idle로 fallback', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={undefined} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('idle');
});
it('notificationCount > 0이면 뱃지 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={3} onClick={() => {}} />);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('notificationCount === 0이면 뱃지 없음', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
);
expect(container.querySelector('.ao-card-badge')).toBeNull();
});
it('notificationCount > 9이면 9+ 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={15} onClick={() => {}} />);
expect(screen.getByText('9+')).toBeInTheDocument();
});
it('클릭 시 onClick 호출', () => {
const onClick = vi.fn();
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={onClick} />
);
fireEvent.click(container.querySelector('.ao-card'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('active prop 시 카드에 active 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} active onClick={() => {}} />
);
expect(container.querySelector('.ao-card')).toHaveClass('active');
});
});

View File

@@ -0,0 +1,33 @@
// src/pages/agent-office/components/AgentGrid.jsx
import { GRID_SLOTS } from '../constants.js';
import AgentCard from './AgentCard.jsx';
import PlaceholderCard from './PlaceholderCard.jsx';
export default function AgentGrid({ agents, notifications, selectedAgent, onSelectAgent, onSelectPlaceholder }) {
return (
<div className="ao-grid">
{GRID_SLOTS.map((slot, idx) => {
if (slot.agentId === null) {
const placeholderKey = `placeholder-${idx}`;
return (
<PlaceholderCard
key={placeholderKey}
active={selectedAgent === placeholderKey}
onClick={() => onSelectPlaceholder(placeholderKey)}
/>
);
}
return (
<AgentCard
key={slot.agentId}
agentId={slot.agentId}
agentState={agents[slot.agentId]}
notificationCount={notifications[slot.agentId] || 0}
active={selectedAgent === slot.agentId}
onClick={() => onSelectAgent(slot.agentId)}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,166 @@
// src/pages/agent-office/components/CommandTab.jsx
import { useState } from 'react';
import { sendAgentCommand, approveAgentTask } from '../../../api';
const QUICK_ACTIONS = {
stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }],
music: [{ action: 'credits', label: 'Check Credits' }],
insta: [{ action: 'extract', label: 'Extract News' }, { action: 'collect_trends', label: 'Collect Trends' }],
realestate: [{ action: 'dashboard', label: 'Dashboard' }],
lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }]
};
const PARAM_ACTIONS = {
stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' },
music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' },
insta: { action: 'render', label: 'Render Slate', placeholder: 'keyword_id (예: 42)' },
realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' },
lotto: null
};
export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) {
const [customAction, setCustomAction] = useState('');
const [customParams, setCustomParams] = useState('');
const [paramInput, setParamInput] = useState('');
const [loading, setLoading] = useState(false);
const quickActions = QUICK_ACTIONS[agentId] || [];
const paramAction = PARAM_ACTIONS[agentId];
const handleQuickAction = async (action) => {
setLoading(true);
try {
const result = await sendAgentCommand(agentId, action, {});
onCommandResult?.(result);
} finally {
setLoading(false);
}
};
const handleParamAction = async () => {
if (!paramAction || !paramInput.trim()) return;
setLoading(true);
try {
let params = {};
if (paramAction.action === 'compose') {
params = { prompt: paramInput };
} else if (paramAction.action === 'research') {
params = { keyword: paramInput };
} else if (paramAction.action === 'render') {
params = { keyword_id: parseInt(paramInput, 10) };
} else {
try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; }
}
const result = await sendAgentCommand(agentId, paramAction.action, params);
onCommandResult?.(result);
setParamInput('');
} finally {
setLoading(false);
}
};
const handleCustomCommand = async () => {
if (!customAction.trim()) return;
setLoading(true);
try {
let params = {};
if (customParams.trim()) {
try { params = JSON.parse(customParams); } catch { params = { value: customParams }; }
}
const result = await sendAgentCommand(agentId, customAction, params);
onCommandResult?.(result);
setCustomAction('');
setCustomParams('');
} finally {
setLoading(false);
}
};
const handleApproval = async (approved) => {
if (!pendingTask) return;
setLoading(true);
try {
await approveAgentTask(agentId, pendingTask.id, approved);
} finally {
setLoading(false);
}
};
return (
<div className="ao-command-tab">
{/* 승인 대기 UI */}
{agentState === 'waiting_approval' && pendingTask && (
<div className="ao-approval-card">
<div className="ao-approval-title">Awaiting Approval</div>
<div className="ao-approval-desc">{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}</div>
<div className="ao-approval-actions">
<button className="ao-btn-approve" onClick={() => handleApproval(true)} disabled={loading}>Approve</button>
<button className="ao-btn-reject" onClick={() => handleApproval(false)} disabled={loading}>Reject</button>
</div>
</div>
)}
{/* Quick Actions */}
<div className="ao-section">
<div className="ao-section-label">Quick Actions</div>
<div className="ao-quick-actions">
{quickActions.map(qa => (
<button
key={qa.action}
className="ao-btn-quick"
onClick={() => handleQuickAction(qa.action)}
disabled={loading}
>
{qa.label}
</button>
))}
</div>
</div>
{/* Parameterized Action */}
{paramAction && (
<div className="ao-section">
<div className="ao-section-label">{paramAction.label}</div>
<div className="ao-param-row">
<input
className="ao-input"
value={paramInput}
onChange={e => setParamInput(e.target.value)}
placeholder={paramAction.placeholder}
onKeyDown={e => e.key === 'Enter' && handleParamAction()}
/>
<button className="ao-btn-send" onClick={handleParamAction} disabled={loading || !paramInput.trim()}>
Send
</button>
</div>
</div>
)}
{/* Custom Command */}
<div className="ao-section">
<div className="ao-section-label">Custom Command</div>
<input
className="ao-input"
value={customAction}
onChange={e => setCustomAction(e.target.value)}
placeholder="Action name"
/>
<input
className="ao-input"
value={customParams}
onChange={e => setCustomParams(e.target.value)}
placeholder='Parameters (JSON)'
style={{ marginTop: 4 }}
/>
<button
className="ao-btn-send"
onClick={handleCustomCommand}
disabled={loading || !customAction.trim()}
style={{ marginTop: 4, width: '100%' }}
>
Send Command
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import CommandTab from './CommandTab.jsx';
vi.mock('../../../api', () => ({
sendAgentCommand: vi.fn(),
approveAgentTask: vi.fn(),
}));
describe('CommandTab approval card', () => {
const samplePendingTask = {
id: 'task-123',
task_type: 'lotto_briefing',
input_data: { draw_no: 1234 },
};
it('agentState가 waiting_approval이고 pendingTask가 있으면 승인 카드를 표시', () => {
render(
<CommandTab
agentId="lotto"
agentState="waiting_approval"
pendingTask={samplePendingTask}
/>
);
expect(screen.getByText('Awaiting Approval')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
});
it('agentState가 working이면 승인 카드를 표시하지 않음', () => {
render(
<CommandTab
agentId="lotto"
agentState="working"
pendingTask={samplePendingTask}
/>
);
expect(screen.queryByText('Awaiting Approval')).toBeNull();
});
it('pendingTask가 null이면 waiting_approval이어도 승인 카드를 표시하지 않음', () => {
render(
<CommandTab
agentId="lotto"
agentState="waiting_approval"
pendingTask={null}
/>
);
expect(screen.queryByText('Awaiting Approval')).toBeNull();
});
});

View File

@@ -0,0 +1,41 @@
// src/pages/agent-office/components/EmptyDetailPanel.jsx
import { PLACEHOLDER_IMAGE } from '../constants.js';
export default function EmptyDetailPanel({ variant = 'initial', onClose }) {
if (variant === 'placeholder') {
return (
<div className="ao-sidepanel">
<div className="ao-sidepanel-header">
<div className="ao-sidepanel-agent">
<div className="ao-sidepanel-icon">
<img src={PLACEHOLDER_IMAGE} alt="준비 중" />
</div>
<div className="ao-sidepanel-info">
<div className="ao-sidepanel-name">준비 </div>
<div className="ao-sidepanel-state"> 미고용 슬롯</div>
</div>
</div>
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
</div>
<div className="ao-sidepanel-content">
<p className="ao-empty">
자리는 아직 비어 있어요.<br />
준비 중인 에이전트입니다.
</p>
</div>
</div>
);
}
// variant === 'initial'
return (
<div className="ao-sidepanel ao-sidepanel-initial">
<div className="ao-sidepanel-content">
<p className="ao-empty">
왼쪽 그리드에서<br />
에이전트를 선택하세요.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
// src/pages/agent-office/components/LogTab.jsx
import { useState, useEffect, useRef } from 'react';
import { getAgentLogs } from '../../../api';
const LEVEL_STYLE = {
info: { color: '#60a5fa' },
warning: { color: '#fbbf24' },
error: { color: '#ef4444' }
};
export default function LogTab({ agentId, refreshTrigger }) {
const [logs, setLogs] = useState([]);
const scrollRef = useRef(null);
useEffect(() => {
let cancelled = false;
getAgentLogs(agentId, 50).then(data => {
if (!cancelled) setLogs(Array.isArray(data) ? data : (data?.logs || []));
});
return () => { cancelled = true; };
}, [agentId, refreshTrigger]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
return (
<div className="ao-log-tab" ref={scrollRef}>
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
{logs.map((log, i) => {
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return (
<div key={log.id || i} className="ao-log-item">
<span className="ao-log-time">{time}</span>
<span className="ao-log-level" style={style}>[{log.level}]</span>
<span className="ao-log-msg">{log.message}</span>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,17 @@
// src/pages/agent-office/components/PlaceholderCard.jsx
import { PLACEHOLDER_IMAGE, PLACEHOLDER_LABEL } from '../constants.js';
export default function PlaceholderCard({ active = false, onClick }) {
return (
<button
type="button"
className={`ao-card placeholder${active ? ' active' : ''}`}
onClick={onClick}
>
<div className="ao-card-image">
<img src={PLACEHOLDER_IMAGE} alt={PLACEHOLDER_LABEL} />
</div>
<div className="ao-card-name">{PLACEHOLDER_LABEL}</div>
</button>
);
}

View File

@@ -0,0 +1,74 @@
// src/pages/agent-office/components/SidePanel.jsx
import { useState } from 'react';
import { AGENT_META } from '../constants.js';
import CommandTab from './CommandTab.jsx';
import TaskTab from './TaskTab.jsx';
import TokenTab from './TokenTab.jsx';
import LogTab from './LogTab.jsx';
const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
const [activeTab, setActiveTab] = useState('Commands');
const [expanded, setExpanded] = useState(false);
const meta = AGENT_META[agentId];
if (!meta) return null;
const stateText = agentState?.detail
? `${agentState.state} - ${agentState.detail}`
: agentState?.state || 'unknown';
return (
<div className={`ao-sidepanel${expanded ? ' expanded' : ''}`}>
<div className="ao-sidepanel-header">
<div className="ao-sidepanel-agent">
<div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
<img src={meta.image} alt={meta.displayName} />
</div>
<div className="ao-sidepanel-info">
<div className="ao-sidepanel-name">{meta.displayName}</div>
<div className="ao-sidepanel-state"> {stateText}</div>
</div>
</div>
<div className="ao-sidepanel-actions">
<button
className="ao-sidepanel-expand"
onClick={() => setExpanded(e => !e)}
aria-label={expanded ? '축소' : '전체 화면'}
title={expanded ? '축소' : '전체 화면'}
>
{expanded ? '⤡' : '⤢'}
</button>
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
</div>
</div>
<div className="ao-sidepanel-tabs">
{TABS.map(tab => (
<button
key={tab}
className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
))}
</div>
<div className="ao-sidepanel-content">
{activeTab === 'Commands' && (
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
)}
{activeTab === 'Tasks' && (
<TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
)}
{activeTab === 'Tokens' && (
<TokenTab agentId={agentId} />
)}
{activeTab === 'Logs' && (
<LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
// src/pages/agent-office/components/TaskTab.jsx
import { useState, useEffect } from 'react';
import { getAgentTasks } from '../../../api';
const STATUS_STYLE = {
succeeded: { bg: '#065f46', fg: '#34d399' },
failed: { bg: '#7f1d1d', fg: '#fca5a5' },
working: { bg: '#1e3a5f', fg: '#60a5fa' },
pending: { bg: '#92400e', fg: '#fbbf24' },
approved: { bg: '#065f46', fg: '#34d399' },
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
};
// result_data는 백엔드에서 dict 또는 string 둘 다 올 수 있다.
// React child로 직접 못 그리는 객체는 stringify, string은 parse 시도 후 pretty,
// 둘 다 안 되면 원본 문자열을 그대로 표시.
function formatResultData(rd) {
if (rd == null) return '';
if (typeof rd === 'object') {
try { return JSON.stringify(rd, null, 2); }
catch { return String(rd); }
}
if (typeof rd === 'string') {
try { return JSON.stringify(JSON.parse(rd), null, 2); }
catch { return rd; }
}
return String(rd);
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
}
export default function TaskTab({ agentId, refreshTrigger }) {
const [tasks, setTasks] = useState([]);
const [expanded, setExpanded] = useState(null);
useEffect(() => {
let cancelled = false;
getAgentTasks(agentId, 20).then(data => {
if (!cancelled) setTasks(Array.isArray(data) ? data : (data?.tasks || []));
});
return () => { cancelled = true; };
}, [agentId, refreshTrigger]);
return (
<div className="ao-task-tab">
{tasks.length === 0 && <div className="ao-empty">No tasks yet</div>}
{tasks.map(task => {
const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending;
return (
<div key={task.id} className="ao-task-item" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>
<div className="ao-task-header">
<span className="ao-task-type">{task.task_type}</span>
<span className="ao-task-badge" style={{ background: style.bg, color: style.fg }}>{task.status}</span>
<span className="ao-task-time">{formatTime(task.created_at)}</span>
</div>
{expanded === task.id && task.result_data && (
<pre className="ao-task-result">
{formatResultData(task.result_data)}
</pre>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import TaskTab from './TaskTab.jsx';
const mockGetAgentTasks = vi.fn();
vi.mock('../../../api', () => ({
getAgentTasks: (...args) => mockGetAgentTasks(...args),
}));
describe('TaskTab response shape handling', () => {
it('백엔드가 {tasks: [...]} 객체로 wrapping해서 응답해도 .map 깨지지 않음', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [
{ id: 't1', task_type: 'compose', status: 'succeeded', created_at: '2026-05-18T08:00:00Z' },
{ id: 't2', task_type: 'fetch_news', status: 'failed', created_at: '2026-05-18T08:05:00Z' },
],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('compose')).toBeInTheDocument());
expect(screen.getByText('fetch_news')).toBeInTheDocument();
});
it('백엔드가 bare array를 반환해도 동작 (backward compat)', async () => {
mockGetAgentTasks.mockResolvedValueOnce([
{ id: 't9', task_type: 'compose', status: 'succeeded', created_at: '2026-05-18T08:00:00Z' },
]);
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('compose')).toBeInTheDocument());
});
it('응답이 falsy/empty이면 No tasks yet 표시', async () => {
mockGetAgentTasks.mockResolvedValueOnce({ tasks: [] });
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('No tasks yet')).toBeInTheDocument());
});
it('task 클릭 → result_data가 객체일 때도 stringify되어 안전하게 렌더', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_compose',
task_type: 'compose',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: { music_task_id: 'abc-123', tracks: [] },
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('compose');
fireEvent.click(row.closest('.ao-task-item'));
const pre = await screen.findByText(/music_task_id/);
expect(pre.textContent).toContain('"music_task_id": "abc-123"');
expect(pre.textContent).toContain('"tracks": []');
});
it('task 클릭 → result_data가 JSON 문자열일 때 parse 후 pretty 렌더', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_str',
task_type: 'compose',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: '{"foo":"bar"}',
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('compose');
fireEvent.click(row.closest('.ao-task-item'));
const pre = await screen.findByText(/foo/);
expect(pre.textContent).toContain('"foo": "bar"');
});
it('task 클릭 → result_data가 plain string이면 그대로 표시 (parse 실패 fallback)', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_plain',
task_type: 'fetch_news',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: 'Just a log line',
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('fetch_news');
fireEvent.click(row.closest('.ao-task-item'));
expect(await screen.findByText('Just a log line')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
// src/pages/agent-office/components/TokenTab.jsx
import { useState, useEffect } from 'react';
import { getAgentTokenUsage } from '../../../api';
export default function TokenTab({ agentId }) {
const [usage, setUsage] = useState(null);
const [days, setDays] = useState(1);
useEffect(() => {
let cancelled = false;
getAgentTokenUsage(agentId, days).then(data => {
if (!cancelled) setUsage(data);
});
return () => { cancelled = true; };
}, [agentId, days]);
if (!usage) return <div className="ao-empty">Loading...</div>;
const inputTokens = usage.input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const cacheRead = usage.cache_read || 0;
const cacheWrite = usage.cache_write || 0;
const total = inputTokens + outputTokens;
const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0;
return (
<div className="ao-token-tab">
<div className="ao-token-period">
{[1, 7, 30].map(d => (
<button
key={d}
className={`ao-btn-period ${days === d ? 'active' : ''}`}
onClick={() => setDays(d)}
>
{d === 1 ? 'Today' : d === 7 ? '7 Days' : '30 Days'}
</button>
))}
</div>
<div className="ao-token-grid">
<div className="ao-token-card">
<div className="ao-token-label">Input Tokens</div>
<div className="ao-token-value">{inputTokens.toLocaleString()}</div>
</div>
<div className="ao-token-card">
<div className="ao-token-label">Output Tokens</div>
<div className="ao-token-value">{outputTokens.toLocaleString()}</div>
</div>
<div className="ao-token-card">
<div className="ao-token-label">Total</div>
<div className="ao-token-value">{total.toLocaleString()}</div>
</div>
<div className="ao-token-card">
<div className="ao-token-label">Cache Hit Rate</div>
<div className="ao-token-value">{cacheHitRate}%</div>
</div>
</div>
{/* Simple bar chart */}
<div className="ao-token-bar">
<div className="ao-token-bar-label">Input vs Output</div>
<div className="ao-token-bar-track">
<div
className="ao-token-bar-fill input"
style={{ width: total > 0 ? `${(inputTokens / total) * 100}%` : '0%' }}
/>
<div
className="ao-token-bar-fill output"
style={{ width: total > 0 ? `${(outputTokens / total) * 100}%` : '0%' }}
/>
</div>
<div className="ao-token-bar-legend">
<span><span className="dot input" />Input</span>
<span><span className="dot output" />Output</span>
</div>
</div>
{cacheRead > 0 && (
<div className="ao-token-detail">
<span>Cache Read: {cacheRead.toLocaleString()}</span>
<span>Cache Write: {cacheWrite.toLocaleString()}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
// src/pages/agent-office/components/TopBar.jsx
export default function TopBar({ connected, reconnectAttempt = 0 }) {
let statusText;
if (connected) {
statusText = 'Connected';
} else if (reconnectAttempt === 0) {
statusText = 'Connecting…';
} else {
statusText = `Disconnected · 재연결 시도 #${reconnectAttempt}`;
}
return (
<div className="ao-topbar">
<div className="ao-topbar-left">
<span className="ao-topbar-title">Agent Office</span>
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
{statusText}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
// src/pages/agent-office/constants.js
import stockImg from './assets/agent_stock.webp';
import musicImg from './assets/agent_music.webp';
import instaImg from './assets/agent_insta.webp';
import realestateImg from './assets/agent_realestate.webp';
import lottoImg from './assets/agent_lotto.webp';
import undeterminedImg from './assets/agent_undetermined.webp';
export const AGENT_META = {
stock: { displayName: '주식 트레이더', color: '#4488cc', image: stockImg },
music: { displayName: '음악 프로듀서', color: '#44aa88', image: musicImg },
insta: { displayName: '인스타 큐레이터', color: '#d97706', image: instaImg },
realestate: { displayName: '청약 애널리스트', color: '#c026d3', image: realestateImg },
lotto: { displayName: '로또 큐레이터', color: '#ef4444', image: lottoImg },
};
// 3x3 슬롯 (좌→우, 위→아래). 처음 5칸은 active, 나머지 4칸은 placeholder.
export const GRID_SLOTS = [
{ agentId: 'stock' },
{ agentId: 'music' },
{ agentId: 'insta' },
{ agentId: 'realestate' },
{ agentId: 'lotto' },
{ agentId: null },
{ agentId: null },
{ agentId: null },
{ agentId: null },
];
export const ACTIVE_AGENT_IDS = GRID_SLOTS.filter(s => s.agentId !== null).map(s => s.agentId);
export const PLACEHOLDER_IMAGE = undeterminedImg;
export const PLACEHOLDER_LABEL = '준비 중';
// 상태 → dot 색상 매핑 (AgentCard에서 공유)
export const STATE_COLORS = {
idle: { color: '#6b7280', pulse: false },
working: { color: '#22c55e', pulse: true },
error: { color: '#ef4444', pulse: false },
waiting_approval: { color: '#f59e0b', pulse: true },
};
export const DEFAULT_STATE_COLOR = STATE_COLORS.idle;

View File

@@ -0,0 +1,35 @@
// src/pages/agent-office/constants.test.js
import { describe, it, expect } from 'vitest';
import { AGENT_META, GRID_SLOTS, ACTIVE_AGENT_IDS } from './constants.js';
describe('agent-office constants', () => {
it('5명의 active 에이전트가 정의됨', () => {
expect(ACTIVE_AGENT_IDS).toEqual(['stock', 'music', 'insta', 'realestate', 'lotto']);
});
it('각 active 에이전트에 displayName/color/image 메타가 있음', () => {
for (const id of ACTIVE_AGENT_IDS) {
expect(AGENT_META[id]).toBeDefined();
expect(AGENT_META[id].displayName).toBeTruthy();
expect(AGENT_META[id].color).toMatch(/^#/);
expect(AGENT_META[id].image).toBeTruthy();
}
});
it('blog 메타는 존재하지 않음 (insta로 대체됨)', () => {
expect(AGENT_META.blog).toBeUndefined();
});
it('GRID_SLOTS는 9칸, 처음 5칸은 active 에이전트', () => {
expect(GRID_SLOTS).toHaveLength(9);
expect(GRID_SLOTS.slice(0, 5).map(s => s.agentId)).toEqual(
['stock', 'music', 'insta', 'realestate', 'lotto']
);
});
it('GRID_SLOTS의 마지막 4칸은 placeholder (agentId=null)', () => {
for (const slot of GRID_SLOTS.slice(5)) {
expect(slot.agentId).toBeNull();
}
});
});

View File

@@ -0,0 +1,133 @@
// src/pages/agent-office/hooks/useAgentManager.js
import { useState, useEffect, useRef, useCallback } from 'react';
// Exponential backoff with cap. 1s → 2s → 4s → 8s → 16s → 30s (cap)
const WS_BACKOFF_BASE_MS = 1000;
const WS_BACKOFF_CAP_MS = 30000;
const WS_BACKOFF_MAX_EXP = 5;
export function useAgentManager() {
const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } }
const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}]
const [notifications, setNotifications] = useState({}); // { agentId: count }
const [connected, setConnected] = useState(false);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용
const wsRef = useRef(null);
const reconnectRef = useRef(null);
const connectRef = useRef(null);
const attemptRef = useRef(0);
const connect = useCallback(() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
attemptRef.current = 0;
setReconnectAttempt(0);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'init': {
// 에이전트 초기 상태 세팅
const agentMap = {};
for (const a of msg.agents) {
agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id };
}
setAgents(agentMap);
setPendingTasks(msg.pending || []);
break;
}
case 'agent_state':
setAgents(prev => ({
...prev,
[msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id }
}));
// idle 전환 시 데이터 리프레시
if (msg.state === 'idle') {
setRefreshTrigger(n => n + 1);
}
break;
case 'task_complete':
setRefreshTrigger(n => n + 1);
break;
case 'notification':
setNotifications(prev => ({
...prev,
[msg.agent]: (prev[msg.agent] || 0) + 1
}));
break;
case 'command_result':
// 사이드 패널에서 처리
break;
default:
break;
}
};
ws.onclose = () => {
setConnected(false);
const exp = Math.min(attemptRef.current, WS_BACKOFF_MAX_EXP);
const delay = Math.min(WS_BACKOFF_BASE_MS * 2 ** exp, WS_BACKOFF_CAP_MS);
attemptRef.current += 1;
setReconnectAttempt(attemptRef.current);
reconnectRef.current = setTimeout(() => connectRef.current?.(), delay);
};
// onerror fires before onclose; swallow so the browser doesn't print an
// unhandled-error pair for every retry. onclose still runs and schedules
// the next attempt.
ws.onerror = () => {};
}, []);
useEffect(() => {
connectRef.current = connect;
}, [connect]);
useEffect(() => {
connect();
return () => {
if (wsRef.current) wsRef.current.close();
if (reconnectRef.current) clearTimeout(reconnectRef.current);
};
}, [connect]);
const sendCommand = useCallback((agent, action, params = {}) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
}
}, []);
const sendApproval = useCallback((agent, taskId, approved) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
}
}, []);
const clearNotifications = useCallback((agentId) => {
setNotifications(prev => ({ ...prev, [agentId]: 0 }));
}, []);
return {
agents,
pendingTasks,
notifications,
connected,
reconnectAttempt,
refreshTrigger,
sendCommand,
sendApproval,
clearNotifications
};
}

View File

@@ -10,11 +10,35 @@
align-items: center;
}
.blog-header__actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.blog-new-btn {
align-self: flex-start;
border: 1px solid rgba(192, 132, 252, 0.45);
background: rgba(192, 132, 252, 0.1);
color: var(--accent-blog);
border-radius: 999px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.blog-new-btn:hover {
background: rgba(192, 132, 252, 0.2);
border-color: rgba(192, 132, 252, 0.7);
}
.blog-kicker {
text-transform: uppercase;
letter-spacing: 0.3em;
font-size: 12px;
color: var(--accent);
color: var(--accent-blog);
margin: 0 0 10px;
}
@@ -56,23 +80,27 @@
.blog-toggle-list {
display: none;
position: fixed;
top: 20px;
left: 20px;
/* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */
bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
right: 24px;
top: auto;
left: auto;
z-index: 1000;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--line);
background: rgba(10, 12, 20, 0.8);
color: var(--text);
border: 1px solid rgba(192, 132, 252, 0.45);
background: rgba(10, 12, 20, 0.88);
color: var(--accent-blog);
font-size: 18px;
cursor: pointer;
backdrop-filter: blur(10px);
backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.blog-toggle-list:hover {
transform: scale(1.1);
transform: scale(1.08);
opacity: 0.9;
}
@@ -98,29 +126,87 @@
}
.blog-category-chip.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.2);
border-color: rgba(192, 132, 252, 0.55);
background: rgba(192, 132, 252, 0.15);
color: var(--accent-blog);
}
.blog-list__item {
.blog-list__item-wrap {
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius-md);
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--shadow-inset);
overflow: hidden;
}
.blog-list__item-wrap:hover {
border-color: var(--line-strong);
background: var(--surface-raised);
box-shadow: var(--shadow-sm), var(--shadow-inset);
}
.blog-list__item-wrap.is-active {
border-color: rgba(192, 132, 252, 0.5);
box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset);
background: rgba(192, 132, 252, 0.05);
}
.blog-list__item-btn {
width: 100%;
padding: 16px;
border-radius: 18px;
text-align: left;
cursor: pointer;
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
background: transparent;
border: none;
color: inherit;
}
.blog-list__item:hover {
border-color: rgba(255, 255, 255, 0.25);
.blog-list__actions {
display: flex;
gap: 6px;
padding: 0 12px 10px;
}
.blog-list__item.is-active {
border-color: rgba(247, 168, 165, 0.6);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
.blog-list__action-btn {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.blog-list__action-btn:hover {
border-color: var(--accent-blog);
color: var(--accent-blog);
}
.blog-list__action-btn--del:hover {
border-color: #f04452;
color: #f04452;
}
.blog-article__edit-btn {
font-size: 11px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
text-transform: none;
letter-spacing: 0;
transition: border-color 0.15s, color 0.15s;
}
.blog-article__edit-btn:hover {
border-color: var(--accent-blog);
color: var(--accent-blog);
}
.blog-pagination {
@@ -168,14 +254,15 @@
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
color: var(--accent-blog);
}
.blog-article {
border: 1px solid var(--line);
border-radius: 24px;
border-radius: var(--radius-lg);
background: rgba(9, 10, 16, 0.65);
padding: 24px;
padding: 28px;
box-shadow: var(--shadow-md), var(--shadow-inset);
}
.blog-article__meta {
@@ -277,8 +364,9 @@
.md-quote {
margin: 0 0 14px;
padding: 12px 16px;
border-left: 3px solid rgba(247, 168, 165, 0.6);
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid rgba(192, 132, 252, 0.5);
background: rgba(192, 132, 252, 0.05);
border-radius: 0 8px 8px 0;
color: var(--muted);
}
@@ -363,22 +451,27 @@
color: var(--muted);
}
@media (max-width: 900px) {
.blog-header,
.blog-grid {
@media (max-width: 768px) {
.blog-header {
grid-template-columns: 1fr;
}
.blog-header__actions {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.blog-toggle-list {
display: block;
}
.blog-list {
display: none;
gap: 10px;
}
.blog-list.is-visible {
display: block;
position: fixed;
top: 0;
left: 0;
@@ -396,6 +489,13 @@
.blog-list.is-visible .blog-category-filter {
margin-bottom: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
}
.blog-list.is-visible .blog-category-filter > * {
flex-shrink: 0;
}
.blog-list.is-visible .blog-pagination {
@@ -404,23 +504,19 @@
.blog-article {
width: 100%;
padding: 18px;
}
}
@media (max-width: 768px) {
.blog-header h1 {
font-size: clamp(24px, 6vw, 32px);
}
.blog-grid {
grid-template-columns: 1fr;
gap: 18px;
}
.blog-list {
gap: 10px;
}
.blog-list__item {
.blog-list__item-btn {
padding: 14px;
}
@@ -432,10 +528,6 @@
font-size: 12px;
}
.blog-article {
padding: 18px;
}
.blog-article__body h1 {
font-size: 24px;
}
@@ -469,3 +561,222 @@
padding: 16px;
}
}
/* ── 블로그 에디터 모달 ──────────────────────────────────────────────────── */
.blog-editor-overlay {
position: fixed;
inset: 0;
background: rgba(4, 6, 14, 0.75);
backdrop-filter: blur(6px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.blog-editor {
background: #0c0f1e;
border: 1px solid rgba(192, 132, 252, 0.25);
border-radius: var(--radius-xl, 20px);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(192, 132, 252, 0.06);
width: 100%;
max-width: 860px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.blog-editor__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 14px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.blog-editor__heading {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--accent-blog);
letter-spacing: 0.04em;
}
.blog-editor__close {
background: transparent;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
line-height: 1;
transition: color 0.15s;
}
.blog-editor__close:hover {
color: var(--text-bright, #fff);
}
.blog-editor__title-input {
margin: 14px 24px 0;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
color: var(--text-bright, #f8f3ee);
font-size: 16px;
font-weight: 600;
outline: none;
transition: border-color 0.15s;
flex-shrink: 0;
}
.blog-editor__title-input:focus {
border-color: rgba(192, 132, 252, 0.5);
}
.blog-editor__title-input::placeholder {
color: var(--muted);
}
.blog-editor__tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 24px 0;
flex-shrink: 0;
}
.blog-editor__tab-bar {
display: flex;
gap: 4px;
padding: 12px 24px 0;
flex-shrink: 0;
}
.blog-editor__tab {
padding: 5px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.blog-editor__tab.is-active {
border-color: rgba(192, 132, 252, 0.55);
background: rgba(192, 132, 252, 0.12);
color: var(--accent-blog);
}
.blog-editor__textarea {
flex: 1;
margin: 10px 24px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
color: var(--text-bright, #f8f3ee);
font-size: 14px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
line-height: 1.75;
resize: none;
outline: none;
min-height: 320px;
transition: border-color 0.15s;
}
.blog-editor__textarea:focus {
border-color: rgba(192, 132, 252, 0.4);
}
.blog-editor__preview {
flex: 1;
margin: 10px 24px 0;
padding: 14px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--line);
border-radius: var(--radius-md, 10px);
overflow-y: auto;
min-height: 320px;
}
.blog-editor__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 24px 18px;
border-top: 1px solid var(--line);
flex-shrink: 0;
margin-top: 12px;
}
.blog-editor__save-btn {
border-color: rgba(192, 132, 252, 0.55) !important;
background: rgba(192, 132, 252, 0.15) !important;
color: var(--accent-blog) !important;
}
.blog-editor__save-btn:hover:not(:disabled) {
background: rgba(192, 132, 252, 0.25) !important;
}
.blog-editor__save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 768px) {
.blog-editor-overlay {
align-items: flex-end;
padding: 0;
}
.blog-editor {
max-width: 100%;
max-height: 95vh;
border-radius: var(--radius-xl, 20px) var(--radius-xl, 20px) 0 0;
}
.blog-editor__title-input,
.blog-editor__tag-row,
.blog-editor__tab-bar,
.blog-editor__textarea,
.blog-editor__preview {
margin-left: 16px;
margin-right: 16px;
}
.blog-editor__header,
.blog-editor__footer {
padding-left: 16px;
padding-right: 16px;
}
.blog-new-btn {
align-self: stretch;
text-align: center;
}
/* 태그/카테고리 필터 가로 스크롤 */
.blog-categories,
.blog-category-list {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
gap: 8px;
}
.blog-categories > *,
.blog-category-list > * {
flex-shrink: 0;
}
}

View File

@@ -1,7 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getBlogPosts } from '../../data/blog';
import {
getBlogPostsApi,
createBlogPost,
updateBlogPost,
deleteBlogPost,
} from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Blog.css';
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
const renderInline = (text) => {
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
const pattern =
@@ -122,9 +132,7 @@ const renderMarkdown = (body) => {
flushList();
if (!line.trim()) {
return;
}
if (!line.trim()) return;
if (line.startsWith('###### ')) {
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
@@ -193,78 +201,281 @@ const renderMarkdown = (body) => {
});
};
// ── 블로그 에디터 모달 ────────────────────────────────────────────────────────
const PRESET_TAGS = ['일상', '개발', '공부', '아이디어', '기타'];
const BlogEditor = ({ post, onSave, onClose }) => {
const [title, setTitle] = useState(post?.title || '');
const [tags, setTags] = useState(post?.tags || []);
const [body, setBody] = useState(post?.body || '');
const [showPreview, setShowPreview] = useState(false);
const [saving, setSaving] = useState(false);
const textareaRef = useRef(null);
// Tab 키로 들여쓰기 삽입
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const el = textareaRef.current;
const start = el.selectionStart;
const end = el.selectionEnd;
const next = body.substring(0, start) + ' ' + body.substring(end);
setBody(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 2;
});
}
};
const toggleTag = (tag) => {
setTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const today = new Date().toISOString().slice(0, 10);
const excerpt = body
.split(/\r?\n/)
.find((l) => l.trim() && !l.startsWith('#'))
?.trim()
.slice(0, 120) || '';
await onSave({
title: title.trim(),
tags,
body,
excerpt,
date: post?.date || today,
});
onClose();
} finally {
setSaving(false);
}
};
// ESC 키로 닫기
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="blog-editor-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="blog-editor">
<div className="blog-editor__header">
<h2 className="blog-editor__heading">
{post?.id ? '글 수정' : '새 글 쓰기'}
</h2>
<button type="button" className="blog-editor__close" onClick={onClose} aria-label="닫기">
</button>
</div>
<input
className="blog-editor__title-input"
type="text"
placeholder="제목을 입력하세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
<div className="blog-editor__tag-row">
{PRESET_TAGS.map((tag) => (
<button
key={tag}
type="button"
className={`blog-category-chip${tags.includes(tag) ? ' is-active' : ''}`}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
<div className="blog-editor__tab-bar">
<button
type="button"
className={`blog-editor__tab${!showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(false)}
>
편집
</button>
<button
type="button"
className={`blog-editor__tab${showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(true)}
>
미리보기
</button>
</div>
{showPreview ? (
<div className="blog-article__body blog-editor__preview">
{body
? renderMarkdown(body)
: <p style={{ color: 'var(--muted)' }}>본문을 입력하면 여기에 미리보기가 표시됩니다.</p>
}
</div>
) : (
<textarea
ref={textareaRef}
className="blog-editor__textarea"
placeholder="마크다운으로 글을 작성하세요...&#10;&#10;예시:&#10;# 제목&#10;## 소제목&#10;**굵게** *기울임* `코드`"
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
/>
)}
<div className="blog-editor__footer">
<button type="button" className="button" onClick={onClose}>
취소
</button>
<button
type="button"
className="button blog-editor__save-btn"
onClick={handleSave}
disabled={saving || !title.trim()}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
);
};
// ── 메인 Blog 컴포넌트 ───────────────────────────────────────────────────────
const Blog = () => {
const posts = useMemo(() => getBlogPosts(), []);
const staticPosts = useMemo(() => getBlogPosts(), []);
const [apiPosts, setApiPosts] = useState([]);
const [apiError, setApiError] = useState(false);
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
const [isEditorOpen, setIsEditorOpen] = useState(false);
const fetchPosts = useCallback(() => {
return getBlogPostsApi()
.then((data) => {
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
})
.catch(() => setApiError(true));
}, []);
// API 글 불러오기
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
// 정적 + API 글 병합 (API 글이 앞에 표시)
const allPosts = useMemo(() => {
const combined = [...apiPosts, ...staticPosts];
return combined.sort((a, b) => {
const aDate = Date.parse(a.date || '') || 0;
const bDate = Date.parse(b.date || '') || 0;
return bDate - aDate;
});
}, [apiPosts, staticPosts]);
const categoryNames = ['일상', '개발', '공부', '아이디어'];
const categorized = useMemo(() => {
const map = new Map(categoryNames.map((name) => [name, []]));
const misc = [];
posts.forEach((post) => {
allPosts.forEach((post) => {
const matched = categoryNames.find((name) => post.tags.includes(name));
if (matched) {
map.get(matched).push(post);
} else {
misc.push(post);
}
if (matched) map.get(matched).push(post);
else misc.push(post);
});
return {
categories: categoryNames.map((name) => ({
name,
items: map.get(name),
})),
categories: categoryNames.map((name) => ({ name, items: map.get(name) })),
misc,
};
}, [posts]);
}, [allPosts]);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [page, setPage] = useState(1);
const [showList, setShowList] = useState(false);
const pageSize = 10;
const filteredPosts = useMemo(() => {
if (selectedCategory === '전체') return posts;
if (selectedCategory === '전체') return allPosts;
if (selectedCategory === '기타') return categorized.misc;
return posts.filter((post) => post.tags.includes(selectedCategory));
}, [posts, categorized.misc, selectedCategory]);
return allPosts.filter((post) => post.tags.includes(selectedCategory));
}, [allPosts, categorized.misc, selectedCategory]);
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
const activePost =
pagedPosts.find((post) => post.slug === activeSlug) || pagedPosts[0];
const activePost = pagedPosts.find((p) => p.slug === activeSlug) || pagedPosts[0];
useEffect(() => { if (page > totalPages) setPage(1); }, [page, totalPages]);
useEffect(() => {
if (page > totalPages) {
setPage(1);
}
}, [page, totalPages]);
useEffect(() => {
if (!pagedPosts.find((post) => post.slug === activeSlug)) {
if (!pagedPosts.find((p) => p.slug === activeSlug)) {
setActiveSlug(pagedPosts[0]?.slug);
}
}, [pagedPosts, activeSlug]);
useEffect(() => { setPage(1); }, [selectedCategory]);
useEffect(() => {
setPage(1);
}, [selectedCategory]);
// 에디터 저장 핸들러
const handleSave = useCallback(async (data) => {
if (editorPost?.id) {
// 수정
const updated = await updateBlogPost(editorPost.id, data);
setApiPosts((prev) =>
prev.map((p) =>
p.id === editorPost.id ? { ...p, ...updated, slug: `api-${updated.id ?? editorPost.id}` } : p
)
);
} else {
// 새 글
const created = await createBlogPost(data);
setApiPosts((prev) => [{ ...created, slug: `api-${created.id}` }, ...prev]);
setActiveSlug(`api-${created.id}`);
}
}, [editorPost]);
// 삭제 핸들러
const handleDelete = useCallback(async (post) => {
if (!window.confirm(`"${post.title}" 글을 삭제하시겠습니까?`)) return;
await deleteBlogPost(post.id);
setApiPosts((prev) => prev.filter((p) => p.id !== post.id));
if (activeSlug === post.slug) setActiveSlug(null);
}, [activeSlug]);
const openNewEditor = () => { setEditorPost({}); setIsEditorOpen(true); };
const openEditEditor = (post) => { setEditorPost(post); setIsEditorOpen(true); };
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
return (
<PullToRefresh onRefresh={fetchPosts}>
<div className="blog">
<header className="blog-header">
<div>
<p className="blog-kicker">Journal</p>
<h1>개인 블로그</h1>
<p className="blog-sub">
마크다운 파일을 추가하면 자동으로 글이 목록에 추가됩니다.
글을 작성하고 태그를 달아 정리하세요.
</p>
</div>
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
<div className="blog-header__actions">
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
</div>
<button type="button" className="blog-new-btn" onClick={openNewEditor}>
+ 쓰기
</button>
</div>
</header>
@@ -283,32 +494,54 @@ const Blog = () => {
<button
key={name}
type="button"
className={`blog-category-chip${
selectedCategory === name ? ' is-active' : ''
}`}
className={`blog-category-chip${selectedCategory === name ? ' is-active' : ''}`}
onClick={() => setSelectedCategory(name)}
>
{name}
</button>
))}
</div>
{pagedPosts.map((post) => (
<button
<div
key={post.slug}
type="button"
className={`blog-list__item${
post.slug === activeSlug ? ' is-active' : ''
}`}
onClick={() => {
setActiveSlug(post.slug);
setShowList(false); // 모바일에서 글 선택 시 리스트 숨김
}}
className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
<button
type="button"
className="blog-list__item-btn"
onClick={() => {
setActiveSlug(post.slug);
setShowList(false);
}}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
{post.id && (
<div className="blog-list__actions">
<button
type="button"
className="blog-list__action-btn"
title="수정"
onClick={() => openEditEditor(post)}
>
편집
</button>
<button
type="button"
className="blog-list__action-btn blog-list__action-btn--del"
title="삭제"
onClick={() => handleDelete(post)}
>
삭제
</button>
</div>
)}
</div>
))}
<div className="blog-pagination">
<button
type="button"
@@ -318,35 +551,41 @@ const Blog = () => {
>
이전
</button>
<span className="blog-page-indicator">
{page} / {totalPages}
</span>
<span className="blog-page-indicator">{page} / {totalPages}</span>
<button
type="button"
className="blog-page-btn"
onClick={() =>
setPage((prev) => Math.min(totalPages, prev + 1))
}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
>
다음
</button>
</div>
</aside>
<article className="blog-article">
{activePost ? (
<>
<div className="blog-article__meta">
<span>{activePost.date || '작성일 미정'}</span>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
))}
</span>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">{tag}</span>
))}
</span>
)}
{activePost.id && (
<button
type="button"
className="blog-article__edit-btn"
onClick={() => openEditEditor(activePost)}
>
편집
</button>
)}
</div>
</div>
<div className="blog-article__body">
{renderMarkdown(activePost.body)}
@@ -354,8 +593,9 @@ const Blog = () => {
</>
) : (
<p className="blog-empty">
아직 작성된 글이 없습니다. `src/content/blog` 마크다운 파일을
추가해 주세요.
{apiError
? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
: '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
</p>
)}
</article>
@@ -376,9 +616,7 @@ const Blog = () => {
>
<div className="blog-category-card__head">
<span>{group.name}</span>
<span className="blog-category-card__count">
{group.items.length}
</span>
<span className="blog-category-card__count">{group.items.length}</span>
</div>
<div className="blog-category-card__list">
{group.items.length ? (
@@ -386,9 +624,7 @@ const Blog = () => {
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">
아직 글이 없습니다.
</span>
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
@@ -400,9 +636,7 @@ const Blog = () => {
>
<div className="blog-category-card__head">
<span>기타</span>
<span className="blog-category-card__count">
{categorized.misc.length}
</span>
<span className="blog-category-card__count">{categorized.misc.length}</span>
</div>
<div className="blog-category-card__list">
{categorized.misc.length ? (
@@ -410,15 +644,24 @@ const Blog = () => {
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">
아직 글이 없습니다.
</span>
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
</div>
</section>
{isEditorOpen && (
<BlogEditor
post={editorPost}
onSave={handleSave}
onClose={closeEditor}
/>
)}
<FAB onClick={openNewEditor} label="글 쓰기" />
</div>
</PullToRefresh>
);
};

View File

@@ -0,0 +1,448 @@
/* ── DayCalc ─────────────────────────────────────────────────────────────── */
.daycalc {
max-width: 860px;
margin: 0 auto;
padding: 32px 24px 64px;
display: flex;
flex-direction: column;
gap: 28px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.daycalc__back {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-decoration: none;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
margin-bottom: 12px;
transition: color 0.2s, border-color 0.2s;
}
.daycalc__back:hover {
color: var(--neon-cyan);
border-color: var(--line-bright);
}
.daycalc__kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-lab);
margin-bottom: 6px;
}
.daycalc__header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
margin-bottom: 6px;
}
.daycalc__desc {
font-size: 14px;
color: var(--text-dim);
margin: 0;
}
/* ── Input Section ───────────────────────────────────────────────────────── */
.daycalc__input-section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.daycalc__date-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: center;
}
.daycalc__date-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.daycalc__date-field label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.daycalc__date-field input[type="date"] {
padding: 10px 14px;
font-size: 15px;
font-family: var(--font-display);
font-weight: 500;
color: var(--text-bright);
background: var(--bg-secondary);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
outline: none;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
color-scheme: dark;
}
.daycalc__date-field input[type="date"]:focus {
border-color: var(--neon-cyan-dim);
box-shadow: 0 0 0 3px var(--neon-cyan-muted);
}
.daycalc__date-fmt {
font-size: 12px;
color: var(--text-dim);
padding-left: 2px;
}
.daycalc__arrow {
font-size: 22px;
font-weight: 300;
color: var(--text-muted);
text-align: center;
user-select: none;
padding-top: 20px;
}
.daycalc__arrow .fwd { color: var(--neon-cyan); }
.daycalc__arrow .bwd { color: var(--neon-purple); }
/* ── Presets ─────────────────────────────────────────────────────────────── */
.daycalc__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.daycalc__presets-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-right: 4px;
}
.daycalc__preset-btn {
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--line);
border-radius: 20px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: border-color 0.18s, color 0.18s, background 0.18s;
}
.daycalc__preset-btn:hover {
border-color: var(--neon-cyan-dim);
color: var(--neon-cyan);
background: var(--neon-cyan-muted);
}
.daycalc__preset-btn--clear {
color: var(--text-muted);
border-color: transparent;
}
.daycalc__preset-btn--clear:hover {
border-color: rgba(248, 113, 113, 0.4);
color: #f87171;
background: rgba(248, 113, 113, 0.08);
}
/* ── Tabs ────────────────────────────────────────────────────────────────── */
.daycalc__tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
padding-bottom: 0;
}
.daycalc__tab {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
border: none;
background: transparent;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.18s, border-color 0.18s;
}
.daycalc__tab:hover {
color: var(--text-bright);
}
.daycalc__tab.is-active {
color: var(--accent-lab);
border-bottom-color: var(--accent-lab);
}
/* ── Result Section ──────────────────────────────────────────────────────── */
.daycalc__result {
display: flex;
flex-direction: column;
gap: 20px;
}
.daycalc__big-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.daycalc__big-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 24px 20px;
text-align: center;
transition: border-color 0.2s;
}
.daycalc__big-card--primary {
border-color: rgba(251, 191, 36, 0.3);
background: rgba(251, 191, 36, 0.04);
}
.daycalc__big-num {
font-family: var(--font-display);
font-size: 40px;
font-weight: 700;
color: var(--text-bright);
line-height: 1;
margin-bottom: 4px;
}
.daycalc__big-card--primary .daycalc__big-num {
color: var(--accent-lab);
text-shadow: 0 0 24px rgba(251, 191, 36, 0.4);
}
.daycalc__big-label {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.daycalc__big-sub {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── Breakdown ───────────────────────────────────────────────────────────── */
.daycalc__breakdown {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 20px 24px;
}
.daycalc__breakdown h3 {
font-size: 13px;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 16px;
}
.daycalc__breakdown-row {
display: flex;
gap: 32px;
align-items: baseline;
margin-bottom: 14px;
}
.daycalc__breakdown-item {
display: flex;
align-items: baseline;
gap: 5px;
}
.daycalc__breakdown-num {
font-family: var(--font-display);
font-size: 32px;
font-weight: 700;
color: var(--text-bright);
}
.daycalc__breakdown-unit {
font-size: 14px;
color: var(--text-dim);
font-weight: 500;
}
.daycalc__breakdown-summary {
font-size: 13px;
color: var(--text-muted);
border-top: 1px solid var(--line-subtle);
padding-top: 12px;
margin: 0;
}
/* ── Milestones ──────────────────────────────────────────────────────────── */
.daycalc__milestones {
display: flex;
flex-direction: column;
gap: 16px;
}
.daycalc__milestones-desc {
font-size: 13px;
color: var(--text-dim);
margin: 0;
}
.daycalc__milestones-desc strong {
color: var(--text-bright);
}
.daycalc__milestone-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.daycalc__milestone-row {
display: grid;
grid-template-columns: 100px 1fr auto;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
transition: border-color 0.18s;
}
.daycalc__milestone-row:hover {
border-color: rgba(251, 191, 36, 0.3);
}
.daycalc__milestone-row.is-past {
opacity: 0.45;
}
.daycalc__milestone-row.is-today {
border-color: var(--accent-lab);
background: rgba(251, 191, 36, 0.06);
opacity: 1;
}
.daycalc__milestone-badge {
font-family: var(--font-display);
font-size: 14px;
font-weight: 700;
color: var(--accent-lab);
}
.daycalc__milestone-row.is-past .daycalc__milestone-badge {
color: var(--text-muted);
}
.daycalc__milestone-date {
font-size: 14px;
color: var(--text);
}
.daycalc__milestone-dday {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-align: right;
white-space: nowrap;
}
.daycalc__milestone-row.is-today .daycalc__milestone-dday {
color: var(--accent-lab);
}
/* ── Empty State ─────────────────────────────────────────────────────────── */
.daycalc__empty {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.daycalc__empty-icon {
font-size: 48px;
margin-bottom: 4px;
}
.daycalc__empty p {
font-size: 14px;
color: var(--text-muted);
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.daycalc {
padding: 20px 16px 48px;
gap: 20px;
}
.daycalc__date-row {
grid-template-columns: 1fr;
gap: 12px;
}
.daycalc__arrow {
display: none;
}
.daycalc__big-cards {
grid-template-columns: 1fr;
}
.daycalc__milestone-row {
grid-template-columns: 80px 1fr auto;
gap: 10px;
}
.daycalc__breakdown-row {
gap: 20px;
}
.daycalc__breakdown-num {
font-size: 26px;
}
}

View File

@@ -0,0 +1,275 @@
import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import './DayCalc.css';
const today = () => new Date().toISOString().slice(0, 10);
const fmt = (d) => {
if (!d) return '';
const [y, m, day] = d.split('-');
return `${y}${parseInt(m)}${parseInt(day)}`;
};
// 두 날짜 사이 diff 계산
const calcDiff = (from, to) => {
const f = new Date(from);
const t = new Date(to);
const totalMs = t - f;
const totalDays = Math.round(totalMs / 86400000);
// 연/월/일 분리 계산
let years = t.getFullYear() - f.getFullYear();
let months = t.getMonth() - f.getMonth();
let days = t.getDate() - f.getDate();
if (days < 0) {
months -= 1;
const prevMonth = new Date(t.getFullYear(), t.getMonth(), 0);
days += prevMonth.getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
const totalMonths = years * 12 + months;
const weeks = Math.floor(Math.abs(totalDays) / 7);
const remDays = Math.abs(totalDays) % 7;
return { totalDays, totalMonths, years, months, days, weeks, remDays };
};
// 특정 날짜로부터 N일 후 날짜 계산
const addDays = (dateStr, n) => {
const d = new Date(dateStr);
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
};
// 기념일 체크포인트
const MILESTONES = [100, 200, 365, 500, 730, 1000, 1461, 2000, 3000];
const QUICK_PRESETS = [
{ label: '오늘 기준', offset: 0 },
{ label: '1주 후', offset: 7 },
{ label: '1개월 후', offset: 30 },
{ label: '3개월 후', offset: 90 },
{ label: '6개월 후', offset: 180 },
{ label: '1년 후', offset: 365 },
];
const DayCalc = () => {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(today());
const [tab, setTab] = useState('diff'); // diff | milestone | future
const result = useMemo(() => {
if (!fromDate || !toDate) return null;
try {
return calcDiff(fromDate, toDate);
} catch {
return null;
}
}, [fromDate, toDate]);
const milestones = useMemo(() => {
if (!fromDate) return [];
return MILESTONES.map((n) => ({
days: n,
date: addDays(fromDate, n - 1),
}));
}, [fromDate]);
const isForward = result ? result.totalDays >= 0 : true;
const applyPreset = (offset) => {
setToDate(addDays(today(), offset));
};
return (
<div className="daycalc">
<header className="daycalc__header">
<div>
<Link to="/lab" className="daycalc__back"> Lab</Link>
<p className="daycalc__kicker">Lab · 날짜 도구</p>
<h1>일수 계산기</h1>
<p className="daycalc__desc"> 날짜 사이의 기간과 기념일 날짜를 계산합니다.</p>
</div>
</header>
{/* 날짜 입력 */}
<section className="daycalc__input-section">
<div className="daycalc__date-row">
<div className="daycalc__date-field">
<label>시작일</label>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
max={toDate || undefined}
/>
{fromDate && <span className="daycalc__date-fmt">{fmt(fromDate)}</span>}
</div>
<div className="daycalc__arrow">
{result
? <span className={isForward ? 'fwd' : 'bwd'}>{isForward ? '→' : '←'}</span>
: <span></span>}
</div>
<div className="daycalc__date-field">
<label>종료일</label>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
/>
{toDate && <span className="daycalc__date-fmt">{fmt(toDate)}</span>}
</div>
</div>
{/* 빠른 종료일 설정 */}
<div className="daycalc__presets">
<span className="daycalc__presets-label">빠른 설정</span>
{QUICK_PRESETS.map((p) => (
<button
key={p.label}
className="daycalc__preset-btn"
onClick={() => applyPreset(p.offset)}
>
{p.label}
</button>
))}
<button
className="daycalc__preset-btn daycalc__preset-btn--clear"
onClick={() => { setFromDate(''); setToDate(today()); }}
>
초기화
</button>
</div>
</section>
{/* 결과 탭 */}
{fromDate && (
<>
<div className="daycalc__tabs">
{[
{ id: 'diff', label: '기간 계산' },
{ id: 'milestone', label: '기념일' },
].map((t) => (
<button
key={t.id}
className={`daycalc__tab${tab === t.id ? ' is-active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
{/* 기간 계산 탭 */}
{tab === 'diff' && result && (
<section className="daycalc__result">
{/* 메인 수치 */}
<div className="daycalc__big-cards">
<div className="daycalc__big-card daycalc__big-card--primary">
<p className="daycalc__big-num">
{isForward ? '+' : ''}{result.totalDays.toLocaleString()}
</p>
<p className="daycalc__big-label"></p>
<p className="daycalc__big-sub">
{isForward ? '경과' : '이전'}
</p>
</div>
<div className="daycalc__big-card">
<p className="daycalc__big-num">{result.totalMonths.toLocaleString()}</p>
<p className="daycalc__big-label">개월</p>
<p className="daycalc__big-sub"> 개월 </p>
</div>
<div className="daycalc__big-card">
<p className="daycalc__big-num">{result.weeks.toLocaleString()}</p>
<p className="daycalc__big-label"> {result.remDays}</p>
<p className="daycalc__big-sub"> 단위</p>
</div>
</div>
{/* 세부 분해 */}
<div className="daycalc__breakdown">
<h3>상세 기간</h3>
<div className="daycalc__breakdown-row">
{result.years > 0 && (
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.years}</span>
<span className="daycalc__breakdown-unit"></span>
</div>
)}
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.months}</span>
<span className="daycalc__breakdown-unit">개월</span>
</div>
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.days}</span>
<span className="daycalc__breakdown-unit"></span>
</div>
</div>
<p className="daycalc__breakdown-summary">
{fmt(fromDate)} 부터 {fmt(toDate)} 까지
</p>
</div>
</section>
)}
{/* 기념일 탭 */}
{tab === 'milestone' && (
<section className="daycalc__milestones">
<p className="daycalc__milestones-desc">
<strong>{fmt(fromDate)}</strong> 기준으로 기념일 날짜입니다.
</p>
<div className="daycalc__milestone-list">
{milestones.map(({ days, date }) => {
const isPast = date < today();
const isToday = date === today();
const diff = calcDiff(today(), date);
return (
<div
key={days}
className={`daycalc__milestone-row${isPast ? ' is-past' : ''}${isToday ? ' is-today' : ''}`}
>
<div className="daycalc__milestone-badge">
{days < 365
? `D+${days}`
: days % 365 === 0
? `${days / 365}주년`
: `D+${days}`}
</div>
<div className="daycalc__milestone-date">{fmt(date)}</div>
<div className="daycalc__milestone-dday">
{isToday
? '🎉 오늘'
: isPast
? `${Math.abs(diff.totalDays)}일 전`
: `D-${diff.totalDays}`}
</div>
</div>
);
})}
</div>
</section>
)}
</>
)}
{!fromDate && (
<div className="daycalc__empty">
<p className="daycalc__empty-icon">📅</p>
<p>시작일을 입력하면 기간 계산을 시작합니다.</p>
</div>
)}
</div>
);
};
export default DayCalc;

View File

@@ -0,0 +1,196 @@
/* ── Lab Landing Page ────────────────────────────────────────────────────── */
.lab {
max-width: 1000px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 40px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.lab__header {
display: flex;
flex-direction: column;
gap: 8px;
}
.lab__kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-lab);
}
.lab__header h1 {
font-size: 36px;
font-weight: 700;
color: var(--text-bright);
line-height: 1.1;
}
.lab__desc {
font-size: 14px;
color: var(--text-dim);
max-width: 560px;
margin: 0;
}
/* ── Grid ────────────────────────────────────────────────────────────────── */
.lab__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 18px;
}
/* ── Lab Card ────────────────────────────────────────────────────────────── */
.lab-card {
--card-accent: var(--neon-cyan);
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
transition: border-color 0.22s, transform 0.22s, box-shadow 0.22s;
}
.lab-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, color-mix(in srgb, var(--card-accent) 8%, transparent), transparent 60%);
opacity: 0;
transition: opacity 0.22s;
pointer-events: none;
}
.lab-card:hover {
border-color: color-mix(in srgb, var(--card-accent) 40%, transparent);
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--card-accent) 15%, transparent);
}
.lab-card:hover::before {
opacity: 1;
}
/* ── Card Top ────────────────────────────────────────────────────────────── */
.lab-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.lab-card__icon {
font-size: 32px;
line-height: 1;
filter: drop-shadow(0 0 10px color-mix(in srgb, var(--card-accent) 50%, transparent));
}
.lab-card__status {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
padding: 3px 8px;
border-radius: 20px;
border: 1px solid;
background: rgba(255, 255, 255, 0.04);
}
/* ── Card Body ───────────────────────────────────────────────────────────── */
.lab-card__body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.lab-card__category {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--card-accent) 80%, var(--text-dim));
margin: 0;
}
.lab-card__title {
font-size: 20px;
font-weight: 700;
color: var(--text-bright);
line-height: 1.2;
}
.lab-card__desc {
font-size: 13px;
color: var(--text-dim);
line-height: 1.6;
margin: 4px 0 0;
}
/* ── Card Footer ─────────────────────────────────────────────────────────── */
.lab-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
}
.lab-card__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lab-card__tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line);
color: var(--text-muted);
}
.lab-card__arrow {
font-size: 18px;
color: color-mix(in srgb, var(--card-accent) 60%, transparent);
transition: transform 0.18s, color 0.18s;
flex-shrink: 0;
}
.lab-card:hover .lab-card__arrow {
transform: translateX(4px);
color: var(--card-accent);
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.lab {
padding: 24px 16px 60px;
gap: 28px;
}
.lab__grid {
grid-template-columns: 1fr;
}
.lab__header h1 {
font-size: 28px;
}
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { Link } from 'react-router-dom';
import './EffectLab.css';
const LAB_ITEMS = [
{
id: 'sword-stream',
path: '/lab/sword-stream',
title: 'Sword Stream',
category: '3D · 인터랙티브',
desc: '1,500개의 검 파티클이 마우스를 따라 흐릅니다. 클릭하면 나선형 궤도로 전환됩니다.',
tags: ['Three.js', '파티클', '인터랙티브'],
accent: '#44aadd',
icon: '⚔️',
status: 'live',
},
{
id: 'day-calc',
path: '/lab/day-calc',
title: '일수 계산기',
category: '유틸리티 · 날짜',
desc: '두 날짜 사이의 기간을 일, 주, 월, 연 단위로 계산하고 기념일 날짜를 확인합니다.',
tags: ['날짜', '계산기', '기념일'],
accent: '#fbbf24',
icon: '📅',
status: 'live',
},
{
id: 'agent-office',
path: '/agent-office',
title: 'Agent Office',
category: 'AI · 자동화',
desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
accent: '#8b5cf6',
icon: '🏢',
status: 'wip',
},
];
const STATUS_LABEL = {
live: { label: 'LIVE', color: '#34d399' },
wip: { label: 'WIP', color: '#fbbf24' },
planned: { label: 'PLANNED', color: '#94a3b8' },
};
const LabCard = ({ item }) => {
const st = STATUS_LABEL[item.status] || STATUS_LABEL.planned;
return (
<Link to={item.path} className="lab-card" style={{ '--card-accent': item.accent }}>
<div className="lab-card__top">
<span className="lab-card__icon">{item.icon}</span>
<span className="lab-card__status" style={{ color: st.color, borderColor: `${st.color}40` }}>
{st.label}
</span>
</div>
<div className="lab-card__body">
<p className="lab-card__category">{item.category}</p>
<h2 className="lab-card__title">{item.title}</h2>
<p className="lab-card__desc">{item.desc}</p>
</div>
<div className="lab-card__footer">
<div className="lab-card__tags">
{item.tags.map((t) => (
<span key={t} className="lab-card__tag">{t}</span>
))}
</div>
<span className="lab-card__arrow"></span>
</div>
</Link>
);
};
const EffectLab = () => (
<div className="lab">
<header className="lab__header">
<p className="lab__kicker">STREAM</p>
<h1>Lab</h1>
<p className="lab__desc">
실험적인 UI, 인터랙티브 효과, 유틸리티 도구를 테스트하고 탐구하는 공간입니다.
</p>
</header>
<div className="lab__grid">
{LAB_ITEMS.map((item) => (
<LabCard key={item.id} item={item} />
))}
</div>
</div>
);
export default EffectLab;

View File

@@ -0,0 +1,93 @@
.sword-stream {
position: relative;
width: 100%;
height: 100%;
min-height: calc(100vh - 80px);
overflow: hidden;
background-color: #050505;
border-radius: 20px;
border: 1px solid var(--line);
}
.sword-stream canvas {
display: block;
outline: none;
}
.sword-stream__overlay {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.7);
pointer-events: none;
z-index: 10;
display: flex;
flex-direction: column;
gap: 8px;
}
.sword-stream__back {
pointer-events: all;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: rgba(68, 170, 221, 0.7);
text-decoration: none;
padding: 5px 10px;
border: 1px solid rgba(68, 170, 221, 0.2);
border-radius: 6px;
background: rgba(68, 170, 221, 0.06);
transition: color 0.2s, border-color 0.2s, background 0.2s;
width: fit-content;
}
.sword-stream__back:hover {
color: #44aadd;
border-color: rgba(68, 170, 221, 0.5);
background: rgba(68, 170, 221, 0.12);
}
.sword-stream__overlay h2 {
margin: 0;
font-family: var(--font-display);
font-size: 28px;
color: var(--text);
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
}
.sword-stream__mode {
display: inline-block;
background: rgba(68, 170, 221, 0.1);
border: 1px solid rgba(68, 170, 221, 0.3);
padding: 6px 12px;
border-radius: 99px;
font-size: 12px;
color: #44aadd;
font-weight: 600;
width: fit-content;
}
.sword-stream__mode span {
color: #fff;
}
.sword-stream__overlay p {
margin: 0;
font-size: 14px;
color: var(--muted);
max-width: 400px;
line-height: 1.5;
}
@media (max-width: 768px) {
.sword-stream {
touch-action: none;
}
.sword-stream__overlay {
padding: 12px;
font-size: 12px;
}
}

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import * as THREE from 'three';
import './SwordStream.css';
const SwordStream = () => {
const containerRef = useRef(null);
const requestRef = useRef();
const [mode, setMode] = useState('HOVER'); // HOVER, ORBIT
useEffect(() => {
if (!containerRef.current) return;
const COUNT = 1500;
const SWORD_COLOR = 0x44aadd;
const SWORD_EMISSIVE = 0x112244;
const rand = (min, max) => Math.random() * (max - min) + min;
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050505, 0.002);
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = 80;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
containerRef.current.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
scene.add(pointLight);
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
geometry.rotateX(Math.PI / 2);
const material = new THREE.MeshPhongMaterial({
color: SWORD_COLOR,
emissive: SWORD_EMISSIVE,
shininess: 100,
flatShading: true,
});
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
scene.add(mesh);
const dummy = new THREE.Object3D();
const particles = [];
for (let i = 0; i < COUNT; i++) {
particles.push({
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
vel: new THREE.Vector3(),
acc: new THREE.Vector3(),
angle: rand(0, Math.PI * 2),
radius: rand(15, 30),
speed: rand(0.02, 0.05),
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)),
});
}
const mouse = new THREE.Vector3();
const target = new THREE.Vector3();
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const raycaster = new THREE.Raycaster();
let isMouseDown = false;
let time = 0;
const handleMouseMove = (e) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
raycaster.ray.intersectPlane(mousePlane, mouse);
pointLight.position.copy(mouse);
pointLight.position.z = 20;
};
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
const animate = () => {
requestRef.current = requestAnimationFrame(animate);
time += 0.01;
for (let i = 0; i < COUNT; i++) {
const p = particles[i];
if (isMouseDown) {
p.angle += p.speed + 0.02;
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
target.set(orbitX, orbitY, orbitZ);
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
} else {
const noiseX = Math.sin(time + i * 0.1) * 5;
const noiseY = Math.cos(time + i * 0.1) * 5;
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
}
p.vel.add(p.acc);
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94);
p.pos.add(p.vel);
dummy.position.copy(p.pos);
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
if (p.vel.lengthSq() > 0.01) {
dummy.lookAt(lookPos);
}
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
dummy.scale.set(1, 1, speedScale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
renderer.render(scene, camera);
};
animate();
const handleResize = () => {
if (!containerRef.current) return;
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(requestRef.current);
if (containerRef.current && renderer.domElement) {
containerRef.current.removeChild(renderer.domElement);
}
geometry.dispose();
material.dispose();
renderer.dispose();
};
}, []);
return (
<div className="sword-stream" ref={containerRef}>
<div className="sword-stream__overlay">
<Link to="/lab" className="sword-stream__back"> Lab</Link>
<h2>Sword Stream</h2>
<div className="sword-stream__mode">
MODE: <span>{mode}</span>
</div>
<p>
<strong>Move</strong> to Guide &nbsp;|&nbsp;
<strong>Click &amp; Hold</strong> to Orbit &amp; Charge
</p>
</div>
</div>
);
};
export default SwordStream;

View File

@@ -1,77 +1,113 @@
/* ═══════════════════════════════════════════════════════════════════════
Home Page — Dashboard Style
═══════════════════════════════════════════════════════════════════════ */
.home {
display: grid;
gap: 60px;
gap: 32px;
animation: fadeIn 0.4s var(--ease-out) both;
}
.home > section {
animation: fadeUp 0.7s ease both;
}
.home > section:nth-child(1) {
animation-delay: 0.05s;
}
.home > section:nth-child(2) {
animation-delay: 0.12s;
}
.home > section:nth-child(3) {
animation-delay: 0.18s;
}
/* ── Hero ────────────────────────────────────────────────────────────── */
.home-hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 32px;
align-items: center;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
gap: 24px;
align-items: stretch;
}
.home-hero__kicker {
font-size: 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--accent);
margin: 0 0 12px;
letter-spacing: 0.3em;
color: var(--neon-cyan);
margin: 0 0 14px;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
}
.home-hero__kicker::before {
content: '';
display: block;
width: 24px;
height: 1.5px;
background: var(--neon-cyan);
border-radius: 2px;
box-shadow: 0 0 6px var(--neon-cyan);
}
.home-hero h1 {
font-family: var(--font-display);
font-size: clamp(32px, 4vw, 46px);
font-size: clamp(28px, 3.5vw, 44px);
margin: 0 0 16px;
line-height: 1.2;
color: var(--text-bright);
letter-spacing: -0.03em;
}
.home-hero__lead {
color: var(--muted);
line-height: 1.7;
color: var(--text-dim);
line-height: 1.75;
margin: 0 0 24px;
font-size: 14px;
}
.home-hero__actions {
display: flex;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
/* ── Hero Card ───────────────────────────────────────────────────────── */
.home-hero__card {
background: var(--surface);
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: 24px;
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
box-shadow: var(--shadow-card);
position: relative;
overflow: hidden;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.home-hero__card-title {
.home-hero__card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0.5;
}
.home-hero__card-eyebrow {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.12em;
color: var(--text-muted);
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
font-family: var(--font-display);
}
.home-hero__card-body h2 {
font-family: var(--font-display);
font-size: 24px;
margin: 0 0 12px;
font-size: 20px;
margin: 0 0 8px;
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-hero__card-body p {
margin: 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
}
.home-hero__stats {
@@ -85,81 +121,184 @@
.stat-label {
margin: 0;
color: var(--muted);
font-size: 12px;
color: var(--text-muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.stat-value {
margin: 6px 0 0;
font-weight: 600;
font-size: 18px;
margin: 5px 0 0;
font-weight: 700;
font-size: 20px;
color: var(--text-bright);
line-height: 1;
font-family: var(--font-display);
letter-spacing: -0.03em;
}
.stat-unit {
font-size: 13px;
font-weight: 500;
color: var(--text-dim);
margin-left: 2px;
}
.stat-value--sm {
font-size: 15px;
}
/* ── Section Header ──────────────────────────────────────────────────── */
.home-section__header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
gap: 4px;
margin-bottom: 16px;
}
.home-section__header h2 {
margin: 0;
font-size: 26px;
font-size: clamp(17px, 2vw, 22px);
font-family: var(--font-display);
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-section__header p {
margin: 0;
color: var(--muted);
color: var(--text-muted);
font-size: 13px;
}
/* ── Navigation Cards Grid ───────────────────────────────────────────── */
.home-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.home-card {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
flex-direction: column;
gap: 12px;
text-decoration: none;
color: inherit;
padding: 18px;
border-radius: 18px;
border-radius: var(--radius-md);
border: 1px solid var(--line);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
transition: transform 0.2s ease, border-color 0.2s ease;
background: var(--surface-card);
box-shadow: var(--shadow-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition:
transform 0.22s var(--ease-out),
border-color 0.22s ease,
box-shadow 0.22s ease,
background 0.22s ease;
position: relative;
overflow: hidden;
}
.home-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0;
transition: opacity 0.25s ease;
}
.home-card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
circle at 30% 0%,
rgba(var(--card-accent-rgb, 0, 212, 255), 0.08),
transparent 60%
);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.home-card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.22);
border-color: rgba(0, 212, 255, 0.2);
box-shadow:
var(--shadow-md),
0 0 0 1px rgba(0, 212, 255, 0.08);
}
.home-card:hover::before {
opacity: 0.6;
}
.home-card:hover::after {
opacity: 1;
}
.home-card__icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.08);
border: 1px solid rgba(0, 212, 255, 0.15);
flex-shrink: 0;
transition: transform 0.22s var(--ease-spring);
color: var(--neon-cyan);
}
.home-card:hover .home-card__icon {
transform: scale(1.1) rotate(-4deg);
}
.home-card__body {
flex: 1;
}
.home-card__title {
font-weight: 600;
font-size: 18px;
margin: 0 0 8px;
font-weight: 700;
font-size: 15px;
margin: 0 0 5px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-card__desc {
margin: 0;
color: var(--muted);
font-size: 14px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-card__cta {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
.home-card__arrow {
font-size: 16px;
color: var(--neon-cyan);
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.22s ease, transform 0.22s ease;
align-self: flex-end;
}
.home-card:hover .home-card__arrow {
opacity: 1;
transform: translateX(0);
}
/* ── Blog Posts ──────────────────────────────────────────────────────── */
.home-posts {
display: grid;
gap: 12px;
gap: 8px;
}
.home-post {
@@ -167,46 +306,300 @@
color: inherit;
border: 1px solid var(--line);
padding: 16px 18px;
border-radius: 16px;
background: var(--surface);
border-radius: var(--radius-md);
background: var(--surface-card);
display: grid;
gap: 8px;
transition: border-color 0.2s ease;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 14px;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
box-shadow: var(--shadow-card);
}
.home-post:hover {
border-color: rgba(255, 255, 255, 0.25);
border-color: rgba(192, 132, 252, 0.25);
background: var(--surface-raised);
transform: translateX(4px);
}
.home-post__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--neon-purple);
box-shadow: 0 0 6px var(--neon-purple);
margin-top: 7px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.home-post:hover .home-post__dot {
opacity: 1;
}
.home-post__content {
display: grid;
gap: 5px;
}
.home-post__title {
margin: 0;
font-weight: 600;
font-size: 18px;
font-size: 15px;
color: var(--text-bright);
letter-spacing: -0.01em;
}
.home-post__excerpt {
margin: 0;
color: var(--muted);
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.home-post__meta {
font-size: 12px;
color: var(--accent);
font-size: 11px;
color: var(--neon-purple-dim);
text-transform: uppercase;
letter-spacing: 0.14em;
letter-spacing: 0.12em;
white-space: nowrap;
padding-top: 4px;
}
/* ── TODO Board ──────────────────────────────────────────────────────── */
.home-todo-wrapper {
position: relative;
}
.home-todo-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--line-bright);
background: var(--surface-raised);
color: var(--text-bright);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, border-color 0.2s ease;
box-shadow: var(--shadow-md);
}
.home-todo-nav:hover {
background: var(--bg-tertiary);
border-color: var(--neon-cyan);
}
.home-todo-nav--left { left: -16px; }
.home-todo-nav--right { right: -16px; }
.home-todo-board {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
padding-bottom: 4px;
}
.home-todo-board::-webkit-scrollbar {
height: 4px;
}
.home-todo-board::-webkit-scrollbar-track {
background: transparent;
}
.home-todo-board::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 2px;
}
.home-todo-col {
flex: 1 0 260px;
min-width: 0;
max-width: 340px;
scroll-snap-align: start;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.home-todo-col__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
flex-shrink: 0;
}
.home-todo-col__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.home-todo-col__label {
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: var(--font-display);
flex: 1;
}
.home-todo-col__count {
font-size: 11px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 7px;
font-family: var(--font-display);
}
.home-todo-col__body {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
max-height: calc(40vh);
min-height: 60px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.home-todo-col__body::-webkit-scrollbar {
width: 3px;
}
.home-todo-col__body::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 2px;
}
.home-todo-col__empty {
margin: auto;
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 16px 0;
}
.home-todo-card {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 11px 13px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 4px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.home-todo-card:hover {
border-color: rgba(0, 212, 255, 0.18);
background: rgba(0, 212, 255, 0.03);
}
.home-todo-card__title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
letter-spacing: -0.01em;
line-height: 1.4;
}
.home-todo-card__desc {
margin: 0;
font-size: 11px;
color: var(--text-dim);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.home-todo-card__date {
margin: 2px 0 0;
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.home-todo-footer {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.home-todo-footer__link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #34d399;
text-decoration: none;
padding: 6px 0;
transition: opacity 0.2s ease;
font-weight: 500;
}
.home-todo-footer__link:hover {
opacity: 0.75;
}
/* ── Profile ─────────────────────────────────────────────────────────── */
.home-profile {
display: grid;
}
.home-profile__card {
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px;
background: var(--surface);
border-radius: var(--radius-lg);
padding: 24px;
background: var(--surface-card);
display: grid;
gap: 16px;
gap: 18px;
box-shadow: var(--shadow-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: relative;
overflow: hidden;
}
.home-profile__card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--grad-accent);
opacity: 0.3;
}
.home-profile__identity {
@@ -216,31 +609,39 @@
}
.home-profile__avatar {
width: 52px;
height: 52px;
width: 56px;
height: 56px;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
box-shadow:
0 0 0 1px rgba(0, 212, 255, 0.2),
0 0 12px rgba(0, 212, 255, 0.1),
0 4px 16px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.home-profile__role {
margin: 0;
font-size: 12px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
letter-spacing: 0.22em;
color: var(--neon-cyan);
font-family: var(--font-display);
}
.home-profile__name {
margin: 6px 0 0;
font-weight: 600;
margin: 4px 0 0;
font-weight: 700;
font-size: 18px;
color: var(--text-bright);
letter-spacing: -0.02em;
}
.home-profile__bio {
margin: 0;
color: var(--muted);
line-height: 1.6;
color: var(--text-dim);
line-height: 1.75;
font-size: 13px;
}
.home-profile__timeline {
@@ -250,10 +651,11 @@
.home-profile__section-title {
margin: 0;
font-size: 12px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--accent);
letter-spacing: 0.24em;
color: var(--neon-cyan);
font-family: var(--font-display);
}
.home-profile__timeline ul {
@@ -261,87 +663,137 @@
margin: 0;
padding: 0;
display: grid;
gap: 10px;
gap: 6px;
}
.home-profile__timeline li {
display: grid;
gap: 4px;
gap: 2px;
padding: 12px 14px;
border-radius: 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.2s ease, background 0.2s ease;
}
.home-profile__timeline span {
font-size: 12px;
color: var(--muted);
.home-profile__timeline li:hover {
border-color: rgba(0, 212, 255, 0.15);
background: rgba(0, 212, 255, 0.03);
}
.timeline-period {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.home-profile__timeline strong {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
}
.home-profile__timeline span:not(.timeline-period) {
font-size: 12px;
color: var(--text-dim);
}
.home-profile__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 6px;
}
.home-profile__tags span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
color: var(--muted);
padding: 4px 10px;
font-size: 11px;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.15s ease, color 0.15s ease;
}
.home-profile__tags span:hover {
border-color: rgba(0, 212, 255, 0.2);
color: var(--neon-cyan);
}
.home-profile__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
@media (max-width: 900px) {
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 1024px) {
.home-hero {
grid-template-columns: 1fr;
}
.home-hero__card {
max-width: 480px;
}
}
@media (max-width: 768px) {
.home {
gap: 24px;
}
.home-todo-col {
flex: 0 0 80vw;
max-width: 80vw;
}
.home-todo-col__body {
max-height: 30vh;
}
.home-todo-nav {
display: none;
}
.home-hero h1 {
font-size: clamp(24px, 6vw, 36px);
font-size: clamp(22px, 6vw, 32px);
}
.home-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.home-card {
padding: 14px;
gap: 12px;
}
.home-card__title {
font-size: 16px;
}
.home-card__desc {
font-size: 13px;
}
.home-posts {
gap: 10px;
}
.home-card__icon {
width: 34px;
height: 34px;
}
.home-card__title {
font-size: 13px;
}
.home-card__desc {
font-size: 11px;
}
.home-post {
padding: 14px 16px;
padding: 12px 14px;
grid-template-columns: auto 1fr;
gap: 10px;
}
.home-post__meta {
grid-column: 2;
}
.home-post__title {
font-size: 16px;
font-size: 14px;
}
.home-profile__card {
@@ -352,7 +804,26 @@
font-size: 16px;
}
.home-profile__bio {
font-size: 14px;
.home-hero__stats {
grid-template-columns: 1fr;
}
.home-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.home-card {
min-height: 80px;
}
.home-posts {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.home-grid {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -1,20 +1,61 @@
import React from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { navLinks } from '../../routes.jsx';
import { getBlogPosts } from '../../data/blog';
import { getTodos } from '../../api';
import { getCurrentTheme } from '../../data/heroConfig';
import myPhoto from '../../assets/myPhoto.jpg';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import PullToRefresh from '../../components/PullToRefresh';
import './Home.css';
const TODO_COLUMNS = [
{ id: 'todo', label: '계획', color: 'var(--neon-purple)' },
{ id: 'in_progress', label: '진행 중', color: '#f59e0b' },
{ id: 'done', label: '완료', color: '#34d399' },
];
const Home = () => {
const posts = getBlogPosts().slice(0, 3);
const highlights = navLinks.filter((link) => link.id !== 'home');
const theme = getCurrentTheme();
const isMobile = useIsMobile();
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
const [portfolio, setPortfolio] = useState(null);
useEffect(() => {
fetch('/api/profile/public')
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => setPortfolio(d));
}, []);
const loadTodos = useCallback(async () => {
const data = await getTodos();
if (!Array.isArray(data)) return;
setTodosByStatus({
todo: data.filter((t) => t.status === 'todo'),
in_progress: data.filter((t) => t.status === 'in_progress'),
done: data.filter((t) => t.status === 'done'),
});
}, []);
useEffect(() => {
loadTodos().catch(() => { /* 조용히 실패 */ });
}, [loadTodos]);
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
const doneTasks = todosByStatus.done.length;
const inProgress = todosByStatus.in_progress.length;
return (
<div className="home">
<section className="home-hero">
<div className="home-hero__text">
<p className="home-hero__kicker">Personal Archive</p>
<h1>기록을 모으고, 이야기를 이어붙이는 작은 .</h1>
<h1>기록을 모으고,<br />이야기를 이어붙이는 작은 .</h1>
<p className="home-hero__lead">
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
</p>
@@ -28,22 +69,23 @@ const Home = () => {
</div>
</div>
<div className="home-hero__card">
<p className="home-hero__card-title">이번 집중 테마</p>
<p className="home-hero__card-eyebrow">이번 집중 테마</p>
<div className="home-hero__card-body">
<h2>느린 기록, 깊은 회고</h2>
<p>
빠르게 업데이트하는 대신, 번쯤 되돌아보며 기록하는 목표로
합니다. 글은 매주 편씩 추가될 예정이에요.
</p>
<h2>{theme.theme}</h2>
<p>{theme.desc}</p>
</div>
<div className="home-hero__stats">
<div>
<p className="stat-label">게시 </p>
<p className="stat-value">{posts.length}</p>
<div className="home-hero__stat">
<p className="stat-label">전체 태스크</p>
<p className="stat-value">
{totalTasks}<span className="stat-unit"></span>
</p>
</div>
<div>
<p className="stat-label">다음 업데이트</p>
<p className="stat-value">이번 주말</p>
<div className="home-hero__stat">
<p className="stat-label">진행 / 완료</p>
<p className="stat-value stat-value--sm">
{inProgress}<span className="stat-unit"> / </span>{doneTasks}
</p>
</div>
</div>
</div>
@@ -56,12 +98,23 @@ const Home = () => {
</div>
<div className="home-grid">
{highlights.map((item) => (
<Link key={item.id} to={item.path} className="home-card">
<div>
<Link
key={item.id}
to={item.path}
className="home-card"
style={{ '--card-accent': item.accent }}
>
<div
className="home-card__icon"
style={{ color: item.accent }}
>
{item.icon}
</div>
<div className="home-card__body">
<p className="home-card__title">{item.label}</p>
<p className="home-card__desc">{item.description}</p>
</div>
<span className="home-card__cta">열기</span>
<span className="home-card__arrow"></span>
</Link>
))}
</div>
@@ -75,14 +128,98 @@ const Home = () => {
<div className="home-posts">
{posts.map((post) => (
<Link key={post.slug} to="/blog" className="home-post">
<p className="home-post__title">{post.title}</p>
<p className="home-post__excerpt">{post.excerpt}</p>
<div className="home-post__dot" />
<div className="home-post__content">
<p className="home-post__title">{post.title}</p>
<p className="home-post__excerpt">{post.excerpt}</p>
</div>
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
</Link>
))}
</div>
</section>
{/* ── TODO 보드 ──────────────────────────────────────────── */}
<section className="home-section">
<div className="home-section__header">
<h2>TODO</h2>
<p>계획 · 진행 · 완료 태스크를 한눈에 확인합니다.</p>
</div>
<PullToRefresh onRefresh={loadTodos}>
{isMobile ? (
<SwipeableView
tabs={[
{
key: 'todo',
label: 'TODO',
content: (
<div className="home-todo-col__body">
{(todosByStatus.todo || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.todo || []).map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))}
</div>
),
},
{
key: 'in_progress',
label: '진행중',
content: (
<div className="home-todo-col__body">
{(todosByStatus.in_progress || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.in_progress || []).map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))}
</div>
),
},
{
key: 'done',
label: '완료',
content: (
<div className="home-todo-col__body">
{(todosByStatus.done || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.done || []).map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && <p className="home-todo-card__desc">{todo.description}</p>}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))}
</div>
),
},
]}
/>
) : (
<TodoBoard todosByStatus={todosByStatus} />
)}
</PullToRefresh>
</section>
<section className="home-section">
<div className="home-section__header">
<h2>Profile</h2>
@@ -93,52 +230,30 @@ const Home = () => {
<div className="home-profile__identity">
<img
className="home-profile__avatar"
src={myPhoto}
src={portfolio?.profile?.photo_url || myPhoto}
alt="Profile"
/>
<div>
<p className="home-profile__role">Server Developer</p>
<p className="home-profile__name"> </p>
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
</div>
</div>
<p className="home-profile__bio">
주변 동료와 함께 소통하며 성장하는걸 좋아합니다. <br />
성능 최적화, 인프라 자동화를 중요하게 생각합니다. <br />
여행과 사진, 새로운 기술 탐구를 좋아합니다.
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
</p>
<div className="home-profile__timeline">
<p className="home-profile__section-title">연혁</p>
<ul>
<li>
<span>2023.02 - 현재</span>
<strong>Server Developer</strong>
<span>내비 TIS 교통 서버/현대오토에버</span>
</li>
<li>
<span>2020.01 - 2023.02</span>
<strong>Embedded Device SW Developer</strong>
<span>캐시비 단말기 개발/롯데정보통신</span>
</li>
<li>
<span>2019.07 - 2019.12</span>
<strong>SSAFY - 삼성 SW Academy</strong>
<span>SSAFY</span>
</li>
</ul>
</div>
<div className="home-profile__tags">
<span>C++</span>
<span>Git</span>
<span>AWS</span>
<span>Jira</span>
<span>MySQL</span>
<span>Docker</span>
<span>Kubernetes</span>
<span>Linux</span>
{(portfolio?.skills || []).slice(0, 8).map((s) => (
<span key={s.id || s.name}>{s.name}</span>
))}
{!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<div className="home-profile__actions">
<button className="button ghost">프로필 수정</button>
<a className="button primary" href="mailto:bgg8988@gmail.com">
<Link className="button ghost" to="/portfolio">
포트폴리오 보기
</Link>
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
연락하기
</a>
</div>
@@ -149,4 +264,99 @@ const Home = () => {
);
};
/* ── TodoBoard ──────────────────────────────────────────────────────── */
const TodoBoard = ({ todosByStatus }) => {
const boardRef = useRef(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = () => {
const el = boardRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
};
useEffect(() => {
checkScroll();
const el = boardRef.current;
if (!el) return;
el.addEventListener('scroll', checkScroll, { passive: true });
const ro = new ResizeObserver(checkScroll);
ro.observe(el);
return () => { el.removeEventListener('scroll', checkScroll); ro.disconnect(); };
}, [todosByStatus]);
const scroll = (dir) => {
const el = boardRef.current;
if (!el) return;
el.scrollBy({ left: dir * 280, behavior: 'smooth' });
};
const isEmpty = TODO_COLUMNS.every((col) => todosByStatus[col.id].length === 0);
return (
<div className="home-todo-wrapper">
{canScrollLeft && (
<button
className="home-todo-nav home-todo-nav--left"
onClick={() => scroll(-1)}
aria-label="왼쪽으로"
></button>
)}
{canScrollRight && (
<button
className="home-todo-nav home-todo-nav--right"
onClick={() => scroll(1)}
aria-label="오른쪽으로"
></button>
)}
<div className="home-todo-board" ref={boardRef}>
{TODO_COLUMNS.map((col) => {
const items = todosByStatus[col.id] ?? [];
return (
<div key={col.id} className="home-todo-col">
<div className="home-todo-col__head">
<span
className="home-todo-col__dot"
style={{ background: col.color, boxShadow: `0 0 6px ${col.color}` }}
/>
<span className="home-todo-col__label">{col.label}</span>
<span className="home-todo-col__count">{items.length}</span>
</div>
<div className="home-todo-col__body">
{items.length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (
items.map((todo) => (
<div key={todo.id} className="home-todo-card">
<p className="home-todo-card__title">{todo.title}</p>
{todo.description && (
<p className="home-todo-card__desc">{todo.description}</p>
)}
<p className="home-todo-card__date">
{todo.updated_at
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
: ''}
</p>
</div>
))
)}
</div>
</div>
);
})}
</div>
<div className="home-todo-footer">
<Link to="/todo" className="home-todo-footer__link">
Todo 보드 열기
</Link>
</div>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,305 @@
/* ── InstaCards ──────────────────────────────────────────────────────────── */
.ic { max-width: 1100px; margin: 0 auto; padding: 16px 12px 80px; }
@media (min-width: 768px) {
.ic { 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컬럼.
minmax(0, 1fr)로 자식 overflow가 부모를 밀어내지 않도록 함 */
.ic-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
.ic-layout > * { min-width: 0; }
@media (min-width: 768px) {
.ic-layout { grid-template-columns: 320px minmax(0, 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; }
/* 키워드 페이저 (10개씩, 이전/다음) */
.ic-keywords__pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 12px; }
.ic-pager-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 99px;
border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.04);
color: rgba(255,255,255,.7); font-size: 1.1rem; cursor: pointer; transition: all .15s;
}
.ic-pager-btn:hover:not(:disabled) { background: rgba(236,72,153,.18); border-color: #ec4899; color: #ec4899; }
.ic-pager-btn:disabled { opacity: .3; cursor: not-allowed; }
.ic-pager-info { font-size: 0.8rem; font-weight: 600; color: rgba(255,255,255,.55); min-width: 48px; text-align: center; }
/* 슬레이트 그리드 — 모바일 2칸 강제, 데스크탑 auto-fill */
.ic-slates-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@media (min-width: 640px) {
.ic-slates-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 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; }
/* 슬레이트 상세 패널 — min-width: 0으로 자식 overflow가 부모 밀지 않게 */
.ic-detail {
margin-top: 20px; padding: 16px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.06); border-radius: 12px;
min-width: 0; max-width: 100%;
}
.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; min-width: 0; }
.ic-detail__actions { display: flex; gap: 8px; }
/* ── pages strip wrapper (chevron + fade + indicator 캐러셀) ── */
.ic-pages-wrap {
position: relative;
margin-bottom: 14px;
min-width: 0;
}
.ic-pages-strip {
display: flex;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
padding: 4px 48px 12px;
-webkit-overflow-scrolling: touch;
/* 양옆 fade로 "더 있다" affordance */
mask-image: linear-gradient(to right,
transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%);
-webkit-mask-image: linear-gradient(to right,
transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%);
}
.ic-pages-strip::-webkit-scrollbar { height: 6px; }
.ic-pages-strip::-webkit-scrollbar-thumb { background: rgba(236,72,153,.4); border-radius: 3px; }
.ic-pages-strip::-webkit-scrollbar-track { background: transparent; }
.ic-page-img {
width: clamp(140px, 42vw, 220px);
flex-shrink: 0;
aspect-ratio: 4/5;
border-radius: 8px;
object-fit: cover;
scroll-snap-align: center;
border: 2px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.04);
cursor: pointer;
transition: transform .15s, border-color .15s;
}
.ic-page-img.is-active {
border-color: #ec4899;
transform: scale(1.03);
}
.ic-pages-nav {
position: absolute;
top: calc(50% - 6px);
transform: translateY(-50%);
width: 40px; height: 40px;
border-radius: 50%; border: 0;
background: rgba(0,0,0,.65); color: #fff;
font-size: 24px; font-weight: 700; line-height: 1;
cursor: pointer; z-index: 2;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
transition: opacity .15s, background .15s;
}
.ic-pages-nav:hover:not(:disabled) { background: rgba(236,72,153,.9); }
.ic-pages-nav:disabled { opacity: .2; cursor: not-allowed; }
.ic-pages-nav--prev { left: 0; }
.ic-pages-nav--next { right: 0; }
.ic-pages-indicator {
display: inline-flex; align-items: baseline; gap: 4px;
margin: 4px auto 0;
padding: 4px 12px;
background: rgba(255,255,255,.06);
border-radius: 99px;
font-size: 0.78rem;
color: rgba(255,255,255,.7);
font-variant-numeric: tabular-nums;
}
.ic-pages-indicator-row { display: flex; justify-content: center; }
.ic-pages-indicator__current { color: #ec4899; font-weight: 700; }
.ic-pages-indicator__sep { opacity: 0.5; }
.ic-pages-indicator__total { opacity: 0.7; }
.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;
}

Some files were not shown because too many files have changed in this diff Show More