@ -2,14 +2,20 @@
FINRA Short Sale Volume endpoints
"""
import logging
import os
from datetime import date , datetime , timedelta , timezone
from typing import List , Optional
from fastapi import APIRouter , Depends, HTTPException , Query
from fastapi . responses import Response
from fastapi import APIRouter , BackgroundTasks, Depends, HTTPException , Query
from fastapi . responses import FileResponse, Response
from sqlalchemy import text
from sqlalchemy . ext . asyncio import AsyncSession
from app . core . database import AsyncSessionLocal
logger = logging . getLogger ( __name__ )
from app . core . config import settings
from app . core . database import get_db
from app . schemas . finra import (
@ -184,6 +190,123 @@ async def get_pit_panel(
}
_EXPORT_PATH = " /app/data/pit_panel.parquet "
async def _run_pit_export ( start_str : str , end_str : str ) - > None :
""" Background task: export PIT panel to /app/data/pit_panel.parquet. """
try :
import pandas as pd
except ImportError :
logger . error ( " pandas not installed " ) ; return
try :
import pyarrow # noqa: F401
except ImportError :
logger . error ( " pyarrow not installed " ) ; return
logger . info ( f " PIT export: loading { start_str } → { end_str } ... " )
from datetime import date as date_type
import pyarrow as pa
import pyarrow . parquet as pq
d_from = date_type . fromisoformat ( start_str )
d_to = date_type . fromisoformat ( end_str )
COLS = [ " date " , " symbol " , " short_volume " , " short_exempt_volume " , " total_volume " ,
" short_ratio " , " open " , " high " , " low " , " close " , " price_volume " , " vwap " ]
SQL = text ( """
SELECT p . d : : date AS date , p . symbol ,
p . short_volume , p . short_exempt_volume , p . total_volume , p . short_ratio ,
a . open , a . high , a . low , a . close ,
a . volume AS price_volume , a . vwap
FROM pit_universe_membership p
LEFT JOIN alpaca_price_data a
ON a . ticker = p . symbol AND a . date : : date = p . d AND a . interval = ' 1d '
WHERE p . d BETWEEN : s AND : e
ORDER BY p . d , p . symbol
""" )
os . makedirs ( os . path . dirname ( _EXPORT_PATH ) , exist_ok = True )
tmp_path = _EXPORT_PATH + " .tmp "
# 연도별 청킹으로 메모리 제한 내 처리
writer = None
total_rows = 0
year = d_from . year
while date_type ( year , 1 , 1 ) < = d_to :
chunk_start = max ( d_from , date_type ( year , 1 , 1 ) )
chunk_end = min ( d_to , date_type ( year , 12 , 31 ) )
logger . info ( f " PIT export: fetching { chunk_start } → { chunk_end } ... " )
async with AsyncSessionLocal ( ) as db :
rows = ( await db . execute ( SQL , { " s " : chunk_start , " e " : chunk_end } ) ) . fetchall ( )
if rows :
df_chunk = pd . DataFrame ( rows , columns = COLS )
df_chunk [ " date " ] = pd . to_datetime ( df_chunk [ " date " ] )
table = pa . Table . from_pandas ( df_chunk , preserve_index = False )
if writer is None :
writer = pq . ParquetWriter ( tmp_path , table . schema , compression = " snappy " )
writer . write_table ( table )
total_rows + = len ( rows )
logger . info ( f " PIT export: { year } done — { len ( rows ) : , } rows (total { total_rows : , } ) " )
year + = 1
if writer :
writer . close ( )
os . replace ( tmp_path , _EXPORT_PATH )
mb = os . path . getsize ( _EXPORT_PATH ) / 1_048_576 if os . path . exists ( _EXPORT_PATH ) else 0
logger . info (
f " PIT export complete: { _EXPORT_PATH } | { mb : .0f } MB | "
f " total_rows= { total_rows : , } | cols= { COLS } "
)
@router.post (
" /admin/export-pit-panel " ,
summary = " PIT 패널 parquet export → /app/data/pit_panel.parquet " ,
description = (
" FINRA × Alpaca(adjustment= ' all ' ) 조인 패널을 parquet으로 저장. \n \n "
" `GET /finra/admin/export-pit-panel/download` 로 다운로드. \n \n "
" 백그라운드 실행 — 전체 기간(2018-08~현재) 기준 수 분 ~ 10여 분 소요. "
) ,
)
async def export_pit_panel (
background_tasks : BackgroundTasks ,
start_date : date = Query ( date ( 2018 , 8 , 1 ) , description = " 시작일 " ) ,
end_date : date = Query ( None , description = " 종료일 (기본: 오늘) " ) ,
) :
_end = end_date or date . today ( )
background_tasks . add_task ( _run_pit_export , start_date . isoformat ( ) , _end . isoformat ( ) )
return {
" status " : " started " ,
" start_date " : start_date . isoformat ( ) ,
" end_date " : _end . isoformat ( ) ,
" output " : _EXPORT_PATH ,
" note " : " 완료 후 GET /finra/admin/export-pit-panel/download 로 다운로드. " ,
}
@router.get (
" /admin/export-pit-panel/download " ,
summary = " pit_panel.parquet 다운로드 " ,
)
async def download_pit_panel ( ) :
if not os . path . exists ( _EXPORT_PATH ) :
raise HTTPException (
status_code = 404 ,
detail = " parquet 파일 없음. POST /finra/admin/export-pit-panel 먼저 실행. " ,
)
return FileResponse (
_EXPORT_PATH ,
media_type = " application/octet-stream " ,
filename = " pit_panel.parquet " ,
)
@router.post (
" /admin/ingest " ,
response_model = IngestResponse ,