fix: KRX data collection + TIGER 200 ticker fix + trade history seed
Some checks are pending
Deploy to Production / deploy (push) Waiting to run

- Upgrade pykrx 1.2.3 → 1.2.6 (KRX login session support)
- Add KRX_ID/KRX_PW env vars for KRX authentication
- Enhance error handling in all pykrx-dependent collectors
  - ETFCollector: raise KRXDataError with login hint
  - ValuationCollector: raise RuntimeError with login hint
  - StockCollector/PriceCollector/ETFPriceCollector: JSONDecodeError handling
- Fix TIGER 200 ticker: 069500 → 102110 in seed data
- Rebuild seed_data.py from actual 33 trade records
- Add trade_history_raw.csv as source data
- Fix pension_allocation recommendation: KODEX 200 → TIGER 200
- Add ticker dropdown to transaction add modal (frontend)
- Update .env.example with KRX credentials
- All 276 tests passing
This commit is contained in:
머니페니 2026-04-15 22:16:42 +09:00
parent 862c1637bd
commit 072b6059d4
15 changed files with 335 additions and 240 deletions

View File

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

View File

@ -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",
]

View File

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

View File

@ -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}")

View File

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

View File

@ -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()

View File

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

View File

@ -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),

View File

@ -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",

View File

@ -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__":

View File

@ -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
1 date name qty avg_price action
2 2025-04-29 ACE KRX금현물 1 21620 buy
3 2025-04-29 TIGER 미국S&P500 329 19770 buy
4 2025-04-29 KIWOOM 국고채10년 1 118000 buy
5 2025-04-29 TIGER 200 16 33815 buy
6 2025-04-30 KODEX 200미국채혼합 355 13235 buy
7 2025-05-13 ACE KRX금현물 260 20820 buy
8 2025-05-13 KODEX 200미국채혼합 14 13165 buy
9 2025-05-14 ACE KRX금현물 45 20760 buy
10 2025-05-14 TIGER 미국S&P500 45 20690 buy
11 2025-05-14 KODEX 200미국채혼합 733 13220 buy
12 2025-05-14 KIWOOM 국고채10년 90 116939 buy
13 2025-05-16 KODEX 200미국채혼합 169 13125 buy
14 2025-06-12 ACE KRX금현물 14 20855 buy
15 2025-06-12 TIGER 미국S&P500 3 20355 buy
16 2025-06-12 KODEX 200미국채혼합 88 13570 buy
17 2025-06-12 KIWOOM 국고채10년 5 115945 buy
18 2025-07-30 KIWOOM 국고채10년 6 116760 buy
19 2025-08-14 ACE KRX금현물 6 21095 buy
20 2025-08-14 TIGER 미국S&P500 3 22200 buy
21 2025-08-14 KODEX 200미국채혼합 27 14465 buy
22 2025-08-14 KIWOOM 국고채10년 1 117075 buy
23 2025-08-19 ACE KRX금현물 2 21030 buy
24 2025-10-13 TIGER 미국S&P500 3 23480 buy
25 2025-10-13 KIWOOM 국고채10년 12 116465 buy
26 2025-12-05 KIWOOM 국고채10년 7 112830 buy
27 2026-01-07 TIGER 미국S&P500 2 25015 buy
28 2026-01-08 KIWOOM 국고채10년 11 109527 buy
29 2026-02-20 TIGER 미국S&P500 20 24685 buy
30 2026-02-20 KIWOOM 국고채10년 9 108500 buy
31 2026-03-23 ACE KRX금현물 41 30095 buy
32 2026-03-23 TIGER 미국S&P500 128 24290 buy
33 2026-03-23 KODEX 200미국채혼합 188 19579 buy
34 2026-03-23 KIWOOM 국고채10년 10 106780 buy

View File

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

View File

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

8
backend/uv.lock generated
View File

@ -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]]

View File

@ -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() {
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tx-ticker"></Label>
<Input
id="tx-ticker"
placeholder="예: 069500"
value={txForm.ticker}
onChange={(e) => setTxForm({ ...txForm, ticker: e.target.value })}
/>
<Label></Label>
{(() => {
// Build unique ticker list from holdings + targets
const tickerSet = new Map<string, string>();
if (portfolio) {
for (const h of portfolio.holdings) {
tickerSet.set(h.ticker, h.name || h.ticker);
}
for (const t of portfolio.targets) {
if (!tickerSet.has(t.ticker)) {
tickerSet.set(t.ticker, t.ticker);
}
}
}
const tickerOptions = Array.from(tickerSet.entries());
if (tickerOptions.length > 0) {
return (
<>
<Select
value={txManualTicker ? '__manual__' : txForm.ticker || undefined}
onValueChange={(v) => {
if (v === '__manual__') {
setTxManualTicker(true);
setTxForm({ ...txForm, ticker: '' });
} else {
setTxManualTicker(false);
setTxForm({ ...txForm, ticker: v });
}
}}
>
<SelectTrigger>
<SelectValue placeholder="종목 선택" />
</SelectTrigger>
<SelectContent>
{tickerOptions.map(([ticker, name]) => (
<SelectItem key={ticker} value={ticker}>
{name} ({ticker})
</SelectItem>
))}
<SelectItem value="__manual__"> </SelectItem>
</SelectContent>
</Select>
{txManualTicker && (
<Input
placeholder="종목코드 입력 (예: 069500)"
value={txForm.ticker}
onChange={(e) => setTxForm({ ...txForm, ticker: e.target.value })}
className="mt-2"
autoFocus
/>
)}
</>
);
}
return (
<Input
placeholder="종목코드 입력 (예: 069500)"
value={txForm.ticker}
onChange={(e) => setTxForm({ ...txForm, ticker: e.target.value })}
/>
);
})()}
</div>
<div className="space-y-2">
<Label> </Label>