Use alpaca instead of ibkr.
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user