Add Alpaca-specific DB table and Redis caching for Alpaca/FINRA endpoints
Separate Alpaca price data into dedicated AlpacaPriceData table to avoid
UniqueConstraint('ticker', 'date') conflicts with Yahoo Finance PriceData.
Add Redis caching (build_cache_key/get_cached_response/set_cached_response)
to 3 Alpaca endpoints and 2 FINRA query endpoints with appropriate TTLs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
parent
93d8018746
commit
bf6932ca2f
@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Alpaca Market Data endpoints — standalone price data via Alpaca API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
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,
|
||||||
|
ErrorType,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
INTRADAY_CACHE_TTL = 300 # 5 minutes for intraday data
|
||||||
|
|
||||||
|
|
||||||
|
def _require_alpaca() -> AlpacaPriceService:
|
||||||
|
svc = AlpacaPriceService()
|
||||||
|
if not svc.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Alpaca API keys not configured. Set ALPACA_API_KEY and ALPACA_SECRET_KEY.",
|
||||||
|
)
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Status (no caching — always real-time)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/status",
|
||||||
|
summary="Alpaca connection status",
|
||||||
|
description="Check Alpaca API key validity and connection health.",
|
||||||
|
)
|
||||||
|
async def alpaca_status():
|
||||||
|
client = AlpacaClient()
|
||||||
|
if not client.is_configured():
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"message": "ALPACA_API_KEY / ALPACA_SECRET_KEY not set",
|
||||||
|
}
|
||||||
|
status = await client.check_connection()
|
||||||
|
await client.close()
|
||||||
|
return {"configured": True, **status}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Raw bars (no DB) — with Redis caching
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/bars/{ticker}",
|
||||||
|
summary="Get Alpaca bars (raw, no DB)",
|
||||||
|
description="Fetch historical bars directly from Alpaca without storing in DB.",
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cache_key = build_cache_key("alpaca:bars", ticker.upper(), interval, start_date, end_date, limit)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = await get_cached_response(cache_key)
|
||||||
|
if cached:
|
||||||
|
cached_body, etag = cached
|
||||||
|
response.headers["X-Cache"] = "HIT"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "redis-cache"
|
||||||
|
return cached_body
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
|
||||||
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "alpaca-api"
|
||||||
|
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.
|
||||||
|
|
||||||
|
- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`
|
||||||
|
- Uses `data_source = "ALPACA"` to distinguish from Yahoo data
|
||||||
|
- Supports: 1m, 5m, 15m, 1h, 1d, 1w, 1mo intervals
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cache_key = build_cache_key("alpaca:data", ticker.upper(), interval, start_date, end_date)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = await get_cached_response(cache_key)
|
||||||
|
if cached:
|
||||||
|
cached_body, etag = cached
|
||||||
|
response.headers["X-Cache"] = "HIT"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "redis-cache"
|
||||||
|
return cached_body
|
||||||
|
|
||||||
|
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
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
body_dict = body.model_dump()
|
||||||
|
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
|
||||||
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "alpaca-api"
|
||||||
|
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)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/intraday/{ticker}",
|
||||||
|
summary="Get intraday candles from Alpaca",
|
||||||
|
description="Fetch intraday bars (1m, 5m, 15m, 1h) directly from Alpaca. Not stored in DB.",
|
||||||
|
)
|
||||||
|
async def get_alpaca_intraday(
|
||||||
|
ticker: str,
|
||||||
|
response: Response,
|
||||||
|
interval: str = Query("1m", description="Interval: 1m, 5m, 15m, 1h"),
|
||||||
|
start_date: Optional[date] = Query(None, description="Start date"),
|
||||||
|
end_date: Optional[date] = Query(None, description="End date"),
|
||||||
|
limit: int = Query(1000, ge=1, le=10000, description="Max candles"),
|
||||||
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
||||||
|
):
|
||||||
|
svc = _require_alpaca()
|
||||||
|
|
||||||
|
# Check cache (short TTL for intraday)
|
||||||
|
cache_key = build_cache_key("alpaca:intraday", ticker.upper(), interval, start_date, end_date)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = await get_cached_response(cache_key)
|
||||||
|
if cached:
|
||||||
|
cached_body, etag = cached
|
||||||
|
response.headers["X-Cache"] = "HIT"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={INTRADAY_CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "redis-cache"
|
||||||
|
return cached_body
|
||||||
|
|
||||||
|
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,
|
||||||
|
"source": "ALPACA",
|
||||||
|
"count": len(bars),
|
||||||
|
"candles": bars,
|
||||||
|
}
|
||||||
|
|
||||||
|
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=INTRADAY_CACHE_TTL)
|
||||||
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={INTRADAY_CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "alpaca-api"
|
||||||
|
return body_dict
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
|
||||||
|
finally:
|
||||||
|
await svc.client.close()
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
FINRA Short Sale Volume endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas.finra import (
|
||||||
|
ShortVolumeEntry,
|
||||||
|
ShortVolumeResponse,
|
||||||
|
ShortRatioHistoryResponse,
|
||||||
|
ShortRatioPoint,
|
||||||
|
IngestResponse,
|
||||||
|
)
|
||||||
|
from app.services.finra_short_volume_service import FinraShortVolumeService
|
||||||
|
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/short-volume/{symbol}",
|
||||||
|
response_model=ShortVolumeResponse,
|
||||||
|
summary="Get short volume data for a symbol",
|
||||||
|
description="Query FINRA RegSHO short sale volume. Auto-ingests if data is missing.",
|
||||||
|
)
|
||||||
|
async def get_short_volume(
|
||||||
|
symbol: str,
|
||||||
|
response: Response,
|
||||||
|
days: int = Query(30, ge=1, le=365, description="Number of days to look back"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="Max entries to return"),
|
||||||
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Check cache
|
||||||
|
cache_key = build_cache_key("finra:short-volume", symbol.upper(), days, limit)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = await get_cached_response(cache_key)
|
||||||
|
if cached:
|
||||||
|
cached_body, etag = cached
|
||||||
|
response.headers["X-Cache"] = "HIT"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "redis-cache"
|
||||||
|
return cached_body
|
||||||
|
|
||||||
|
svc = FinraShortVolumeService()
|
||||||
|
end = datetime.now(timezone.utc).date()
|
||||||
|
start = end - timedelta(days=days)
|
||||||
|
|
||||||
|
rows, total = await svc.get_short_volume(
|
||||||
|
db, symbol=symbol, start_date=start, end_date=end, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = [ShortVolumeEntry.from_orm_obj(r) for r in rows]
|
||||||
|
|
||||||
|
body = ShortVolumeResponse(
|
||||||
|
symbol=symbol.upper(),
|
||||||
|
entries=entries,
|
||||||
|
total_count=total,
|
||||||
|
metadata={
|
||||||
|
"days_requested": days,
|
||||||
|
"start_date": start.isoformat(),
|
||||||
|
"end_date": end.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
body_dict = body.model_dump()
|
||||||
|
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
|
||||||
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "finra-db"
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/short-ratio/{symbol}",
|
||||||
|
response_model=ShortRatioHistoryResponse,
|
||||||
|
summary="Get short ratio history for a symbol",
|
||||||
|
description="Return daily short_ratio (aggregated across markets) for the last N days.",
|
||||||
|
)
|
||||||
|
async def get_short_ratio(
|
||||||
|
symbol: str,
|
||||||
|
response: Response,
|
||||||
|
days: int = Query(60, ge=1, le=365, description="Number of days"),
|
||||||
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Check cache
|
||||||
|
cache_key = build_cache_key("finra:short-ratio", symbol.upper(), days)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = await get_cached_response(cache_key)
|
||||||
|
if cached:
|
||||||
|
cached_body, etag = cached
|
||||||
|
response.headers["X-Cache"] = "HIT"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "redis-cache"
|
||||||
|
return cached_body
|
||||||
|
|
||||||
|
svc = FinraShortVolumeService()
|
||||||
|
history = await svc.get_short_ratio_history(db, symbol=symbol, days=days)
|
||||||
|
|
||||||
|
points = [ShortRatioPoint(**h) for h in history]
|
||||||
|
avg = (
|
||||||
|
sum(p.short_ratio for p in points) / len(points) if points else None
|
||||||
|
)
|
||||||
|
|
||||||
|
body = ShortRatioHistoryResponse(
|
||||||
|
symbol=symbol.upper(),
|
||||||
|
history=points,
|
||||||
|
avg_short_ratio=round(avg, 6) if avg is not None else None,
|
||||||
|
metadata={"days_requested": days, "data_points": len(points)},
|
||||||
|
)
|
||||||
|
|
||||||
|
body_dict = body.model_dump()
|
||||||
|
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
|
||||||
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["X-Data-Source"] = "finra-db"
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin/ingest",
|
||||||
|
response_model=IngestResponse,
|
||||||
|
summary="Manually ingest FINRA short volume data",
|
||||||
|
description="Download and ingest FINRA short volume file(s) for a specific date or date range.",
|
||||||
|
)
|
||||||
|
async def ingest_short_volume(
|
||||||
|
date_str: Optional[str] = Query(None, alias="date", description="Single date (YYYY-MM-DD)"),
|
||||||
|
start_date: Optional[date] = Query(None, description="Range start (YYYY-MM-DD)"),
|
||||||
|
end_date: Optional[date] = Query(None, description="Range end (YYYY-MM-DD)"),
|
||||||
|
force: bool = Query(False, description="Re-ingest even if data exists"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = FinraShortVolumeService()
|
||||||
|
|
||||||
|
# Single date
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
target = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
count = await svc.ingest_date(db, target, force_refresh=force)
|
||||||
|
return IngestResponse(
|
||||||
|
date=target.isoformat(),
|
||||||
|
records_ingested=count,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
if start_date and end_date:
|
||||||
|
if start_date > end_date:
|
||||||
|
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
|
||||||
|
count = await svc.ingest_date_range(db, start_date, end_date, force_refresh=force)
|
||||||
|
return IngestResponse(
|
||||||
|
date_range={"start": start_date.isoformat(), "end": end_date.isoformat()},
|
||||||
|
records_ingested=count,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Provide either 'date' (single date) or both 'start_date' and 'end_date'.",
|
||||||
|
)
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Alpaca-specific price data model — separate from Yahoo Finance PriceData
|
||||||
|
to avoid UniqueConstraint('ticker', 'date') conflicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Float, Integer, UniqueConstraint, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AlpacaPriceData(Base):
|
||||||
|
__tablename__ = "alpaca_price_data"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticker = Column(String(10), nullable=False, index=True)
|
||||||
|
date = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
open = Column(Float, nullable=True)
|
||||||
|
high = Column(Float, nullable=True)
|
||||||
|
low = Column(Float, nullable=True)
|
||||||
|
close = Column(Float, nullable=False)
|
||||||
|
volume = Column(Float, nullable=True)
|
||||||
|
vwap = Column(Float, nullable=True) # Volume-weighted average price (Alpaca-specific)
|
||||||
|
trade_count = Column(Integer, nullable=True) # Number of trades (Alpaca-specific)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
data_source = Column(String(50), default='ALPACA')
|
||||||
|
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('ticker', 'date', name='uq_alpaca_price_data'),
|
||||||
|
Index('idx_alpaca_price_ticker_date', 'ticker', 'date'),
|
||||||
|
)
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
FINRA RegSHO Short Sale Volume model
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Float, Index, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FinraShortVolume(Base):
|
||||||
|
__tablename__ = "finra_short_volume"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
date = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
symbol = Column(String(10), nullable=False, index=True)
|
||||||
|
short_volume = Column(Float, nullable=False)
|
||||||
|
short_exempt_volume = Column(Float, default=0)
|
||||||
|
total_volume = Column(Float, nullable=False)
|
||||||
|
market = Column(String(10), nullable=True) # e.g. "B", "Q", "N"
|
||||||
|
short_ratio = Column(Float, nullable=True) # short_volume / total_volume
|
||||||
|
created_at = Column(
|
||||||
|
TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("symbol", "date", "market", name="uq_finra_short_volume"),
|
||||||
|
Index("idx_finra_symbol_date", "symbol", "date"),
|
||||||
|
Index("idx_finra_date", "date"),
|
||||||
|
)
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for FINRA short volume data
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ShortVolumeEntry(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
date: date
|
||||||
|
symbol: str
|
||||||
|
short_volume: float
|
||||||
|
short_exempt_volume: float = 0.0
|
||||||
|
total_volume: float
|
||||||
|
market: Optional[str] = None
|
||||||
|
short_ratio: Optional[float] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_orm_obj(cls, obj):
|
||||||
|
d = obj.date
|
||||||
|
if isinstance(d, datetime):
|
||||||
|
d = d.date()
|
||||||
|
return cls(
|
||||||
|
date=d,
|
||||||
|
symbol=obj.symbol,
|
||||||
|
short_volume=obj.short_volume,
|
||||||
|
short_exempt_volume=obj.short_exempt_volume or 0.0,
|
||||||
|
total_volume=obj.total_volume,
|
||||||
|
market=obj.market,
|
||||||
|
short_ratio=obj.short_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShortVolumeResponse(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
entries: List[ShortVolumeEntry]
|
||||||
|
total_count: int
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ShortRatioPoint(BaseModel):
|
||||||
|
date: date
|
||||||
|
short_volume: float
|
||||||
|
short_exempt_volume: float
|
||||||
|
total_volume: float
|
||||||
|
short_ratio: float
|
||||||
|
|
||||||
|
|
||||||
|
class ShortRatioHistoryResponse(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
history: List[ShortRatioPoint]
|
||||||
|
avg_short_ratio: Optional[float] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class IngestResponse(BaseModel):
|
||||||
|
date: Optional[str] = None
|
||||||
|
date_range: Optional[Dict[str, str]] = None
|
||||||
|
records_ingested: int
|
||||||
|
status: str
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Alpaca bars → AlpacaPriceData conversion and DB storage
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
|
from app.models.alpaca_price import AlpacaPriceData
|
||||||
|
from app.services.alpaca_client import AlpacaClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AlpacaPriceService:
|
||||||
|
"""Alpaca bars → AlpacaPriceData conversion and DB storage"""
|
||||||
|
|
||||||
|
def __init__(self, client: Optional[AlpacaClient] = None):
|
||||||
|
self.client = client or AlpacaClient()
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self.client.is_configured()
|
||||||
|
|
||||||
|
async def fetch_and_store_bars(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
ticker: str,
|
||||||
|
start_date: datetime,
|
||||||
|
end_date: datetime,
|
||||||
|
interval: str = "1d",
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Fetch bars from Alpaca and upsert into AlpacaPriceData table.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of newly inserted records.
|
||||||
|
"""
|
||||||
|
ticker = ticker.upper()
|
||||||
|
start_str = start_date.strftime("%Y-%m-%d")
|
||||||
|
end_str = end_date.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
bars = await self.client.get_bars(
|
||||||
|
symbol=ticker,
|
||||||
|
timeframe=interval,
|
||||||
|
start=start_str,
|
||||||
|
end=end_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not bars:
|
||||||
|
logger.warning(f"Alpaca returned 0 bars for {ticker}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Fetch existing dates for dedup
|
||||||
|
result = await db.execute(
|
||||||
|
select(AlpacaPriceData.date).where(AlpacaPriceData.ticker == ticker)
|
||||||
|
)
|
||||||
|
existing_dates = {row[0].date() for row in result.fetchall()}
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for bar in bars:
|
||||||
|
bar_dt = _parse_bar_timestamp(bar["t"])
|
||||||
|
if bar_dt.date() in existing_dates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
record = AlpacaPriceData(
|
||||||
|
ticker=ticker,
|
||||||
|
date=bar_dt,
|
||||||
|
open=float(bar.get("o", 0)),
|
||||||
|
high=float(bar.get("h", 0)),
|
||||||
|
low=float(bar.get("l", 0)),
|
||||||
|
close=float(bar.get("c", 0)),
|
||||||
|
volume=float(bar.get("v", 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",
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
existing_dates.add(bar_dt.date())
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
if inserted:
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Alpaca: inserted {inserted} bars for {ticker}")
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
async def fetch_bars_raw(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
interval: str = "1d",
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Return raw bar dicts without touching the DB (useful for intraday / non-persistent use).
|
||||||
|
"""
|
||||||
|
start_str = start_date.strftime("%Y-%m-%d") if start_date else None
|
||||||
|
end_str = end_date.strftime("%Y-%m-%d") if end_date else None
|
||||||
|
|
||||||
|
bars = await self.client.get_bars(
|
||||||
|
symbol=ticker.upper(),
|
||||||
|
timeframe=interval,
|
||||||
|
start=start_str,
|
||||||
|
end=end_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"timestamp": bar["t"],
|
||||||
|
"open": bar.get("o"),
|
||||||
|
"high": bar.get("h"),
|
||||||
|
"low": bar.get("l"),
|
||||||
|
"close": bar.get("c"),
|
||||||
|
"volume": bar.get("v"),
|
||||||
|
"vwap": bar.get("vw"),
|
||||||
|
"trade_count": bar.get("n"),
|
||||||
|
}
|
||||||
|
for bar in bars
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bar_timestamp(ts_str: str) -> datetime:
|
||||||
|
"""Parse Alpaca bar timestamp (RFC-3339) into a timezone-aware datetime."""
|
||||||
|
# Alpaca returns e.g. "2024-01-02T05:00:00Z"
|
||||||
|
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
Loading…
Reference in New Issue