배치 수정
This commit is contained in:
@@ -246,10 +246,12 @@ class PortfolioCache:
|
|||||||
# D+2 정산액 및 기타 상세 정보 추출
|
# D+2 정산액 및 기타 상세 정보 추출
|
||||||
d2_settlement = 0.0
|
d2_settlement = 0.0
|
||||||
stock_value_from_api = 0.0
|
stock_value_from_api = 0.0
|
||||||
|
bfdy_total_assets = 0.0
|
||||||
if balance_data and "output2" in balance_data:
|
if balance_data and "output2" in balance_data:
|
||||||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
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"])
|
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"])
|
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
|
overseas_total_krw = 0.0
|
||||||
@@ -263,6 +265,10 @@ class PortfolioCache:
|
|||||||
total_assets += overseas_total_krw
|
total_assets += overseas_total_krw
|
||||||
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
|
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 = {
|
self.summary = {
|
||||||
"total_stocks": total_stocks,
|
"total_stocks": total_stocks,
|
||||||
"total_value": total_value,
|
"total_value": total_value,
|
||||||
@@ -273,6 +279,9 @@ class PortfolioCache:
|
|||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
"d2_settlement": d2_settlement,
|
"d2_settlement": d2_settlement,
|
||||||
"stock_value_from_api": stock_value_from_api,
|
"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_usd": overseas_value_usd,
|
||||||
"overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw,
|
"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():
|
if portfolio_cache.summary and not portfolio_cache.should_sync():
|
||||||
logger.debug("Portfolio cache hit (summary)")
|
logger.debug("Portfolio cache hit (summary)")
|
||||||
resp = dict(portfolio_cache.summary)
|
resp = dict(portfolio_cache.summary)
|
||||||
resp.update({
|
else:
|
||||||
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
|
try:
|
||||||
"last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
|
await sync_portfolio(db, force=False)
|
||||||
"last_error": portfolio_cache.last_error,
|
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:
|
try:
|
||||||
await sync_portfolio(db, force=False)
|
realized_result = await db.execute(
|
||||||
except HTTPException:
|
select(func.sum(TradeHistory.net_profit_loss)).where(
|
||||||
logger.warning("get_portfolio_summary: sync failed, returning cached summary")
|
TradeHistory.order_type == "SELL",
|
||||||
base = portfolio_cache.summary or {
|
TradeHistory.status.in_(["EXECUTED", "COMPLETED"]),
|
||||||
"total_stocks": 0,
|
TradeHistory.net_profit_loss.isnot(None),
|
||||||
"total_value": 0,
|
)
|
||||||
"total_investment": 0,
|
)
|
||||||
"total_profit_loss": 0,
|
cumulative_realized_pl = float(realized_result.scalar() or 0)
|
||||||
"total_profit_loss_rate": 0,
|
except Exception as e:
|
||||||
"available_cash": 0,
|
logger.warning(f"누적 실현손익 조회 실패: {e}")
|
||||||
"total_assets": 0
|
cumulative_realized_pl = 0.0
|
||||||
}
|
|
||||||
resp = dict(base)
|
resp["cumulative_realized_pl"] = cumulative_realized_pl
|
||||||
resp.update({
|
resp.update({
|
||||||
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
|
"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_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
|
||||||
|
|||||||
@@ -61,9 +61,8 @@ class Settings(BaseSettings):
|
|||||||
KIS_ACCOUNT_NO: str = ""
|
KIS_ACCOUNT_NO: str = ""
|
||||||
KIS_BASE_URL: str = ""
|
KIS_BASE_URL: str = ""
|
||||||
|
|
||||||
# KRX OpenAPI (data-dbg.krx.co.kr)
|
# 공공데이터포털 금융위원회 주식시세 API (apis.data.go.kr)
|
||||||
KRX_API_KEY: str = ""
|
DATAGOKR_API_KEY: str = ""
|
||||||
KRX_API_URL: str = "https://openapi.krx.co.kr" # 포털 URL (참고용)
|
|
||||||
|
|
||||||
# WebSocket 접속키 (수동 지정 시 우선, 비어 있으면 자동 발급)
|
# WebSocket 접속키 (수동 지정 시 우선, 비어 있으면 자동 발급)
|
||||||
KIS_WS_APPROVAL_KEY: str = ""
|
KIS_WS_APPROVAL_KEY: str = ""
|
||||||
|
|||||||
@@ -331,6 +331,72 @@ async def day_trade_scheduler():
|
|||||||
raise
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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.auto_trade_task = asyncio.create_task(auto_trade_scheduler())
|
||||||
app.state.batch_analysis_task = asyncio.create_task(batch_analysis_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.day_trade_task = asyncio.create_task(day_trade_scheduler())
|
||||||
|
app.state.krx_collector_task = asyncio.create_task(krx_data_collector_scheduler())
|
||||||
|
|
||||||
# KIS 실시간 스트림 시작 (설정으로 제어 가능)
|
# KIS 실시간 스트림 시작 (설정으로 제어 가능)
|
||||||
if settings.KIS_WS_ENABLED:
|
if settings.KIS_WS_ENABLED:
|
||||||
@@ -445,6 +512,7 @@ async def lifespan(app: FastAPI):
|
|||||||
"auto_trade_task",
|
"auto_trade_task",
|
||||||
"batch_analysis_task",
|
"batch_analysis_task",
|
||||||
"day_trade_task",
|
"day_trade_task",
|
||||||
|
"krx_collector_task",
|
||||||
"db_worker_task",
|
"db_worker_task",
|
||||||
"kis_stream_task",
|
"kis_stream_task",
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||||
import aiohttp
|
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from app.models.models import StockDailyPrice
|
from app.models.models import StockDailyPrice
|
||||||
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
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]]:
|
async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]:
|
||||||
"""거래 가능 종목 조회 (네이버 금융 sise 사용)
|
"""KIS 거래량 순위 API로 거래 가능 종목 조회 (KOSPI+KOSDAQ 각 top 40).
|
||||||
|
|
||||||
네이버 금융 전체 시세 페이지에서 KOSPI+KOSDAQ 종목 수집.
|
KIS FHPST01710000: 거래량 순위 — 5,000원 이상 활성 종목.
|
||||||
(data.krx.co.kr IP 차단 우회 — 네이버 서버는 별도)
|
|
||||||
ETF / 레버리지 / 스팩 등 특수종목 자동 제외.
|
ETF / 레버리지 / 스팩 등 특수종목 자동 제외.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단
|
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 = [
|
logger.info("📡 KIS 거래량 순위 종목 조회 (KOSPI+KOSDAQ 각 top 40)")
|
||||||
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
stocks = await _kis_get_stocks()
|
||||||
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
|
|
||||||
"선물", "스팩", "리츠", "SPAC",
|
|
||||||
]
|
|
||||||
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"}
|
|
||||||
|
|
||||||
logger.info("📡 네이버 금융 종목 조회 (KOSPI+KOSDAQ)")
|
if len(stocks) < MIN_STOCK_COUNT:
|
||||||
|
|
||||||
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)
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"종목 조회 실패: 네이버({naver_total}개) + KIS API 모두 불가. "
|
f"종목 조회 실패: KIS API 결과 부족 ({len(stocks)}개 < {MIN_STOCK_COUNT}개). "
|
||||||
f"네트워크 및 API 설정을 확인하세요."
|
f"KIS API 설정을 확인하세요."
|
||||||
)
|
)
|
||||||
|
|
||||||
# KOSPI 60% / KOSDAQ 40% 비율로 합산
|
result = stocks[:limit]
|
||||||
kospi_n = int(limit * 0.6)
|
logger.info("✅ 선정 %d개", len(result))
|
||||||
kosdaq_n = limit - kospi_n
|
return result
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
@@ -420,7 +301,7 @@ async def batch_analyze_all_stocks(limit: int = None):
|
|||||||
# Step 1: 시장 레짐 판별
|
# Step 1: 시장 레짐 판별
|
||||||
regime = await detect_and_log_market_regime()
|
regime = await detect_and_log_market_regime()
|
||||||
|
|
||||||
# Step 2: 종목 목록 조회 (KRX OpenAPI)
|
# Step 2: 종목 목록 조회 (KIS 거래량 순위 API)
|
||||||
all_stocks = await get_all_tradable_stocks(limit=500)
|
all_stocks = await get_all_tradable_stocks(limit=500)
|
||||||
filtered_stocks = all_stocks[:limit] if limit else all_stocks
|
filtered_stocks = all_stocks[:limit] if limit else all_stocks
|
||||||
total = len(filtered_stocks)
|
total = len(filtered_stocks)
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
KRX 히스토리컬 데이터 수집 스크립트
|
공공데이터포털 금융위원회 주식시세 API 기반 일봉 데이터 수집 스크립트
|
||||||
|
|
||||||
KRX OpenAPI (data-dbg.krx.co.kr)를 사용하여 KOSPI/KOSDAQ 종목의
|
API: GetStockSecuritiesInfoService (apis.data.go.kr)
|
||||||
일봉 OHLCV 데이터를 DB에 저장합니다.
|
- API 키 필요 (data.go.kr 에서 무료 신청)
|
||||||
|
- KOSPI / KOSDAQ 전종목 일별 OHLCV 제공
|
||||||
- KIS API 의존성 완전 제거
|
- KRX / pykrx / FinanceDataReader 의존성 없음
|
||||||
- 날짜 1건 호출 → 해당일 전 종목 OHLCV 일괄 수신 (효율적)
|
|
||||||
|
|
||||||
사용법:
|
사용법:
|
||||||
python collect_krx_data.py # 전체 수집 (2020-01-01~)
|
python collect_krx_data.py # 전체 수집 (2020-01-01~)
|
||||||
@@ -18,13 +17,12 @@ KRX OpenAPI (data-dbg.krx.co.kr)를 사용하여 KOSPI/KOSDAQ 종목의
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiohttp
|
import httpx
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
# 프로젝트 루트를 Python 경로에 추가
|
# 프로젝트 루트를 Python 경로에 추가
|
||||||
@@ -33,31 +31,26 @@ sys.path.insert(0, str(Path(__file__).parent))
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
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.core.database import AsyncSessionLocal, engine, Base
|
||||||
from app.models.models import StockDailyPrice
|
from app.models.models import StockDailyPrice
|
||||||
|
|
||||||
# ─── KRX OpenAPI 설정 ─────────────────────────────────────────────
|
# ETF/특수 종목 제외 키워드
|
||||||
# 실제 데이터 엔드포인트 (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/특수 종목 제외 키워드 ────────────────────────────────────
|
|
||||||
SKIP_KEYWORDS = [
|
SKIP_KEYWORDS = [
|
||||||
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
||||||
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
|
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
|
||||||
"선물", "스팩", "리츠", "SPAC",
|
"선물", "스팩", "리츠", "SPAC",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 날짜별 동시 요청 수 (KRX API 쓰로틀링 고려)
|
# 공공데이터포털 API 설정
|
||||||
COLLECT_CONCURRENCY = 3
|
DATAGOKR_BASE_URL = (
|
||||||
REQUEST_INTERVAL = 0.5 # 요청 간 최소 대기(초)
|
"https://apis.data.go.kr/1160100/service"
|
||||||
|
"/GetStockSecuritiesInfoService/getStockPriceInfo"
|
||||||
|
)
|
||||||
|
ROWS_PER_PAGE = 1000 # API 최대 허용값
|
||||||
|
|
||||||
|
# 날짜별 요청 간격 (초)
|
||||||
|
REQUEST_INTERVAL = 0.2
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
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()
|
e = datetime.strptime(end, "%Y%m%d").date()
|
||||||
dates: list[str] = []
|
dates: list[str] = []
|
||||||
while d <= e:
|
while d <= e:
|
||||||
if d.weekday() < 5: # 0=Mon … 4=Fri
|
if d.weekday() < 5:
|
||||||
dates.append(d.strftime("%Y%m%d"))
|
dates.append(d.strftime("%Y%m%d"))
|
||||||
d += timedelta(days=1)
|
d += timedelta(days=1)
|
||||||
return dates
|
return dates
|
||||||
|
|
||||||
|
|
||||||
def _int(val) -> int:
|
# ─── 공공데이터포털 API 수집 ───────────────────────────────────────
|
||||||
"""문자열/숫자를 int로 안전 변환 (쉼표 포함 처리)."""
|
|
||||||
try:
|
|
||||||
return int(str(val).replace(",", "").strip() or "0")
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||||
|
"""공공데이터포털 금융위원회 주식시세 API로 전종목 OHLCV 조회.
|
||||||
|
|
||||||
def _float(val) -> float:
|
market: 'kospi' | 'kosdaq'
|
||||||
"""문자열/숫자를 float로 안전 변환."""
|
bas_dd: 'YYYYMMDD'
|
||||||
try:
|
반환: DataFrame (stock_code, stock_name, date, open, high, low, close, volume, change_rate)
|
||||||
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"
|
|
||||||
"""
|
"""
|
||||||
url = f"{KRX_DATA_BASE}/{endpoint}"
|
mrkt_cls = "KOSPI" if market == "kospi" else "KOSDAQ"
|
||||||
try:
|
api_key = settings.DATAGOKR_API_KEY
|
||||||
async with session.get(
|
if not api_key:
|
||||||
url,
|
raise RuntimeError(
|
||||||
params={"basDd": bas_dd},
|
"DATAGOKR_API_KEY 가 설정되지 않았습니다. "
|
||||||
headers={"AUTH_KEY": KRX_API_KEY},
|
".env 에 DATAGOKR_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()
|
|
||||||
|
|
||||||
trade_date = datetime.strptime(bas_dd, "%Y%m%d").date()
|
trade_date = datetime.strptime(bas_dd, "%Y%m%d").date()
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
|
||||||
for item in items:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
name = (item.get("ISU_ABBRV") or item.get("ISU_NM") or "").strip()
|
while True:
|
||||||
if any(kw in name for kw in SKIP_KEYWORDS):
|
params = {
|
||||||
continue
|
"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()
|
body = data.get("response", {}).get("body", {})
|
||||||
if not code:
|
total_count = int(body.get("totalCount", 0))
|
||||||
continue
|
items_wrap = body.get("items", {})
|
||||||
|
|
||||||
volume = _int(item.get("ACC_TRDVOL"))
|
# 데이터 없음 (휴장일)
|
||||||
if volume == 0:
|
if not items_wrap:
|
||||||
continue # 거래정지일 제외
|
break
|
||||||
|
|
||||||
close = _int(item.get("TDD_CLSPRC"))
|
items = items_wrap.get("item", [])
|
||||||
if close == 0:
|
if not isinstance(items, list):
|
||||||
continue
|
items = [items] # 1건이면 dict로 올 수 있음
|
||||||
|
|
||||||
rows.append({
|
for item in items:
|
||||||
"stock_code": code,
|
name = item.get("itmsNm", "")
|
||||||
"stock_name": name,
|
if any(kw in name for kw in SKIP_KEYWORDS):
|
||||||
"date": trade_date,
|
continue
|
||||||
"open": _int(item.get("TDD_OPNPRC")),
|
|
||||||
"high": _int(item.get("TDD_HGPRC")),
|
volume = int(item.get("trqu", 0) or 0)
|
||||||
"low": _int(item.get("TDD_LWPRC")),
|
if volume == 0:
|
||||||
"close": close,
|
continue # 거래 정지
|
||||||
"volume": volume,
|
|
||||||
"change_rate": _float(item.get("FLUC_RT")) / 100.0,
|
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()
|
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 헬퍼 ──────────────────────────────────────────────────────
|
# ─── DB 헬퍼 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_tables():
|
async def create_tables():
|
||||||
@@ -207,7 +178,6 @@ async def create_tables():
|
|||||||
|
|
||||||
|
|
||||||
async def get_last_trade_date(session) -> date | None:
|
async def get_last_trade_date(session) -> date | None:
|
||||||
"""DB 내 마지막 거래일 조회 (전 종목 공통)."""
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text("SELECT MAX(trade_date) FROM stock_daily_prices")
|
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"]),
|
"close_price": int(row["close"]),
|
||||||
"volume": int(row["volume"]),
|
"volume": int(row["volume"]),
|
||||||
"change_rate": float(row["change_rate"]) if pd.notna(row["change_rate"]) else None,
|
"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
|
batch_size = 500
|
||||||
inserted = 0
|
inserted = 0
|
||||||
for i in range(0, len(records), batch_size):
|
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 = pg_insert(StockDailyPrice).values(batch)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
constraint="uq_stock_daily",
|
constraint="uq_stock_daily",
|
||||||
@@ -270,7 +240,6 @@ async def collect_date(
|
|||||||
bas_dd: str,
|
bas_dd: str,
|
||||||
market: str,
|
market: str,
|
||||||
semaphore: asyncio.Semaphore,
|
semaphore: asyncio.Semaphore,
|
||||||
http_session: aiohttp.ClientSession,
|
|
||||||
) -> tuple[bool, int]:
|
) -> tuple[bool, int]:
|
||||||
"""단일 날짜 수집 (세마포어 적용)."""
|
"""단일 날짜 수집 (세마포어 적용)."""
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
@@ -283,17 +252,16 @@ async def collect_date(
|
|||||||
|
|
||||||
frames: list[pd.DataFrame] = []
|
frames: list[pd.DataFrame] = []
|
||||||
for mkt in markets:
|
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:
|
if not df.empty:
|
||||||
frames.append(df)
|
frames.append(df)
|
||||||
await asyncio.sleep(REQUEST_INTERVAL)
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
|
||||||
if not frames:
|
if not frames:
|
||||||
logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd)
|
logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd)
|
||||||
return True, 0 # 휴장일은 성공으로 처리
|
return True, 0
|
||||||
|
|
||||||
combined = pd.concat(frames, ignore_index=True)
|
combined = pd.concat(frames, ignore_index=True)
|
||||||
# 같은 날짜 내 코드 중복 제거 (KOSPI/KOSDAQ 겹침 방지)
|
|
||||||
combined = combined.drop_duplicates(subset=["stock_code", "date"])
|
combined = combined.drop_duplicates(subset=["stock_code", "date"])
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
@@ -315,16 +283,7 @@ async def collect_all(
|
|||||||
start_date: str = "20200101",
|
start_date: str = "20200101",
|
||||||
update_mode: bool = False,
|
update_mode: bool = False,
|
||||||
):
|
):
|
||||||
"""메인 수집 로직 (KRX OpenAPI 기반, 날짜별 전종목 일괄 수집)."""
|
"""메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API)."""
|
||||||
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)
|
|
||||||
|
|
||||||
await create_tables()
|
await create_tables()
|
||||||
|
|
||||||
end_date = datetime.now().strftime("%Y%m%d")
|
end_date = datetime.now().strftime("%Y%m%d")
|
||||||
@@ -346,27 +305,25 @@ async def collect_all(
|
|||||||
logger.warning("수집할 날짜가 없습니다.")
|
logger.warning("수집할 날짜가 없습니다.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --limit N → 최근 N거래일만 (테스트용)
|
|
||||||
if limit > 0:
|
if limit > 0:
|
||||||
dates = dates[-limit:]
|
dates = dates[-limit:]
|
||||||
|
|
||||||
total = len(dates)
|
total = len(dates)
|
||||||
logger.info("수집 날짜: %d일 (%s ~ %s)", total, dates[0], dates[-1])
|
logger.info("수집 날짜: %d일 (%s ~ %s)", total, dates[0], dates[-1])
|
||||||
|
|
||||||
semaphore = asyncio.Semaphore(COLLECT_CONCURRENCY)
|
# 공공데이터 API: 동시 3 요청까지 허용
|
||||||
conn = aiohttp.TCPConnector(limit=10)
|
semaphore = asyncio.Semaphore(3)
|
||||||
|
|
||||||
async with aiohttp.ClientSession(connector=conn) as http_session:
|
results = await asyncio.gather(
|
||||||
results = await asyncio.gather(
|
*[
|
||||||
*[
|
collect_date(i, total, d, market, semaphore)
|
||||||
collect_date(i, total, d, market, semaphore, http_session)
|
for i, d in enumerate(dates, 1)
|
||||||
for i, d in enumerate(dates, 1)
|
],
|
||||||
],
|
return_exceptions=True,
|
||||||
return_exceptions=True,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
success = sum(1 for r in results if isinstance(r, tuple) and r[0])
|
success = sum(1 for r in results if isinstance(r, tuple) and r[0])
|
||||||
fail = total - success
|
fail = total - success
|
||||||
total_rows = sum(r[1] for r in results if isinstance(r, tuple))
|
total_rows = sum(r[1] for r in results if isinstance(r, tuple))
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
@@ -378,7 +335,9 @@ async def collect_all(
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="KRX 히스토리컬 데이터 수집 (KRX OpenAPI)")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="일봉 데이터 수집 (공공데이터포털 금융위원회 주식시세 API)"
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--market", choices=["kospi", "kosdaq", "all"], default="all",
|
"--market", choices=["kospi", "kosdaq", "all"], default="all",
|
||||||
help="수집 시장 (기본: all)",
|
help="수집 시장 (기본: all)",
|
||||||
@@ -397,7 +356,7 @@ def main():
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logger.info("KRX 데이터 수집 시작 (KRX OpenAPI)")
|
logger.info("일봉 데이터 수집 시작 (공공데이터포털)")
|
||||||
logger.info(" 시장: %s", args.market)
|
logger.info(" 시장: %s", args.market)
|
||||||
logger.info(" 날짜 제한: %s", "전체" if args.limit == 0 else f"최근 {args.limit}거래일")
|
logger.info(" 날짜 제한: %s", "전체" if args.limit == 0 else f"최근 {args.limit}거래일")
|
||||||
logger.info(" 시작일: %s", args.start_date)
|
logger.info(" 시작일: %s", args.start_date)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pytest-asyncio==0.21.1
|
|||||||
feedparser==6.0.11
|
feedparser==6.0.11
|
||||||
joblib>=1.3.0
|
joblib>=1.3.0
|
||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
|
httpx>=0.27.1
|
||||||
# ML Ensemble
|
# ML Ensemble
|
||||||
torch>=2.1.0
|
torch>=2.1.0
|
||||||
lightgbm>=4.1.0
|
lightgbm>=4.1.0
|
||||||
|
|||||||
@@ -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 {
|
interface PortfolioSummaryProps {
|
||||||
summary: any;
|
summary: any;
|
||||||
@@ -17,8 +17,8 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {
|
|||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-pulse">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 animate-pulse">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="bg-gray-800/50 h-24 rounded-xl" />
|
<div key={i} className="bg-gray-800/50 h-24 rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,72 +27,107 @@ export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
total_assets,
|
total_assets,
|
||||||
total_value, // 주식 평가금액
|
total_value, // 주식 평가금액
|
||||||
total_investment, // 총 매입금액
|
total_investment, // 총 매입금액
|
||||||
total_profit_loss,
|
total_profit_loss,
|
||||||
total_profit_loss_rate,
|
total_profit_loss_rate,
|
||||||
available_cash, // 예수금 (현금)
|
available_cash, // 예수금 (현금)
|
||||||
|
today_profit_loss, // 금일 평가손익
|
||||||
|
today_profit_loss_rate, // 금일 수익률
|
||||||
|
cumulative_realized_pl, // 누적 실현 손익
|
||||||
} = summary;
|
} = summary;
|
||||||
const isProfit = (total_profit_loss ?? 0) >= 0;
|
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();
|
const fmt = (v: number) => Math.round(v || 0).toLocaleString();
|
||||||
|
|
||||||
return (
|
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="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-1.5">
|
||||||
<p className="text-gray-400 text-sm font-medium">총 자산</p>
|
<p className="text-gray-400 text-xs font-medium">총 자산</p>
|
||||||
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
|
<div className="p-1.5 bg-blue-500/10 rounded-lg text-blue-400">
|
||||||
<DollarSign size={18} />
|
<DollarSign size={14} />
|
||||||
</div>
|
</div>
|
||||||
</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)}원
|
{fmt(total_assets)}원
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500">예수금 + 주식평가</p>
|
<p className="text-[10px] text-gray-500">예수금 + 주식평가</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 예수금 (현금) */}
|
{/* 예수금 (현금) */}
|
||||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
<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-2">
|
<div className="flex justify-between items-start mb-1.5">
|
||||||
<p className="text-gray-400 text-sm font-medium">예수금</p>
|
<p className="text-gray-400 text-xs font-medium">예수금</p>
|
||||||
<div className="p-2 bg-purple-500/10 rounded-lg text-purple-400">
|
<div className="p-1.5 bg-purple-500/10 rounded-lg text-purple-400">
|
||||||
<Activity size={18} />
|
<Activity size={14} />
|
||||||
</div>
|
</div>
|
||||||
</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)}원
|
{fmt(available_cash)}원
|
||||||
</h3>
|
</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>
|
||||||
|
|
||||||
{/* 총 평가손익 */}
|
{/* 총 평가손익 */}
|
||||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
<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-2">
|
<div className="flex justify-between items-start mb-1.5">
|
||||||
<p className="text-gray-400 text-sm font-medium">총 평가손익</p>
|
<p className="text-gray-400 text-xs font-medium">총 평가손익</p>
|
||||||
<div className={`p-2 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
<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={18} /> : <TrendingDown size={18} />}
|
{isProfit ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||||
</div>
|
</div>
|
||||||
</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)}원
|
{(total_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(total_profit_loss)}원
|
||||||
</h3>
|
</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>
|
||||||
|
|
||||||
{/* 수익률 */}
|
{/* 수익률 */}
|
||||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
<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-2">
|
<div className="flex justify-between items-start mb-1.5">
|
||||||
<p className="text-gray-400 text-sm font-medium">수익률</p>
|
<p className="text-gray-400 text-xs font-medium">수익률</p>
|
||||||
<div className={`p-2 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
<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={18} /> : <ArrowDownRight size={18} />}
|
{isProfit ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||||
</div>
|
</div>
|
||||||
</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)}%
|
{(total_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(total_profit_loss_rate ?? 0).toFixed(2)}%
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -286,10 +286,16 @@ const Dashboard = () => {
|
|||||||
const liveProfitLoss = liveValue - totalInvestment
|
const liveProfitLoss = liveValue - totalInvestment
|
||||||
const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0
|
const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0
|
||||||
|
|
||||||
// 총 자산 = 예수금(KIS 기준) + 주식 평가금액(live)
|
// 총 자산 = KIS tot_evlu_amt(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영
|
||||||
// KIS API total_assets가 있으면 그 예수금 부분만 분리해서 live 주식값 합산
|
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
|
||||||
const availableCash = portfolioSummary.available_cash || 0
|
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
|
||||||
const liveTotal = availableCash + liveValue
|
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 {
|
return {
|
||||||
...portfolioSummary,
|
...portfolioSummary,
|
||||||
@@ -298,6 +304,8 @@ const Dashboard = () => {
|
|||||||
total_profit_loss: liveProfitLoss,
|
total_profit_loss: liveProfitLoss,
|
||||||
total_profit_loss_rate: liveProfitLossRate,
|
total_profit_loss_rate: liveProfitLossRate,
|
||||||
total_assets: liveTotal,
|
total_assets: liveTotal,
|
||||||
|
today_profit_loss: liveTodayPL,
|
||||||
|
today_profit_loss_rate: liveTodayPLRate,
|
||||||
}
|
}
|
||||||
}, [portfolioSummary, portfolio, livePrices])
|
}, [portfolioSummary, portfolio, livePrices])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user