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