From 3e73077b291ca3ef2ae320fb62a97c341a8f90c2 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:43:03 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs(stock):=20=EA=B4=80=EC=8B=AC=EC=A2=85?= =?UTF-8?q?=EB=AA=A9=20=ED=83=AD=20=EC=84=A4=EA=B3=84=C2=B7=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- .../plans/2026-07-03-watchlist-tab.md | 906 ++++++++++++++++++ .../specs/2026-07-03-watchlist-tab-design.md | 174 ++++ 2 files changed, 1080 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-watchlist-tab.md create mode 100644 docs/superpowers/specs/2026-07-03-watchlist-tab-design.md diff --git a/docs/superpowers/plans/2026-07-03-watchlist-tab.md b/docs/superpowers/plans/2026-07-03-watchlist-tab.md new file mode 100644 index 0000000..dd26f1b --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-watchlist-tab.md @@ -0,0 +1,906 @@ +# 관심종목 탭 (Watchlist Tab) 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/trade` 거래 데스크에 관심종목 CRUD + 최근 매매 시그널 알림 이력을 보여주는 "관심종목" 탭을 추가한다. + +**Architecture:** 순수 헬퍼(`watchlistUtils.js`) → API 헬퍼(`api.js`) → 상태 훅(`useWatchlist.js`) → 표현 컴포넌트(`WatchlistTab.jsx`) → 탭 등재(`StockTrade.jsx`). 기존 `HoldingsIntelTab`/`usePortfolio` 패턴(훅을 `StockTrade`에서 인스턴스화해 탭에 props로 전달)을 그대로 따른다. + +**Tech Stack:** React 18 (함수형 + hooks), Vite, Vitest + @testing-library/react, 기존 `apiGet/apiPost/apiDelete` 헬퍼. + +## Global Constraints + +- **API는 항상 상대경로** (`/api/...`). 절대 URL 금지 (Mixed Content). +- **모든 fetch는 `src/api.js`의 `apiGet/apiPost/apiDelete` 경유.** +- 테스트: `import { describe, it, expect } from 'vitest'`. 실행 `npm run test:run`. 파일 컨벤션 `*.test.js(x)` 동일 디렉토리 배치. +- 색상: 매수 `#22c55e`, 매도 `#ef4444` (기존 `ACTION_MAP` 팔레트 일치). +- CSS 토큰 재사용: `--line`, `--surface`, `--radius-lg`, `--muted`, `--accent-stock`. 카드 관례: `background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.12); border-radius: 10px`. +- 커밋은 `web-ui` 경로에서만. `.env`·무관 파일 커밋 금지 (변경 파일만 명시적 `git add`). +- BE 계약 (소비 대상): + - `GET /api/stock/watchlist` → `{ watchlist: [{ ticker, name, note, params, added_at }] }` + - `POST /api/stock/watchlist` body `{ ticker, name?, note? }` → `{ ok: true }` + - `DELETE /api/stock/watchlist/{ticker}` → 200/404 + - `GET /api/stock/trade-alerts?days=N` → `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }` + - `kind`: `buy`|`sell`. `condition`: `buy_ma20_pullback`/`buy_breakout`/`buy_rsi_bounce`/`sell_stop_loss`/`sell_ma_break`/`sell_take_profit`/`sell_climax`/`sell_trailing_stop`. +- 응답은 방어적 파싱: 배열 직접 반환 / 래핑(`watchlist`·`alerts`) 둘 다 허용. + +--- + +### Task 1: 순수 헬퍼 `watchlistUtils.js` (라벨/색/시간 매핑) + +**Files:** +- Create: `src/pages/stock/watchlistUtils.js` +- Test: `src/pages/stock/watchlistUtils.test.js` + +**Interfaces:** +- Produces: + - `KIND_META: { buy: {label,color,bg}, sell: {label,color,bg} }` + - `kindMeta(kind: string) => { label, color, bg }` (미정의 → 회색 폴백 + 원문 label) + - `CONDITION_LABEL: Record` + - `conditionLabel(cond: string) => string` (미정의 → 원문 폴백) + - `normalizeTicker(str) => string` (trim만) + - `relativeTime(iso: string, now?: number) => string` + +- [ ] **Step 1: Write the failing test** + +Create `src/pages/stock/watchlistUtils.test.js`: + +```js +import { describe, it, expect } from 'vitest'; +import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js'; + +describe('kindMeta', () => { + it('buy/sell 라벨과 색을 반환', () => { + expect(kindMeta('buy').label).toBe('매수'); + expect(kindMeta('buy').color).toBe('#22c55e'); + expect(kindMeta('sell').label).toBe('매도'); + expect(kindMeta('sell').color).toBe('#ef4444'); + }); + it('미정의 kind는 회색 폴백 + 원문 label', () => { + const m = kindMeta('weird'); + expect(m.label).toBe('weird'); + expect(m.color).toBe('#94a3b8'); + }); +}); + +describe('conditionLabel', () => { + it('정의된 8종을 한글로 매핑', () => { + expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등'); + expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파'); + expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등'); + expect(conditionLabel('sell_stop_loss')).toBe('손절 라인'); + expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈'); + expect(conditionLabel('sell_take_profit')).toBe('목표가 도달'); + expect(conditionLabel('sell_climax')).toBe('과열 소진'); + expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱'); + }); + it('미정의 condition은 원문 폴백', () => { + expect(conditionLabel('buy_unknown')).toBe('buy_unknown'); + expect(conditionLabel(undefined)).toBe(''); + }); +}); + +describe('normalizeTicker', () => { + it('공백 trim', () => { + expect(normalizeTicker(' 005930 ')).toBe('005930'); + expect(normalizeTicker(undefined)).toBe(''); + }); +}); + +describe('relativeTime', () => { + const now = new Date('2026-07-03T12:00:00Z').getTime(); + it('60초 미만은 방금', () => { + expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금'); + }); + it('분/시간/어제/일 경계', () => { + expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전'); + expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전'); + expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제'); + expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전'); + }); + it('잘못된/빈 값은 빈 문자열', () => { + expect(relativeTime('', now)).toBe(''); + expect(relativeTime('not-a-date', now)).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js` +Expected: FAIL — `Failed to resolve import "./watchlistUtils.js"` (파일 없음). + +- [ ] **Step 3: Write the implementation** + +Create `src/pages/stock/watchlistUtils.js`: + +```js +/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */ + +export const KIND_META = { + buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, + sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, +}; + +const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' }; + +export const kindMeta = (kind) => { + const meta = KIND_META[kind]; + if (meta) return meta; + return { ...FALLBACK_KIND, label: kind ?? '' }; +}; + +export const CONDITION_LABEL = { + buy_ma20_pullback: 'MA20 눌림 반등', + buy_breakout: '박스 상단 돌파', + buy_rsi_bounce: 'RSI 과매도 반등', + sell_stop_loss: '손절 라인', + sell_ma_break: '이평선 이탈', + sell_take_profit: '목표가 도달', + sell_climax: '과열 소진', + sell_trailing_stop: '트레일링 스톱', +}; + +export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? ''; + +export const normalizeTicker = (str) => String(str ?? '').trim(); + +export const relativeTime = (iso, now = Date.now()) => { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffMs = now - then; + if (diffMs < 0) return '방금'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return '방금'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}분 전`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}시간 전`; + const day = Math.floor(hr / 24); + if (day === 1) return '어제'; + if (day < 7) return `${day}일 전`; + return new Date(iso).toLocaleDateString('ko-KR'); +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js` +Expected: PASS (4 describe 블록 전부 통과). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/stock/watchlistUtils.js src/pages/stock/watchlistUtils.test.js +git commit -m "feat(stock): 관심종목 탭 순수 헬퍼(watchlistUtils) + 테스트" +``` + +--- + +### Task 2: API 헬퍼 + `useWatchlist` 훅 + +**Files:** +- Modify: `src/api.js` (파일 끝에 추가) +- Create: `src/pages/stock/hooks/useWatchlist.js` +- Test: `src/pages/stock/hooks/useWatchlist.test.js` + +**Interfaces:** +- Consumes (Task 1): `normalizeTicker` +- Produces: + - `api.js`: `getWatchlist()`, `addWatchlist(body)`, `removeWatchlist(ticker)`, `getTradeAlerts(days=7)` + - `useWatchlist() => { items, alerts, alertDays, setAlertDays, loading, error, alertError, adding, add, remove, reload }` + - `add({ ticker, name?, note? })` — 낙관적 추가 후 `reload`, 실패 시 롤백 + - `remove(ticker)` — 낙관적 제거, 실패 시 롤백 + +- [ ] **Step 1: Add API helpers** + +`src/api.js` 파일 맨 끝(마지막 `compatDeleteReading` 함수 뒤)에 추가: + +```js +// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ── +// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] } +// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true } +// DELETE /api/stock/watchlist/{ticker} → 200/404 +// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } +export const getWatchlist = () => apiGet('/api/stock/watchlist'); +export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); +export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`); +export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`); +``` + +- [ ] **Step 2: Write the failing hook test** + +Create `src/pages/stock/hooks/useWatchlist.test.js`: + +```js +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +vi.mock('../../../api', () => ({ + getWatchlist: vi.fn(), + addWatchlist: vi.fn(), + removeWatchlist: vi.fn(), + getTradeAlerts: vi.fn(), +})); + +import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; +import useWatchlist from './useWatchlist'; + +beforeEach(() => { + vi.clearAllMocks(); + getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] }); + getTradeAlerts.mockResolvedValue({ alerts: [] }); + addWatchlist.mockResolvedValue({ ok: true }); + removeWatchlist.mockResolvedValue({ ok: true }); +}); + +describe('useWatchlist', () => { + it('마운트 시 watchlist를 로드', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].ticker).toBe('005930'); + }); + + it('배열 직접 반환도 방어적으로 파싱', async () => { + getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].ticker).toBe('000660'); + }); + + it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => { + getWatchlist + .mockResolvedValueOnce({ watchlist: [] }) + .mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] }); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); }); + expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined }); + await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true)); + }); + + it('add 실패 시 롤백 + error', async () => { + getWatchlist.mockResolvedValue({ watchlist: [] }); + addWatchlist.mockRejectedValue(new Error('HTTP 500 err')); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.add({ ticker: '000660' }); }); + await waitFor(() => expect(result.current.error).toContain('HTTP 500')); + expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false); + }); + + it('중복 ticker는 add 차단', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { await result.current.add({ ticker: '005930' }); }); + expect(addWatchlist).not.toHaveBeenCalled(); + expect(result.current.error).toContain('이미'); + }); + + it('remove: 낙관적 제거 + DELETE 호출', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { await result.current.remove('005930'); }); + expect(removeWatchlist).toHaveBeenCalledWith('005930'); + expect(result.current.items).toHaveLength(0); + }); + + it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => { + getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing')); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await waitFor(() => expect(result.current.alertError).toContain('HTTP 404')); + expect(result.current.alerts).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js` +Expected: FAIL — `Failed to resolve import "./useWatchlist"` (파일 없음). + +- [ ] **Step 4: Write the hook** + +Create `src/pages/stock/hooks/useWatchlist.js`: + +```js +import { useCallback, useEffect, useState } from 'react'; +import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; +import { normalizeTicker } from '../watchlistUtils'; + +const asArray = (data, key) => { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data[key])) return data[key]; + return []; +}; + +const byFiredAtDesc = (a, b) => + new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime(); + +export default function useWatchlist() { + const [items, setItems] = useState([]); + const [alerts, setAlerts] = useState([]); + const [alertDays, setAlertDays] = useState(7); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [alertError, setAlertError] = useState(''); + const [adding, setAdding] = useState(false); + + const loadWatchlist = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await getWatchlist(); + setItems(asArray(data, 'watchlist')); + } catch (e) { + setError(e?.message ?? String(e)); + } finally { + setLoading(false); + } + }, []); + + const loadAlerts = useCallback(async (days) => { + setAlertError(''); + try { + const data = await getTradeAlerts(days); + setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc)); + } catch (e) { + setAlertError(e?.message ?? String(e)); + setAlerts([]); + } + }, []); + + useEffect(() => { loadWatchlist(); }, [loadWatchlist]); + useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]); + + const add = useCallback(async ({ ticker, name, note }) => { + const t = normalizeTicker(ticker); + if (!t) return; + if (items.some((it) => it.ticker === t)) { + setError(`이미 관심종목에 있습니다: ${t}`); + return; + } + setAdding(true); + setError(''); + const cleanName = (name ?? '').trim(); + const cleanNote = (note ?? '').trim(); + const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() }; + setItems((prev) => [optimistic, ...prev]); + try { + await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined }); + await loadWatchlist(); + } catch (e) { + setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백 + setError(e?.message ?? String(e)); + } finally { + setAdding(false); + } + }, [items, loadWatchlist]); + + const remove = useCallback(async (ticker) => { + const prev = items; + setItems((cur) => cur.filter((it) => it.ticker !== ticker)); + setError(''); + try { + await removeWatchlist(ticker); + } catch (e) { + setItems(prev); // 롤백 + setError(e?.message ?? String(e)); + } + }, [items]); + + return { + items, alerts, alertDays, setAlertDays, + loading, error, alertError, adding, + add, remove, reload: loadWatchlist, + }; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js` +Expected: PASS (7 케이스 통과). + +- [ ] **Step 6: Commit** + +```bash +git add src/api.js src/pages/stock/hooks/useWatchlist.js src/pages/stock/hooks/useWatchlist.test.js +git commit -m "feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트" +``` + +--- + +### Task 3: `WatchlistTab.jsx` 컴포넌트 + 스타일 + +**Files:** +- Create: `src/pages/stock/components/WatchlistTab.jsx` +- Modify: `src/pages/stock/Stock.css` (파일 끝에 `wl-*` 섹션 추가) +- Test: `src/pages/stock/components/WatchlistTab.test.jsx` + +**Interfaces:** +- Consumes (Task 1): `kindMeta`, `conditionLabel`, `relativeTime`; (stockUtils) `formatNumber`; (Task 2) `useWatchlist` 반환 형태 — 단, 컴포넌트는 훅 결과를 `wl` **prop**으로 받는다(테스트/뱃지 용이). +- Produces: `WatchlistTab({ wl })` 기본 export (React 컴포넌트). + +- [ ] **Step 1: Write the failing smoke test** + +Create `src/pages/stock/components/WatchlistTab.test.jsx`: + +```jsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import WatchlistTab from './WatchlistTab.jsx'; + +const baseWl = { + items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(), + loading: false, error: '', alertError: '', adding: false, + add: vi.fn(), remove: vi.fn(), reload: vi.fn(), +}; + +describe('WatchlistTab', () => { + it('빈 상태: 헤딩과 빈 안내 노출', () => { + render(); + expect(screen.getByText('관심종목 관리')).toBeInTheDocument(); + expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument(); + expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument(); + }); + + it('종목·알림이 있으면 렌더', () => { + const wl = { + ...baseWl, + items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }], + alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }], + }; + render(); + expect(screen.getByText('삼성전자')).toBeInTheDocument(); + expect(screen.getByText('매수')).toBeInTheDocument(); + expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument(); + }); +}); +``` + +> 참고: `toBeInTheDocument` 매처는 `@testing-library/jest-dom`(devDependency)에서 제공된다. 기존 테스트 셋업에서 전역 등록이 안 되어 있으면 테스트 파일 상단에 `import '@testing-library/jest-dom';` 한 줄을 추가한다. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx` +Expected: FAIL — `Failed to resolve import "./WatchlistTab.jsx"` (파일 없음). + +- [ ] **Step 3: Write the component** + +Create `src/pages/stock/components/WatchlistTab.jsx`: + +```jsx +import React, { useState } from 'react'; +import Loading from '../../../components/Loading'; +import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils'; +import { formatNumber } from '../stockUtils'; + +const DAYS_OPTIONS = [ + { value: 1, label: '1D' }, + { value: 7, label: '7D' }, + { value: 30, label: '30D' }, +]; + +const AlertCard = ({ a }) => { + const meta = kindMeta(a.kind); + return ( +
+
+ {meta.label} + {a.name || a.ticker} + {a.ticker} + {relativeTime(a.fired_at)} +
+
+ {conditionLabel(a.condition)} + {a.price != null && {formatNumber(a.price)}원} +
+ {a.detail &&
{a.detail}
} +
+ ); +}; + +const WatchlistTab = ({ wl }) => { + const [form, setForm] = useState({ ticker: '', name: '', note: '' }); + + const submit = async (e) => { + e.preventDefault(); + if (!form.ticker.trim()) return; + await wl.add(form); + setForm({ ticker: '', name: '', note: '' }); + }; + + return ( + <> + {/* 관심종목 관리 */} +
+
+
+

관심종목

+

관심종목 관리

+

등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.

+
+
{wl.loading && }
+
+ +
+ setForm((f) => ({ ...f, ticker: e.target.value }))} + /> + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, note: e.target.value }))} + /> + +
+ + {wl.error &&

{wl.error}

} + + {wl.items.length === 0 ? ( +

아직 관심종목이 없습니다. 종목코드를 추가해 보세요.

+ ) : ( +
    + {wl.items.map((it) => ( +
  • +
    + {it.name || it.ticker} + {it.ticker} + {it.note && {it.note}} +
    + +
  • + ))} +
+ )} +
+ + {/* 최근 시그널 알림 */} +
+
+
+

시그널

+

최근 매매 알림

+

감시 종목에서 발생한 매수·매도 시그널 이력입니다.

+
+
+ {DAYS_OPTIONS.map((o) => ( + + ))} +
+
+ + {wl.alertError &&

{wl.alertError}

} + + {wl.alerts.length === 0 ? ( +

해당 기간에 발생한 알림이 없습니다.

+ ) : ( +
+ {wl.alerts.map((a) => ( + + ))} +
+ )} + +

※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.

+
+ + ); +}; + +export default WatchlistTab; +``` + +- [ ] **Step 4: Append styles to `Stock.css`** + +`src/pages/stock/Stock.css` 파일 맨 끝에 추가: + +```css +/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */ +.wl-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.wl-form__input { + flex: 1 1 140px; + min-width: 120px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + color: inherit; + font-size: 13px; +} +.wl-form__input:focus { + outline: none; + border-color: var(--accent-stock); +} + +.wl-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.wl-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 10px 14px; +} +.wl-row__meta { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.wl-row__name { font-size: 14px; } +.wl-row__ticker { font-size: 12px; color: var(--muted); } +.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; } +.wl-del { + flex: none; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 4px 6px; + border-radius: 6px; +} +.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); } + +.wl-period-toggle { display: flex; gap: 4px; } +.wl-period { + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + border-radius: 8px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} +.wl-period.is-active { + color: var(--accent-stock); + border-color: var(--accent-stock); + background: rgba(148, 163, 184, 0.08); +} + +.wl-alerts { + display: flex; + flex-direction: column; + gap: 10px; +} +.wl-alert { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.wl-alert__head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.wl-kind-badge { + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; +} +.wl-alert__name { font-size: 14px; } +.wl-alert__ticker { font-size: 12px; color: var(--muted); } +.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; } +.wl-alert__body { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} +.wl-cond { font-size: 13px; font-weight: 600; } +.wl-alert__price { font-size: 13px; color: var(--muted); } +.wl-alert__detail { font-size: 12px; color: var(--muted); } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx` +Expected: PASS (2 케이스 통과). + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/stock/components/WatchlistTab.jsx src/pages/stock/components/WatchlistTab.test.jsx src/pages/stock/Stock.css +git commit -m "feat(stock): WatchlistTab 컴포넌트 + wl-* 스타일 + 스모크 테스트" +``` + +--- + +### Task 4: `StockTrade`에 탭 등재 + 문서 갱신 + +**Files:** +- Modify: `src/pages/stock/stockUtils.js:152` (TAB 상수 추가) +- Modify: `src/pages/stock/StockTrade.jsx` (import·훅·탭 배열·렌더) +- Modify: `CLAUDE.md` (API 엔드포인트 테이블 — web-ui 루트가 아닌 `web-ui/CLAUDE.md`) + +**Interfaces:** +- Consumes (Task 2·3): `useWatchlist`, `WatchlistTab` +- Produces: 없음 (통합 지점, 최종 배선) + +- [ ] **Step 1: Add TAB constant** + +`src/pages/stock/stockUtils.js` 맨 끝(`export const TAB_HOLDINGS_INTEL = 'holdings_intel';` 뒤)에 추가: + +```js +export const TAB_WATCHLIST = 'watchlist'; +``` + +- [ ] **Step 2: Wire into StockTrade.jsx** + +`src/pages/stock/StockTrade.jsx` 수정 — 4곳: + +(a) stockUtils import에 `TAB_WATCHLIST` 추가 (기존 import 블록 line 6-10): + +```js +import { + formatNumber, formatPercent, + toNumeric, profitColorClass, + TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST, +} from './stockUtils'; +``` + +(b) 탭 컴포넌트 import 추가 (기존 `import HoldingsIntelTab ...` 뒤, line 25 근처): + +```js +import HoldingsIntelTab from './components/HoldingsIntelTab'; +import WatchlistTab from './components/WatchlistTab'; +``` + +(c) 훅 인스턴스화 + `TAB_ORDER`/`tabLabels` 확장. `const [activeTab, ...]` 아래(line 31 근처)와 hooks 블록에 추가: + +```js + const wl = useWatchlist(); + + const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST]; + const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목']; +``` + +그리고 파일 상단 hooks import 목록에 훅 import 추가 (line 19 `import useAdvisor ...` 뒤): + +```js +import useAdvisor from './hooks/useAdvisor'; +import useWatchlist from './hooks/useWatchlist'; +``` + +`const wl = useWatchlist();` 는 다른 훅들(`const advisor = useAdvisor({...});`) 뒤에 배치. + +(d) 모바일 SwipeableView content 분기에 watchlist 추가. 기존 `: ,` 를 다음으로 교체: + +```js + content: tabId === TAB_PORTFOLIO + ? + : tabId === TAB_REPORT + ? + : tabId === TAB_ADVISOR + ? + : tabId === TAB_HOLDINGS_INTEL + ? + : , +``` + +(e) 데스크탑 탭 버튼 배열에 항목 추가. 기존 `{ id: TAB_HOLDINGS_INTEL, ... }` 항목 뒤에 추가: + +```js + { id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' }, + { id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null }, +``` + +(f) 데스크탑 조건부 렌더 추가. 기존 `{activeTab === TAB_HOLDINGS_INTEL && }` 뒤에 추가: + +```js + {activeTab === TAB_HOLDINGS_INTEL && } + {activeTab === TAB_WATCHLIST && } +``` + +- [ ] **Step 3: Run the full test suite** + +Run: `npm run test:run` +Expected: PASS — 신규 3개 테스트 파일 포함 전체 통과 (기존 테스트 회귀 없음). + +- [ ] **Step 4: Lint + build** + +Run: `npm run lint` +Expected: 신규 파일 관련 에러 0. (기존 코드의 사전 경고는 무시하되, 신규 파일이 새 에러를 만들지 않을 것.) + +Run: `npm run build` +Expected: 빌드 성공 (`dist/` 생성, 에러 없음). + +- [ ] **Step 5: Manual verification (dev server)** + +```bash +npm run dev +``` +브라우저에서 `http://localhost:3007/stock/trade` 접속 → "관심종목" 탭이 데스크탑 탭바(⭐)와 모바일 스와이프에 노출되는지 확인. 종목코드 입력 후 추가 → 목록 반영, 삭제 버튼 동작, 기간 토글(1D/7D/30D) 확인. (BE 미배포 시 알림 패널은 에러/빈 상태로 표시되고 CRUD는 독립 동작해야 함.) + +- [ ] **Step 6: Update `web-ui/CLAUDE.md` API 테이블** + +`CLAUDE.md` (web-ui 프로젝트 루트) 의 "API 엔드포인트 목록" 테이블에 행 추가 (스크리너 관련 행 근처): + +```markdown +| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } | +| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } | +| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` | +| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } | +``` + +그리고 페이지 구조 표의 `/stock/trade` 행 설명에 "(포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭)" 취지를 반영. + +- [ ] **Step 7: Commit** + +```bash +git add src/pages/stock/stockUtils.js src/pages/stock/StockTrade.jsx CLAUDE.md +git commit -m "feat(stock): 거래 데스크에 관심종목 탭 등재 + API 문서 갱신" +``` + +--- + +## Self-Review 결과 + +**Spec coverage** (설계 §1–§10 대비): +- §2 계약 4종 → Task 2 (api 헬퍼) ✅ +- §3 탭 등재 → Task 4 ✅ +- §4 컴포넌트 구조(훅+자립형 탭+utils) → Task 1/2/3 ✅ +- §5 API 레이어 → Task 2 ✅ +- §6 UX(낙관적 갱신·중복 차단·기간 토글·정렬) → Task 2(훅)·Task 3(뷰) ✅ +- §7 스타일 `wl-*` → Task 3 ✅ +- §8 테스트 → Task 1(utils)·Task 2(훅)·Task 3(컴포넌트) ✅ +- §9 완료 기준 → Task 4 Step 3–6 ✅ +- §10 리스크(방어적 파싱·알림 독립) → `asArray` + `alertError` 분리 ✅ + +**Placeholder scan:** 모든 코드/명령/기대출력 구체값 명시. TBD/TODO 없음. ✅ + +**Type consistency:** `kindMeta`/`conditionLabel`/`relativeTime`/`normalizeTicker` (Task1) ↔ 훅/컴포넌트 사용처 일치. `useWatchlist` 반환 키(`items/alerts/alertDays/setAlertDays/loading/error/alertError/adding/add/remove/reload`) ↔ `WatchlistTab` prop 사용처 일치. `getWatchlist/addWatchlist/removeWatchlist/getTradeAlerts` (api) ↔ 훅 import 일치. ✅ + +**참고 — StockTrade 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입. diff --git a/docs/superpowers/specs/2026-07-03-watchlist-tab-design.md b/docs/superpowers/specs/2026-07-03-watchlist-tab-design.md new file mode 100644 index 0000000..9c58b8f --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-watchlist-tab-design.md @@ -0,0 +1,174 @@ +# 관심종목 탭 (Watchlist Tab) — FE 설계 + +- **작성일**: 2026-07-03 +- **역할/저장소**: FE (`web-ui`) +- **상위 스펙(BE)**: `web-page-backend/docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md` §2·§5.3 +- **상위 플랜(BE)**: `web-page-backend/docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md` +- **범위**: FE(web-ui)만. BE 계약(§5.3)을 소비하는 "관심종목" 탭 구현. 워커(web-ai)·BE는 별도 세션. + +--- + +## 1. 배경 & 목표 + +실시간 매매 알림 시스템의 매수 유니버스는 **"watchlist(사용자 관리) ∪ 당일 스크리너 후보"** 로 정의된다(BE 스펙 §2). 관심종목 관리 수단은 **"텔레그램 봇 명령 + web-ui 탭 둘 다"** 로 결정되었다. 본 문서는 그중 **web-ui 탭**을 정의한다. + +목표: +1. 사용자가 관심종목을 웹에서 추가/조회/삭제(CRUD)할 수 있다. +2. 최근 발생한 매수·매도 시그널 알림 이력을 웹에서 확인할 수 있다. + +비목표(YAGNI, v1 제외): +- 종목별 조건 오버라이드(`params_json`: trailing_pct, stop_pct 등) 편집 — BE POST/PUT params 계약 미확정. +- 실시간 WebSocket 알림 스트림 — 폴링/수동 새로고침으로 충분. +- 텔레그램 설정 UI. + +--- + +## 2. 소비할 BE 계약 (§5.3) + +| 메서드 | 경로 | 요청 | 응답 | +|--------|------|------|------| +| GET | `/api/stock/watchlist` | — | `{ watchlist: [{ ticker, name, note, params, added_at }] }` | +| POST | `/api/stock/watchlist` | `{ ticker, name?, note? }` | 201 `{ ok: true }` | +| DELETE | `/api/stock/watchlist/{ticker}` | — | 200 / 404 | +| GET | `/api/stock/trade-alerts?days=N` | — | `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }` | + +**알림 필드 enum (BE 스펙 §5.3):** + +- `kind`: `buy` | `sell` +- `condition` (buy): `buy_ma20_pullback` · `buy_breakout` · `buy_rsi_bounce` +- `condition` (sell): `sell_stop_loss` · `sell_ma_break` · `sell_take_profit` · `sell_climax` · `sell_trailing_stop` + +> 응답 래핑 키(`watchlist`/`alerts`)와 `params` 필드는 BE 스펙 문구 기준. FE는 방어적으로 파싱한다(배열 직접 반환 / 래핑 둘 다 허용, `params` 미사용이면 무시). + +--- + +## 3. 배치 & 탭 등재 + +`/stock/trade` (거래 데스크)에 5번째 메인 탭 **"관심종목"** 추가. 기존 탭 등재 패턴을 그대로 확장한다. + +- `src/pages/stock/stockUtils.js`: `export const TAB_WATCHLIST = 'watchlist';` +- `src/pages/stock/StockTrade.jsx`: + - `TAB_ORDER` 배열에 `TAB_WATCHLIST` 추가 + - `tabLabels` 에 `'관심종목'` 추가 + - 데스크탑 탭 버튼 배열에 `{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: }` 추가 + - 모바일 `SwipeableView` content 분기에 `WatchlistTab` 추가 + - 데스크탑 조건부 렌더 `{activeTab === TAB_WATCHLIST && }` 추가 +- 탭 뱃지 = 관심종목 개수(훅에서 노출). + +--- + +## 4. 컴포넌트 구조 (접근안 A: 훅 + 자립형 탭) + +기존 `HoldingsIntelTab` 패턴(자립형 탭 컴포넌트 + api 헬퍼)에 상태 로직을 훅으로 분리한 형태. + +``` +src/pages/stock/ +├── hooks/ +│ └── useWatchlist.js # CRUD + 알림 이력 상태·액션 +├── components/ +│ └── WatchlistTab.jsx # 표현 (내부 소형 컴포넌트: WatchlistForm/Row, AlertCard) +├── watchlistUtils.js # 순수 헬퍼 (라벨/색/시간 매핑) +└── watchlistUtils.test.js # 헬퍼 유닛 테스트 +``` + +### 4.1 `useWatchlist.js` (훅) + +상태: +- `items: []` — 관심종목 목록 +- `alerts: []` — 알림 이력 +- `alertDays: 7` — 알림 기간 필터(1/7/30) +- `loading`, `error`, `adding` (폼 제출 중) + +액션: +- `load()` — `getWatchlist()` + `getTradeAlerts(alertDays)` 병렬 로드 +- `add({ ticker, name, note })` — 낙관적 추가 → 성공 시 `load()` 재조회, 실패 시 롤백 + 에러 +- `remove(ticker)` — 낙관적 제거 → 실패 시 롤백 +- `setAlertDays(days)` — 변경 시 알림만 재조회 + +노출: `{ items, alerts, alertDays, setAlertDays, loading, error, adding, add, remove, load }` + +### 4.2 `WatchlistTab.jsx` (표현) + +- 마운트 시 `load()`. +- **상단 패널 — 관심종목 관리**: 인라인 추가 폼(ticker 필수, name·note 선택) + 목록. 각 행: 종목명/코드/메모/등록일 + 삭제 버튼. 빈 상태 안내. +- **하단 패널 — 최근 시그널**: 기간 토글(1D/7D/30D) + 알림 카드. 카드: `kind` 뱃지, `condition` 한글 라벨, `ticker`/`name`, `price`, `detail`, `fired_at` 상대시간. +- 로딩/에러/빈 상태: `stock-panel` · `stock-error` · `stock-empty` 등 기존 클래스 재사용. +- 하단 면책 문구(`hi-disclaimer` 유사): "※ 어드바이저리 알림이며 자동매매가 아닙니다." + +### 4.3 `watchlistUtils.js` (순수 헬퍼 — 테스트 대상) + +```js +KIND_META = { buy: { label: '매수', color, bg }, sell: { label: '매도', color, bg } } +CONDITION_LABEL = { buy_ma20_pullback: 'MA20 눌림 반등', buy_breakout: '박스 상단 돌파', + buy_rsi_bounce: 'RSI 과매도 반등', sell_stop_loss: '손절 라인', sell_ma_break: '이평선 이탈', + sell_take_profit: '목표가 도달', sell_climax: '과열 소진', sell_trailing_stop: '트레일링 스톱' } + +kindMeta(kind) // 미정의 → 회색 폴백 + 원문 label +conditionLabel(cond) // 미정의 → 원문 그대로 반환 +normalizeTicker(str) // trim만 수행(한국 종목코드=6자리 숫자, 대문자화 불필요) +relativeTime(iso) // '3분 전' / '2시간 전' / '어제' 등, 잘못된 값 → '' 폴백 +``` + +--- + +## 5. API 레이어 (`src/api.js` 추가) + +```js +// ── Stock Watchlist / Trade Alerts ── +export const getWatchlist = () => apiGet('/api/stock/watchlist'); +export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); // { ticker, name?, note? } +export const removeWatchlist= (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`); +export const getTradeAlerts = (days = 7)=> apiGet(`/api/stock/trade-alerts?days=${days}`); +``` + +전부 상대경로, 기존 `apiGet/apiPost/apiDelete` 재사용. `getWatchlist`/`getTradeAlerts` 응답은 훅에서 `data.watchlist ?? data ?? []`, `data.alerts ?? data ?? []` 로 방어적 파싱. + +--- + +## 6. UX / 상호작용 세부 + +- **추가 폼**: ticker 미입력 시 제출 비활성. 제출 중 `adding` → 버튼 로딩. 성공 시 폼 초기화. +- **낙관적 갱신**: add/remove 즉시 UI 반영, 실패 시 이전 상태 롤백 + `stock-error` 메시지. +- **중복 방지**: 이미 목록에 있는 ticker면 폼에서 안내(추가 차단). +- **알림 카드 정렬**: `fired_at` 내림차순(최신 우선). +- **빈 상태**: 관심종목 0개 / 알림 0개 각각 안내 문구. +- **반응형**: 데스크탑 2열/모바일 1열은 기존 `stock-panel` 그리드 관례 따름. + +--- + +## 7. 스타일 + +`src/pages/stock/Stock.css` 하단에 `wl-*` 프리픽스 섹션 추가 (기존 `hi-*` 패턴과 동일 구성): +- `.wl-form`, `.wl-list`, `.wl-row`, `.wl-row__meta`, `.wl-del` +- `.wl-alerts`, `.wl-alert`, `.wl-kind-badge`, `.wl-cond`, `.wl-period-toggle` +- 색상: 매수 초록 `#22c55e`, 매도 빨강 `#ef4444` (기존 `ACTION_MAP` 팔레트와 일치). + +--- + +## 8. 테스트 (TDD) + +`watchlistUtils.test.js` — 순수 헬퍼 검증: +1. `conditionLabel`: 정의된 8종 매핑 정확, 미정의 값은 원문 폴백. +2. `kindMeta`: buy/sell 라벨·색, 미정의 kind 회색 폴백. +3. `relativeTime`: 방금/분/시간/일 경계, 잘못된 입력 `''` 폴백. +4. `normalizeTicker`: 공백 trim. + +컴포넌트/훅은 수동 검증(개발 서버 3007 + BE 계약) + 빌드/lint 통과로 확인. (기존 스크리너 훅 테스트처럼 필요 시 훅 테스트 추가 가능하나 v1 필수 아님.) + +--- + +## 9. 완료 기준 (Acceptance) + +- [ ] 거래 데스크에 "관심종목" 탭 노출(데스크탑·모바일), 뱃지에 개수 표시. +- [ ] 종목 추가/삭제가 BE 계약대로 동작(낙관적 갱신 + 실패 롤백). +- [ ] 최근 알림 이력이 기간 토글별로 조회되고, kind/condition 한글 라벨·색으로 표시. +- [ ] `watchlistUtils.test.js` 통과. +- [ ] `npm run lint` · `npm run build` 통과. + +--- + +## 10. 리스크 / 오픈 이슈 + +- **응답 래핑 형태 미확정**: BE가 `{ watchlist: [...] }` 인지 배열 직접인지 문구 기준 불확실 → 방어적 파싱으로 흡수. +- **알림 엔드포인트 미배포 가능성**: BE 세션 미완 시 GET `/api/stock/trade-alerts` 404/네트워크 오류 → 알림 패널은 에러 상태를 조용히 표시하고 관심종목 CRUD는 독립 동작하도록 분리. +- **params 편집**: v1 제외. 추후 BE POST/PUT params 계약 확정 후 별도 스펙으로 확장. From ae33aa4def00a1dbab759768b42d40462102a57a Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:44:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(stock):=20=EA=B4=80=EC=8B=AC=EC=A2=85?= =?UTF-8?q?=EB=AA=A9=20=ED=83=AD=20=EC=88=9C=EC=88=98=20=ED=97=AC=ED=8D=BC?= =?UTF-8?q?(watchlistUtils)=20+=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/stock/watchlistUtils.js | 47 +++++++++++++++++++++ src/pages/stock/watchlistUtils.test.js | 57 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/pages/stock/watchlistUtils.js create mode 100644 src/pages/stock/watchlistUtils.test.js diff --git a/src/pages/stock/watchlistUtils.js b/src/pages/stock/watchlistUtils.js new file mode 100644 index 0000000..3afa0d6 --- /dev/null +++ b/src/pages/stock/watchlistUtils.js @@ -0,0 +1,47 @@ +/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */ + +export const KIND_META = { + buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, + sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, +}; + +const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' }; + +export const kindMeta = (kind) => { + const meta = KIND_META[kind]; + if (meta) return meta; + return { ...FALLBACK_KIND, label: kind ?? '' }; +}; + +export const CONDITION_LABEL = { + buy_ma20_pullback: 'MA20 눌림 반등', + buy_breakout: '박스 상단 돌파', + buy_rsi_bounce: 'RSI 과매도 반등', + sell_stop_loss: '손절 라인', + sell_ma_break: '이평선 이탈', + sell_take_profit: '목표가 도달', + sell_climax: '과열 소진', + sell_trailing_stop: '트레일링 스톱', +}; + +export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? ''; + +export const normalizeTicker = (str) => String(str ?? '').trim(); + +export const relativeTime = (iso, now = Date.now()) => { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const diffMs = now - then; + if (diffMs < 0) return '방금'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return '방금'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}분 전`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}시간 전`; + const day = Math.floor(hr / 24); + if (day === 1) return '어제'; + if (day < 7) return `${day}일 전`; + return new Date(iso).toLocaleDateString('ko-KR'); +}; diff --git a/src/pages/stock/watchlistUtils.test.js b/src/pages/stock/watchlistUtils.test.js new file mode 100644 index 0000000..4e694cf --- /dev/null +++ b/src/pages/stock/watchlistUtils.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js'; + +describe('kindMeta', () => { + it('buy/sell 라벨과 색을 반환', () => { + expect(kindMeta('buy').label).toBe('매수'); + expect(kindMeta('buy').color).toBe('#22c55e'); + expect(kindMeta('sell').label).toBe('매도'); + expect(kindMeta('sell').color).toBe('#ef4444'); + }); + it('미정의 kind는 회색 폴백 + 원문 label', () => { + const m = kindMeta('weird'); + expect(m.label).toBe('weird'); + expect(m.color).toBe('#94a3b8'); + }); +}); + +describe('conditionLabel', () => { + it('정의된 8종을 한글로 매핑', () => { + expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등'); + expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파'); + expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등'); + expect(conditionLabel('sell_stop_loss')).toBe('손절 라인'); + expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈'); + expect(conditionLabel('sell_take_profit')).toBe('목표가 도달'); + expect(conditionLabel('sell_climax')).toBe('과열 소진'); + expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱'); + }); + it('미정의 condition은 원문 폴백', () => { + expect(conditionLabel('buy_unknown')).toBe('buy_unknown'); + expect(conditionLabel(undefined)).toBe(''); + }); +}); + +describe('normalizeTicker', () => { + it('공백 trim', () => { + expect(normalizeTicker(' 005930 ')).toBe('005930'); + expect(normalizeTicker(undefined)).toBe(''); + }); +}); + +describe('relativeTime', () => { + const now = new Date('2026-07-03T12:00:00Z').getTime(); + it('60초 미만은 방금', () => { + expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금'); + }); + it('분/시간/어제/일 경계', () => { + expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전'); + expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전'); + expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제'); + expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전'); + }); + it('잘못된/빈 값은 빈 문자열', () => { + expect(relativeTime('', now)).toBe(''); + expect(relativeTime('not-a-date', now)).toBe(''); + }); +}); From a52fd0db8f595fca9847908f0b54bbea68a5fdbd Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:49:44 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(stock):=20watchlist=20API=20=ED=97=AC?= =?UTF-8?q?=ED=8D=BC=20+=20useWatchlist=20=ED=9B=85(=EB=82=99=EA=B4=80?= =?UTF-8?q?=EC=A0=81=20CRUD=C2=B7=EC=95=8C=EB=A6=BC)=20+=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/api.js | 10 +++ src/pages/stock/hooks/useWatchlist.js | 91 ++++++++++++++++++++++ src/pages/stock/hooks/useWatchlist.test.js | 80 +++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/pages/stock/hooks/useWatchlist.js create mode 100644 src/pages/stock/hooks/useWatchlist.test.js diff --git a/src/api.js b/src/api.js index daca5d8..604f502 100644 --- a/src/api.js +++ b/src/api.js @@ -852,3 +852,13 @@ export function compatPatchReading(id, body) { export function compatDeleteReading(id) { return apiDelete(`/api/saju/compat/readings/${id}`); } + +// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ── +// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] } +// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true } +// DELETE /api/stock/watchlist/{ticker} → 200/404 +// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } +export const getWatchlist = () => apiGet('/api/stock/watchlist'); +export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); +export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`); +export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`); diff --git a/src/pages/stock/hooks/useWatchlist.js b/src/pages/stock/hooks/useWatchlist.js new file mode 100644 index 0000000..3e59ccb --- /dev/null +++ b/src/pages/stock/hooks/useWatchlist.js @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; +import { normalizeTicker } from '../watchlistUtils'; + +const asArray = (data, key) => { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data[key])) return data[key]; + return []; +}; + +const byFiredAtDesc = (a, b) => + new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime(); + +export default function useWatchlist() { + const [items, setItems] = useState([]); + const [alerts, setAlerts] = useState([]); + const [alertDays, setAlertDays] = useState(7); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [alertError, setAlertError] = useState(''); + const [adding, setAdding] = useState(false); + + const loadWatchlist = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await getWatchlist(); + setItems(asArray(data, 'watchlist')); + } catch (e) { + setError(e?.message ?? String(e)); + } finally { + setLoading(false); + } + }, []); + + const loadAlerts = useCallback(async (days) => { + setAlertError(''); + try { + const data = await getTradeAlerts(days); + setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc)); + } catch (e) { + setAlertError(e?.message ?? String(e)); + setAlerts([]); + } + }, []); + + useEffect(() => { loadWatchlist(); }, [loadWatchlist]); + useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]); + + const add = useCallback(async ({ ticker, name, note }) => { + const t = normalizeTicker(ticker); + if (!t) return; + if (items.some((it) => it.ticker === t)) { + setError(`이미 관심종목에 있습니다: ${t}`); + return; + } + setAdding(true); + setError(''); + const cleanName = (name ?? '').trim(); + const cleanNote = (note ?? '').trim(); + const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() }; + setItems((prev) => [optimistic, ...prev]); + try { + await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined }); + await loadWatchlist(); + } catch (e) { + setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백 + setError(e?.message ?? String(e)); + } finally { + setAdding(false); + } + }, [items, loadWatchlist]); + + const remove = useCallback(async (ticker) => { + const prev = items; + setItems((cur) => cur.filter((it) => it.ticker !== ticker)); + setError(''); + try { + await removeWatchlist(ticker); + } catch (e) { + setItems(prev); // 롤백 + setError(e?.message ?? String(e)); + } + }, [items]); + + return { + items, alerts, alertDays, setAlertDays, + loading, error, alertError, adding, + add, remove, reload: loadWatchlist, + }; +} diff --git a/src/pages/stock/hooks/useWatchlist.test.js b/src/pages/stock/hooks/useWatchlist.test.js new file mode 100644 index 0000000..6931d4c --- /dev/null +++ b/src/pages/stock/hooks/useWatchlist.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +vi.mock('../../../api', () => ({ + getWatchlist: vi.fn(), + addWatchlist: vi.fn(), + removeWatchlist: vi.fn(), + getTradeAlerts: vi.fn(), +})); + +import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api'; +import useWatchlist from './useWatchlist'; + +beforeEach(() => { + vi.clearAllMocks(); + getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] }); + getTradeAlerts.mockResolvedValue({ alerts: [] }); + addWatchlist.mockResolvedValue({ ok: true }); + removeWatchlist.mockResolvedValue({ ok: true }); +}); + +describe('useWatchlist', () => { + it('마운트 시 watchlist를 로드', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].ticker).toBe('005930'); + }); + + it('배열 직접 반환도 방어적으로 파싱', async () => { + getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].ticker).toBe('000660'); + }); + + it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => { + getWatchlist + .mockResolvedValueOnce({ watchlist: [] }) + .mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] }); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); }); + expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined }); + await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true)); + }); + + it('add 실패 시 롤백 + error', async () => { + getWatchlist.mockResolvedValue({ watchlist: [] }); + addWatchlist.mockRejectedValue(new Error('HTTP 500 err')); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.add({ ticker: '000660' }); }); + await waitFor(() => expect(result.current.error).toContain('HTTP 500')); + expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false); + }); + + it('중복 ticker는 add 차단', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { await result.current.add({ ticker: '005930' }); }); + expect(addWatchlist).not.toHaveBeenCalled(); + expect(result.current.error).toContain('이미'); + }); + + it('remove: 낙관적 제거 + DELETE 호출', async () => { + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { await result.current.remove('005930'); }); + expect(removeWatchlist).toHaveBeenCalledWith('005930'); + expect(result.current.items).toHaveLength(0); + }); + + it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => { + getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing')); + const { result } = renderHook(() => useWatchlist()); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await waitFor(() => expect(result.current.alertError).toContain('HTTP 404')); + expect(result.current.alerts).toHaveLength(0); + }); +}); From e8091a03914cdaaf5cacc69b90c97550125419c4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:58:24 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(stock):=20WatchlistTab=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20+=20wl-*=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20+=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/stock/Stock.css | 118 ++++++++++++++ src/pages/stock/components/WatchlistTab.jsx | 145 ++++++++++++++++++ .../stock/components/WatchlistTab.test.jsx | 31 ++++ 3 files changed, 294 insertions(+) create mode 100644 src/pages/stock/components/WatchlistTab.jsx create mode 100644 src/pages/stock/components/WatchlistTab.test.jsx diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index 3f6deb3..35c4686 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -3232,3 +3232,121 @@ display: none; } } + +/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */ +.wl-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.wl-form__input { + flex: 1 1 140px; + min-width: 120px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + color: inherit; + font-size: 13px; +} +.wl-form__input:focus { + outline: none; + border-color: var(--accent-stock); +} + +.wl-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.wl-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 10px 14px; +} +.wl-row__meta { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.wl-row__name { font-size: 14px; } +.wl-row__ticker { font-size: 12px; color: var(--muted); } +.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; } +.wl-del { + flex: none; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 4px 6px; + border-radius: 6px; +} +.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); } + +.wl-period-toggle { display: flex; gap: 4px; } +.wl-period { + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + border-radius: 8px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} +.wl-period.is-active { + color: var(--accent-stock); + border-color: var(--accent-stock); + background: rgba(148, 163, 184, 0.08); +} + +.wl-alerts { + display: flex; + flex-direction: column; + gap: 10px; +} +.wl-alert { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.wl-alert__head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.wl-kind-badge { + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; +} +.wl-alert__name { font-size: 14px; } +.wl-alert__ticker { font-size: 12px; color: var(--muted); } +.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; } +.wl-alert__body { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} +.wl-cond { font-size: 13px; font-weight: 600; } +.wl-alert__price { font-size: 13px; color: var(--muted); } +.wl-alert__detail { font-size: 12px; color: var(--muted); } diff --git a/src/pages/stock/components/WatchlistTab.jsx b/src/pages/stock/components/WatchlistTab.jsx new file mode 100644 index 0000000..aff97ab --- /dev/null +++ b/src/pages/stock/components/WatchlistTab.jsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import Loading from '../../../components/Loading'; +import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils'; +import { formatNumber } from '../stockUtils'; + +const DAYS_OPTIONS = [ + { value: 1, label: '1D' }, + { value: 7, label: '7D' }, + { value: 30, label: '30D' }, +]; + +const AlertCard = ({ a }) => { + const meta = kindMeta(a.kind); + return ( +
+
+ {meta.label} + {a.name || a.ticker} + {a.ticker} + {relativeTime(a.fired_at)} +
+
+ {conditionLabel(a.condition)} + {a.price != null && {formatNumber(a.price)}원} +
+ {a.detail &&
{a.detail}
} +
+ ); +}; + +const WatchlistTab = ({ wl }) => { + const [form, setForm] = useState({ ticker: '', name: '', note: '' }); + + const submit = async (e) => { + e.preventDefault(); + if (!form.ticker.trim()) return; + await wl.add(form); + setForm({ ticker: '', name: '', note: '' }); + }; + + return ( + <> + {/* 관심종목 관리 */} +
+
+
+

관심종목

+

관심종목 관리

+

등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.

+
+
{wl.loading && }
+
+ +
+ setForm((f) => ({ ...f, ticker: e.target.value }))} + /> + setForm((f) => ({ ...f, name: e.target.value }))} + /> + setForm((f) => ({ ...f, note: e.target.value }))} + /> + +
+ + {wl.error &&

{wl.error}

} + + {wl.items.length === 0 ? ( +

아직 관심종목이 없습니다. 종목코드를 추가해 보세요.

+ ) : ( +
    + {wl.items.map((it) => ( +
  • +
    + {it.name || it.ticker} + {it.ticker} + {it.note && {it.note}} +
    + +
  • + ))} +
+ )} +
+ + {/* 최근 시그널 알림 */} +
+
+
+

시그널

+

최근 매매 알림

+

감시 종목에서 발생한 매수·매도 시그널 이력입니다.

+
+
+ {DAYS_OPTIONS.map((o) => ( + + ))} +
+
+ + {wl.alertError &&

{wl.alertError}

} + + {wl.alerts.length === 0 ? ( +

해당 기간에 발생한 알림이 없습니다.

+ ) : ( +
+ {wl.alerts.map((a) => ( + + ))} +
+ )} + +

※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.

+
+ + ); +}; + +export default WatchlistTab; diff --git a/src/pages/stock/components/WatchlistTab.test.jsx b/src/pages/stock/components/WatchlistTab.test.jsx new file mode 100644 index 0000000..ecc4574 --- /dev/null +++ b/src/pages/stock/components/WatchlistTab.test.jsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import WatchlistTab from './WatchlistTab.jsx'; + +const baseWl = { + items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(), + loading: false, error: '', alertError: '', adding: false, + add: vi.fn(), remove: vi.fn(), reload: vi.fn(), +}; + +describe('WatchlistTab', () => { + it('빈 상태: 헤딩과 빈 안내 노출', () => { + render(); + expect(screen.getByText('관심종목 관리')).toBeInTheDocument(); + expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument(); + expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument(); + }); + + it('종목·알림이 있으면 렌더', () => { + const wl = { + ...baseWl, + items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }], + alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }], + }; + render(); + // '삼성전자'는 관심종목 목록 행과 알림 카드 양쪽에 렌더되므로 getAllByText로 확인 + expect(screen.getAllByText('삼성전자')).toHaveLength(2); + expect(screen.getByText('매수')).toBeInTheDocument(); + expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument(); + }); +}); From 3656ee9a599bff58d6ccb2be53ff4b24c0148270 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 02:03:46 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(stock):=20=EA=B1=B0=EB=9E=98=20?= =?UTF-8?q?=EB=8D=B0=EC=8A=A4=ED=81=AC=EC=97=90=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=ED=83=AD=20=EB=93=B1=EC=9E=AC=20+=20API?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- CLAUDE.md | 6 +++++- src/pages/stock/StockTrade.jsx | 15 +++++++++++---- src/pages/stock/stockUtils.js | 1 + 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd35849..3828027 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ | `/blog` | `Blog` | 마크다운 기반 블로그 | | `/lotto` | `Lotto` | 로또 추천/통계 | | `/stock` | `Stock` | 주식 뉴스/지수 | -| `/stock/trade` | `StockTrade` | 주식 트레이딩 | +| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) | | `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | | `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) | | `/realestate/property` | `RealEstate` | 관심 단지 정보 | @@ -102,6 +102,10 @@ proxy: { | 스크리너 | POST | `/api/stock/screener/snapshot/refresh` | | 스크리너 | GET | `/api/stock/screener/runs?limit=N` | | 스크리너 | GET | `/api/stock/screener/runs/:id` | +| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } | +| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } | +| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` | +| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } | | 포트폴리오 | GET/POST | `/api/portfolio` | | 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` | | 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` | diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index e751d72..17d53a0 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -6,7 +6,7 @@ import SwipeableView from '../../components/SwipeableView'; import { formatNumber, formatPercent, toNumeric, profitColorClass, - TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, + TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST, } from './stockUtils'; /* ── hooks ──────────────────────────────────────────────────────── */ @@ -17,12 +17,14 @@ import useAssetHistory from './hooks/useAssetHistory'; import useMarketContext from './hooks/useMarketContext'; import useReportData from './hooks/useReportData'; import useAdvisor from './hooks/useAdvisor'; +import useWatchlist from './hooks/useWatchlist'; /* ── tab components ─────────────────────────────────────────────── */ import PortfolioTab from './components/PortfolioTab'; import ReportTab from './components/ReportTab'; import AdvisorTab from './components/AdvisorTab'; import HoldingsIntelTab from './components/HoldingsIntelTab'; +import WatchlistTab from './components/WatchlistTab'; import SellHistoryDrawer from './components/SellHistoryDrawer'; /* ── component ───────────────────────────────────────────────────── */ @@ -31,8 +33,8 @@ const StockTrade = () => { const [activeTab, setActiveTab] = React.useState(TAB_REPORT); const isMobile = useIsMobile(); - const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL]; - const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔']; + const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST]; + const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목']; const tabIndex = TAB_ORDER.indexOf(activeTab); const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps @@ -62,6 +64,7 @@ const StockTrade = () => { totalAssets: pf.totalAssets, marketCtx, }); + const wl = useWatchlist(); /* ── sell history filter derived ─────────────────────────────── */ const sellHistoryBrokers = useMemo(() => { @@ -169,7 +172,9 @@ const StockTrade = () => { ? : tabId === TAB_ADVISOR ? - : , + : tabId === TAB_HOLDINGS_INTEL + ? + : , }))} activeIndex={tabIndex} onTabChange={handleTabChange} @@ -182,6 +187,7 @@ const StockTrade = () => { { id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' }, { id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' }, { id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' }, + { id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null }, ].map(({ id, icon, label, sub, badge, className: cls }) => (