Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
907 lines
34 KiB
Markdown
907 lines
34 KiB
Markdown
# 관심종목 탭 (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<string,string>`
|
||
- `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(<WatchlistTab wl={baseWl} />);
|
||
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(<WatchlistTab wl={wl} />);
|
||
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 (
|
||
<div className="wl-alert">
|
||
<div className="wl-alert__head">
|
||
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
|
||
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
|
||
<span className="wl-alert__ticker">{a.ticker}</span>
|
||
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
|
||
</div>
|
||
<div className="wl-alert__body">
|
||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||
</div>
|
||
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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 (
|
||
<>
|
||
{/* 관심종목 관리 */}
|
||
<section className="stock-panel stock-panel--wide wl-panel">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">관심종목</p>
|
||
<h3>관심종목 관리</h3>
|
||
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
|
||
</div>
|
||
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
|
||
</div>
|
||
|
||
<form className="wl-form" onSubmit={submit}>
|
||
<input
|
||
className="wl-form__input"
|
||
placeholder="종목코드 (예: 005930)"
|
||
value={form.ticker}
|
||
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
|
||
/>
|
||
<input
|
||
className="wl-form__input"
|
||
placeholder="종목명 (선택)"
|
||
value={form.name}
|
||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||
/>
|
||
<input
|
||
className="wl-form__input"
|
||
placeholder="메모 (선택)"
|
||
value={form.note}
|
||
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
|
||
/>
|
||
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
|
||
{wl.adding ? '추가 중…' : '추가'}
|
||
</button>
|
||
</form>
|
||
|
||
{wl.error && <p className="stock-error">{wl.error}</p>}
|
||
|
||
{wl.items.length === 0 ? (
|
||
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
|
||
) : (
|
||
<ul className="wl-list">
|
||
{wl.items.map((it) => (
|
||
<li key={it.ticker} className="wl-row">
|
||
<div className="wl-row__meta">
|
||
<strong className="wl-row__name">{it.name || it.ticker}</strong>
|
||
<span className="wl-row__ticker">{it.ticker}</span>
|
||
{it.note && <span className="wl-row__note">{it.note}</span>}
|
||
</div>
|
||
<button
|
||
className="wl-del"
|
||
type="button"
|
||
aria-label={`${it.ticker} 삭제`}
|
||
onClick={() => wl.remove(it.ticker)}
|
||
>
|
||
✕
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
{/* 최근 시그널 알림 */}
|
||
<section className="stock-panel stock-panel--wide wl-panel">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">시그널</p>
|
||
<h3>최근 매매 알림</h3>
|
||
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
|
||
</div>
|
||
<div className="wl-period-toggle">
|
||
{DAYS_OPTIONS.map((o) => (
|
||
<button
|
||
key={o.value}
|
||
type="button"
|
||
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
|
||
onClick={() => wl.setAlertDays(o.value)}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
|
||
|
||
{wl.alerts.length === 0 ? (
|
||
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
|
||
) : (
|
||
<div className="wl-alerts">
|
||
{wl.alerts.map((a) => (
|
||
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<p className="hi-disclaimer">※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
|
||
</section>
|
||
</>
|
||
);
|
||
};
|
||
|
||
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 추가. 기존 `: <HoldingsIntelTab />,` 를 다음으로 교체:
|
||
|
||
```js
|
||
content: tabId === TAB_PORTFOLIO
|
||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||
: tabId === TAB_REPORT
|
||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||
: tabId === TAB_ADVISOR
|
||
? <AdvisorTab pf={pf} advisor={advisor} />
|
||
: tabId === TAB_HOLDINGS_INTEL
|
||
? <HoldingsIntelTab />
|
||
: <WatchlistTab wl={wl} />,
|
||
```
|
||
|
||
(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 && <HoldingsIntelTab />}` 뒤에 추가:
|
||
|
||
```js
|
||
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
|
||
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
|
||
```
|
||
|
||
- [ ] **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 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.
|