""" PIT(Point-in-Time) 패널 데이터 parquet 익스포트. FINRA short-volume ⋈ Alpaca 일봉 가격을 (date, symbol)로 조인하여 생존편향-0 리서치 패널을 data/pit_panel.parquet에 저장한다. 리서치 레이어에 Postgres 직접 접속이 없을 때 이 파일을 핸드오프로 사용. 패널 컬럼: date, symbol, short_volume, short_exempt_volume, total_volume, short_ratio, open, high, low, close, volume, vwap, (옵션) sector, exchange ← 현재 활성 종목 한정 심볼 정규화: FINRA symbol과 alpaca_price_data ticker 간 컨벤션 차이 (BRK/B vs BRK.B)를 동일 정규화 함수로 매핑. 조인 후 매칭 0행인 심볼 수를 리포트한다. 사전 조건: - backfill_alpaca_daily_pit.py 완료 후 실행 - pandas + pyarrow 설치 필요: pip install pandas pyarrow Usage (컨테이너 내부): python scripts/export_pit_panel.py Usage (호스트): docker exec stock_oracle_api python scripts/export_pit_panel.py # 출력: ./data/pit_panel.parquet (볼륨 마운트로 호스트에서 접근 가능) 옵션: --start YYYY-MM-DD 데이터 시작일 (기본: 2018-08-01) --end YYYY-MM-DD 데이터 종료일 (기본: 오늘) --out PATH 출력 경로 (기본: data/pit_panel.parquet) """ import argparse import asyncio import logging import os import sys from datetime import datetime, timezone 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_export") _DEFAULT_START = "2018-08-01" _DEFAULT_OUT = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "pit_panel.parquet" ) def finra_to_alpaca(symbol: str) -> str: """FINRA 심볼 → Alpaca 정규화 (/ → ., - → .).""" return symbol.replace("/", ".").replace("-", ".") async def run_export(start_str: str, end_str: str, out_path: str): try: import pandas as pd except ImportError: logger.error("pandas not installed. Run: pip install pandas pyarrow") return try: import pyarrow # noqa: F401 except ImportError: logger.error("pyarrow not installed. Run: pip install pyarrow") return from sqlalchemy import text from app.core.database import AsyncSessionLocal logger.info(f"PIT 패널 익스포트: {start_str} → {end_str}") # ------------------------------------------------------------------ # # Step 1: FINRA short-volume 전체 로드 # # ------------------------------------------------------------------ # logger.info("FINRA short-volume 로드 중 ...") async with AsyncSessionLocal() as db: result = await db.execute( text(""" SELECT date::date AS date, symbol, short_volume, short_exempt_volume, total_volume, short_ratio FROM finra_short_volume WHERE date >= :start AND date <= :end ORDER BY date, symbol """), {"start": start_str, "end": end_str}, ) finra_rows = result.fetchall() logger.info(f"FINRA rows: {len(finra_rows):,}") if not finra_rows: logger.error("FINRA 데이터 없음. 백필 먼저 실행하세요.") return df_finra = pd.DataFrame(finra_rows, columns=[ "date", "symbol", "short_volume", "short_exempt_volume", "total_volume", "short_ratio", ]) # 조인 키: 정규화된 심볼 df_finra["symbol_norm"] = df_finra["symbol"].apply(finra_to_alpaca).str.upper() # ------------------------------------------------------------------ # # Step 2: Alpaca 1d 가격 로드 # # ------------------------------------------------------------------ # logger.info("Alpaca 1d 가격 로드 중 ...") async with AsyncSessionLocal() as db: result = await db.execute( text(""" SELECT date::date AS date, ticker, open, high, low, close, volume, vwap FROM alpaca_price_data WHERE interval = '1d' AND date >= :start AND date <= :end ORDER BY date, ticker """), {"start": start_str, "end": end_str}, ) price_rows = result.fetchall() logger.info(f"Alpaca price rows: {len(price_rows):,}") df_price = pd.DataFrame(price_rows, columns=[ "date", "ticker", "open", "high", "low", "close", "volume", "vwap", ]) df_price["symbol_norm"] = df_price["ticker"].apply(finra_to_alpaca).str.upper() # ------------------------------------------------------------------ # # Step 3: 조인 # # ------------------------------------------------------------------ # logger.info("조인 중 ...") # FINRA에 market(B,Q,N) 컬럼이 있어 (date, symbol)이 여러 행일 수 있음. # 리서치용으로 시장 통합(aggregated) 값을 사용하는 것이 일반적. # 먼저 (date, symbol) 기준으로 집계 (이미 집계된 B,Q,N 합산 행이 보통이지만 혹시 분리돼 있을 경우) df_finra_agg = ( df_finra .groupby(["date", "symbol", "symbol_norm"], as_index=False) .agg( short_volume = ("short_volume", "sum"), short_exempt_volume= ("short_exempt_volume","sum"), total_volume = ("total_volume", "sum"), ) ) df_finra_agg["short_ratio"] = ( df_finra_agg["short_volume"] / df_finra_agg["total_volume"].replace(0, float("nan")) ) # Alpaca에도 (date, ticker) 중복이 있을 수 있음 (adjustment 차이 등) → dedup df_price_dedup = df_price.drop_duplicates(subset=["date", "symbol_norm"], keep="last") df_panel = df_finra_agg.merge( df_price_dedup[["date", "symbol_norm", "open", "high", "low", "close", "volume", "vwap"]], on=["date", "symbol_norm"], how="left", ) # ------------------------------------------------------------------ # # Step 4: 커버리지 체크 # # ------------------------------------------------------------------ # matched = df_panel["close"].notna().sum() total_rows = len(df_panel) missing_pct = 100.0 * (total_rows - matched) / total_rows if total_rows else 0 no_price_syms = set( df_panel.loc[df_panel["close"].isna(), "symbol"].unique() ) logger.info(f"패널 총 행: {total_rows:,}") logger.info(f"가격 매칭 행: {matched:,} ({100-missing_pct:.1f}%)") logger.info(f"가격 누락 행: {total_rows-matched:,} ({missing_pct:.1f}%)") logger.info(f"가격 없는 고유 심볼 수: {len(no_price_syms):,}") if no_price_syms: sample = sorted(no_price_syms)[:30] logger.info(f"가격 없는 심볼 샘플(30): {sample}") # ------------------------------------------------------------------ # # Step 5: 옵션 — sector/exchange 추가 (활성 종목 한정) # # ------------------------------------------------------------------ # logger.info("sector/exchange 메타데이터 조인 중 (활성 종목 한정) ...") async with AsyncSessionLocal() as db: result = await db.execute( text(""" SELECT ticker, sector, exchange FROM universe_ticker_registry WHERE is_active = true """) ) meta_rows = result.fetchall() df_meta = pd.DataFrame(meta_rows, columns=["ticker", "sector", "exchange"]) df_meta["symbol_norm"] = df_meta["ticker"].apply(finra_to_alpaca).str.upper() df_panel = df_panel.merge( df_meta[["symbol_norm", "sector", "exchange"]], on="symbol_norm", how="left", ) # ------------------------------------------------------------------ # # Step 6: 컬럼 정리 + 저장 # # ------------------------------------------------------------------ # # 최종 컬럼 순서 keep_cols = [ "date", "symbol", "short_volume", "short_exempt_volume", "total_volume", "short_ratio", "open", "high", "low", "close", "volume", "vwap", "sector", "exchange", ] df_out = df_panel[keep_cols].copy() df_out["date"] = pd.to_datetime(df_out["date"]) os.makedirs(os.path.dirname(out_path), exist_ok=True) df_out.to_parquet(out_path, index=False, engine="pyarrow") file_mb = os.path.getsize(out_path) / 1_048_576 logger.info("=" * 70) logger.info("PIT 패널 익스포트 완료") logger.info(f" 출력 파일 : {out_path}") logger.info(f" 파일 크기 : {file_mb:.1f} MB") logger.info(f" 패널 shape : {df_out.shape}") logger.info(f" 날짜 범위 : {df_out['date'].min().date()} ~ {df_out['date'].max().date()}") logger.info(f" 고유 심볼 : {df_out['symbol'].nunique():,}") logger.info(f" 가격 커버 : {df_out['close'].notna().mean()*100:.1f}%") logger.info(f" 잔존편향 : {missing_pct:.2f}% (row-weighted)") logger.info("=" * 70) logger.info("") logger.info("사용 예 (Python):") logger.info(" import pandas as pd") logger.info(f" df = pd.read_parquet('{out_path}')") logger.info(" # PIT 유니버스 (특정 날짜 활동 종목):") logger.info(" pit_2023_03_09 = df[df.date == '2023-03-09']['symbol'].unique()") logger.info(" # 그날 공매도비율:") logger.info(" df[df.date == '2023-03-09'][['symbol','short_ratio','close']]") if __name__ == "__main__": parser = argparse.ArgumentParser(description="PIT 패널 parquet 익스포트") parser.add_argument("--start", default=_DEFAULT_START, help="시작일 (YYYY-MM-DD)") parser.add_argument("--end", default=datetime.now(timezone.utc).strftime("%Y-%m-%d"), help="종료일 (YYYY-MM-DD)") parser.add_argument("--out", default=_DEFAULT_OUT, help="출력 parquet 경로") args = parser.parse_args() asyncio.run(run_export(args.start, args.end, args.out))