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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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