배치 수정

This commit is contained in:
2026-03-06 14:12:54 +09:00
parent 6d39069d3f
commit ac98bb856f
8 changed files with 321 additions and 351 deletions

View File

@@ -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,
})
return resp
else:
try:
await sync_portfolio(db, force=False)
except HTTPException:
logger.warning("get_portfolio_summary: sync failed, returning cached summary")
base = portfolio_cache.summary or {
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
}
resp = dict(base)
"total_assets": 0,
})
# 누적 실현 손익 (TradeHistory SELL 체결 기준)
try:
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,

View File

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

View File

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

View File

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

View File

@@ -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
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
body = data.get("response", {}).get("body", {})
total_count = int(body.get("totalCount", 0))
items_wrap = body.get("items", {})
# 데이터 없음 (휴장일)
if not items_wrap:
break
items = items_wrap.get("item", [])
if not isinstance(items, list):
items = [items] # 1건이면 dict로 올 수 있음
for item in items:
name = (item.get("ISU_ABBRV") or item.get("ISU_NM") or "").strip()
name = item.get("itmsNm", "")
if any(kw in name for kw in SKIP_KEYWORDS):
continue
code = item.get("ISU_SRT_CD", "").strip()
if not code:
continue
volume = _int(item.get("ACC_TRDVOL"))
volume = int(item.get("trqu", 0) or 0)
if volume == 0:
continue # 거래정지일 제외
continue # 거래 정지
close = _int(item.get("TDD_CLSPRC"))
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": code,
"stock_code": stock_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")),
"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": _float(item.get("FLUC_RT")) / 100.0,
"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,7 +204,7 @@ 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
@@ -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,20 +305,18 @@ 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)
collect_date(i, total, d, market, semaphore)
for i, d in enumerate(dates, 1)
],
return_exceptions=True,
@@ -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)

View File

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

View File

@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-pulse">
{[...Array(4)].map((_, i) => (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 animate-pulse">
{[...Array(6)].map((_, i) => (
<div key={i} className="bg-gray-800/50 h-24 rounded-xl" />
))}
</div>
@@ -32,67 +32,102 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {
total_profit_loss,
total_profit_loss_rate,
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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{/* 총 자산 (예수금 + 주식평가) */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
<div className="flex justify-between items-start mb-2">
<p className="text-gray-400 text-sm font-medium">총 자산</p>
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
<DollarSign size={18} />
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
<div className="flex justify-between items-start mb-1.5">
<p className="text-gray-400 text-xs font-medium">총 자산</p>
<div className="p-1.5 bg-blue-500/10 rounded-lg text-blue-400">
<DollarSign size={14} />
</div>
</div>
<h3 className="text-2xl font-bold text-white mb-1">
<h3 className="text-lg font-bold text-white mb-1 leading-tight">
{fmt(total_assets)}원
</h3>
<p className="text-xs text-gray-500">예수금 + 주식평가</p>
<p className="text-[10px] text-gray-500">예수금 + 주식평가</p>
</div>
{/* 예수금 (현금) */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
<div className="flex justify-between items-start mb-2">
<p className="text-gray-400 text-sm font-medium">예수금</p>
<div className="p-2 bg-purple-500/10 rounded-lg text-purple-400">
<Activity size={18} />
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
<div className="flex justify-between items-start mb-1.5">
<p className="text-gray-400 text-xs font-medium">예수금</p>
<div className="p-1.5 bg-purple-500/10 rounded-lg text-purple-400">
<Activity size={14} />
</div>
</div>
<h3 className="text-2xl font-bold text-white mb-1">
<h3 className="text-lg font-bold text-white mb-1 leading-tight">
{fmt(available_cash)}원
</h3>
<p className="text-xs text-gray-500">주식평가: {fmt(total_value)}원</p>
<p className="text-[10px] text-gray-500">주식평가: {fmt(total_value)}원</p>
</div>
{/* 총 평가손익 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
<div className="flex justify-between items-start mb-2">
<p className="text-gray-400 text-sm font-medium">총 평가손익</p>
<div className={`p-2 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
{isProfit ? <TrendingUp size={18} /> : <TrendingDown size={18} />}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
<div className="flex justify-between items-start mb-1.5">
<p className="text-gray-400 text-xs font-medium">총 평가손익</p>
<div className={`p-1.5 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
{isProfit ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
</div>
</div>
<h3 className={`text-2xl font-bold mb-1 ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
<h3 className={`text-lg font-bold mb-1 leading-tight ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
{(total_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(total_profit_loss)}원
</h3>
<p className="text-xs text-gray-500">매입: {fmt(total_investment)}원</p>
<p className="text-[10px] text-gray-500">매입: {fmt(total_investment)}원</p>
</div>
{/* 수익률 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
<div className="flex justify-between items-start mb-2">
<p className="text-gray-400 text-sm font-medium">수익률</p>
<div className={`p-2 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
{isProfit ? <ArrowUpRight size={18} /> : <ArrowDownRight size={18} />}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
<div className="flex justify-between items-start mb-1.5">
<p className="text-gray-400 text-xs font-medium">수익률</p>
<div className={`p-1.5 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
{isProfit ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
</div>
</div>
<h3 className={`text-2xl font-bold mb-1 ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
<h3 className={`text-lg font-bold mb-1 leading-tight ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
{(total_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(total_profit_loss_rate ?? 0).toFixed(2)}%
</h3>
<p className="text-xs text-gray-500">주식 매입 대비</p>
<p className="text-[10px] text-gray-500">주식 매입 대비</p>
</div>
{/* 금일 손익 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
<div className="flex justify-between items-start mb-1.5">
<p className="text-gray-400 text-xs font-medium">금일 손익</p>
<div className={`p-1.5 rounded-lg ${isTodayProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
<Calendar size={14} />
</div>
</div>
<h3 className={`text-lg font-bold mb-1 leading-tight ${isTodayProfit ? 'text-red-400' : 'text-blue-400'}`}>
{(today_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(today_profit_loss)}원
</h3>
<p className="text-[10px] text-gray-500">
{(today_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(today_profit_loss_rate ?? 0).toFixed(2)}% 전일 대비
</p>
</div>
{/* 누적 실현손익 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
<div className="flex justify-between items-start mb-1.5">
<p className="text-gray-400 text-xs font-medium">누적 실현손익</p>
<div className={`p-1.5 rounded-lg ${isCumulativeProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
<BarChart2 size={14} />
</div>
</div>
<h3 className={`text-lg font-bold mb-1 leading-tight ${isCumulativeProfit ? 'text-red-400' : 'text-blue-400'}`}>
{(cumulative_realized_pl ?? 0) > 0 ? '+' : ''}{fmt(cumulative_realized_pl)}원
</h3>
<p className="text-[10px] text-gray-500">매도 체결 기준</p>
</div>
</div>
);

View File

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