Files
web-page/docs/superpowers/plans/2026-07-03-watchlist-tab.md
2026-07-03 01:43:03 +09:00

34 KiB
Raw Blame History

관심종목 탭 (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.jsapiGet/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:

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:

/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */

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
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 함수 뒤)에 추가:

// ── 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:

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:

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
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:

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:

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 파일 맨 끝에 추가:

/* ── 관심종목 탭 (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
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'; 뒤)에 추가:

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):

import {
    formatNumber, formatPercent,
    toNumeric, profitColorClass,
    TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
} from './stockUtils';

(b) 탭 컴포넌트 import 추가 (기존 import HoldingsIntelTab ... 뒤, line 25 근처):

import HoldingsIntelTab from './components/HoldingsIntelTab';
import WatchlistTab from './components/WatchlistTab';

(c) 훅 인스턴스화 + TAB_ORDER/tabLabels 확장. const [activeTab, ...] 아래(line 31 근처)와 hooks 블록에 추가:

    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 ... 뒤):

import useAdvisor from './hooks/useAdvisor';
import useWatchlist from './hooks/useWatchlist';

const wl = useWatchlist(); 는 다른 훅들(const advisor = useAdvisor({...});) 뒤에 배치.

(d) 모바일 SwipeableView content 분기에 watchlist 추가. 기존 : <HoldingsIntelTab />, 를 다음으로 교체:

                        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, ... } 항목 뒤에 추가:

                            { 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 />} 뒤에 추가:

                    {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)
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 엔드포인트 목록" 테이블에 행 추가 (스크리너 관련 행 근처):

| 관심종목 | 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
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 36
  • §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 라인 번호: 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.