Files
signal-platform/app/providers/alpaca.py
T
dennisthiessenandClaude Fable 5 1fa3d70dec fix: fetch today's in-progress bar; name weekday crons
Two independent bugs left the near-close scan running on the previous
session's close, silently degrading live execution to the stale_close
floor (~1.57 Sharpe) instead of the intended ~1.77 close-fill case.

1. OHLCV window never covered the current day. Daily bars are stamped at
   session start (04:00Z under EDT), so an end of midnight-on-end_date
   landed before that day's bar and dropped it. Widening the window alone
   fails the whole request with 'subscription does not permit querying
   recent SIP data', so end is also clamped to now-20min. Today's bar is
   now returned, roughly 20 minutes behind live -- within the staleness
   the near-close design already assumed.

   Intraday runs therefore store a partial bar and ingestion progress
   reaches today, which made incremental resume skip the after-close
   refresh entirely. collect_ohlcv_final() re-pulls the last sessions so
   the consolidated bar overwrites the partial one before outcome eval.

2. APScheduler's from_crontab() passes day-of-week to its own field where
   0=Monday, so '1-5' meant Tue-Sat: every Monday was skipped and the
   scanner ran Saturdays on stale data. Weekday schedules now use names.
   Stored settings already corrected via Admin; this fixes the defaults.

Tests cover both: today's bar inside the window, the delayed-data clamp,
historical windows untruncated, and a week of fire times asserting Monday
is present and weekends are not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:10:35 +02:00

105 lines
4.3 KiB
Python

"""Alpaca Markets OHLCV provider using the alpaca-py SDK."""
from __future__ import annotations
import asyncio
import logging
from datetime import date, datetime, time, timedelta, timezone
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
from alpaca.data.enums import Adjustment
from app.exceptions import ProviderError, RateLimitError
from app.providers.protocol import OHLCVData
logger = logging.getLogger(__name__)
# Free plans may not query data from the most recent ~15 minutes, and a window
# reaching into it fails the *entire* request — which would silently leave the
# near-close scan on yesterday's close. Margin over the documented boundary.
_RECENT_DATA_CUTOFF = timedelta(minutes=20)
class AlpacaOHLCVProvider:
"""Fetches daily OHLCV bars from Alpaca Markets Data API."""
def __init__(self, api_key: str, api_secret: str) -> None:
if not api_key or not api_secret:
raise ProviderError("Alpaca API key and secret are required")
self._client = StockHistoricalDataClient(api_key, api_secret)
@staticmethod
def _resolve_window(start_date: date, end_date: date) -> tuple[datetime, datetime]:
"""Return the instants covering ``start_date``..``end_date`` inclusive.
Two boundaries have to be right or today's bar disappears:
* Daily bars are stamped at the session start in UTC (04:00Z under EDT),
so an ``end`` of midnight on ``end_date`` lands *before* that day's bar
and silently drops it — extend to the following midnight instead.
* The window must stay out of the delayed-data period, otherwise the
request is rejected outright with "subscription does not permit
querying recent SIP data". Clamping keeps today's in-progress bar
available, roughly 20 minutes behind live.
"""
start = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
end = datetime.combine(
end_date + timedelta(days=1), time.min, tzinfo=timezone.utc
)
return start, min(end, datetime.now(timezone.utc) - _RECENT_DATA_CUTOFF)
@staticmethod
def _to_alpaca_symbol(symbol: str) -> str:
"""Convert internal symbol format (BRK-B) to Alpaca format (BRK.B)."""
return symbol.replace("-", ".")
@staticmethod
def _from_alpaca_symbol(symbol: str) -> str:
"""Convert Alpaca symbol format (BRK.B) back to internal format (BRK-B)."""
return symbol.replace(".", "-")
async def fetch_ohlcv(
self, ticker: str, start_date: date, end_date: date
) -> list[OHLCVData]:
"""Fetch daily OHLCV bars for *ticker* between *start_date* and *end_date*."""
alpaca_symbol = self._to_alpaca_symbol(ticker)
start, end = self._resolve_window(start_date, end_date)
if end <= start:
return []
try:
request = StockBarsRequest(
symbol_or_symbols=alpaca_symbol,
timeframe=TimeFrame.Day,
start=start,
end=end,
adjustment=Adjustment.SPLIT,
)
# alpaca-py's client is synchronous — run in a thread
bars = await asyncio.to_thread(self._client.get_stock_bars, request)
results: list[OHLCVData] = []
bar_set = bars.get(alpaca_symbol, []) if hasattr(bars, "get") else getattr(bars, "data", {}).get(alpaca_symbol, [])
for bar in bar_set:
results.append(
OHLCVData(
ticker=ticker, # use original internal symbol
date=bar.timestamp.date(),
open=float(bar.open),
high=float(bar.high),
low=float(bar.low),
close=float(bar.close),
volume=int(bar.volume),
)
)
return results
except Exception as exc:
msg = str(exc).lower()
if "rate" in msg and "limit" in msg:
raise RateLimitError(f"Alpaca rate limit hit for {ticker}") from exc
logger.error("Alpaca provider error for %s: %s", ticker, exc)
raise ProviderError(f"Alpaca provider error for {ticker}: {exc}") from exc