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