""" PIT(Point-in-Time) Alpaca 일봉 가격 백필 — 생존편향-0 가격 데이터셋. FINRA short-volume DB에 등장한 모든 심볼(현 활성 유니버스 9,635 + 상폐/합병 과거 심볼 ~16,143 포함, 계 22,722)의 일봉(1d)을 Alpaca SIP에서 수집한다. 이 스크립트 없이는 가격(returns) 측이 현 생존자(~3k) 종목으로만 계산 가능해 숏볼륨 신호 검정 자체가 생존편향으로 무효화된다. 전략: 1. FINRA 심볼 리스트 + 활동기간(min/max date) 로드. 2. alpaca_price_data 1d 현재 커버리지 로드. 3. 커버리지 불충분 심볼만 Alpaca 요청(DB-first, idempotent). 4. adjustment='all'(분할+배당 조정) 저장 — 상폐 종목은 future-proof. 5. 완료 후: covered/missing 심볼 수 + row-weighted 잔존편향 % 리포트. 심볼 정규화: - FINRA "/" (BRK/B, BF/B, AAC/U 등) → Alpaca "." (BRK.B, BF.B, AAC.U) - 워런트/유닛(/U, /WS 등)은 Alpaca에 데이터 없음 → 빈 응답 (무해) - 일반 심볼(대다수): 그대로 전달 환경 요건: - ALPACA_API_KEY, ALPACA_SECRET_KEY 환경변수 설정 필요 - Alpaca 일봉 SIP 데이터는 무료 플랜에서 2016-01-04까지 제공 - 2018-08-01 기점 전 구간 PIT 달성 가능 (실증 검증 완료) 소요 시간: - 신규 심볼 ~19k × 8년치 / 200req/min ≈ 1-3시간 - 이미 수집된 심볼은 스킵 (on_conflict_do_nothing, idempotent) Usage (컨테이너 내부): python scripts/backfill_alpaca_daily_pit.py Usage (호스트): docker exec stock_oracle_api python scripts/backfill_alpaca_daily_pit.py """ import asyncio import fcntl import logging import os import sys from datetime import datetime, timezone, timedelta, date sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("pit_price_backfill") _LOCK_FILE = "/tmp/backfill_alpaca_pit.lock" # 2018-08-01: earliest date where all current FINRA data starts # (Alpaca 일봉은 2016-01-04부터 가능, 2018-08 기점 전 구간 커버) _BACKFILL_START_STR = "2018-08-01" _BATCH_SIZE = 100 # 심볼/요청 (Alpaca multi-bar 한도 내) _CHUNK_SIZE = 2300 # 행/upsert (asyncpg 32767 bind-param 한도: 2300×14=32,200) _ADJUSTMENT = "all" # split+dividend 조정 (상폐 종목은 future-proof, 생존종목도 정확) # Coverage tolerance: alpaca_max vs finra_last 이 N일 이상 차이나면 re-fetch _COVERAGE_TOLERANCE_DAYS = 5 def finra_to_alpaca(symbol: str) -> str: """FINRA 심볼을 Alpaca 심볼 형식으로 변환. FINRA는 주식 클래스와 워런트/유닛에 '/'를 사용 (BRK/B → BRK.B). Yahoo Finance 호환 하이픈도 처리 (BRK-B → BRK.B). """ return symbol.replace("/", ".").replace("-", ".") async def run_backfill(): from sqlalchemy import text from sqlalchemy.dialects.postgresql import insert as pg_insert from app.core.database import AsyncSessionLocal from app.models.alpaca_price import AlpacaPriceData from app.services.alpaca_client import AlpacaClient client = AlpacaClient() if not client.is_configured(): logger.error("ALPACA_API_KEY / ALPACA_SECRET_KEY 미설정. 중단.") return now_utc = datetime.now(timezone.utc) # 당일 봉은 아직 확정 전일 수 있으므로 1일 버퍼 end_str = (now_utc - timedelta(days=1)).strftime("%Y-%m-%d") start_str = _BACKFILL_START_STR logger.info(f"PIT 가격 백필 시작: {start_str} → {end_str}") # ------------------------------------------------------------------ # # Step 1: FINRA 심볼 + 활동기간 로드 # # ------------------------------------------------------------------ # logger.info("FINRA PIT 심볼 리스트 로드 중 ...") async with AsyncSessionLocal() as db: rows = await db.execute( text(""" SELECT symbol, MIN(date)::date AS finra_first, MAX(date)::date AS finra_last, COUNT(DISTINCT date::date) AS finra_days FROM finra_short_volume GROUP BY symbol ORDER BY symbol """) ) finra_info = {r.symbol: (r.finra_first, r.finra_last, r.finra_days) for r in rows} total_syms = len(finra_info) total_obs = sum(v[2] for v in finra_info.values()) logger.info(f"FINRA 심볼 수: {total_syms:,} | 총 관측치(심볼-일): {total_obs:,}") # ------------------------------------------------------------------ # # Step 2: Alpaca 1d 현재 커버리지 로드 # # ------------------------------------------------------------------ # logger.info("기존 Alpaca 1d 커버리지 로드 중 ...") async with AsyncSessionLocal() as db: rows = await db.execute( text(""" SELECT ticker, MAX(date)::date AS alpaca_max FROM alpaca_price_data WHERE interval = '1d' GROUP BY ticker """) ) # 정규화 키로 저장 (BF-B, BF.B 등 혼재 → 점 형식으로 통일) alpaca_coverage: dict[str, date] = {} for r in rows: key = finra_to_alpaca(r.ticker).upper() # 동일 정규화 키가 여러 ticker로 존재할 수 있음 (BF-B, BF.B) → 최신값 사용 if key not in alpaca_coverage or r.alpaca_max > alpaca_coverage[key]: alpaca_coverage[key] = r.alpaca_max logger.info(f"Alpaca 1d 기존 커버리지 심볼 수: {len(alpaca_coverage):,}") # ------------------------------------------------------------------ # # Step 3: 수집 필요 심볼 결정 # # ------------------------------------------------------------------ # # 조건: alpaca 데이터 없음, 또는 alpaca_max < finra_last - tolerance need_fetch: list[str] = [] for sym, (finra_first, finra_last, _) in finra_info.items(): alpaca_key = finra_to_alpaca(sym).upper() alpaca_max = alpaca_coverage.get(alpaca_key) if alpaca_max is None: need_fetch.append(sym) elif (finra_last - alpaca_max).days > _COVERAGE_TOLERANCE_DAYS: need_fetch.append(sym) logger.info( f"수집 필요 심볼: {len(need_fetch):,} / {total_syms:,} " f"(기존 충분: {total_syms - len(need_fetch):,})" ) if not need_fetch: logger.info("모든 심볼 커버리지 충분. 수집 불필요.") else: # ------------------------------------------------------------------ # # Step 4: 배치 수집 + upsert # # ------------------------------------------------------------------ # total_inserted = 0 total_bars_fetched = 0 n_batches = (len(need_fetch) + _BATCH_SIZE - 1) // _BATCH_SIZE for batch_idx in range(0, len(need_fetch), _BATCH_SIZE): batch = need_fetch[batch_idx: batch_idx + _BATCH_SIZE] batch_num = batch_idx // _BATCH_SIZE + 1 if batch_num == 1 or batch_num % 20 == 0: logger.info( f"배치 {batch_num}/{n_batches}: {len(batch)}개 심볼 " f"({batch[0]}..{batch[-1]}) | 누적 삽입: {total_inserted:,}" ) # FINRA 심볼 → Alpaca 정규화 역방향 맵 (Alpaca 응답 키 → FINRA 원본 심볼) reverse_map: dict[str, str] = { finra_to_alpaca(s).upper(): s for s in batch } try: raw = await client.get_multi_bars( symbols=batch, timeframe="1d", start=start_str, end=end_str, adjustment=_ADJUSTMENT, ) except Exception as exc: logger.warning(f"배치 {batch_num} 요청 실패: {exc} — 스킵") continue rows_to_insert = [] for alpaca_sym, bar_list in raw.items(): if not bar_list: continue # Alpaca 응답 키는 정규화된 형식 (예: BRK.B) — 역맵으로 FINRA 원본 복원 original = reverse_map.get(alpaca_sym.upper(), alpaca_sym) for bar in bar_list: ts = bar["t"] dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) rows_to_insert.append({ "ticker": original, "date": dt, "interval": "1d", "open": float(bar.get("o") or 0), "high": float(bar.get("h") or 0), "low": float(bar.get("l") or 0), "close": float(bar.get("c") or 0), "volume": float(bar.get("v") or 0), "vwap": float(bar["vw"]) if bar.get("vw") else None, "trade_count": int(bar["n"]) if bar.get("n") else None, "data_source": "ALPACA", }) total_bars_fetched += len(rows_to_insert) if rows_to_insert: async with AsyncSessionLocal() as db: for i in range(0, len(rows_to_insert), _CHUNK_SIZE): stmt = pg_insert(AlpacaPriceData).values( rows_to_insert[i: i + _CHUNK_SIZE] ) stmt = stmt.on_conflict_do_nothing( constraint="uq_alpaca_price_data" ) result = await db.execute(stmt) total_inserted += result.rowcount await asyncio.sleep(0) # event loop yield await db.commit() logger.info( f"수집 완료 — 총 봉: {total_bars_fetched:,} | DB 삽입: {total_inserted:,} " f"(중복 스킵: {total_bars_fetched - total_inserted:,})" ) # ------------------------------------------------------------------ # # Step 5: 완료 후 커버리지 리포트 # # ------------------------------------------------------------------ # logger.info("최종 커버리지 리포트 계산 중 ...") async with AsyncSessionLocal() as db: rows = await db.execute( text(""" SELECT ticker, COUNT(DISTINCT date::date) AS alpaca_days FROM alpaca_price_data WHERE interval = '1d' GROUP BY ticker """) ) new_coverage_keys: set[str] = { finra_to_alpaca(r.ticker).upper() for r in rows } covered_syms = 0 covered_obs = 0 missing_syms_list: list[str] = [] missing_obs = 0 for sym, (finra_first, finra_last, finra_days) in finra_info.items(): key = finra_to_alpaca(sym).upper() if key in new_coverage_keys: covered_syms += 1 covered_obs += finra_days else: missing_syms_list.append(sym) missing_obs += finra_days bias_pct = 100.0 * missing_obs / total_obs if total_obs else 0.0 logger.info("=" * 70) logger.info("PIT 가격 백필 완료 — 커버리지 리포트") logger.info(f" FINRA 전체 심볼 : {total_syms:,}") logger.info(f" Alpaca 가격 있는 심볼 : {covered_syms:,}") logger.info(f" 가격 없는 심볼 (워런트/OTC 등): {len(missing_syms_list):,}") logger.info(f" FINRA 전체 관측치(심볼-일) : {total_obs:,}") logger.info(f" 가격 매칭 관측치 : {covered_obs:,}") logger.info(f" 가격 누락 관측치 : {missing_obs:,}") logger.info(f" 잔존 편향 (row-weighted) : {bias_pct:.2f}%") logger.info("=" * 70) if missing_syms_list: sample = sorted(missing_syms_list)[:30] logger.info(f"가격 누락 심볼 샘플 (상위 30): {sample}") logger.info("(대부분은 워런트/유닛 (/U, /WS) 또는 초저유동 OTC — 분석단에서 필터)") logger.info("") logger.info("검증 쿼리:") logger.info(" SELECT ticker, MIN(date)::date, MAX(date)::date, COUNT(*)") logger.info(" FROM alpaca_price_data WHERE interval='1d'") logger.info(" AND ticker IN ('SIVB','SBNY','FRC','TWTR','ATVI','DISCA')") logger.info(" GROUP BY 1;") if __name__ == "__main__": lock_fh = open(_LOCK_FILE, "w") try: fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: logger.error("백필이 이미 실행 중 (lock 파일 보유). 종료.") sys.exit(1) try: asyncio.run(run_backfill()) finally: fcntl.flock(lock_fh, fcntl.LOCK_UN) lock_fh.close() try: os.unlink(_LOCK_FILE) except OSError: pass