You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

258 lines
10 KiB
Python

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