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.
stock-oracle/scripts/backfill_alpaca_daily_pit.py

314 lines
13 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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