docs(stock): 관심종목 탭 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
@@ -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<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 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.
|
||||||
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
@@ -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: <count> }` 추가
|
||||||
|
- 모바일 `SwipeableView` content 분기에 `WatchlistTab` 추가
|
||||||
|
- 데스크탑 조건부 렌더 `{activeTab === TAB_WATCHLIST && <WatchlistTab />}` 추가
|
||||||
|
- 탭 뱃지 = 관심종목 개수(훅에서 노출).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 계약 확정 후 별도 스펙으로 확장.
|
||||||
Reference in New Issue
Block a user