Use alpaca instead of ibkr.

This commit is contained in:
2026-08-02 22:18:48 +03:00
parent a699bfc0fb
commit fddcc9190a
10 changed files with 692 additions and 11 deletions
+314
View File
@@ -0,0 +1,314 @@
"""Alpaca daily candle fetcher."""
from __future__ import annotations
import argparse
import os
import re
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import pandas as pd
from dotenv import load_dotenv
# Load environment variables from .env file if available
load_dotenv()
DEFAULT_OUTPUT_DIR = Path("data/alpaca/daily")
DEFAULT_DURATION = "1 W"
EASTERN_TZ = ZoneInfo("America/New_York")
DURATION_PATTERN = re.compile(r"^(\d+)\s*([DWMY])$")
@dataclass(frozen=True)
class DailyCandle:
"""Daily OHLCV market data for one trading session."""
trading_day: date
open: float
high: float
low: float
close: float
volume: int
def default_end_date() -> date:
"""Return yesterday's date in the US/Eastern market timezone."""
return datetime.now(EASTERN_TZ).date() - timedelta(days=1)
def current_market_date() -> date:
"""Return today's date in the US/Eastern market timezone."""
return datetime.now(EASTERN_TZ).date()
def parse_end_date(value: str) -> date:
"""Parse an end date in YYYYMMDD format."""
try:
return datetime.strptime(value, "%Y%m%d").date()
except ValueError as exc:
raise argparse.ArgumentTypeError(
"end date must use YYYYMMDD format, such as 20250605"
) from exc
def parse_duration(value: str) -> str:
"""Normalize and validate a duration string like '1 W' or '1 M'."""
normalized = " ".join(value.upper().split())
if not DURATION_PATTERN.fullmatch(normalized):
raise argparse.ArgumentTypeError(
"duration must look like '1 D', '1 W', '1 M', or '1 Y'"
)
return normalized
def duration_to_start_date(end_date: date, duration: str) -> date:
"""Calculate the start date based on end date and a duration string."""
match = DURATION_PATTERN.match(duration)
if not match:
raise ValueError(f"Invalid duration format: {duration}")
amount = int(match.group(1))
unit = match.group(2)
if unit == "D":
return end_date - timedelta(days=amount)
if unit == "W":
return end_date - timedelta(weeks=amount)
if unit == "M":
return end_date - timedelta(days=amount * 30)
if unit == "Y":
return end_date - timedelta(days=amount * 365)
raise ValueError(f"Unsupported duration unit: {unit}")
def fetch_daily_candles(
symbol: str,
end_date: date,
duration: str,
api_key: str | None = None,
secret_key: str | None = None,
) -> list[DailyCandle]:
"""Fetch daily candles for a symbol using Alpaca Market Data API."""
try:
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
except ImportError as exc:
raise SystemExit(
"Missing dependency: alpaca-py. Install it via 'pip install alpaca-py' "
"before running the script."
) from exc
start_date = duration_to_start_date(end_date, duration)
start_dt = datetime.combine(start_date, time.min, tzinfo=EASTERN_TZ)
end_dt = datetime.combine(end_date, time.max, tzinfo=EASTERN_TZ)
print(start_dt, end_dt)
client = StockHistoricalDataClient(api_key=api_key, secret_key=secret_key)
request_params = StockBarsRequest(
symbol_or_symbols=symbol,
timeframe=TimeFrame.Day,
start=start_dt,
end=end_dt,
)
bars = client.get_stock_bars(request_params)
if not bars or symbol not in bars.data:
print(f"Alpaca returned no historical bars for {symbol}.")
return []
candles: list[DailyCandle] = []
for bar in bars[symbol]:
trading_day = bar.timestamp.astimezone(EASTERN_TZ).date()
candles.append(
DailyCandle(
trading_day=trading_day,
open=float(bar.open),
high=float(bar.high),
low=float(bar.low),
close=float(bar.close),
volume=int(bar.volume),
)
)
return candles
def candles_to_frame(symbol: str, candles: list[DailyCandle]) -> pd.DataFrame:
"""Convert candles to a date-indexed dataframe ready for Parquet storage."""
rows = [
{
"date": candle.trading_day,
"symbol": symbol,
"open": candle.open,
"high": candle.high,
"low": candle.low,
"close": candle.close,
"volume": candle.volume,
}
for candle in candles
]
frame = pd.DataFrame.from_records(rows)
if frame.empty:
return pd.DataFrame(
columns=["symbol", "open", "high", "low", "close", "volume"],
index=pd.Index([], name="date"),
)
frame["date"] = pd.to_datetime(frame["date"]).dt.date
return frame.set_index("date")
def read_existing_candles(path: Path) -> pd.DataFrame:
"""Read an existing candle Parquet file as a date-indexed dataframe."""
if not path.exists():
return pd.DataFrame(
columns=["symbol", "open", "high", "low", "close", "volume"],
index=pd.Index([], name="date"),
)
frame = pd.read_parquet(path)
if "date" in frame.columns:
frame["date"] = pd.to_datetime(frame["date"]).dt.date
frame = frame.set_index("date")
frame.index = pd.to_datetime(frame.index).date
frame.index.name = "date"
return frame
def oldest_stored_date_or_today(path: Path) -> date:
"""Return the oldest stored candle date, or today if no data exists yet."""
existing = read_existing_candles(path)
if existing.empty:
return current_market_date()
return min(existing.index)
def write_candles(path: Path, symbol: str, candles: list[DailyCandle]) -> pd.DataFrame:
"""Append candles to a ticker Parquet file, keeping one row per date."""
existing = read_existing_candles(path)
fetched = candles_to_frame(symbol, candles)
combined = pd.concat([existing, fetched])
if not combined.empty:
combined = combined[~combined.index.duplicated(keep="last")]
combined = combined.sort_index()
path.parent.mkdir(parents=True, exist_ok=True)
combined.to_parquet(path, index=True)
return combined
def print_candles(symbol: str, candles: list[DailyCandle]) -> None:
"""Print candles in a compact table."""
print("date,symbol,open,high,low,close,volume")
for candle in candles:
print(
f"{candle.trading_day.isoformat()},"
f"{symbol},"
f"{candle.open:.2f},"
f"{candle.high:.2f},"
f"{candle.low:.2f},"
f"{candle.close:.2f},"
f"{candle.volume}"
)
def parse_args() -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description="Fetch daily Alpaca candles.")
parser.add_argument("symbol", help="Ticker symbol to fetch, such as SPY.")
end_date_group = parser.add_mutually_exclusive_group()
end_date_group.add_argument(
"--end-date",
type=parse_end_date,
help="Request end date in YYYYMMDD format. Defaults to yesterday.",
)
end_date_group.add_argument(
"--end-date-from-parquet",
action="store_true",
help=(
"Use the oldest date from the symbol Parquet file as the request "
"end date. Defaults to today if the file is missing or empty."
),
)
parser.add_argument(
"--duration",
type=parse_duration,
default=DEFAULT_DURATION,
help=(
"Duration string, such as '1 W', '1 M', or '1 Y'. "
f"Defaults to {DEFAULT_DURATION}."
),
)
parser.add_argument(
"--output-dir",
type=Path,
default=DEFAULT_OUTPUT_DIR,
help=f"Directory for Parquet files. Defaults to {DEFAULT_OUTPUT_DIR}.",
)
parser.add_argument(
"--api-key",
default=os.getenv("ALPACA_API_KEY"),
help="Alpaca API Key ID (defaults to ALPACA_API_KEY env var or .env).",
)
parser.add_argument(
"--secret-key",
default=os.getenv("ALPACA_SECRET_KEY"),
help="Alpaca Secret Key (defaults to ALPACA_SECRET_KEY env var or .env).",
)
return parser.parse_args()
def main() -> None:
"""Run the daily candle fetcher."""
args = parse_args()
symbol = args.symbol.upper()
output_path = args.output_dir / f"{symbol}.parquet"
end_date = (
oldest_stored_date_or_today(output_path)
if args.end_date_from_parquet
else args.end_date or default_end_date()
)
print(oldest_stored_date_or_today(output_path))
print(
f"Fetching {symbol} daily candles ending {end_date:%Y-%m-%d} "
f"for duration {args.duration} via Alpaca API"
)
candles = fetch_daily_candles(
symbol,
end_date,
args.duration,
api_key=args.api_key,
secret_key=args.secret_key,
)
#print_candles(symbol, candles)
stored = write_candles(output_path, symbol, candles)
print(f"Wrote {len(stored)} total daily rows to {output_path}")
if __name__ == "__main__":
main()