refactor: 백필 스크립트 → REST admin 엔드포인트로 전환
직접 DB/서비스에 접근하는 스크립트 패턴을 REST API 패턴으로 교체: - 삭제: scripts/backfill_finra_2020_gap.py (기존 POST /finra/admin/ingest으로 충분) - 삭제: scripts/backfill_alpaca_daily_pit.py - 삭제: scripts/export_pit_panel.py (외부 DB 연결으로 대체) - 추가: POST /api/v1/alpaca/admin/backfill-pit BackgroundTasks 패턴, FINRA 전체 PIT 심볼 백필, adjustment=all Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
888dc5be91
commit
ab693d332c
@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
"""
|
|
||||||
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