fix: KRX data collection + TIGER 200 ticker fix + trade history seed
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
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:
parent
862c1637bd
commit
072b6059d4
@ -12,6 +12,11 @@ KIS_APP_KEY=your_kis_app_key
|
|||||||
KIS_APP_SECRET=your_kis_app_secret
|
KIS_APP_SECRET=your_kis_app_secret
|
||||||
KIS_ACCOUNT_NO=your_account_number
|
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 OpenAPI (Financial Statements, optional)
|
||||||
DART_API_KEY=your_dart_api_key
|
DART_API_KEY=your_dart_api_key
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from app.services.collectors.stock_collector import StockCollector
|
|||||||
from app.services.collectors.sector_collector import SectorCollector
|
from app.services.collectors.sector_collector import SectorCollector
|
||||||
from app.services.collectors.price_collector import PriceCollector
|
from app.services.collectors.price_collector import PriceCollector
|
||||||
from app.services.collectors.valuation_collector import ValuationCollector
|
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.etf_price_collector import ETFPriceCollector
|
||||||
from app.services.collectors.financial_collector import FinancialCollector
|
from app.services.collectors.financial_collector import FinancialCollector
|
||||||
|
|
||||||
@ -16,4 +16,5 @@ __all__ = [
|
|||||||
"ETFCollector",
|
"ETFCollector",
|
||||||
"ETFPriceCollector",
|
"ETFPriceCollector",
|
||||||
"FinancialCollector",
|
"FinancialCollector",
|
||||||
|
"KRXDataError",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -17,6 +17,11 @@ from app.models.stock import ETF, AssetClass
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
class ETFCollector(BaseCollector):
|
||||||
"""Collects ETF master data from KRX."""
|
"""Collects ETF master data from KRX."""
|
||||||
|
|
||||||
@ -47,22 +52,24 @@ class ETFCollector(BaseCollector):
|
|||||||
last_exc = None
|
last_exc = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
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:
|
except (JSONDecodeError, ConnectionError, ValueError, KeyError) as e:
|
||||||
last_exc = e
|
last_exc = e
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
logger.warning(f"ETF fetch failed (attempt 1/2), retrying in 3s: {e}")
|
logger.warning(f"ETF fetch failed (attempt 1/2), retrying in 3s: {e}")
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
logger.error(f"ETF fetch failed after 2 attempts: {last_exc}")
|
error_msg = f"ETF fetch failed after 2 attempts: {last_exc}"
|
||||||
return pd.DataFrame()
|
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:
|
def collect(self) -> int:
|
||||||
"""Collect ETF master data."""
|
"""Collect ETF master data."""
|
||||||
df = self._fetch_etf_data()
|
df = self._fetch_etf_data() # raises KRXDataError on failure
|
||||||
|
|
||||||
if df.empty:
|
|
||||||
logger.warning("No ETF data returned from KRX.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
records = []
|
records = []
|
||||||
for _, row in df.iterrows():
|
for _, row in df.iterrows():
|
||||||
|
|||||||
@ -3,6 +3,7 @@ ETF price data collector using pykrx.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pykrx import stock as pykrx_stock
|
from pykrx import stock as pykrx_stock
|
||||||
@ -107,6 +108,13 @@ class ETFPriceCollector(BaseCollector):
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
total_records += len(records)
|
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:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
logger.warning(f"Failed to fetch ETF prices for {ticker}: {e}")
|
logger.warning(f"Failed to fetch ETF prices for {ticker}: {e}")
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Price data collector using pykrx.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pykrx import stock as pykrx_stock
|
from pykrx import stock as pykrx_stock
|
||||||
@ -127,8 +128,15 @@ class PriceCollector(BaseCollector):
|
|||||||
self.db.commit() # Commit per ticker
|
self.db.commit() # Commit per ticker
|
||||||
total_records += len(records)
|
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:
|
except Exception as e:
|
||||||
self.db.rollback() # Rollback on failure
|
self.db.rollback()
|
||||||
logger.warning(f"Failed to fetch prices for {ticker}: {e}")
|
logger.warning(f"Failed to fetch prices for {ticker}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Stock data collector using pykrx.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pykrx import stock as pykrx_stock
|
from pykrx import stock as pykrx_stock
|
||||||
@ -54,8 +55,14 @@ class StockCollector(BaseCollector):
|
|||||||
def collect(self) -> int:
|
def collect(self) -> int:
|
||||||
"""Collect stock master data."""
|
"""Collect stock master data."""
|
||||||
# Get tickers per market (also caches ticker-name mappings internally)
|
# Get tickers per market (also caches ticker-name mappings internally)
|
||||||
kospi_tickers = pykrx_stock.get_market_ticker_list(self.biz_day, market="KOSPI")
|
try:
|
||||||
kosdaq_tickers = pykrx_stock.get_market_ticker_list(self.biz_day, market="KOSDAQ")
|
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 = {}
|
ticker_market = {}
|
||||||
for t in kospi_tickers:
|
for t in kospi_tickers:
|
||||||
@ -68,8 +75,17 @@ class StockCollector(BaseCollector):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Fetch bulk data
|
# Fetch bulk data
|
||||||
cap_df = pykrx_stock.get_market_cap_by_ticker(self.biz_day)
|
try:
|
||||||
fund_df = pykrx_stock.get_market_fundamental_by_ticker(self.biz_day, market="ALL")
|
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()
|
base_date = datetime.strptime(self.biz_day, "%Y%m%d").date()
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,8 @@ from app.models.stock import Valuation
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REQUIRED_FUNDAMENTAL_COLS = {"BPS", "PER", "PBR", "EPS", "DIV", "DPS"}
|
||||||
|
|
||||||
|
|
||||||
class ValuationCollector(BaseCollector):
|
class ValuationCollector(BaseCollector):
|
||||||
"""Collects valuation metrics (PER, PBR, etc.) using pykrx."""
|
"""Collects valuation metrics (PER, PBR, etc.) using pykrx."""
|
||||||
@ -43,20 +45,55 @@ class ValuationCollector(BaseCollector):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _fetch_fundamental_data(self) -> tuple[pd.DataFrame, str]:
|
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")
|
target_date = datetime.strptime(self.biz_day, "%Y%m%d")
|
||||||
|
krx_auth_error = False
|
||||||
|
|
||||||
for day_offset in range(4): # today + 3 days back
|
for day_offset in range(4): # today + 3 days back
|
||||||
try_date = target_date - timedelta(days=day_offset)
|
try_date = target_date - timedelta(days=day_offset)
|
||||||
try_date_str = try_date.strftime("%Y%m%d")
|
try_date_str = try_date.strftime("%Y%m%d")
|
||||||
try:
|
try:
|
||||||
df = pykrx_stock.get_market_fundamental_by_ticker(try_date_str, market="ALL")
|
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:
|
if day_offset > 0:
|
||||||
logger.info(f"Fell back to {try_date_str} (offset -{day_offset}d)")
|
logger.info(f"Fell back to {try_date_str} (offset -{day_offset}d)")
|
||||||
return df, try_date_str
|
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}")
|
logger.warning(f"Fundamental fetch failed for {try_date_str}: {e}")
|
||||||
continue
|
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")
|
logger.error(f"Fundamental fetch failed for {self.biz_day} and 3 previous days")
|
||||||
return pd.DataFrame(), self.biz_day
|
return pd.DataFrame(), self.biz_day
|
||||||
|
|
||||||
|
|||||||
@ -182,7 +182,7 @@ def get_recommendation(
|
|||||||
foreign_equity_ratio = (equity_pct * Decimal("0.5")).quantize(Decimal("0.01"))
|
foreign_equity_ratio = (equity_pct * Decimal("0.5")).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
recommendations.append(RecommendationItem(
|
recommendations.append(RecommendationItem(
|
||||||
asset_name="KODEX 200",
|
asset_name="TIGER 200",
|
||||||
asset_type="risky",
|
asset_type="risky",
|
||||||
category="equity_etf",
|
category="equity_etf",
|
||||||
ratio=float(domestic_equity_ratio),
|
ratio=float(domestic_equity_ratio),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ dependencies = [
|
|||||||
"python-multipart==0.0.22",
|
"python-multipart==0.0.22",
|
||||||
"apscheduler==3.11.2",
|
"apscheduler==3.11.2",
|
||||||
"setuptools",
|
"setuptools",
|
||||||
"pykrx==1.2.3",
|
"pykrx>=1.2.6",
|
||||||
"requests==2.32.5",
|
"requests==2.32.5",
|
||||||
"beautifulsoup4==4.14.3",
|
"beautifulsoup4==4.14.3",
|
||||||
"lxml==6.0.2",
|
"lxml==6.0.2",
|
||||||
|
|||||||
@ -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:
|
Usage:
|
||||||
cd backend && python -m scripts.seed_data
|
cd backend && python -m scripts.seed_data
|
||||||
@ -9,7 +12,7 @@ Requires: DATABASE_URL environment variable or default dev connection.
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
# Add backend to path
|
# Add backend to path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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.core.database import SessionLocal
|
||||||
from app.models.portfolio import (
|
from app.models.portfolio import (
|
||||||
Portfolio, PortfolioType, Target, Holding,
|
Portfolio, PortfolioType, Target, Holding,
|
||||||
PortfolioSnapshot, SnapshotHolding,
|
|
||||||
Transaction, TransactionType,
|
Transaction, TransactionType,
|
||||||
)
|
)
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@ -26,135 +28,91 @@ from app.models.user import User
|
|||||||
|
|
||||||
# ETF name -> ticker mapping
|
# ETF name -> ticker mapping
|
||||||
ETF_MAP = {
|
ETF_MAP = {
|
||||||
"TIGER 200": "069500",
|
"TIGER 200": "102110",
|
||||||
"KIWOOM 국고채10년": "148070",
|
"KIWOOM 국고채10년": "148070",
|
||||||
"KODEX 200미국채혼합": "284430",
|
"KODEX 200미국채혼합": "284430",
|
||||||
"TIGER 미국S&P500": "360750",
|
"TIGER 미국S&P500": "360750",
|
||||||
"ACE KRX금현물": "411060",
|
"ACE KRX금현물": "411060",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Target ratios
|
# Target ratios (percentage of total portfolio)
|
||||||
TARGETS = {
|
TARGETS = {
|
||||||
"069500": Decimal("0.83"),
|
"102110": Decimal("0.83"),
|
||||||
"148070": Decimal("25"),
|
"148070": Decimal("25"),
|
||||||
"284430": Decimal("41.67"),
|
"284430": Decimal("41.67"),
|
||||||
"360750": Decimal("17.5"),
|
"360750": Decimal("17.5"),
|
||||||
"411060": Decimal("15"),
|
"411060": Decimal("15"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Actual total invested amounts per ticker (from brokerage records)
|
# Actual trade history (date, name, quantity, price_per_unit)
|
||||||
TOTAL_INVESTED = {
|
TRADES = [
|
||||||
"069500": Decimal("541040"),
|
# 2025-04-29: Initial purchases
|
||||||
"148070": Decimal("15432133"),
|
(date(2025, 4, 29), "ACE KRX금현물", 1, Decimal("21620")),
|
||||||
"284430": Decimal("18375975"),
|
(date(2025, 4, 29), "TIGER 미국S&P500", 329, Decimal("19770")),
|
||||||
"360750": Decimal("7683515"),
|
(date(2025, 4, 29), "KIWOOM 국고채10년", 1, Decimal("118000")),
|
||||||
"411060": Decimal("6829620"),
|
(date(2025, 4, 29), "TIGER 200", 16, Decimal("33815")),
|
||||||
}
|
(date(2025, 4, 30), "KODEX 200미국채혼합", 355, Decimal("13235")),
|
||||||
|
|
||||||
# Historical snapshots from data.txt
|
# 2025-05-13 ~ 05-16
|
||||||
SNAPSHOTS = [
|
(date(2025, 5, 13), "ACE KRX금현물", 260, Decimal("20820")),
|
||||||
{
|
(date(2025, 5, 13), "KODEX 200미국채혼합", 14, Decimal("13165")),
|
||||||
"date": date(2025, 4, 28),
|
(date(2025, 5, 14), "ACE KRX금현물", 45, Decimal("20760")),
|
||||||
"total_assets": Decimal("42485834"),
|
(date(2025, 5, 14), "TIGER 미국S&P500", 45, Decimal("20690")),
|
||||||
"holdings": [
|
(date(2025, 5, 14), "KODEX 200미국채혼합", 733, Decimal("13220")),
|
||||||
{"ticker": "069500", "qty": 16, "price": Decimal("33815"), "value": Decimal("541040")},
|
(date(2025, 5, 14), "KIWOOM 국고채10년", 90, Decimal("116939")),
|
||||||
{"ticker": "148070", "qty": 1, "price": Decimal("118000"), "value": Decimal("118000")},
|
(date(2025, 5, 16), "KODEX 200미국채혼합", 169, Decimal("13125")),
|
||||||
{"ticker": "284430", "qty": 355, "price": Decimal("13235"), "value": Decimal("4698435")},
|
|
||||||
{"ticker": "360750", "qty": 329, "price": Decimal("19770"), "value": Decimal("6504330")},
|
# 2025-06-12
|
||||||
{"ticker": "411060", "qty": 1, "price": Decimal("21620"), "value": Decimal("21620")},
|
(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")),
|
||||||
"date": date(2025, 5, 13),
|
|
||||||
"total_assets": Decimal("42485834"),
|
# 2025-07-30
|
||||||
"holdings": [
|
(date(2025, 7, 30), "KIWOOM 국고채10년", 6, Decimal("116760")),
|
||||||
{"ticker": "069500", "qty": 16, "price": Decimal("34805"), "value": Decimal("556880")},
|
|
||||||
{"ticker": "148070", "qty": 1, "price": Decimal("117010"), "value": Decimal("117010")},
|
# 2025-08-14 ~ 08-19
|
||||||
{"ticker": "284430", "qty": 369, "price": Decimal("13175"), "value": Decimal("4861575")},
|
(date(2025, 8, 14), "ACE KRX금현물", 6, Decimal("21095")),
|
||||||
{"ticker": "360750", "qty": 329, "price": Decimal("20490"), "value": Decimal("6741210")},
|
(date(2025, 8, 14), "TIGER 미국S&P500", 3, Decimal("22200")),
|
||||||
{"ticker": "411060", "qty": 261, "price": Decimal("20840"), "value": Decimal("5439240")},
|
(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")),
|
||||||
{
|
|
||||||
"date": date(2025, 6, 11),
|
# 2025-10-13
|
||||||
"total_assets": Decimal("44263097"),
|
(date(2025, 10, 13), "TIGER 미국S&P500", 3, Decimal("23480")),
|
||||||
"holdings": [
|
(date(2025, 10, 13), "KIWOOM 국고채10년", 12, Decimal("116465")),
|
||||||
{"ticker": "069500", "qty": 16, "price": Decimal("39110"), "value": Decimal("625760")},
|
|
||||||
{"ticker": "148070", "qty": 91, "price": Decimal("115790"), "value": Decimal("10536890")},
|
# 2025-12-05
|
||||||
{"ticker": "284430", "qty": 1271, "price": Decimal("13570"), "value": Decimal("17247470")},
|
(date(2025, 12, 5), "KIWOOM 국고채10년", 7, Decimal("112830")),
|
||||||
{"ticker": "360750", "qty": 374, "price": Decimal("20570"), "value": Decimal("7693180")},
|
|
||||||
{"ticker": "411060", "qty": 306, "price": Decimal("20670"), "value": Decimal("6325020")},
|
# 2026-01-07 ~ 01-08
|
||||||
],
|
(date(2026, 1, 7), "TIGER 미국S&P500", 2, Decimal("25015")),
|
||||||
},
|
(date(2026, 1, 8), "KIWOOM 국고채10년", 11, Decimal("109527")),
|
||||||
{
|
|
||||||
"date": date(2025, 7, 30),
|
# 2026-02-20
|
||||||
"total_assets": Decimal("47395573"),
|
(date(2026, 2, 20), "TIGER 미국S&P500", 20, Decimal("24685")),
|
||||||
"holdings": [
|
(date(2026, 2, 20), "KIWOOM 국고채10년", 9, Decimal("108500")),
|
||||||
{"ticker": "069500", "qty": 16, "price": Decimal("43680"), "value": Decimal("698880")},
|
|
||||||
{"ticker": "148070", "qty": 96, "price": Decimal("116470"), "value": Decimal("11181120")},
|
# 2026-03-23
|
||||||
{"ticker": "284430", "qty": 1359, "price": Decimal("14550"), "value": Decimal("19773450")},
|
(date(2026, 3, 23), "ACE KRX금현물", 41, Decimal("30095")),
|
||||||
{"ticker": "360750", "qty": 377, "price": Decimal("22085"), "value": Decimal("8326045")},
|
(date(2026, 3, 23), "TIGER 미국S&P500", 128, Decimal("24290")),
|
||||||
{"ticker": "411060", "qty": 320, "price": Decimal("20870"), "value": Decimal("6678400")},
|
(date(2026, 3, 23), "KODEX 200미국채혼합", 188, Decimal("19579")),
|
||||||
],
|
(date(2026, 3, 23), "KIWOOM 국고채10년", 10, Decimal("106780")),
|
||||||
},
|
|
||||||
{
|
|
||||||
"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")},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def seed(db: Session):
|
||||||
"""Import historical data into database."""
|
"""Import historical data into database."""
|
||||||
# Find admin user (first user in DB)
|
# 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))
|
db.add(Target(portfolio_id=portfolio.id, ticker=ticker, target_ratio=ratio))
|
||||||
print(f"Set {len(TARGETS)} targets")
|
print(f"Set {len(TARGETS)} targets")
|
||||||
|
|
||||||
# Create snapshots
|
# Create transactions from trade history
|
||||||
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
|
|
||||||
tx_count = 0
|
tx_count = 0
|
||||||
for i, snap in enumerate(SNAPSHOTS):
|
for trade_date, name, qty, price in TRADES:
|
||||||
current_holdings = {h["ticker"]: h for h in snap["holdings"]}
|
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:
|
# Set current holdings from computed totals
|
||||||
# First snapshot: all holdings are initial buys
|
computed = _compute_holdings(TRADES)
|
||||||
prev_holdings = {}
|
for ticker, data in computed.items():
|
||||||
else:
|
qty = data["qty"]
|
||||||
prev_holdings = {h["ticker"]: h for h in SNAPSHOTS[i - 1]["holdings"]}
|
avg_price = (data["total_cost"] / qty).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
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"))
|
|
||||||
db.add(Holding(
|
db.add(Holding(
|
||||||
portfolio_id=portfolio.id,
|
portfolio_id=portfolio.id,
|
||||||
ticker=ticker,
|
ticker=ticker,
|
||||||
quantity=qty,
|
quantity=qty,
|
||||||
avg_price=avg_price,
|
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()
|
db.commit()
|
||||||
print("Done!")
|
print("\nDone!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
34
backend/scripts/trade_history_raw.csv
Normal file
34
backend/scripts/trade_history_raw.csv
Normal 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
|
||||||
|
@ -23,7 +23,7 @@ def _seed_stock(db: Session):
|
|||||||
|
|
||||||
def _seed_etf(db: Session):
|
def _seed_etf(db: Session):
|
||||||
"""Add test ETF data."""
|
"""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(etf)
|
||||||
db.add(ETFPrice(ticker="069500", date=date(2025, 1, 2), close=43000, volume=500000))
|
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))
|
db.add(ETFPrice(ticker="069500", date=date(2025, 1, 3), close=43500, volume=600000))
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Tests verify that collectors handle KRX API failures gracefully:
|
|||||||
- JSONDecodeError, ConnectionError, KeyError from pykrx
|
- JSONDecodeError, ConnectionError, KeyError from pykrx
|
||||||
- Retry logic and fallback behavior
|
- Retry logic and fallback behavior
|
||||||
- Existing DB data is never deleted on failure
|
- Existing DB data is never deleted on failure
|
||||||
|
- Clear error messages when KRX login is required
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
@ -18,7 +19,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from app.models.stock import ETF, Valuation
|
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
|
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.time.sleep")
|
||||||
@patch("app.services.collectors.etf_collector.ETF_전종목기본종목")
|
@patch("app.services.collectors.etf_collector.ETF_전종목기본종목")
|
||||||
def test_json_decode_error_retries_once(self, mock_etf_cls, mock_sleep, db):
|
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 = MagicMock()
|
||||||
mock_fetcher.fetch.side_effect = [
|
mock_fetcher.fetch.side_effect = [
|
||||||
JSONDecodeError("msg", "doc", 0),
|
JSONDecodeError("msg", "doc", 0),
|
||||||
pd.DataFrame(), # retry returns empty
|
JSONDecodeError("msg", "doc", 0),
|
||||||
]
|
]
|
||||||
mock_etf_cls.return_value = mock_fetcher
|
mock_etf_cls.return_value = mock_fetcher
|
||||||
|
|
||||||
collector = ETFCollector(db)
|
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
|
assert mock_fetcher.fetch.call_count == 2
|
||||||
mock_sleep.assert_called_once_with(3)
|
mock_sleep.assert_called_once_with(3)
|
||||||
|
|
||||||
@patch("app.services.collectors.etf_collector.time.sleep")
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
||||||
@patch("app.services.collectors.etf_collector.ETF_전종목기본종목")
|
@patch("app.services.collectors.etf_collector.ETF_전종목기본종목")
|
||||||
def test_connection_error_retries_and_returns_zero(self, mock_etf_cls, mock_sleep, db):
|
def test_connection_error_retries_and_raises(self, mock_etf_cls, mock_sleep, db):
|
||||||
"""ConnectionError on both attempts returns 0 without raising."""
|
"""ConnectionError on both attempts raises KRXDataError."""
|
||||||
mock_fetcher = MagicMock()
|
mock_fetcher = MagicMock()
|
||||||
mock_fetcher.fetch.side_effect = ConnectionError("timeout")
|
mock_fetcher.fetch.side_effect = ConnectionError("timeout")
|
||||||
mock_etf_cls.return_value = mock_fetcher
|
mock_etf_cls.return_value = mock_fetcher
|
||||||
|
|
||||||
collector = ETFCollector(db)
|
collector = ETFCollector(db)
|
||||||
result = collector.collect()
|
with pytest.raises(KRXDataError):
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
assert result == 0
|
|
||||||
assert mock_fetcher.fetch.call_count == 2
|
assert mock_fetcher.fetch.call_count == 2
|
||||||
|
|
||||||
@patch("app.services.collectors.etf_collector.time.sleep")
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
||||||
@ -115,9 +116,9 @@ class TestETFCollectorResilience:
|
|||||||
mock_etf_cls.return_value = mock_fetcher
|
mock_etf_cls.return_value = mock_fetcher
|
||||||
|
|
||||||
collector = ETFCollector(db)
|
collector = ETFCollector(db)
|
||||||
result = collector.collect()
|
with pytest.raises(KRXDataError):
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
assert result == 0
|
|
||||||
existing = db.query(ETF).filter_by(ticker="069500").first()
|
existing = db.query(ETF).filter_by(ticker="069500").first()
|
||||||
assert existing is not None
|
assert existing is not None
|
||||||
assert existing.name == "KODEX 200"
|
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):
|
def test_key_error_falls_back_to_previous_days(self, mock_biz, mock_pykrx, db):
|
||||||
"""KeyError triggers fallback to previous business days."""
|
"""KeyError triggers fallback to previous business days."""
|
||||||
good_df = pd.DataFrame(
|
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"],
|
index=["005930"],
|
||||||
)
|
)
|
||||||
mock_pykrx.get_market_fundamental_by_ticker.side_effect = [
|
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.valuation_collector.pykrx_stock")
|
||||||
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
@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):
|
def test_all_days_fail_raises_with_login_hint(self, mock_biz, mock_pykrx, db):
|
||||||
"""When all 4 date attempts fail, returns 0 without raising."""
|
"""When all 4 date attempts fail with KeyError, raises RuntimeError with login hint."""
|
||||||
mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS")
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS")
|
||||||
|
|
||||||
collector = ValuationCollector(db, biz_day="20260327")
|
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
|
assert mock_pykrx.get_market_fundamental_by_ticker.call_count == 4
|
||||||
|
|
||||||
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
||||||
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
@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):
|
def test_json_decode_error_raises_with_login_hint(self, mock_biz, mock_pykrx, db):
|
||||||
"""JSONDecodeError is caught and triggers date fallback."""
|
"""JSONDecodeError on all attempts raises RuntimeError with login hint."""
|
||||||
mock_pykrx.get_market_fundamental_by_ticker.side_effect = JSONDecodeError("msg", "doc", 0)
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = JSONDecodeError("msg", "doc", 0)
|
||||||
|
|
||||||
collector = ValuationCollector(db, biz_day="20260327")
|
collector = ValuationCollector(db, biz_day="20260327")
|
||||||
result = collector.collect()
|
with pytest.raises(RuntimeError, match="KRX requires login"):
|
||||||
|
collector.collect()
|
||||||
assert result == 0
|
|
||||||
|
|
||||||
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
||||||
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
@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):
|
def test_empty_df_triggers_fallback(self, mock_biz, mock_pykrx, db):
|
||||||
"""Empty DataFrame (not exception) also triggers fallback."""
|
"""Empty DataFrame (not exception) also triggers fallback."""
|
||||||
good_df = pd.DataFrame(
|
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"],
|
index=["005930"],
|
||||||
)
|
)
|
||||||
mock_pykrx.get_market_fundamental_by_ticker.side_effect = [
|
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")
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS")
|
||||||
|
|
||||||
collector = ValuationCollector(db, biz_day="20260327")
|
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()
|
existing = db.query(Valuation).filter_by(ticker="005930").first()
|
||||||
assert existing is not None
|
assert existing is not None
|
||||||
assert existing.per == 10.0
|
assert existing.per == 10.0
|
||||||
|
|||||||
8
backend/uv.lock
generated
8
backend/uv.lock
generated
@ -520,7 +520,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic", extras = ["email"], specifier = "==2.12.5" },
|
{ name = "pydantic", extras = ["email"], specifier = "==2.12.5" },
|
||||||
{ name = "pydantic-settings", specifier = "==2.12.0" },
|
{ name = "pydantic-settings", specifier = "==2.12.0" },
|
||||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.11.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", marker = "extra == 'dev'", specifier = "==8.3.4" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.1.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.1.0" },
|
||||||
{ name = "python-multipart", specifier = "==0.0.22" },
|
{ name = "python-multipart", specifier = "==0.0.22" },
|
||||||
@ -1275,7 +1275,7 @@ crypto = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pykrx"
|
name = "pykrx"
|
||||||
version = "1.2.3"
|
version = "1.2.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "deprecated" },
|
{ name = "deprecated" },
|
||||||
@ -1285,9 +1285,9 @@ dependencies = [
|
|||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "requests" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export default function PortfolioDetailPage() {
|
|||||||
// Transaction modal state
|
// Transaction modal state
|
||||||
const [txModalOpen, setTxModalOpen] = useState(false);
|
const [txModalOpen, setTxModalOpen] = useState(false);
|
||||||
const [txSubmitting, setTxSubmitting] = useState(false);
|
const [txSubmitting, setTxSubmitting] = useState(false);
|
||||||
|
const [txManualTicker, setTxManualTicker] = useState(false);
|
||||||
const [txForm, setTxForm] = useState({
|
const [txForm, setTxForm] = useState({
|
||||||
ticker: '',
|
ticker: '',
|
||||||
tx_type: 'buy',
|
tx_type: 'buy',
|
||||||
@ -251,6 +252,7 @@ export default function PortfolioDetailPage() {
|
|||||||
});
|
});
|
||||||
setTxModalOpen(false);
|
setTxModalOpen(false);
|
||||||
setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', executed_at: '', memo: '' });
|
setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', executed_at: '', memo: '' });
|
||||||
|
setTxManualTicker(false);
|
||||||
await Promise.all([fetchPortfolio(), fetchTransactions()]);
|
await Promise.all([fetchPortfolio(), fetchTransactions()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '거래 추가 실패';
|
const message = err instanceof Error ? err.message : '거래 추가 실패';
|
||||||
@ -744,13 +746,70 @@ export default function PortfolioDetailPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tx-ticker">종목코드</Label>
|
<Label>종목</Label>
|
||||||
<Input
|
{(() => {
|
||||||
id="tx-ticker"
|
// Build unique ticker list from holdings + targets
|
||||||
placeholder="예: 069500"
|
const tickerSet = new Map<string, string>();
|
||||||
value={txForm.ticker}
|
if (portfolio) {
|
||||||
onChange={(e) => setTxForm({ ...txForm, ticker: e.target.value })}
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>거래 유형</Label>
|
<Label>거래 유형</Label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user