diff --git a/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md b/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md index 439a83a..a3a31df 100644 --- a/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md +++ b/docs/superpowers/plans/2026-05-16-insta-trends-implementation.md @@ -2,6 +2,10 @@ > **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. +## ⚠️ 변경 이력 + +- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 task와 코드 블록은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint(RSS + dailytrends JSON 양쪽) 모두 404 폐기 확인. YouTube Data API v3 mostPopular로 source 대체 + pytrends 의존성 제거. 운영 코드는 현재 `youtube_trending` 사용 중. 이 plan을 다시 실행할 일이 있으면 본문의 `google_trends` 단어를 `youtube_trending`으로 읽어달라. 자세한 사유와 교체 체크리스트는 `feedback_external_data_sources.md`. + **Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline. **Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact). diff --git a/docs/superpowers/specs/2026-05-16-insta-trends-design.md b/docs/superpowers/specs/2026-05-16-insta-trends-design.md index 94e5027..2593831 100644 --- a/docs/superpowers/specs/2026-05-16-insta-trends-design.md +++ b/docs/superpowers/specs/2026-05-16-insta-trends-design.md @@ -4,6 +4,10 @@ 상태: 사용자 승인 대기 → writing-plans 진입 예정 연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계) +## ⚠️ 변경 이력 + +- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular®ionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends` → `youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`. + --- ## 1. 목적·배경 diff --git a/stock/API_SPEC.md b/stock/API_SPEC.md index 44b1a70..5b81a87 100644 --- a/stock/API_SPEC.md +++ b/stock/API_SPEC.md @@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용. "name": "삼성전자", "quantity": 100, "avg_price": 72000, + "purchase_price": 72000, "current_price": 74500, "price_session": "NXT_AFTER", "price_as_of": "2026-05-11T19:21:40+09:00", @@ -159,6 +160,10 @@ KB증권·삼성증권 등 Open API 미제공 증권사용. } ``` +> **`purchase_price` 필드**: 종목별 매입 단가(1주당). 사용자가 수동 등록한 매입가가 +> 평균단가(`avg_price`)와 다를 때 표시용으로 분리한다. 미설정 시 `avg_price`로 폴백. +> `summary.total_buy = SUM(purchase_price × quantity)` (CODE_REVIEW F4에서 명세 정합화). + > **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다. > 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요. diff --git a/stock/app/main.py b/stock/app/main.py index 4ae084a..1a11d66 100644 --- a/stock/app/main.py +++ b/stock/app/main.py @@ -354,11 +354,11 @@ def get_portfolio(): price_session = detail["session"] if detail else None price_as_of = detail["as_of"] if detail else None # avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준 - # purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백) + # purchase_price: 매입 단가(1주당) — 없으면 avg_price로 폴백 (CODE_REVIEW F4) purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"] cost_basis = item["avg_price"] * item["quantity"] - # 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산) - buy_amount = purchase_price + # 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치 + buy_amount = purchase_price * item["quantity"] eval_amount = current_price * item["quantity"] if current_price is not None else None profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None diff --git a/stock/tests/test_portfolio_total_buy.py b/stock/tests/test_portfolio_total_buy.py new file mode 100644 index 0000000..a6be9f9 --- /dev/null +++ b/stock/tests/test_portfolio_total_buy.py @@ -0,0 +1,77 @@ +"""포트폴리오 /api/portfolio 응답의 total_buy 계산 회귀 테스트 (CODE_REVIEW F4). + +purchase_price는 종목별 단가(1주당) 의미. total_buy = SUM(purchase_price × quantity). +purchase_price가 없으면 avg_price로 폴백 후 동일하게 수량 곱산. +""" +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.main import app + + +def _fake_db_setup(monkeypatch, items, cash=None): + from app import main as stock_main + monkeypatch.setattr(stock_main, "get_all_portfolio", lambda: items) + monkeypatch.setattr(stock_main, "get_all_broker_cash", lambda: cash or []) + + +def test_portfolio_total_buy_uses_purchase_price_times_quantity(monkeypatch): + """purchase_price 설정 시: total_buy = purchase_price × quantity 의 합.""" + items = [ + {"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자", + "quantity": 100, "avg_price": 72000, "purchase_price": 70000}, + ] + fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}} + _fake_db_setup(monkeypatch, items) + from app import main as stock_main + monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices) + + client = TestClient(app) + resp = client.get("/api/portfolio") + assert resp.status_code == 200 + data = resp.json() + # purchase_price=70000 × quantity=100 = 7,000,000 + assert data["summary"]["total_buy"] == 7_000_000 + + +def test_portfolio_total_buy_falls_back_to_avg_price_with_quantity(monkeypatch): + """purchase_price 미설정 시: avg_price 폴백 + 수량 곱산. API_SPEC 예시와 일치.""" + items = [ + {"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자", + "quantity": 100, "avg_price": 72000, "purchase_price": None}, + ] + fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}} + _fake_db_setup(monkeypatch, items) + from app import main as stock_main + monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices) + + client = TestClient(app) + resp = client.get("/api/portfolio") + assert resp.status_code == 200 + data = resp.json() + # avg_price=72000 × quantity=100 = 7,200,000 (API_SPEC.md 예시와 일치) + assert data["summary"]["total_buy"] == 7_200_000 + + +def test_portfolio_total_buy_sums_multiple_holdings(monkeypatch): + """여러 종목 합산도 단가 × 수량 합.""" + items = [ + {"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자", + "quantity": 100, "avg_price": 70000, "purchase_price": 70000}, + {"id": 2, "broker": "NH", "ticker": "000660", "name": "SK하이닉스", + "quantity": 50, "avg_price": 130000, "purchase_price": 130000}, + ] + fake_prices = { + "005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}, + "000660": {"price": 140000, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}, + } + _fake_db_setup(monkeypatch, items) + from app import main as stock_main + monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices) + + client = TestClient(app) + resp = client.get("/api/portfolio") + data = resp.json() + # 70000*100 + 130000*50 = 7,000,000 + 6,500,000 = 13,500,000 + assert data["summary"]["total_buy"] == 13_500_000