@ -6,32 +6,19 @@ from datetime import date, datetime, timezone, timedelta
from typing import Optional
from fastapi import APIRouter , Depends , HTTPException , Query
from fastapi . responses import Response
from starlette . responses import JSONResponse
from sqlalchemy . ext . asyncio import AsyncSession
from sqlalchemy import select , and_
from app . core . config import settings
from app . core . database import get_db
from app . models . alpaca_price import AlpacaPriceData
from app . schemas . financial import (
PriceDataResponse ,
AlpacaPriceDataPoint ,
AlpacaBarsResponse ,
AlpacaIntradayResponse ,
AlpacaSnapshotResponse ,
AlpacaMultiSnapshotResponse ,
AlpacaMultiBarsResponse ,
ErrorType ,
AlpacaMultiSnapshotResponse ,
AlpacaSnapshotResponse ,
)
from app . services . alpaca_client import AlpacaClient , normalize_ticker
from app . services . alpaca_client import AlpacaClient
from app . services . alpaca_price_service import AlpacaPriceService
from app . utils . cache import build_cache_key , get_cached_response , set_cached_response , with_cache
router = APIRouter ( )
INTRADAY_CACHE_TTL = 300 # 5 minutes for intraday data
def _require_alpaca ( ) - > AlpacaPriceService :
svc = AlpacaPriceService ( )
@ -44,7 +31,7 @@ def _require_alpaca() -> AlpacaPriceService:
# ------------------------------------------------------------------
# Status (no caching — always real-time)
# Status
# ------------------------------------------------------------------
@router.get (
@ -65,200 +52,28 @@ async def alpaca_status():
# ------------------------------------------------------------------
# Raw bars (no DB) — with Redis caching
# ------------------------------------------------------------------
@router.get (
" /bars/ {ticker} " ,
response_model = AlpacaBarsResponse ,
summary = " Get Alpaca bars (raw, no DB) " ,
description = (
" Fetch historical bars directly from Alpaca without storing in DB. \n \n "
" **데이터 소스 (피드 자동 선택)** \n \n "
" | interval | 피드 | 특성 | \n "
" |----------|------|------| \n "
" | `1m` `5m` `15m` `30m` `1h` | **IEX** | 실시간, 거래량 2~5 % , 무료 | \n "
" | `1d` `1w` `1mo` | **SIP** | 전체 거래소 통합, 정확, 무료 | \n \n "
" - DB에 저장하지 않음 — 저장이 필요하면 `/alpaca/data/ {ticker} ` 사용 \n "
" - 조회 범위: 2016년~ (Alpaca 무료 플랜 기준) \n "
" - Redis 24시간 캐시 적용 (`force_refresh=true`로 우회) \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` "
) ,
)
@with_cache ( namespace = " alpaca:bars " , ttl = 86400 , key_params = [ " ticker " , " interval " , " start_date " , " end_date " , " limit " ] )
async def get_alpaca_bars (
ticker : str ,
response : Response ,
interval : str = Query ( " 1d " , description = " Interval: 1m, 5m, 15m, 1h, 1d, 1w, 1mo " ) ,
start_date : Optional [ date ] = Query ( None , description = " Start date (YYYY-MM-DD) " ) ,
end_date : Optional [ date ] = Query ( None , description = " End date (YYYY-MM-DD) " ) ,
limit : int = Query ( 1000 , ge = 1 , le = 10000 , description = " Max bars to return " ) ,
force_refresh : bool = Query ( False , description = " Bypass cache " ) ,
) :
svc = _require_alpaca ( )
start_dt = datetime . combine ( start_date , datetime . min . time ( ) ) . replace ( tzinfo = timezone . utc ) if start_date else None
end_dt = datetime . combine ( end_date , datetime . min . time ( ) ) . replace ( tzinfo = timezone . utc ) if end_date else None
try :
bars = await svc . fetch_bars_raw (
ticker = ticker . upper ( ) ,
interval = interval ,
start_date = start_dt ,
end_date = end_dt ,
)
bars = bars [ : limit ]
body_dict = {
" ticker " : ticker . upper ( ) ,
" interval " : interval ,
" count " : len ( bars ) ,
" bars " : bars ,
}
return body_dict
except Exception as e :
raise HTTPException ( status_code = 502 , detail = f " Alpaca API error: { e } " )
finally :
await svc . client . close ( )
# ------------------------------------------------------------------
# Price data (DB storage) — with Redis caching
# ------------------------------------------------------------------
@router.get (
" /data/ {ticker} " ,
response_model = PriceDataResponse ,
summary = " Get price data via Alpaca (with DB storage) " ,
description = (
" Fetch OHLCV price data from Alpaca, store in AlpacaPriceData table, and return "
" in PriceDataResponse format. Includes vwap and trade_count in metadata. \n \n "
" **데이터 소스 (피드 자동 선택)** \n \n "
" | interval | 피드 | 특성 | \n "
" |----------|------|------| \n "
" | `1m` `5m` `15m` `30m` `1h` | **IEX** | 실시간, 거래량 2~5 % , 무료 | \n "
" | `1d` `1w` `1mo` | **SIP** | 전체 거래소 통합, 정확, 무료 | \n \n "
" - DB 저장 후 재요청 시 Alpaca 미사용 (DB-first) \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` \n "
" - `data_source = \" ALPACA \" `로 Yahoo Finance 데이터와 구분 \n "
" - 조회 범위: 2016년~ (Alpaca 무료 플랜 기준) "
) ,
)
@with_cache ( namespace = " alpaca:data " , ttl = 86400 , key_params = [ " ticker " , " interval " , " start_date " , " end_date " ] )
async def get_alpaca_price_data (
ticker : str ,
response : Response ,
interval : str = Query ( " 1d " , description = " Interval: 1m, 5m, 15m, 1h, 1d, 1w, 1mo " ) ,
start_date : date = Query ( . . . , description = " Start date (YYYY-MM-DD) " ) ,
end_date : date = Query ( . . . , description = " End date (YYYY-MM-DD) " ) ,
force_refresh : bool = Query ( False , description = " Re-fetch even if data exists in DB " ) ,
db : AsyncSession = Depends ( get_db ) ,
) :
svc = _require_alpaca ( )
start_dt = datetime . combine ( start_date , datetime . min . time ( ) ) . replace ( tzinfo = timezone . utc )
end_dt = datetime . combine ( end_date , datetime . min . time ( ) ) . replace ( tzinfo = timezone . utc )
if start_dt > = end_dt :
raise HTTPException ( status_code = 400 , detail = " start_date must be before end_date " )
try :
count = await svc . fetch_and_store_bars (
db , ticker , start_dt , end_dt , interval
)
# Read back from AlpacaPriceData table
result = await db . execute (
select ( AlpacaPriceData )
. where (
and_ (
AlpacaPriceData . ticker == ticker . upper ( ) ,
AlpacaPriceData . date > = start_dt ,
AlpacaPriceData . date < = end_dt ,
)
)
. order_by ( AlpacaPriceData . date )
)
rows = result . scalars ( ) . all ( )
alpaca_points = [ AlpacaPriceDataPoint . model_validate ( r ) for r in rows ]
# Build PriceDataResponse-compatible data with vwap/trade_count in metadata
from app . schemas . financial import PriceDataPoint
price_points = [
PriceDataPoint (
date = p . date ,
open = p . open ,
high = p . high ,
low = p . low ,
close = p . close ,
volume = p . volume ,
adjusted_close = p . vwap , # Map vwap -> adjusted_close for compatibility
data_source = p . data_source ,
)
for p in alpaca_points
]
body = PriceDataResponse (
ticker = ticker . upper ( ) ,
interval = interval ,
data = price_points ,
metadata = {
" source " : " ALPACA " ,
" data_points " : len ( price_points ) ,
" new_bars_inserted " : count ,
" date_range " : {
" start " : start_date . isoformat ( ) ,
" end " : end_date . isoformat ( ) ,
} ,
" alpaca_fields " : [
{ " date " : p . date . isoformat ( ) , " vwap " : p . vwap , " trade_count " : p . trade_count }
for p in alpaca_points
] ,
} ,
)
return body
except HTTPException :
raise
except Exception as e :
raise HTTPException ( status_code = 502 , detail = f " Alpaca error: { e } " )
finally :
await svc . client . close ( )
# ------------------------------------------------------------------
# Intraday (raw — no DB) — with Redis caching (short TTL)
# Intraday bars (DB-backed)
# ------------------------------------------------------------------
@router.get (
" /intraday " ,
response_model = AlpacaMultiBarsResponse ,
summary = " Get historical intraday bars for multiple tickers via Alpaca SIP ( DB-backed)" ,
summary = " Get historical intraday bars for multiple tickers (SIP feed, DB-backed) " ,
description = (
" Fetch historical intraday OHLCV bars for up to ~500 tickers via Alpaca **SIP 피드**. "
" Results are stored in DB so subsequent calls for the same period skip Alpaca. \n \n "
" **⚠️ 날짜 제한: 어제(yesterday)까지만 조회 가능** \n \n "
" Alpaca 무료 플랜에서 SIP 피드는 15분 이상 지난 데이터만 접근 가능합니다. "
" 당일(오늘) 데이터가 필요하면 → **`GET /api/v1/alpaca/intraday/now`** 사용 \n \n "
" **SIP 피드 특성** \n \n "
" 멀티 종목 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. "
" DB에 저장되며 재요청 시 Alpaca 미호출. \n \n "
" **⚠️ 어제(yesterday)까지만 조회 가능** — 당일 데이터는 `/intraday/today` 사용 \n \n "
" | 항목 | 내용 | \n "
" |------|------| \n "
" | 피드 | **SIP 피드** (전체 미국 거래소 통합) | \n "
" | 거래량 커버리지 | **100 % ** (NYSE, NASDAQ, BATS 등 전체) | \n "
" | OHLCV 정확도 | **정확** — 백테스트에 적합 | \n "
" | 조회 가능 범위 | **2016년~어제** (당일 조회 시 400 에러) | \n "
" | 히스토리 | 2016년부터 제공 (무료 플랜 기준) | \n \n "
" **권장 용도**: 백테스트, 과거 분봉 분석, ORB 전략 히스토리컬 검증 \n \n "
" - `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B` \n "
" | 피드 | **SIP** (전체 미국 거래소 통합) | \n "
" | 거래량 | **100 % ** 정확 | \n "
" | 조회 범위 | **2016년~어제** | \n "
" | DB 저장 | 있음 (재요청 시 Alpaca 미사용) | \n \n "
" **권장 용도**: 백테스트, 과거 분봉 분석 \n \n "
" - `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B` \n "
" - `interval`: `1m`, `5m`, `15m`, `30m`, `1h` \n "
" - DB 저장 후 재요청 시 Alpaca 미사용 \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`. \n \n "
" **⚠️ Alpaca 배치 제한** \n \n "
" Alpaca multi-bar 엔드포인트는 요청당 **~100개 심볼**이 실질적 상한입니다 "
" (공식 문서 미명시, 커뮤니티 보고 및 실제 운용 기준 — 초과 시 502 발생). "
" 내부적으로 **100개 단위로 자동 분할**하여 요청하므로 클라이언트는 신경 쓸 필요 없음. "
" 단, 배치 수가 늘어날수록 응답 시간이 선형적으로 증가함 (500종목 → Alpaca 5회 호출). "
" - 내부 100개 단위 자동 배치 분할 (500종목 → Alpaca 5회 호출) \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` "
) ,
)
async def get_alpaca_intraday_multi (
@ -288,7 +103,6 @@ async def get_alpaca_intraday_multi(
)
svc = _require_alpaca ( )
start_dt = datetime . combine ( _start , datetime . min . time ( ) ) . replace ( tzinfo = timezone . utc )
end_dt = datetime . combine ( _end , datetime . max . time ( ) ) . replace ( tzinfo = timezone . utc )
@ -323,35 +137,29 @@ async def get_alpaca_intraday_multi(
for ticker , rows in data . items ( )
}
return AlpacaMultiBarsResponse (
interval = interval ,
count = len ( symbols ) ,
bars = bars ,
)
return AlpacaMultiBarsResponse ( interval = interval , count = len ( symbols ) , bars = bars )
@router.get (
" /intraday/today " ,
response_model = AlpacaMultiBarsResponse ,
summary = " Get today ' s real-time intraday bars via Alpaca IEX ( DB-backed)" ,
summary = " Get today ' s real-time intraday bars for multiple tickers (IEX feed, DB-backed)" ,
description = (
" 당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. "
" DB에 저장되며, 장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n \n "
" ** IEX 피드 특성** \n \n "
" 장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n \n "
" ** ⚠️ 오늘 데이터만 조회 가능** — 과거 데이터는 `/intraday` 사용 \n \n "
" | 항목 | 내용 | \n "
" |------|------| \n "
" | 피드 | **IEX 피드 ** (IEX 거래소 단일) |\n "
" | 피드 | **IEX ** (IEX 거래소 단일) |\n "
" | 지연 | **실시간** (지연 없음) | \n "
" | 거래량 커버리지 | 미국 전체 시장의 약 **2~5 % ** | \n "
" | 가격 방향성 | **신뢰 가능** (대형주 기준) | \n "
" | High/Low range | SIP 대비 **좁게** 표시될 수 있음 | \n "
" | 조회 가능 범위 | **오늘만** (어제 이전 데이터는 `/intraday` 사용) | \n \n "
" | 거래량 | 실제의 약 **2~5 % ** (IEX 거래소 거래만 집계) | \n "
" | High/Low range | SIP 대비 좁게 표시될 수 있음 | \n "
" | DB 저장 | 있음 (장 중 항상 재조회) | \n \n "
" **권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링 \n \n "
" - `tickers`: comma-separated list , e.g. `AAPL,MSFT,BF-B`\n "
" - `tickers`: comma-separated , e.g. `AAPL,MSFT,BF-B`\n "
" - `interval`: `1m`, `5m`, `15m`, `30m`, `1h` \n "
" - 과거 분봉 히스토리가 필요하면 → `GET /api/v1/alpaca/intraday` 사용 \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`. \n \n "
" **⚠️ Alpaca 배치 제한**: 내부적으로 100개 단위 자동 분할 처리. "
" - 내부 100개 단위 자동 배치 분할 \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` "
) ,
)
async def get_alpaca_intraday_today (
@ -403,84 +211,11 @@ async def get_alpaca_intraday_today(
for ticker , rows in data . items ( )
}
return AlpacaMultiBarsResponse (
interval = interval ,
count = len ( symbols ) ,
bars = bars ,
)
@router.get (
" /intraday/ {ticker} " ,
response_model = AlpacaIntradayResponse ,
summary = " Get historical intraday candles from Alpaca (single ticker, SIP feed) " ,
description = (
" 단일 종목의 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. \n \n "
" **데이터 소스: SIP 피드** \n \n "
" | 항목 | 내용 | \n "
" |------|------| \n "
" | 피드 | **SIP** (전체 미국 거래소 통합) | \n "
" | 거래량 커버리지 | **100 % ** | \n "
" | OHLCV 정확도 | **정확** | \n "
" | 조회 가능 범위 | **2016년~어제** (당일 조회 시 400 에러) | \n "
" | DB 저장 | **없음** — 매 요청 Alpaca 직접 호출 | \n "
" | 캐시 | Redis **24시간 TTL** | \n \n "
" 당일 실시간 데이터가 필요하면 → `GET /api/v1/alpaca/intraday/today` \n \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` "
) ,
)
@with_cache ( namespace = " alpaca:intraday " , ttl = 86400 , key_params = [ " ticker " , " interval " , " start_date " , " end_date " ] )
async def get_alpaca_intraday (
ticker : str ,
response : Response ,
interval : str = Query ( " 5m " , description = " Interval: 1m, 5m, 15m, 30m, 1h " ) ,
start_date : Optional [ date ] = Query ( None , description = " Start date (YYYY-MM-DD). Default: yesterday " ) ,
end_date : Optional [ date ] = Query ( None , description = " End date (YYYY-MM-DD). Must be before today. Default: yesterday " ) ,
limit : int = Query ( 1000 , ge = 1 , le = 10000 , description = " Max candles " ) ,
force_refresh : bool = Query ( False , description = " Bypass cache " ) ,
) :
yesterday = date . today ( ) - timedelta ( days = 1 )
_end = end_date or yesterday
if _end > = date . today ( ) :
raise HTTPException (
status_code = 400 ,
detail = " 이 엔드포인트는 어제(yesterday)까지의 과거 데이터만 조회 가능합니다. "
" 당일 실시간 데이터는 GET /api/v1/alpaca/intraday/today 를 사용하세요. " ,
)
svc = _require_alpaca ( )
_start = start_date or yesterday
start_dt = datetime . combine ( _start , datetime . min . time ( ) ) . replace ( tzinfo = timezone . utc )
end_dt = datetime . combine ( _end , datetime . max . time ( ) ) . replace ( tzinfo = timezone . utc )
try :
bars = await svc . fetch_bars_raw (
ticker = ticker . upper ( ) ,
interval = interval ,
start_date = start_dt ,
end_date = end_dt ,
feed = " sip " ,
)
bars = bars [ : limit ]
body_dict = {
" ticker " : ticker . upper ( ) ,
" interval " : interval ,
" source " : " ALPACA " ,
" count " : len ( bars ) ,
" candles " : bars ,
}
return body_dict
except Exception as e :
raise HTTPException ( status_code = 502 , detail = f " Alpaca API error: { e } " )
finally :
await svc . client . close ( )
return AlpacaMultiBarsResponse ( interval = interval , count = len ( symbols ) , bars = bars )
# ------------------------------------------------------------------
# Real-time snapshot (no cache)
# Real-time snapshot
# ------------------------------------------------------------------
def _parse_snapshot ( ticker : str , raw : dict ) - > AlpacaSnapshotResponse :
@ -515,55 +250,24 @@ def _parse_snapshot(ticker: str, raw: dict) -> AlpacaSnapshotResponse:
)
@router.get (
" /snapshot/ {ticker} " ,
response_model = AlpacaSnapshotResponse ,
summary = " Real-time snapshot for a single ticker (IEX feed) " ,
description = (
" 단일 종목의 실시간 스냅샷을 반환합니다. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함. \n \n "
" **데이터 소스: IEX 피드 (무료 플랜 강제)** \n \n "
" | 항목 | 내용 | \n "
" |------|------| \n "
" | 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 | \n "
" | 지연 | **실시간** (지연 없음) | \n "
" | 거래량 | IEX 기준 (실제의 2~5 % ) | \n "
" | High/Low | IEX 기준 (실제보다 range 좁을 수 있음) | \n "
" | 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 | \n \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` "
) ,
)
async def get_snapshot ( ticker : str ) :
client = AlpacaClient ( )
if not client . is_configured ( ) :
raise HTTPException ( status_code = 503 , detail = " Alpaca API keys not configured. " )
try :
raw = await client . get_snapshot ( ticker )
return _parse_snapshot ( ticker , raw )
except Exception as e :
raise HTTPException ( status_code = 502 , detail = f " Alpaca API error: { e } " )
finally :
await client . close ( )
@router.get (
" /snapshot " ,
response_model = AlpacaMultiSnapshotResponse ,
summary = " Real-time snapshots for multiple tickers (IEX feed) " ,
description = (
" 멀티 종목 의 실시간 스냅샷을 한 번의 요청으로 반환합니다 .\n \n "
" **데이터 소스: IEX 피드 (무료 플랜 강제)** \n \n "
" 멀티 종목 실시간 스냅샷. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함. \n \n "
" 단일 종목도 `?tickers=AAPL`로 조회 가능. \n \n "
" | 항목 | 내용 | \n "
" |------|------| \n "
" | 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 | \n "
" | 지연 | **실시간** (지연 없음) | \n "
" | 거래량 | IEX 기준 (실제의 2~5 % ) | \n "
" | 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 | \n \n "
" **Usage**: `?tickers=AAPL,MSFT,NVDA` \n \n "
" - Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` "
) ,
)
async def get_snapshots (
tickers : str = Query ( . . . , description = " Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA (max 1000) " ) ,
tickers : str = Query ( . . . , description = " Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA " ) ,
) :
symbols = [ s . strip ( ) . upper ( ) for s in tickers . split ( " , " ) if s . strip ( ) ]
if not symbols :
@ -576,10 +280,7 @@ async def get_snapshots(
raise HTTPException ( status_code = 503 , detail = " Alpaca API keys not configured. " )
try :
raw_map = await client . get_snapshots ( symbols )
results = [
_parse_snapshot ( sym , raw_map . get ( sym , { } ) )
for sym in symbols
]
results = [ _parse_snapshot ( sym , raw_map . get ( sym , { } ) ) for sym in symbols ]
return AlpacaMultiSnapshotResponse ( count = len ( results ) , snapshots = results )
except Exception as e :
raise HTTPException ( status_code = 502 , detail = f " Alpaca API error: { e } " )