feat: 생존편향-0 FINRA PIT 데이터셋 — 상폐 가격 백필 + PIT 뷰 + 2020 갭 메우기
숏볼륨 신호 검정을 위한 편향-0 데이터셋 완성: - alpaca_client/price_service: adjustment 파라미터 추가 (get_bars, get_multi_bars, get_or_fetch_multi_bars) — adjustment='all'로 분할+배당 조정 일봉 수집 지원 - scripts/backfill_alpaca_daily_pit.py: FINRA 22,722 심볼(상폐 포함) 전체에 Alpaca SIP 일봉 백필, row-weighted 잔존편향 리포트 출력 - scripts/backfill_finra_2020_gap.py: 2020-04~10 COVID 갭 (~138 거래일) 메우기 - scripts/export_pit_panel.py: FINRA×Alpaca 조인 패널 → parquet 핸드오프 export - alembic q8h9i0j1k2l3: pit_universe_membership VIEW 생성 (날짜별 PIT 종목 집합) - docker-compose: ./scripts live-mount 추가 (app/alembic과 동일 패턴) - docs/DATA_COVERAGE.md: 실측 커버리지·API 파라미터·PIT 한계 업데이트 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
0c797ceb22
commit
888dc5be91
@ -0,0 +1,83 @@
|
||||
"""
|
||||
FINRA short-volume 2020 갭 메우기 (2020-04-01 ~ 2020-10-31).
|
||||
|
||||
DB 조사 결과: 전 구간(2018-08 ~ 2026-05)에서 유일한 데이터 결손이
|
||||
2020-04-01 ~ 2020-10-31 (약 138 평일). COVID 폭락장 + 회복장을 포함하는
|
||||
이 구간이 없으면 멀티레짐 검정력이 크게 훼손된다.
|
||||
|
||||
특성:
|
||||
- FINRA CDN 공개 무료 (API 키 불필요)
|
||||
- idempotent: 이미 수집된 날짜는 자동 스킵 (_count_for_date 검사)
|
||||
- 주말/공휴일(파일 없는 날)은 404 수신 후 자동 스킵
|
||||
- 1년치 약 20-40분 소요 → 이 스크립트는 7개월, 약 10-20분 예상
|
||||
|
||||
Usage (컨테이너 내부):
|
||||
python scripts/backfill_finra_2020_gap.py
|
||||
|
||||
Usage (호스트):
|
||||
docker exec stock_oracle_api python scripts/backfill_finra_2020_gap.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import 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("finra_gap_fill")
|
||||
|
||||
_LOCK_FILE = "/tmp/backfill_finra_2020.lock"
|
||||
|
||||
# 2020-10 is partially ingested (15 days) → start from 2020-04-01, end 2020-11-01
|
||||
_GAP_START = date(2020, 4, 1)
|
||||
_GAP_END = date(2020, 11, 1)
|
||||
|
||||
|
||||
async def run_gap_fill():
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.services.finra_short_volume_service import FinraShortVolumeService
|
||||
|
||||
logger.info(f"Starting FINRA gap fill: {_GAP_START} → {_GAP_END}")
|
||||
|
||||
svc = FinraShortVolumeService()
|
||||
async with AsyncSessionLocal() as db:
|
||||
inserted_total = await svc.ingest_date_range(
|
||||
db=db,
|
||||
start_date=_GAP_START,
|
||||
end_date=_GAP_END,
|
||||
force_refresh=False, # skip already-ingested dates
|
||||
)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("FINRA 2020 GAP FILL COMPLETE")
|
||||
logger.info(f" Period : {_GAP_START} → {_GAP_END}")
|
||||
logger.info(f" Inserted : {inserted_total:,} rows (0 = all dates already present or holiday)")
|
||||
logger.info("=" * 60)
|
||||
logger.info("Verify with:")
|
||||
logger.info(" SELECT to_char(date,'YYYY-MM'), COUNT(DISTINCT date::date)")
|
||||
logger.info(" FROM finra_short_volume")
|
||||
logger.info(" WHERE date >= '2020-03-01' AND date < '2021-02-01'")
|
||||
logger.info(" GROUP BY 1 ORDER 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("Gap fill is already running (lock file held). Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
asyncio.run(run_gap_fill())
|
||||
finally:
|
||||
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
||||
lock_fh.close()
|
||||
try:
|
||||
os.unlink(_LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
@ -0,0 +1,257 @@
|
||||
"""
|
||||
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))
|
||||
Loading…
Reference in New Issue