diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 073611a..64d26e8 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -246,10 +246,12 @@ class PortfolioCache: # D+2 정산액 및 기타 상세 정보 추출 d2_settlement = 0.0 stock_value_from_api = 0.0 + bfdy_total_assets = 0.0 if balance_data and "output2" in balance_data: output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"] d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"]) stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"]) + bfdy_total_assets = pick_float(output2, ["bfdy_tot_asst_evlu_amt"]) # 해외 잔고 합산 (원화 환산 총자산에 포함) overseas_total_krw = 0.0 @@ -263,6 +265,10 @@ class PortfolioCache: total_assets += overseas_total_krw logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원") + # 금일 평가손익 (전일 총자산 대비 오늘 변화) + today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0 + today_profit_loss_rate = (today_profit_loss / bfdy_total_assets * 100) if bfdy_total_assets > 0 else 0.0 + self.summary = { "total_stocks": total_stocks, "total_value": total_value, @@ -273,6 +279,9 @@ class PortfolioCache: "total_assets": total_assets, "d2_settlement": d2_settlement, "stock_value_from_api": stock_value_from_api, + "bfdy_total_assets": bfdy_total_assets, + "today_profit_loss": today_profit_loss, + "today_profit_loss_rate": today_profit_loss_rate, # 해외 관련 추가 필드 "overseas_value_usd": overseas_value_usd, "overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw, @@ -632,26 +641,36 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)): if portfolio_cache.summary and not portfolio_cache.should_sync(): logger.debug("Portfolio cache hit (summary)") resp = dict(portfolio_cache.summary) - resp.update({ - "sync_status": "ok" if portfolio_cache.last_error is None else "error", - "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, - "last_error": portfolio_cache.last_error, + else: + try: + await sync_portfolio(db, force=False) + except HTTPException: + logger.warning("get_portfolio_summary: sync failed, returning cached summary") + resp = dict(portfolio_cache.summary or { + "total_stocks": 0, + "total_value": 0, + "total_investment": 0, + "total_profit_loss": 0, + "total_profit_loss_rate": 0, + "available_cash": 0, + "total_assets": 0, }) - return resp + + # 누적 실현 손익 (TradeHistory SELL 체결 기준) try: - await sync_portfolio(db, force=False) - except HTTPException: - logger.warning("get_portfolio_summary: sync failed, returning cached summary") - base = portfolio_cache.summary or { - "total_stocks": 0, - "total_value": 0, - "total_investment": 0, - "total_profit_loss": 0, - "total_profit_loss_rate": 0, - "available_cash": 0, - "total_assets": 0 - } - resp = dict(base) + realized_result = await db.execute( + select(func.sum(TradeHistory.net_profit_loss)).where( + TradeHistory.order_type == "SELL", + TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), + TradeHistory.net_profit_loss.isnot(None), + ) + ) + cumulative_realized_pl = float(realized_result.scalar() or 0) + except Exception as e: + logger.warning(f"누적 실현손익 조회 실패: {e}") + cumulative_realized_pl = 0.0 + + resp["cumulative_realized_pl"] = cumulative_realized_pl resp.update({ "sync_status": "ok" if portfolio_cache.last_error is None else "error", "last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0c49047..8cbb853 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -61,9 +61,8 @@ class Settings(BaseSettings): KIS_ACCOUNT_NO: str = "" KIS_BASE_URL: str = "" - # KRX OpenAPI (data-dbg.krx.co.kr) - KRX_API_KEY: str = "" - KRX_API_URL: str = "https://openapi.krx.co.kr" # 포털 URL (참고용) + # 공공데이터포털 금융위원회 주식시세 API (apis.data.go.kr) + DATAGOKR_API_KEY: str = "" # WebSocket 접속키 (수동 지정 시 우선, 비어 있으면 자동 발급) KIS_WS_APPROVAL_KEY: str = "" diff --git a/backend/app/main.py b/backend/app/main.py index e555074..3df208a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -331,6 +331,72 @@ async def day_trade_scheduler(): raise +async def krx_data_collector_scheduler(): + """장 종료 후 KRX 일봉 데이터 자동 수집 스케줄러. + + 매일 15:40 KST 이후 collect_all(update_mode=True) 를 1회 실행. + 이미 오늘 수집 완료된 경우 다음 날까지 스킵. + """ + import pytz as _pytz + from datetime import datetime as _dt, date as _date + + _KST = _pytz.timezone("Asia/Seoul") + _last_collected: _date | None = None + + await asyncio.sleep(30) # 부팅 안정화 + logger.info("📅 KRX data collector scheduler started (daily after 15:40 KST)") + + try: + while True: + now_kst = _dt.now(_KST) + today = now_kst.date() + + # 오늘 이미 수집했으면 자정까지 대기 + if _last_collected == today: + # 다음 날 16:00까지 남은 초 계산 + from datetime import timedelta as _td + tomorrow_16 = _KST.localize( + _dt(today.year, today.month, today.day, 16, 0, 0) + ) + _td(days=1) + sleep_sec = (tomorrow_16 - now_kst).total_seconds() + logger.info(f"📅 KRX 오늘 수집 완료. 다음 수집까지 {sleep_sec/3600:.1f}시간 대기") + await asyncio.sleep(max(sleep_sec, 60)) + continue + + # 15:40 이전이면 대기 + collect_hour, collect_min = 15, 40 + if (now_kst.hour, now_kst.minute) < (collect_hour, collect_min): + # 오늘 15:40까지 남은 초 + target = _KST.localize( + _dt(today.year, today.month, today.day, collect_hour, collect_min, 0) + ) + sleep_sec = (target - now_kst).total_seconds() + if sleep_sec > 0: + logger.info(f"📅 KRX 수집 대기 중... 15:40까지 {sleep_sec/60:.0f}분 남음") + await asyncio.sleep(min(sleep_sec, 1800)) # 최대 30분마다 재확인 + continue + + # 주말(토/일)은 스킵하고 다음 날 재확인 + if today.weekday() >= 5: + await asyncio.sleep(3600) + continue + + # 수집 실행 + try: + logger.info("📥 [KRX] 일봉 데이터 증분 수집 시작 (update mode)...") + from collect_krx_data import collect_all + await collect_all(update_mode=True) + _last_collected = today + logger.info("✅ [KRX] 일봉 데이터 수집 완료") + except Exception as e: + logger.error(f"❌ [KRX] 데이터 수집 실패: {e}", exc_info=True) + await asyncio.sleep(1800) # 실패 시 30분 후 재시도 + + except asyncio.CancelledError: + logger.info("⏹️ KRX data collector scheduler stopped") + raise + + @asynccontextmanager async def lifespan(app: FastAPI): """애플리케이션 시작/종료 시 실행될 코드""" @@ -428,6 +494,7 @@ async def lifespan(app: FastAPI): app.state.auto_trade_task = asyncio.create_task(auto_trade_scheduler()) app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler()) app.state.day_trade_task = asyncio.create_task(day_trade_scheduler()) + app.state.krx_collector_task = asyncio.create_task(krx_data_collector_scheduler()) # KIS 실시간 스트림 시작 (설정으로 제어 가능) if settings.KIS_WS_ENABLED: @@ -445,6 +512,7 @@ async def lifespan(app: FastAPI): "auto_trade_task", "batch_analysis_task", "day_trade_task", + "krx_collector_task", "db_worker_task", "kis_stream_task", ): diff --git a/backend/batch_daily_analysis.py b/backend/batch_daily_analysis.py index efafce4..6fc9994 100644 --- a/backend/batch_daily_analysis.py +++ b/backend/batch_daily_analysis.py @@ -31,7 +31,6 @@ sys.path.insert(0, str(Path(__file__).parent)) from app.core.config import settings from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용 -import aiohttp from sqlalchemy import select as sa_select from app.models.models import StockDailyPrice from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화 @@ -120,146 +119,28 @@ async def detect_and_log_market_regime() -> str: # ------------------------------------------------------------------- async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]: - """거래 가능 종목 조회 (네이버 금융 sise 사용) + """KIS 거래량 순위 API로 거래 가능 종목 조회 (KOSPI+KOSDAQ 각 top 40). - 네이버 금융 전체 시세 페이지에서 KOSPI+KOSDAQ 종목 수집. - (data.krx.co.kr IP 차단 우회 — 네이버 서버는 별도) + KIS FHPST01710000: 거래량 순위 — 5,000원 이상 활성 종목. ETF / 레버리지 / 스팩 등 특수종목 자동 제외. Raises: RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단 """ - from bs4 import BeautifulSoup + from app.services.market_analyzer import get_all_tradable_stocks as _kis_get_stocks - SKIP_KEYWORDS = [ - "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", - "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE", - "선물", "스팩", "리츠", "SPAC", - ] - HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"} + logger.info("📡 KIS 거래량 순위 종목 조회 (KOSPI+KOSDAQ 각 top 40)") + stocks = await _kis_get_stocks() - logger.info("📡 네이버 금융 종목 조회 (KOSPI+KOSDAQ)") - - async def _fetch_market_naver( - session: aiohttp.ClientSession, sosok: int, market_name: str - ) -> list[tuple[str, str, str]]: - """sosok=0: KOSPI, sosok=1: KOSDAQ""" - base_url = "https://finance.naver.com/sise/sise_market_sum.naver" - results: list[tuple[str, str, str]] = [] - - # 첫 페이지로 총 페이지 수 파악 - async with session.get( - base_url, params={"sosok": sosok, "page": 1}, headers=HEADERS, - timeout=aiohttp.ClientTimeout(total=30) - ) as resp: - if resp.status != 200: - logger.warning(" 네이버 %s HTTP %s", market_name, resp.status) - return [] - html = await resp.text(encoding="euc-kr", errors="replace") - - soup = BeautifulSoup(html, "html.parser") - # 총 페이지 수 추출 - pager = soup.select("td.pgRR a") - if pager: - import re - m = re.search(r"page=(\d+)", pager[-1].get("href", "")) - total_pages = int(m.group(1)) if m else 1 - else: - total_pages = 1 - total_pages = min(total_pages, 30) # 최대 30페이지 (약 1,500종목) - - # 첫 페이지 파싱 - def _parse_page(s: BeautifulSoup) -> list[tuple[str, str, str]]: - rows = s.select("table.type_2 tr") - items = [] - for row in rows: - a_tag = row.select_one("a.tltle") - if not a_tag: - continue - name = a_tag.get_text(strip=True) - href = a_tag.get("href", "") - import re as _re - m2 = _re.search(r"code=(\d+)", href) - if not m2: - continue - code = m2.group(1) - if any(kw in name for kw in SKIP_KEYWORDS): - continue - items.append((code, name, market_name)) - return items - - results.extend(_parse_page(soup)) - - # 나머지 페이지 병렬 수집 - sem = asyncio.Semaphore(5) - - async def _fetch_page(page: int): - async with sem: - try: - async with session.get( - base_url, params={"sosok": sosok, "page": page}, - headers=HEADERS, timeout=aiohttp.ClientTimeout(total=30) - ) as r: - if r.status != 200: - return [] - h = await r.text(encoding="euc-kr", errors="replace") - return _parse_page(BeautifulSoup(h, "html.parser")) - except Exception: - return [] - - tasks = [_fetch_page(p) for p in range(2, total_pages + 1)] - pages_data = await asyncio.gather(*tasks) - for page_items in pages_data: - results.extend(page_items) - - # 중복 제거 - seen: set[str] = set() - unique = [] - for item in results: - if item[0] not in seen: - seen.add(item[0]) - unique.append(item) - - logger.info(" %s: %d개 선정 (총 %d페이지)", market_name, len(unique), total_pages) - return unique - - kospi_stocks: list[tuple[str, str, str]] = [] - kosdaq_stocks: list[tuple[str, str, str]] = [] - async with aiohttp.ClientSession() as session: - kospi_stocks = await _fetch_market_naver(session, 0, "KOSPI") - kosdaq_stocks = await _fetch_market_naver(session, 1, "KOSDAQ") - - # 네이버 스크래핑 실패 시 KIS API로 폴백 - naver_total = len(kospi_stocks) + len(kosdaq_stocks) - if naver_total < MIN_STOCK_COUNT: - logger.warning( - "⚠️ 네이버 금융 조회 부족(%d개) — KIS API로 폴백", naver_total - ) - try: - from app.services.market_analyzer import get_all_tradable_stocks as _kis_stocks - kis_result = await _kis_stocks() - if len(kis_result) >= MIN_STOCK_COUNT: - logger.info("✅ KIS API 폴백 성공: %d개", len(kis_result)) - return kis_result[:limit] - except Exception as _exc: - logger.error("❌ KIS API 폴백도 실패: %s", _exc) + if len(stocks) < MIN_STOCK_COUNT: raise RuntimeError( - f"종목 조회 실패: 네이버({naver_total}개) + KIS API 모두 불가. " - f"네트워크 및 API 설정을 확인하세요." + f"종목 조회 실패: KIS API 결과 부족 ({len(stocks)}개 < {MIN_STOCK_COUNT}개). " + f"KIS API 설정을 확인하세요." ) - # KOSPI 60% / KOSDAQ 40% 비율로 합산 - kospi_n = int(limit * 0.6) - kosdaq_n = limit - kospi_n - all_stocks = kospi_stocks[:kospi_n] + kosdaq_stocks[:kosdaq_n] - top_with_mkt = all_stocks[:limit] - top_stocks = [(code, name) for code, name, _ in top_with_mkt] - - kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI") - kosdaq_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSDAQ") - logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n) - - return top_stocks + result = stocks[:limit] + logger.info("✅ 선정 %d개", len(result)) + return result # ------------------------------------------------------------------- @@ -420,7 +301,7 @@ async def batch_analyze_all_stocks(limit: int = None): # Step 1: 시장 레짐 판별 regime = await detect_and_log_market_regime() - # Step 2: 종목 목록 조회 (KRX OpenAPI) + # Step 2: 종목 목록 조회 (KIS 거래량 순위 API) all_stocks = await get_all_tradable_stocks(limit=500) filtered_stocks = all_stocks[:limit] if limit else all_stocks total = len(filtered_stocks) diff --git a/backend/collect_krx_data.py b/backend/collect_krx_data.py index 965bf19..3c195e0 100644 --- a/backend/collect_krx_data.py +++ b/backend/collect_krx_data.py @@ -1,11 +1,10 @@ """ -KRX 히스토리컬 데이터 수집 스크립트 +공공데이터포털 금융위원회 주식시세 API 기반 일봉 데이터 수집 스크립트 -KRX OpenAPI (data-dbg.krx.co.kr)를 사용하여 KOSPI/KOSDAQ 종목의 -일봉 OHLCV 데이터를 DB에 저장합니다. - -- KIS API 의존성 완전 제거 -- 날짜 1건 호출 → 해당일 전 종목 OHLCV 일괄 수신 (효율적) +API: GetStockSecuritiesInfoService (apis.data.go.kr) +- API 키 필요 (data.go.kr 에서 무료 신청) +- KOSPI / KOSDAQ 전종목 일별 OHLCV 제공 +- KRX / pykrx / FinanceDataReader 의존성 없음 사용법: python collect_krx_data.py # 전체 수집 (2020-01-01~) @@ -18,13 +17,12 @@ KRX OpenAPI (data-dbg.krx.co.kr)를 사용하여 KOSPI/KOSDAQ 종목의 import argparse import asyncio import logging -import os import sys import time from datetime import date, datetime, timedelta from pathlib import Path -import aiohttp +import httpx import pandas as pd # 프로젝트 루트를 Python 경로에 추가 @@ -33,31 +31,26 @@ sys.path.insert(0, str(Path(__file__).parent)) from sqlalchemy import text from sqlalchemy.dialects.postgresql import insert as pg_insert +from app.core.config import settings from app.core.database import AsyncSessionLocal, engine, Base from app.models.models import StockDailyPrice -# ─── KRX OpenAPI 설정 ───────────────────────────────────────────── -# 실제 데이터 엔드포인트 (openapi.krx.co.kr 는 포털, 데이터는 data-dbg) -KRX_DATA_BASE = "https://data-dbg.krx.co.kr/svc/apis" - -try: - from app.core.config import settings as _cfg - KRX_API_KEY: str = _cfg.KRX_API_KEY -except Exception: - from dotenv import load_dotenv - load_dotenv(Path(__file__).parent.parent / ".env") - KRX_API_KEY = os.environ.get("KRX_API_KEY", "") - -# ─── ETF/특수 종목 제외 키워드 ──────────────────────────────────── +# ETF/특수 종목 제외 키워드 SKIP_KEYWORDS = [ "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "선물", "스팩", "리츠", "SPAC", ] -# 날짜별 동시 요청 수 (KRX API 쓰로틀링 고려) -COLLECT_CONCURRENCY = 3 -REQUEST_INTERVAL = 0.5 # 요청 간 최소 대기(초) +# 공공데이터포털 API 설정 +DATAGOKR_BASE_URL = ( + "https://apis.data.go.kr/1160100/service" + "/GetStockSecuritiesInfoService/getStockPriceInfo" +) +ROWS_PER_PAGE = 1000 # API 최대 허용값 + +# 날짜별 요청 간격 (초) +REQUEST_INTERVAL = 0.2 logging.basicConfig( level=logging.INFO, @@ -74,130 +67,108 @@ def generate_trading_dates(start: str, end: str) -> list[str]: e = datetime.strptime(end, "%Y%m%d").date() dates: list[str] = [] while d <= e: - if d.weekday() < 5: # 0=Mon … 4=Fri + if d.weekday() < 5: dates.append(d.strftime("%Y%m%d")) d += timedelta(days=1) return dates -def _int(val) -> int: - """문자열/숫자를 int로 안전 변환 (쉼표 포함 처리).""" - try: - return int(str(val).replace(",", "").strip() or "0") - except (ValueError, TypeError): - return 0 +# ─── 공공데이터포털 API 수집 ─────────────────────────────────────── +async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame: + """공공데이터포털 금융위원회 주식시세 API로 전종목 OHLCV 조회. -def _float(val) -> float: - """문자열/숫자를 float로 안전 변환.""" - try: - return float(str(val).replace(",", "").strip() or "0") - except (ValueError, TypeError): - return 0.0 - - -# ─── KRX API 호출 ───────────────────────────────────────────────── - -async def _fetch_krx( - session: aiohttp.ClientSession, - endpoint: str, - bas_dd: str, -) -> list: - """KRX OpenAPI 단건 호출 → OutBlock_1 리스트 반환. - - endpoint 예: "sto/stk_bydd_trd" + market: 'kospi' | 'kosdaq' + bas_dd: 'YYYYMMDD' + 반환: DataFrame (stock_code, stock_name, date, open, high, low, close, volume, change_rate) """ - url = f"{KRX_DATA_BASE}/{endpoint}" - try: - async with session.get( - url, - params={"basDd": bas_dd}, - headers={"AUTH_KEY": KRX_API_KEY}, - timeout=aiohttp.ClientTimeout(total=30), - ) as resp: - if resp.status != 200: - logger.warning("KRX %s %s → HTTP %s", endpoint, bas_dd, resp.status) - return [] - data = await resp.json(content_type=None) - return data.get("OutBlock_1") or [] - except Exception as exc: - logger.error("KRX API 오류 [%s %s]: %s", endpoint, bas_dd, exc) - return [] - - -async def fetch_daily_prices( - session: aiohttp.ClientSession, - market: str, - bas_dd: str, -) -> pd.DataFrame: - """특정 날짜의 전 종목 OHLCV 조회. - - KRX 응답 필드: - ISU_SRT_CD : 단축 종목코드 (e.g. "005930") - ISU_ABBRV : 종목 약칭 - TDD_OPNPRC : 시가 - TDD_HGPRC : 고가 - TDD_LWPRC : 저가 - TDD_CLSPRC : 종가 - ACC_TRDVOL : 누적 거래량 - FLUC_RT : 등락률 (%) - """ - endpoint = "sto/stk_bydd_trd" if market == "kospi" else "sto/ksq_bydd_trd" - items = await _fetch_krx(session, endpoint, bas_dd) - if not items: - return pd.DataFrame() + mrkt_cls = "KOSPI" if market == "kospi" else "KOSDAQ" + api_key = settings.DATAGOKR_API_KEY + if not api_key: + raise RuntimeError( + "DATAGOKR_API_KEY 가 설정되지 않았습니다. " + ".env 에 DATAGOKR_API_KEY=<발급키> 를 추가하세요." + ) trade_date = datetime.strptime(bas_dd, "%Y%m%d").date() rows: list[dict] = [] + page = 1 - for item in items: - name = (item.get("ISU_ABBRV") or item.get("ISU_NM") or "").strip() - if any(kw in name for kw in SKIP_KEYWORDS): - continue + async with httpx.AsyncClient(timeout=30.0) as client: + while True: + params = { + "serviceKey": api_key, + "numOfRows": str(ROWS_PER_PAGE), + "pageNo": str(page), + "resultType": "json", + "basDt": bas_dd, + "mrktCls": mrkt_cls, + } + try: + resp = await client.get(DATAGOKR_BASE_URL, params=params) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + logger.error("API 요청 실패 [%s %s page%d]: %s", market, bas_dd, page, exc) + break - code = item.get("ISU_SRT_CD", "").strip() - if not code: - continue + body = data.get("response", {}).get("body", {}) + total_count = int(body.get("totalCount", 0)) + items_wrap = body.get("items", {}) - volume = _int(item.get("ACC_TRDVOL")) - if volume == 0: - continue # 거래정지일 제외 + # 데이터 없음 (휴장일) + if not items_wrap: + break - close = _int(item.get("TDD_CLSPRC")) - if close == 0: - continue + items = items_wrap.get("item", []) + if not isinstance(items, list): + items = [items] # 1건이면 dict로 올 수 있음 - rows.append({ - "stock_code": code, - "stock_name": name, - "date": trade_date, - "open": _int(item.get("TDD_OPNPRC")), - "high": _int(item.get("TDD_HGPRC")), - "low": _int(item.get("TDD_LWPRC")), - "close": close, - "volume": volume, - "change_rate": _float(item.get("FLUC_RT")) / 100.0, - }) + for item in items: + name = item.get("itmsNm", "") + if any(kw in name for kw in SKIP_KEYWORDS): + continue + + volume = int(item.get("trqu", 0) or 0) + if volume == 0: + continue # 거래 정지 + + close = int(item.get("clpr", 0) or 0) + if close == 0: + continue + + try: + change_rate = float(item.get("fltRt", 0) or 0) / 100.0 + except (TypeError, ValueError): + change_rate = 0.0 + + # srtnCd: 단축코드(6자리), 앞에 공백 있을 수 있어 strip + stock_code = str(item.get("srtnCd", "")).strip().zfill(6) + + rows.append({ + "stock_code": stock_code, + "stock_name": name, + "date": trade_date, + "open": int(item.get("mkp", 0) or 0), + "high": int(item.get("hipr", 0) or 0), + "low": int(item.get("lopr", 0) or 0), + "close": close, + "volume": volume, + "change_rate": change_rate, + "market_cap": int(item.get("mrktTotAmt", 0) or 0) or None, + }) + + # 페이지네이션 종료 조건 + fetched_so_far = (page - 1) * ROWS_PER_PAGE + len(items) + if fetched_so_far >= total_count or len(items) < ROWS_PER_PAGE: + break + + page += 1 + await asyncio.sleep(REQUEST_INTERVAL) return pd.DataFrame(rows) if rows else pd.DataFrame() -async def get_stock_codes( - session: aiohttp.ClientSession, - market: str, - bas_dd: str, -) -> set[str]: - """특정 날짜 기준 상장 종목 코드 집합 조회 (선택적 필터용).""" - endpoint = "sto/stk_isu_base_info" if market == "kospi" else "sto/ksq_isu_base_info" - items = await _fetch_krx(session, endpoint, bas_dd) - codes: set[str] = set() - for item in items: - code = item.get("ISU_SRT_CD", "").strip() - if code: - codes.add(code) - return codes - - # ─── DB 헬퍼 ────────────────────────────────────────────────────── async def create_tables(): @@ -207,7 +178,6 @@ async def create_tables(): async def get_last_trade_date(session) -> date | None: - """DB 내 마지막 거래일 조회 (전 종목 공통).""" result = await session.execute( text("SELECT MAX(trade_date) FROM stock_daily_prices") ) @@ -234,13 +204,13 @@ async def upsert_daily_prices(session, df: pd.DataFrame) -> int: "close_price": int(row["close"]), "volume": int(row["volume"]), "change_rate": float(row["change_rate"]) if pd.notna(row["change_rate"]) else None, - "market_cap": None, + "market_cap": int(row["market_cap"]) if pd.notna(row.get("market_cap")) and row.get("market_cap") else None, }) batch_size = 500 inserted = 0 for i in range(0, len(records), batch_size): - batch = records[i : i + batch_size] + batch = records[i: i + batch_size] stmt = pg_insert(StockDailyPrice).values(batch) stmt = stmt.on_conflict_do_update( constraint="uq_stock_daily", @@ -270,7 +240,6 @@ async def collect_date( bas_dd: str, market: str, semaphore: asyncio.Semaphore, - http_session: aiohttp.ClientSession, ) -> tuple[bool, int]: """단일 날짜 수집 (세마포어 적용).""" async with semaphore: @@ -283,17 +252,16 @@ async def collect_date( frames: list[pd.DataFrame] = [] for mkt in markets: - df = await fetch_daily_prices(http_session, mkt, bas_dd) + df = await fetch_daily_prices(mkt, bas_dd) if not df.empty: frames.append(df) await asyncio.sleep(REQUEST_INTERVAL) if not frames: logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd) - return True, 0 # 휴장일은 성공으로 처리 + return True, 0 combined = pd.concat(frames, ignore_index=True) - # 같은 날짜 내 코드 중복 제거 (KOSPI/KOSDAQ 겹침 방지) combined = combined.drop_duplicates(subset=["stock_code", "date"]) async with AsyncSessionLocal() as db: @@ -315,16 +283,7 @@ async def collect_all( start_date: str = "20200101", update_mode: bool = False, ): - """메인 수집 로직 (KRX OpenAPI 기반, 날짜별 전종목 일괄 수집).""" - if not KRX_API_KEY: - logger.error( - "KRX_API_KEY가 설정되지 않았습니다.\n" - " 1) https://openapi.krx.co.kr 에서 API 키 발급\n" - " 2) .env 파일에 KRX_API_KEY=<발급받은키> 추가\n" - " 3) 다시 실행하세요." - ) - sys.exit(1) - + """메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API).""" await create_tables() end_date = datetime.now().strftime("%Y%m%d") @@ -346,27 +305,25 @@ async def collect_all( logger.warning("수집할 날짜가 없습니다.") return - # --limit N → 최근 N거래일만 (테스트용) if limit > 0: dates = dates[-limit:] total = len(dates) logger.info("수집 날짜: %d일 (%s ~ %s)", total, dates[0], dates[-1]) - semaphore = asyncio.Semaphore(COLLECT_CONCURRENCY) - conn = aiohttp.TCPConnector(limit=10) + # 공공데이터 API: 동시 3 요청까지 허용 + semaphore = asyncio.Semaphore(3) - async with aiohttp.ClientSession(connector=conn) as http_session: - results = await asyncio.gather( - *[ - collect_date(i, total, d, market, semaphore, http_session) - for i, d in enumerate(dates, 1) - ], - return_exceptions=True, - ) + results = await asyncio.gather( + *[ + collect_date(i, total, d, market, semaphore) + for i, d in enumerate(dates, 1) + ], + return_exceptions=True, + ) - success = sum(1 for r in results if isinstance(r, tuple) and r[0]) - fail = total - success + success = sum(1 for r in results if isinstance(r, tuple) and r[0]) + fail = total - success total_rows = sum(r[1] for r in results if isinstance(r, tuple)) logger.info("=" * 60) @@ -378,7 +335,9 @@ async def collect_all( def main(): - parser = argparse.ArgumentParser(description="KRX 히스토리컬 데이터 수집 (KRX OpenAPI)") + parser = argparse.ArgumentParser( + description="일봉 데이터 수집 (공공데이터포털 금융위원회 주식시세 API)" + ) parser.add_argument( "--market", choices=["kospi", "kosdaq", "all"], default="all", help="수집 시장 (기본: all)", @@ -397,7 +356,7 @@ def main(): ) args = parser.parse_args() - logger.info("KRX 데이터 수집 시작 (KRX OpenAPI)") + logger.info("일봉 데이터 수집 시작 (공공데이터포털)") logger.info(" 시장: %s", args.market) logger.info(" 날짜 제한: %s", "전체" if args.limit == 0 else f"최근 {args.limit}거래일") logger.info(" 시작일: %s", args.start_date) diff --git a/backend/requirements.txt b/backend/requirements.txt index f852f8d..ee159f1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,6 +28,7 @@ pytest-asyncio==0.21.1 feedparser==6.0.11 joblib>=1.3.0 beautifulsoup4>=4.12.0 +httpx>=0.27.1 # ML Ensemble torch>=2.1.0 lightgbm>=4.1.0 diff --git a/frontend/src/components/PortfolioSummary.tsx b/frontend/src/components/PortfolioSummary.tsx index 0069626..75ca582 100644 --- a/frontend/src/components/PortfolioSummary.tsx +++ b/frontend/src/components/PortfolioSummary.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, ArrowDownRight, AlertTriangle, Activity, TrendingUp, TrendingDown, DollarSign } from 'lucide-react'; +import { ArrowUpRight, ArrowDownRight, AlertTriangle, Activity, TrendingUp, TrendingDown, DollarSign, Calendar, BarChart2 } from 'lucide-react'; interface PortfolioSummaryProps { summary: any; @@ -17,8 +17,8 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => { if (!summary) { return ( -
- {[...Array(4)].map((_, i) => ( +
+ {[...Array(6)].map((_, i) => (
))}
@@ -27,72 +27,107 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => { const { total_assets, - total_value, // 주식 평가금액 - total_investment, // 총 매입금액 + total_value, // 주식 평가금액 + total_investment, // 총 매입금액 total_profit_loss, total_profit_loss_rate, - available_cash, // 예수금 (현금) + available_cash, // 예수금 (현금) + today_profit_loss, // 금일 평가손익 + today_profit_loss_rate, // 금일 수익률 + cumulative_realized_pl, // 누적 실현 손익 } = summary; const isProfit = (total_profit_loss ?? 0) >= 0; + const isTodayProfit = (today_profit_loss ?? 0) >= 0; + const isCumulativeProfit = (cumulative_realized_pl ?? 0) >= 0; const fmt = (v: number) => Math.round(v || 0).toLocaleString(); return ( -
+
{/* 총 자산 (예수금 + 주식평가) */} -
-
-

총 자산

-
- +
+
+

총 자산

+
+
-

+

{fmt(total_assets)}원

-

예수금 + 주식평가

+

예수금 + 주식평가

{/* 예수금 (현금) */} -
-
-

예수금

-
- +
+
+

예수금

+
+
-

+

{fmt(available_cash)}원

-

주식평가: {fmt(total_value)}원

+

주식평가: {fmt(total_value)}원

{/* 총 평가손익 */} -
-
-

총 평가손익

-
- {isProfit ? : } +
+
+

총 평가손익

+
+ {isProfit ? : }
-

+

{(total_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(total_profit_loss)}원

-

매입: {fmt(total_investment)}원

+

매입: {fmt(total_investment)}원

{/* 수익률 */} -
-
-

수익률

-
- {isProfit ? : } +
+
+

수익률

+
+ {isProfit ? : }
-

+

{(total_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(total_profit_loss_rate ?? 0).toFixed(2)}%

-

주식 매입 대비

+

주식 매입 대비

+
+ + {/* 금일 손익 */} +
+
+

금일 손익

+
+ +
+
+

+ {(today_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(today_profit_loss)}원 +

+

+ {(today_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(today_profit_loss_rate ?? 0).toFixed(2)}% 전일 대비 +

+
+ + {/* 누적 실현손익 */} +
+
+

누적 실현손익

+
+ +
+
+

+ {(cumulative_realized_pl ?? 0) > 0 ? '+' : ''}{fmt(cumulative_realized_pl)}원 +

+

매도 체결 기준

); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e45b316..f2a757b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -286,10 +286,16 @@ const Dashboard = () => { const liveProfitLoss = liveValue - totalInvestment const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0 - // 총 자산 = 예수금(KIS 기준) + 주식 평가금액(live) - // KIS API total_assets가 있으면 그 예수금 부분만 분리해서 live 주식값 합산 - const availableCash = portfolioSummary.available_cash || 0 - const liveTotal = availableCash + liveValue + // 총 자산 = KIS tot_evlu_amt(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영 + // KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고 + // 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt) + const backendStockValue = portfolioSummary.stock_value_from_api || 0 + const liveTotal = (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue) + + // 금일 손익도 live 주식 변동분 반영 + const bfdyTotal = portfolioSummary.bfdy_total_assets || 0 + const liveTodayPL = bfdyTotal > 0 ? liveTotal - bfdyTotal : (portfolioSummary.today_profit_loss || 0) + const liveTodayPLRate = bfdyTotal > 0 ? (liveTodayPL / bfdyTotal) * 100 : (portfolioSummary.today_profit_loss_rate || 0) return { ...portfolioSummary, @@ -298,6 +304,8 @@ const Dashboard = () => { total_profit_loss: liveProfitLoss, total_profit_loss_rate: liveProfitLossRate, total_assets: liveTotal, + today_profit_loss: liveTodayPL, + today_profit_loss_rate: liveTodayPLRate, } }, [portfolioSummary, portfolio, livePrices])