diff --git a/.env.example b/.env.example index 55d6845..27066ad 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ KIS_APP_KEY=your_kis_app_key KIS_APP_SECRET=your_kis_app_secret KIS_ACCOUNT_NO=your_account_number +# KRX Data Portal (required for data collection since 2026) +# Register at https://data.krx.co.kr to get credentials +KRX_ID=your_krx_login_id +KRX_PW=your_krx_password + # DART OpenAPI (Financial Statements, optional) DART_API_KEY=your_dart_api_key diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py index 787cb3f..e31c6a0 100644 --- a/backend/app/services/collectors/__init__.py +++ b/backend/app/services/collectors/__init__.py @@ -3,7 +3,7 @@ from app.services.collectors.stock_collector import StockCollector from app.services.collectors.sector_collector import SectorCollector from app.services.collectors.price_collector import PriceCollector from app.services.collectors.valuation_collector import ValuationCollector -from app.services.collectors.etf_collector import ETFCollector +from app.services.collectors.etf_collector import ETFCollector, KRXDataError from app.services.collectors.etf_price_collector import ETFPriceCollector from app.services.collectors.financial_collector import FinancialCollector @@ -16,4 +16,5 @@ __all__ = [ "ETFCollector", "ETFPriceCollector", "FinancialCollector", + "KRXDataError", ] diff --git a/backend/app/services/collectors/etf_collector.py b/backend/app/services/collectors/etf_collector.py index c18cc50..aea73a5 100644 --- a/backend/app/services/collectors/etf_collector.py +++ b/backend/app/services/collectors/etf_collector.py @@ -17,6 +17,11 @@ from app.models.stock import ETF, AssetClass logger = logging.getLogger(__name__) +class KRXDataError(Exception): + """Raised when KRX returns invalid or empty data (e.g. login required, server down).""" + pass + + class ETFCollector(BaseCollector): """Collects ETF master data from KRX.""" @@ -47,22 +52,24 @@ class ETFCollector(BaseCollector): last_exc = None for attempt in range(2): try: - return ETF_전종목기본종목().fetch() + df = ETF_전종목기본종목().fetch() + if df is None or df.empty: + raise KRXDataError("KRX returned empty ETF data (login may be required)") + return df except (JSONDecodeError, ConnectionError, ValueError, KeyError) as e: last_exc = e if attempt == 0: logger.warning(f"ETF fetch failed (attempt 1/2), retrying in 3s: {e}") time.sleep(3) - logger.error(f"ETF fetch failed after 2 attempts: {last_exc}") - return pd.DataFrame() + error_msg = f"ETF fetch failed after 2 attempts: {last_exc}" + if isinstance(last_exc, JSONDecodeError): + error_msg += " (KRX may require login — set KRX_ID/KRX_PW env vars)" + logger.error(error_msg) + raise KRXDataError(error_msg) def collect(self) -> int: """Collect ETF master data.""" - df = self._fetch_etf_data() - - if df.empty: - logger.warning("No ETF data returned from KRX.") - return 0 + df = self._fetch_etf_data() # raises KRXDataError on failure records = [] for _, row in df.iterrows(): diff --git a/backend/app/services/collectors/etf_price_collector.py b/backend/app/services/collectors/etf_price_collector.py index 22d8c78..6117c50 100644 --- a/backend/app/services/collectors/etf_price_collector.py +++ b/backend/app/services/collectors/etf_price_collector.py @@ -3,6 +3,7 @@ ETF price data collector using pykrx. """ import logging from datetime import datetime, timedelta +from json import JSONDecodeError import pandas as pd from pykrx import stock as pykrx_stock @@ -107,6 +108,13 @@ class ETFPriceCollector(BaseCollector): self.db.commit() total_records += len(records) + except JSONDecodeError as e: + self.db.rollback() + logger.warning( + f"ETF price fetch for {ticker}: JSON decode error ({e}). " + "KRX may require login — set KRX_ID/KRX_PW env vars." + ) + continue except Exception as e: self.db.rollback() logger.warning(f"Failed to fetch ETF prices for {ticker}: {e}") diff --git a/backend/app/services/collectors/price_collector.py b/backend/app/services/collectors/price_collector.py index 9549a44..827891f 100644 --- a/backend/app/services/collectors/price_collector.py +++ b/backend/app/services/collectors/price_collector.py @@ -3,6 +3,7 @@ Price data collector using pykrx. """ import logging from datetime import datetime, timedelta +from json import JSONDecodeError import pandas as pd from pykrx import stock as pykrx_stock @@ -127,8 +128,15 @@ class PriceCollector(BaseCollector): self.db.commit() # Commit per ticker total_records += len(records) + except JSONDecodeError as e: + self.db.rollback() + logger.warning( + f"Price fetch for {ticker}: JSON decode error ({e}). " + "KRX may require login — set KRX_ID/KRX_PW env vars." + ) + continue except Exception as e: - self.db.rollback() # Rollback on failure + self.db.rollback() logger.warning(f"Failed to fetch prices for {ticker}: {e}") continue diff --git a/backend/app/services/collectors/stock_collector.py b/backend/app/services/collectors/stock_collector.py index 8c905dd..8734f74 100644 --- a/backend/app/services/collectors/stock_collector.py +++ b/backend/app/services/collectors/stock_collector.py @@ -3,6 +3,7 @@ Stock data collector using pykrx. """ import logging from datetime import datetime +from json import JSONDecodeError import pandas as pd from pykrx import stock as pykrx_stock @@ -54,8 +55,14 @@ class StockCollector(BaseCollector): def collect(self) -> int: """Collect stock master data.""" # Get tickers per market (also caches ticker-name mappings internally) - kospi_tickers = pykrx_stock.get_market_ticker_list(self.biz_day, market="KOSPI") - kosdaq_tickers = pykrx_stock.get_market_ticker_list(self.biz_day, market="KOSDAQ") + try: + kospi_tickers = pykrx_stock.get_market_ticker_list(self.biz_day, market="KOSPI") + kosdaq_tickers = pykrx_stock.get_market_ticker_list(self.biz_day, market="KOSDAQ") + except (JSONDecodeError, ConnectionError, ValueError) as e: + raise RuntimeError( + f"Failed to fetch ticker list from KRX: {e}. " + "KRX may require login — set KRX_ID/KRX_PW env vars." + ) ticker_market = {} for t in kospi_tickers: @@ -68,8 +75,17 @@ class StockCollector(BaseCollector): return 0 # Fetch bulk data - cap_df = pykrx_stock.get_market_cap_by_ticker(self.biz_day) - fund_df = pykrx_stock.get_market_fundamental_by_ticker(self.biz_day, market="ALL") + try: + cap_df = pykrx_stock.get_market_cap_by_ticker(self.biz_day) + except (JSONDecodeError, KeyError, ConnectionError, ValueError) as e: + logger.warning(f"Market cap fetch failed: {e}") + cap_df = pd.DataFrame() + + try: + fund_df = pykrx_stock.get_market_fundamental_by_ticker(self.biz_day, market="ALL") + except (JSONDecodeError, KeyError, ConnectionError, ValueError) as e: + logger.warning(f"Fundamental data fetch failed: {e}") + fund_df = pd.DataFrame() base_date = datetime.strptime(self.biz_day, "%Y%m%d").date() diff --git a/backend/app/services/collectors/valuation_collector.py b/backend/app/services/collectors/valuation_collector.py index 81a6748..96aa3f7 100644 --- a/backend/app/services/collectors/valuation_collector.py +++ b/backend/app/services/collectors/valuation_collector.py @@ -17,6 +17,8 @@ from app.models.stock import Valuation logger = logging.getLogger(__name__) +REQUIRED_FUNDAMENTAL_COLS = {"BPS", "PER", "PBR", "EPS", "DIV", "DPS"} + class ValuationCollector(BaseCollector): """Collects valuation metrics (PER, PBR, etc.) using pykrx.""" @@ -43,20 +45,55 @@ class ValuationCollector(BaseCollector): return None def _fetch_fundamental_data(self) -> tuple[pd.DataFrame, str]: - """Fetch fundamental data with fallback to previous business days (up to 3 days back).""" + """Fetch fundamental data with fallback to previous business days (up to 3 days back). + + Raises: + RuntimeError: When KRX returns data without expected columns + (typically means login is required). + """ target_date = datetime.strptime(self.biz_day, "%Y%m%d") + krx_auth_error = False + for day_offset in range(4): # today + 3 days back try_date = target_date - timedelta(days=day_offset) try_date_str = try_date.strftime("%Y%m%d") try: df = pykrx_stock.get_market_fundamental_by_ticker(try_date_str, market="ALL") - if not df.empty: + if df is not None and not df.empty: + # Validate expected columns exist + missing = REQUIRED_FUNDAMENTAL_COLS - set(df.columns) + if missing: + logger.warning( + f"Fundamental data for {try_date_str} missing columns: {missing}. " + "KRX may require login." + ) + krx_auth_error = True + continue if day_offset > 0: logger.info(f"Fell back to {try_date_str} (offset -{day_offset}d)") return df, try_date_str - except (KeyError, JSONDecodeError, ConnectionError, ValueError) as e: + except KeyError as e: + if "BPS" in str(e) or "PER" in str(e): + logger.warning( + f"Fundamental fetch for {try_date_str}: column mismatch ({e}). " + "KRX may require login — set KRX_ID/KRX_PW env vars." + ) + krx_auth_error = True + else: + logger.warning(f"Fundamental fetch failed for {try_date_str}: {e}") + continue + except (JSONDecodeError, ConnectionError, ValueError) as e: + if isinstance(e, JSONDecodeError): + krx_auth_error = True logger.warning(f"Fundamental fetch failed for {try_date_str}: {e}") continue + + if krx_auth_error: + raise RuntimeError( + f"KRX fundamental data unavailable for {self.biz_day}: " + "KRX requires login — set KRX_ID and KRX_PW environment variables. " + "Register at https://data.krx.co.kr" + ) logger.error(f"Fundamental fetch failed for {self.biz_day} and 3 previous days") return pd.DataFrame(), self.biz_day diff --git a/backend/app/services/pension_allocation.py b/backend/app/services/pension_allocation.py index 759888b..22beb7b 100644 --- a/backend/app/services/pension_allocation.py +++ b/backend/app/services/pension_allocation.py @@ -182,7 +182,7 @@ def get_recommendation( foreign_equity_ratio = (equity_pct * Decimal("0.5")).quantize(Decimal("0.01")) recommendations.append(RecommendationItem( - asset_name="KODEX 200", + asset_name="TIGER 200", asset_type="risky", category="equity_etf", ratio=float(domestic_equity_ratio), diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e23de65..ab22397 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "python-multipart==0.0.22", "apscheduler==3.11.2", "setuptools", - "pykrx==1.2.3", + "pykrx>=1.2.6", "requests==2.32.5", "beautifulsoup4==4.14.3", "lxml==6.0.2", diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index 800efc3..b200891 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -1,5 +1,8 @@ """ -One-time script to import historical portfolio data from data.txt. +One-time script to import historical portfolio data. + +Builds portfolio from actual trade history with accurate average prices +and cumulative holdings. Usage: cd backend && python -m scripts.seed_data @@ -9,7 +12,7 @@ Requires: DATABASE_URL environment variable or default dev connection. import sys import os from datetime import date, datetime -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP # Add backend to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -18,7 +21,6 @@ from sqlalchemy.orm import Session from app.core.database import SessionLocal from app.models.portfolio import ( Portfolio, PortfolioType, Target, Holding, - PortfolioSnapshot, SnapshotHolding, Transaction, TransactionType, ) from app.models.user import User @@ -26,135 +28,91 @@ from app.models.user import User # ETF name -> ticker mapping ETF_MAP = { - "TIGER 200": "069500", + "TIGER 200": "102110", "KIWOOM 국고채10년": "148070", "KODEX 200미국채혼합": "284430", "TIGER 미국S&P500": "360750", "ACE KRX금현물": "411060", } -# Target ratios +# Target ratios (percentage of total portfolio) TARGETS = { - "069500": Decimal("0.83"), + "102110": Decimal("0.83"), "148070": Decimal("25"), "284430": Decimal("41.67"), "360750": Decimal("17.5"), "411060": Decimal("15"), } -# Actual total invested amounts per ticker (from brokerage records) -TOTAL_INVESTED = { - "069500": Decimal("541040"), - "148070": Decimal("15432133"), - "284430": Decimal("18375975"), - "360750": Decimal("7683515"), - "411060": Decimal("6829620"), -} +# Actual trade history (date, name, quantity, price_per_unit) +TRADES = [ + # 2025-04-29: Initial purchases + (date(2025, 4, 29), "ACE KRX금현물", 1, Decimal("21620")), + (date(2025, 4, 29), "TIGER 미국S&P500", 329, Decimal("19770")), + (date(2025, 4, 29), "KIWOOM 국고채10년", 1, Decimal("118000")), + (date(2025, 4, 29), "TIGER 200", 16, Decimal("33815")), + (date(2025, 4, 30), "KODEX 200미국채혼합", 355, Decimal("13235")), -# Historical snapshots from data.txt -SNAPSHOTS = [ - { - "date": date(2025, 4, 28), - "total_assets": Decimal("42485834"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("33815"), "value": Decimal("541040")}, - {"ticker": "148070", "qty": 1, "price": Decimal("118000"), "value": Decimal("118000")}, - {"ticker": "284430", "qty": 355, "price": Decimal("13235"), "value": Decimal("4698435")}, - {"ticker": "360750", "qty": 329, "price": Decimal("19770"), "value": Decimal("6504330")}, - {"ticker": "411060", "qty": 1, "price": Decimal("21620"), "value": Decimal("21620")}, - ], - }, - { - "date": date(2025, 5, 13), - "total_assets": Decimal("42485834"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("34805"), "value": Decimal("556880")}, - {"ticker": "148070", "qty": 1, "price": Decimal("117010"), "value": Decimal("117010")}, - {"ticker": "284430", "qty": 369, "price": Decimal("13175"), "value": Decimal("4861575")}, - {"ticker": "360750", "qty": 329, "price": Decimal("20490"), "value": Decimal("6741210")}, - {"ticker": "411060", "qty": 261, "price": Decimal("20840"), "value": Decimal("5439240")}, - ], - }, - { - "date": date(2025, 6, 11), - "total_assets": Decimal("44263097"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("39110"), "value": Decimal("625760")}, - {"ticker": "148070", "qty": 91, "price": Decimal("115790"), "value": Decimal("10536890")}, - {"ticker": "284430", "qty": 1271, "price": Decimal("13570"), "value": Decimal("17247470")}, - {"ticker": "360750", "qty": 374, "price": Decimal("20570"), "value": Decimal("7693180")}, - {"ticker": "411060", "qty": 306, "price": Decimal("20670"), "value": Decimal("6325020")}, - ], - }, - { - "date": date(2025, 7, 30), - "total_assets": Decimal("47395573"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("43680"), "value": Decimal("698880")}, - {"ticker": "148070", "qty": 96, "price": Decimal("116470"), "value": Decimal("11181120")}, - {"ticker": "284430", "qty": 1359, "price": Decimal("14550"), "value": Decimal("19773450")}, - {"ticker": "360750", "qty": 377, "price": Decimal("22085"), "value": Decimal("8326045")}, - {"ticker": "411060", "qty": 320, "price": Decimal("20870"), "value": Decimal("6678400")}, - ], - }, - { - "date": date(2025, 8, 13), - "total_assets": Decimal("47997732"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("43795"), "value": Decimal("700720")}, - {"ticker": "148070", "qty": 102, "price": Decimal("116800"), "value": Decimal("11913600")}, - {"ticker": "284430", "qty": 1359, "price": Decimal("14435"), "value": Decimal("19617165")}, - {"ticker": "360750", "qty": 377, "price": Decimal("22090"), "value": Decimal("8327930")}, - {"ticker": "411060", "qty": 320, "price": Decimal("20995"), "value": Decimal("6718400")}, - ], - }, - { - "date": date(2025, 10, 12), - "total_assets": Decimal("54188966"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("50850"), "value": Decimal("813600")}, - {"ticker": "148070", "qty": 103, "price": Decimal("116070"), "value": Decimal("11955210")}, - {"ticker": "284430", "qty": 1386, "price": Decimal("15665"), "value": Decimal("21711690")}, - {"ticker": "360750", "qty": 380, "price": Decimal("23830"), "value": Decimal("9055400")}, - {"ticker": "411060", "qty": 328, "price": Decimal("27945"), "value": Decimal("9165960")}, - ], - }, - { - "date": date(2025, 12, 4), - "total_assets": Decimal("56860460"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("57190"), "value": Decimal("915040")}, - {"ticker": "148070", "qty": 115, "price": Decimal("112900"), "value": Decimal("12983500")}, - {"ticker": "284430", "qty": 1386, "price": Decimal("16825"), "value": Decimal("23319450")}, - {"ticker": "360750", "qty": 383, "price": Decimal("25080"), "value": Decimal("9605640")}, - {"ticker": "411060", "qty": 328, "price": Decimal("27990"), "value": Decimal("9180720")}, - ], - }, - { - "date": date(2026, 1, 6), - "total_assets": Decimal("58949962"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("66255"), "value": Decimal("1060080")}, - {"ticker": "148070", "qty": 122, "price": Decimal("108985"), "value": Decimal("13296170")}, - {"ticker": "284430", "qty": 1386, "price": Decimal("17595"), "value": Decimal("24386670")}, - {"ticker": "360750", "qty": 383, "price": Decimal("24840"), "value": Decimal("9513720")}, - {"ticker": "411060", "qty": 328, "price": Decimal("29605"), "value": Decimal("9710440")}, - ], - }, - { - "date": date(2026, 2, 16), - "total_assets": Decimal("62433665"), - "holdings": [ - {"ticker": "069500", "qty": 16, "price": Decimal("81835"), "value": Decimal("1309360")}, - {"ticker": "148070", "qty": 133, "price": Decimal("108290"), "value": Decimal("14402570")}, - {"ticker": "284430", "qty": 1386, "price": Decimal("19250"), "value": Decimal("26680500")}, - {"ticker": "360750", "qty": 385, "price": Decimal("24435"), "value": Decimal("9407475")}, - {"ticker": "411060", "qty": 328, "price": Decimal("32420"), "value": Decimal("10633760")}, - ], - }, + # 2025-05-13 ~ 05-16 + (date(2025, 5, 13), "ACE KRX금현물", 260, Decimal("20820")), + (date(2025, 5, 13), "KODEX 200미국채혼합", 14, Decimal("13165")), + (date(2025, 5, 14), "ACE KRX금현물", 45, Decimal("20760")), + (date(2025, 5, 14), "TIGER 미국S&P500", 45, Decimal("20690")), + (date(2025, 5, 14), "KODEX 200미국채혼합", 733, Decimal("13220")), + (date(2025, 5, 14), "KIWOOM 국고채10년", 90, Decimal("116939")), + (date(2025, 5, 16), "KODEX 200미국채혼합", 169, Decimal("13125")), + + # 2025-06-12 + (date(2025, 6, 12), "ACE KRX금현물", 14, Decimal("20855")), + (date(2025, 6, 12), "TIGER 미국S&P500", 3, Decimal("20355")), + (date(2025, 6, 12), "KODEX 200미국채혼합", 88, Decimal("13570")), + (date(2025, 6, 12), "KIWOOM 국고채10년", 5, Decimal("115945")), + + # 2025-07-30 + (date(2025, 7, 30), "KIWOOM 국고채10년", 6, Decimal("116760")), + + # 2025-08-14 ~ 08-19 + (date(2025, 8, 14), "ACE KRX금현물", 6, Decimal("21095")), + (date(2025, 8, 14), "TIGER 미국S&P500", 3, Decimal("22200")), + (date(2025, 8, 14), "KODEX 200미국채혼합", 27, Decimal("14465")), + (date(2025, 8, 14), "KIWOOM 국고채10년", 1, Decimal("117075")), + (date(2025, 8, 19), "ACE KRX금현물", 2, Decimal("21030")), + + # 2025-10-13 + (date(2025, 10, 13), "TIGER 미국S&P500", 3, Decimal("23480")), + (date(2025, 10, 13), "KIWOOM 국고채10년", 12, Decimal("116465")), + + # 2025-12-05 + (date(2025, 12, 5), "KIWOOM 국고채10년", 7, Decimal("112830")), + + # 2026-01-07 ~ 01-08 + (date(2026, 1, 7), "TIGER 미국S&P500", 2, Decimal("25015")), + (date(2026, 1, 8), "KIWOOM 국고채10년", 11, Decimal("109527")), + + # 2026-02-20 + (date(2026, 2, 20), "TIGER 미국S&P500", 20, Decimal("24685")), + (date(2026, 2, 20), "KIWOOM 국고채10년", 9, Decimal("108500")), + + # 2026-03-23 + (date(2026, 3, 23), "ACE KRX금현물", 41, Decimal("30095")), + (date(2026, 3, 23), "TIGER 미국S&P500", 128, Decimal("24290")), + (date(2026, 3, 23), "KODEX 200미국채혼합", 188, Decimal("19579")), + (date(2026, 3, 23), "KIWOOM 국고채10년", 10, Decimal("106780")), ] +def _compute_holdings(trades: list) -> dict: + """Compute current holdings with weighted average prices from trade history.""" + holdings = {} + for _, name, qty, price in trades: + ticker = ETF_MAP[name] + if ticker not in holdings: + holdings[ticker] = {"qty": 0, "total_cost": Decimal("0")} + holdings[ticker]["qty"] += qty + holdings[ticker]["total_cost"] += qty * price + return holdings + + def seed(db: Session): """Import historical data into database.""" # Find admin user (first user in DB) @@ -188,86 +146,48 @@ def seed(db: Session): db.add(Target(portfolio_id=portfolio.id, ticker=ticker, target_ratio=ratio)) print(f"Set {len(TARGETS)} targets") - # Create snapshots - for snap in SNAPSHOTS: - snapshot = PortfolioSnapshot( - portfolio_id=portfolio.id, - total_value=snap["total_assets"], - snapshot_date=snap["date"], - ) - db.add(snapshot) - db.flush() - - total = snap["total_assets"] - for h in snap["holdings"]: - ratio = (h["value"] / total * 100).quantize(Decimal("0.01")) if total > 0 else Decimal("0") - db.add(SnapshotHolding( - snapshot_id=snapshot.id, - ticker=h["ticker"], - quantity=h["qty"], - price=h["price"], - value=h["value"], - current_ratio=ratio, - )) - print(f" Snapshot {snap['date']}: {len(snap['holdings'])} holdings") - - # Create transactions by comparing consecutive snapshots + # Create transactions from trade history tx_count = 0 - for i, snap in enumerate(SNAPSHOTS): - current_holdings = {h["ticker"]: h for h in snap["holdings"]} + for trade_date, name, qty, price in TRADES: + ticker = ETF_MAP[name] + db.add(Transaction( + portfolio_id=portfolio.id, + ticker=ticker, + tx_type=TransactionType.BUY, + quantity=qty, + price=price, + executed_at=datetime.combine(trade_date, datetime.min.time()), + memo=f"{name} 매수", + )) + tx_count += 1 + print(f"Created {tx_count} transactions") - if i == 0: - # First snapshot: all holdings are initial buys - prev_holdings = {} - else: - prev_holdings = {h["ticker"]: h for h in SNAPSHOTS[i - 1]["holdings"]} - - all_tickers = set(current_holdings.keys()) | set(prev_holdings.keys()) - for ticker in all_tickers: - cur_qty = current_holdings[ticker]["qty"] if ticker in current_holdings else 0 - prev_qty = prev_holdings[ticker]["qty"] if ticker in prev_holdings else 0 - diff = cur_qty - prev_qty - - if diff == 0: - continue - - if diff > 0: - tx_type = TransactionType.BUY - price = current_holdings[ticker]["price"] - else: - tx_type = TransactionType.SELL - price = prev_holdings[ticker]["price"] - - db.add(Transaction( - portfolio_id=portfolio.id, - ticker=ticker, - tx_type=tx_type, - quantity=abs(diff), - price=price, - executed_at=datetime.combine(snap["date"], datetime.min.time()), - )) - tx_count += 1 - - print(f"Created {tx_count} transactions from snapshot diffs") - - # Set current holdings from latest snapshot - # avg_price = total invested amount / quantity (from actual brokerage records) - latest = SNAPSHOTS[-1] - for h in latest["holdings"]: - ticker = h["ticker"] - qty = h["qty"] - invested = TOTAL_INVESTED[ticker] - avg_price = (invested / qty).quantize(Decimal("0.01")) + # Set current holdings from computed totals + computed = _compute_holdings(TRADES) + for ticker, data in computed.items(): + qty = data["qty"] + avg_price = (data["total_cost"] / qty).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) db.add(Holding( portfolio_id=portfolio.id, ticker=ticker, quantity=qty, avg_price=avg_price, )) - print(f"Set {len(latest['holdings'])} current holdings from {latest['date']}") + print(f"Set {len(computed)} current holdings") + + # Print summary + print("\n=== Holdings Summary ===") + total_invested = Decimal("0") + for ticker in sorted(computed.keys()): + d = computed[ticker] + avg = (d["total_cost"] / d["qty"]).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + name = [n for n, t in ETF_MAP.items() if t == ticker][0] + print(f" {name:20s} ({ticker}) qty={d['qty']:>5d} avg={avg:>12} invested={d['total_cost']:>15}") + total_invested += d["total_cost"] + print(f" {'TOTAL':20s} invested={total_invested:>15}") db.commit() - print("Done!") + print("\nDone!") if __name__ == "__main__": diff --git a/backend/scripts/trade_history_raw.csv b/backend/scripts/trade_history_raw.csv new file mode 100644 index 0000000..427a1fe --- /dev/null +++ b/backend/scripts/trade_history_raw.csv @@ -0,0 +1,34 @@ +date,name,qty,avg_price,action +2025-04-29,ACE KRX금현물,1,21620,buy +2025-04-29,TIGER 미국S&P500,329,19770,buy +2025-04-29,KIWOOM 국고채10년,1,118000,buy +2025-04-29,TIGER 200,16,33815,buy +2025-04-30,KODEX 200미국채혼합,355,13235,buy +2025-05-13,ACE KRX금현물,260,20820,buy +2025-05-13,KODEX 200미국채혼합,14,13165,buy +2025-05-14,ACE KRX금현물,45,20760,buy +2025-05-14,TIGER 미국S&P500,45,20690,buy +2025-05-14,KODEX 200미국채혼합,733,13220,buy +2025-05-14,KIWOOM 국고채10년,90,116939,buy +2025-05-16,KODEX 200미국채혼합,169,13125,buy +2025-06-12,ACE KRX금현물,14,20855,buy +2025-06-12,TIGER 미국S&P500,3,20355,buy +2025-06-12,KODEX 200미국채혼합,88,13570,buy +2025-06-12,KIWOOM 국고채10년,5,115945,buy +2025-07-30,KIWOOM 국고채10년,6,116760,buy +2025-08-14,ACE KRX금현물,6,21095,buy +2025-08-14,TIGER 미국S&P500,3,22200,buy +2025-08-14,KODEX 200미국채혼합,27,14465,buy +2025-08-14,KIWOOM 국고채10년,1,117075,buy +2025-08-19,ACE KRX금현물,2,21030,buy +2025-10-13,TIGER 미국S&P500,3,23480,buy +2025-10-13,KIWOOM 국고채10년,12,116465,buy +2025-12-05,KIWOOM 국고채10년,7,112830,buy +2026-01-07,TIGER 미국S&P500,2,25015,buy +2026-01-08,KIWOOM 국고채10년,11,109527,buy +2026-02-20,TIGER 미국S&P500,20,24685,buy +2026-02-20,KIWOOM 국고채10년,9,108500,buy +2026-03-23,ACE KRX금현물,41,30095,buy +2026-03-23,TIGER 미국S&P500,128,24290,buy +2026-03-23,KODEX 200미국채혼합,188,19579,buy +2026-03-23,KIWOOM 국고채10년,10,106780,buy diff --git a/backend/tests/e2e/test_data_explorer.py b/backend/tests/e2e/test_data_explorer.py index 271a5f2..f3941de 100644 --- a/backend/tests/e2e/test_data_explorer.py +++ b/backend/tests/e2e/test_data_explorer.py @@ -23,7 +23,7 @@ def _seed_stock(db: Session): def _seed_etf(db: Session): """Add test ETF data.""" - etf = ETF(ticker="069500", name="TIGER 200", asset_class=AssetClass.EQUITY, market="ETF") + etf = ETF(ticker="069500", name="KODEX 200", asset_class=AssetClass.EQUITY, market="ETF") db.add(etf) db.add(ETFPrice(ticker="069500", date=date(2025, 1, 2), close=43000, volume=500000)) db.add(ETFPrice(ticker="069500", date=date(2025, 1, 3), close=43500, volume=600000)) diff --git a/backend/tests/unit/test_collector_resilience.py b/backend/tests/unit/test_collector_resilience.py index 20a4d0e..19e1b50 100644 --- a/backend/tests/unit/test_collector_resilience.py +++ b/backend/tests/unit/test_collector_resilience.py @@ -5,6 +5,7 @@ Tests verify that collectors handle KRX API failures gracefully: - JSONDecodeError, ConnectionError, KeyError from pykrx - Retry logic and fallback behavior - Existing DB data is never deleted on failure +- Clear error messages when KRX login is required """ from datetime import date from json import JSONDecodeError @@ -18,7 +19,7 @@ from sqlalchemy.pool import StaticPool from app.core.database import Base from app.models.stock import ETF, Valuation -from app.services.collectors.etf_collector import ETFCollector +from app.services.collectors.etf_collector import ETFCollector, KRXDataError from app.services.collectors.valuation_collector import ValuationCollector @@ -46,33 +47,33 @@ class TestETFCollectorResilience: @patch("app.services.collectors.etf_collector.time.sleep") @patch("app.services.collectors.etf_collector.ETF_전종목기본종목") def test_json_decode_error_retries_once(self, mock_etf_cls, mock_sleep, db): - """JSONDecodeError on first attempt triggers 1 retry with 3s delay.""" + """JSONDecodeError on both attempts raises KRXDataError with login hint.""" mock_fetcher = MagicMock() mock_fetcher.fetch.side_effect = [ JSONDecodeError("msg", "doc", 0), - pd.DataFrame(), # retry returns empty + JSONDecodeError("msg", "doc", 0), ] mock_etf_cls.return_value = mock_fetcher collector = ETFCollector(db) - result = collector.collect() + with pytest.raises(KRXDataError, match="KRX_ID/KRX_PW"): + collector.collect() - assert result == 0 assert mock_fetcher.fetch.call_count == 2 mock_sleep.assert_called_once_with(3) @patch("app.services.collectors.etf_collector.time.sleep") @patch("app.services.collectors.etf_collector.ETF_전종목기본종목") - def test_connection_error_retries_and_returns_zero(self, mock_etf_cls, mock_sleep, db): - """ConnectionError on both attempts returns 0 without raising.""" + def test_connection_error_retries_and_raises(self, mock_etf_cls, mock_sleep, db): + """ConnectionError on both attempts raises KRXDataError.""" mock_fetcher = MagicMock() mock_fetcher.fetch.side_effect = ConnectionError("timeout") mock_etf_cls.return_value = mock_fetcher collector = ETFCollector(db) - result = collector.collect() + with pytest.raises(KRXDataError): + collector.collect() - assert result == 0 assert mock_fetcher.fetch.call_count == 2 @patch("app.services.collectors.etf_collector.time.sleep") @@ -115,9 +116,9 @@ class TestETFCollectorResilience: mock_etf_cls.return_value = mock_fetcher collector = ETFCollector(db) - result = collector.collect() + with pytest.raises(KRXDataError): + collector.collect() - assert result == 0 existing = db.query(ETF).filter_by(ticker="069500").first() assert existing is not None assert existing.name == "KODEX 200" @@ -133,7 +134,7 @@ class TestValuationCollectorResilience: def test_key_error_falls_back_to_previous_days(self, mock_biz, mock_pykrx, db): """KeyError triggers fallback to previous business days.""" good_df = pd.DataFrame( - {"PER": [10.0], "PBR": [1.5], "DIV": [2.0]}, + {"BPS": [50000], "PER": [10.0], "PBR": [1.5], "EPS": [5000], "DIV": [2.0], "DPS": [1000]}, index=["005930"], ) mock_pykrx.get_market_fundamental_by_ticker.side_effect = [ @@ -150,33 +151,32 @@ class TestValuationCollectorResilience: @patch("app.services.collectors.valuation_collector.pykrx_stock") @patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327") - def test_all_days_fail_returns_zero(self, mock_biz, mock_pykrx, db): - """When all 4 date attempts fail, returns 0 without raising.""" + def test_all_days_fail_raises_with_login_hint(self, mock_biz, mock_pykrx, db): + """When all 4 date attempts fail with KeyError, raises RuntimeError with login hint.""" mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS") collector = ValuationCollector(db, biz_day="20260327") - result = collector.collect() + with pytest.raises(RuntimeError, match="KRX_ID"): + collector.collect() - assert result == 0 assert mock_pykrx.get_market_fundamental_by_ticker.call_count == 4 @patch("app.services.collectors.valuation_collector.pykrx_stock") @patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327") - def test_json_decode_error_handled(self, mock_biz, mock_pykrx, db): - """JSONDecodeError is caught and triggers date fallback.""" + def test_json_decode_error_raises_with_login_hint(self, mock_biz, mock_pykrx, db): + """JSONDecodeError on all attempts raises RuntimeError with login hint.""" mock_pykrx.get_market_fundamental_by_ticker.side_effect = JSONDecodeError("msg", "doc", 0) collector = ValuationCollector(db, biz_day="20260327") - result = collector.collect() - - assert result == 0 + with pytest.raises(RuntimeError, match="KRX requires login"): + collector.collect() @patch("app.services.collectors.valuation_collector.pykrx_stock") @patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327") def test_empty_df_triggers_fallback(self, mock_biz, mock_pykrx, db): """Empty DataFrame (not exception) also triggers fallback.""" good_df = pd.DataFrame( - {"PER": [15.0], "PBR": [2.0], "DIV": [1.5]}, + {"BPS": [60000], "PER": [15.0], "PBR": [2.0], "EPS": [4000], "DIV": [1.5], "DPS": [800]}, index=["005930"], ) mock_pykrx.get_market_fundamental_by_ticker.side_effect = [ @@ -204,9 +204,9 @@ class TestValuationCollectorResilience: mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS") collector = ValuationCollector(db, biz_day="20260327") - result = collector.collect() + with pytest.raises(RuntimeError): + collector.collect() - assert result == 0 existing = db.query(Valuation).filter_by(ticker="005930").first() assert existing is not None assert existing.per == 10.0 diff --git a/backend/uv.lock b/backend/uv.lock index 1a0d7ff..2600357 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -520,7 +520,7 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = "==2.12.5" }, { name = "pydantic-settings", specifier = "==2.12.0" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.11.0" }, - { name = "pykrx", specifier = "==1.2.3" }, + { name = "pykrx", specifier = ">=1.2.6" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.1.0" }, { name = "python-multipart", specifier = "==0.0.22" }, @@ -1275,7 +1275,7 @@ crypto = [ [[package]] name = "pykrx" -version = "1.2.3" +version = "1.2.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, @@ -1285,9 +1285,9 @@ dependencies = [ { name = "pandas" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/b6/c0362752fc8ddcde5b3b0221823ffccc5ebddaebb8a8202cc1b4ccc57461/pykrx-1.2.3.tar.gz", hash = "sha256:b03eed334c0a0bb1a61490af9116b2dfa3621420e6a73a2f5b784f32388f64ff", size = 5866376, upload-time = "2026-01-25T06:53:25.905Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/81/f4d01dd1f6b96bfe1da6de3fca6fb7b58823034434271c94af88e3ca8ab3/pykrx-1.2.6.tar.gz", hash = "sha256:8ab8c28a6ed5d2072670c5eb1b214d340bde57ff0e1107dc2d9660409f00a782", size = 5597487, upload-time = "2026-04-14T10:17:41.41Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/a1/30bfab28d78f440eb1ec7b9c77ff61464205dcb76b37052b90723fdfb350/pykrx-1.2.3-py3-none-any.whl", hash = "sha256:22d4adb4987de5d1c4bbb35cc7e47662fe156564f8dbf52f8c05cbeafdc2f0ee", size = 2191780, upload-time = "2026-01-25T06:53:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/5bd38daca2de1f514c861f9b175f2b11b4396fdfec3785110f56f1132c01/pykrx-1.2.6-py3-none-any.whl", hash = "sha256:3abb819efe501d2fab055ed913ebd407fa185bb74155378c1e2f831a0ed15398", size = 2196530, upload-time = "2026-04-14T10:17:40.063Z" }, ] [[package]] diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index c34e094..8e6063b 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -122,6 +122,7 @@ export default function PortfolioDetailPage() { // Transaction modal state const [txModalOpen, setTxModalOpen] = useState(false); const [txSubmitting, setTxSubmitting] = useState(false); + const [txManualTicker, setTxManualTicker] = useState(false); const [txForm, setTxForm] = useState({ ticker: '', tx_type: 'buy', @@ -251,6 +252,7 @@ export default function PortfolioDetailPage() { }); setTxModalOpen(false); setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', executed_at: '', memo: '' }); + setTxManualTicker(false); await Promise.all([fetchPortfolio(), fetchTransactions()]); } catch (err) { const message = err instanceof Error ? err.message : '거래 추가 실패'; @@ -744,13 +746,70 @@ export default function PortfolioDetailPage() {