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