배치 수정

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

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

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