211 lines
8.4 KiB
Python
211 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Fetch NYSE trade data from Alpaca and display like an old stock ticker tape."""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
import requests
|
|
|
|
# NYSE-listed stocks (tape A). AAPL, MSFT, INTC etc are Nasdaq-listed (tape C).
|
|
DEFAULT_SYMBOLS = "JPM,JNJ,V,PG,UNH,HD,BAC,XOM,WMT,DIS,KO,PFE,VZ,MRK,T,GE,F,GM,CVX,CL"
|
|
|
|
BASE_URL = "https://data.alpaca.markets/v2/stocks/trades"
|
|
|
|
|
|
def fetch_trades(api_key, api_secret, symbols, start, end, limit=1000, page_token=None, debug=False):
|
|
headers = {
|
|
"APCA-API-KEY-ID": api_key,
|
|
"APCA-API-SECRET-KEY": api_secret,
|
|
}
|
|
params = {
|
|
"symbols": symbols,
|
|
"start": start,
|
|
"end": end,
|
|
"feed": "sip",
|
|
"limit": limit,
|
|
"sort": "asc",
|
|
}
|
|
if page_token:
|
|
params["page_token"] = page_token
|
|
|
|
if debug:
|
|
print(f"[DEBUG] GET {BASE_URL}", file=sys.stderr)
|
|
print(f"[DEBUG] params: {params}", file=sys.stderr)
|
|
|
|
time.sleep(0.3) # stay under 200 req/min
|
|
resp = requests.get(BASE_URL, headers=headers, params=params)
|
|
|
|
# retry once on rate limit
|
|
if resp.status_code == 429:
|
|
print("[RATE LIMITED] sleeping 5s...", file=sys.stderr)
|
|
time.sleep(5)
|
|
resp = requests.get(BASE_URL, headers=headers, params=params)
|
|
|
|
if debug:
|
|
print(f"[DEBUG] HTTP {resp.status_code}", file=sys.stderr)
|
|
if not resp.ok:
|
|
print(f"[DEBUG] response body: {resp.text[:500]}", file=sys.stderr)
|
|
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if debug:
|
|
top_keys = list(data.keys())
|
|
symbols_returned = list(data.get("trades", {}).keys()) if isinstance(data.get("trades"), dict) else "N/A"
|
|
trade_count = sum(len(v) for v in data.get("trades", {}).values()) if isinstance(data.get("trades"), dict) else 0
|
|
print(f"[DEBUG] response keys: {top_keys}", file=sys.stderr)
|
|
print(f"[DEBUG] symbols in response: {symbols_returned}", file=sys.stderr)
|
|
print(f"[DEBUG] total trades returned: {trade_count}", file=sys.stderr)
|
|
print(f"[DEBUG] next_page_token: {data.get('next_page_token', 'none')}", file=sys.stderr)
|
|
|
|
return data
|
|
|
|
|
|
def flatten_and_sort(data):
|
|
"""Flatten the per-symbol trade dict into a single time-sorted list."""
|
|
trades = []
|
|
for symbol, symbol_trades in data.get("trades", {}).items():
|
|
for t in symbol_trades:
|
|
trades.append({"sym": symbol, **t})
|
|
trades.sort(key=lambda t: t["t"])
|
|
return trades
|
|
|
|
|
|
def format_tape_line(sym, lots, price):
|
|
"""Format a trade like an old ticker tape: SYM lots price.
|
|
A bare price means 1 round lot. '2s' means 2 lots (200 shares)."""
|
|
vol = f"{lots}s" if lots > 1 else ""
|
|
parts = [sym]
|
|
if vol:
|
|
parts.append(vol)
|
|
parts.append(f"{price:.2f}")
|
|
return " ".join(parts)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="NYSE ticker tape via Alpaca market data")
|
|
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS,
|
|
help=f"Comma-separated symbols (default: {DEFAULT_SYMBOLS})")
|
|
parser.add_argument("--symbol-file", metavar="FILE",
|
|
help="Read symbols from FILE (one per line) and batch requests")
|
|
parser.add_argument("--start", help="Start datetime, YYYY-MM-DD or RFC-3339 (default: previous trading day)")
|
|
parser.add_argument("--end", help="End datetime (default: same day as start)")
|
|
parser.add_argument("--limit", type=int, default=10000,
|
|
help="Max trades to fetch per API call (default: 10000, max: 10000)")
|
|
parser.add_argument("--all", action="store_true",
|
|
help="Paginate through ALL trades in the range (many API calls)")
|
|
parser.add_argument("--tape", default="A",
|
|
help="Filter by SIP tape: A=NYSE-listed, B=regional/Arca, C=Nasdaq-listed, all=no filter (default: A)")
|
|
parser.add_argument("--no-filter", action="store_true",
|
|
help="Show all trades regardless of tape/exchange")
|
|
parser.add_argument("--raw", action="store_true",
|
|
help="Output raw CSV instead of tape format: time,symbol,lots,price")
|
|
parser.add_argument("--debug", action="store_true",
|
|
help="Print debug info: request URL/params, response structure, exchange codes seen")
|
|
args = parser.parse_args()
|
|
|
|
api_key = os.environ.get("APCA_API_KEY_ID", "")
|
|
api_secret = os.environ.get("APCA_API_SECRET_KEY", "")
|
|
if not api_key or not api_secret:
|
|
print("Set APCA_API_KEY_ID and APCA_API_SECRET_KEY environment variables.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Default to last Friday if today is weekend, else yesterday
|
|
if not args.start:
|
|
today = datetime.now()
|
|
days_back = 1 if today.weekday() > 0 else 3 # Monday->Friday
|
|
if today.weekday() == 6: # Sunday
|
|
days_back = 2
|
|
args.start = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
|
|
|
# Default start to market open (9:30 ET = 13:30 UTC) if only date given
|
|
if "T" not in args.start:
|
|
args.start = args.start + "T13:30:00Z"
|
|
|
|
if not args.end:
|
|
# Default end = start day at market close (4pm ET = 20:00 UTC)
|
|
args.end = args.start[:10] + "T20:00:00Z"
|
|
|
|
tape_filter = None if args.no_filter or args.tape.lower() == "all" else args.tape.upper()
|
|
|
|
# Build symbol batches
|
|
if args.symbol_file:
|
|
with open(args.symbol_file) as f:
|
|
all_symbols = [line.strip() for line in f if line.strip()]
|
|
# Batch into groups of 500 to stay under URL length limits
|
|
batch_size = 500
|
|
batches = []
|
|
for i in range(0, len(all_symbols), batch_size):
|
|
batches.append(",".join(all_symbols[i:i + batch_size]))
|
|
print(f"{len(all_symbols)} symbols in {len(batches)} batches", file=sys.stderr)
|
|
# --all-nyse implies --all (paginate everything)
|
|
args.all = True
|
|
else:
|
|
batches = [args.symbols]
|
|
|
|
if args.debug:
|
|
print(f"[DEBUG] start={args.start} end={args.end}", file=sys.stderr)
|
|
print(f"[DEBUG] tape_filter={tape_filter} (A=NYSE-listed, B=regional, C=Nasdaq)", file=sys.stderr)
|
|
|
|
total = 0
|
|
api_calls = 0
|
|
exchanges_seen = {}
|
|
|
|
for batch_num, symbols in enumerate(batches, 1):
|
|
if len(batches) > 1:
|
|
print(f"Batch {batch_num}/{len(batches)}...", file=sys.stderr)
|
|
|
|
page_token = None
|
|
while True:
|
|
data = fetch_trades(api_key, api_secret, symbols,
|
|
args.start, args.end, args.limit, page_token, debug=args.debug)
|
|
api_calls += 1
|
|
trades = flatten_and_sort(data)
|
|
|
|
if args.debug:
|
|
for trade in trades:
|
|
ex = trade.get("x", "?")
|
|
tape = trade.get("z", "?")
|
|
exchanges_seen[ex] = exchanges_seen.get(ex, 0) + 1
|
|
exchanges_seen[f"tape:{tape}"] = exchanges_seen.get(f"tape:{tape}", 0) + 1
|
|
|
|
for trade in trades:
|
|
if tape_filter and trade.get("z") != tape_filter:
|
|
continue
|
|
if trade["s"] < 100:
|
|
continue
|
|
|
|
lots = trade["s"] // 100
|
|
|
|
if args.raw:
|
|
print(f"{trade['t']},{trade['sym']},{lots},{trade['p']:.2f}")
|
|
else:
|
|
ts = trade["t"][:19].replace("T", " ")
|
|
line = format_tape_line(trade["sym"], lots, trade["p"])
|
|
print(f"{ts} {line}")
|
|
total += 1
|
|
|
|
page_token = data.get("next_page_token")
|
|
if not page_token or not args.all:
|
|
break
|
|
|
|
if api_calls % 100 == 0:
|
|
print(f" {api_calls} API calls, {total} trades so far...", file=sys.stderr)
|
|
|
|
if args.debug and exchanges_seen:
|
|
print(f"[DEBUG] exchange codes seen: {exchanges_seen}", file=sys.stderr)
|
|
print(f"[DEBUG] (exchanges: N=NYSE, Q=Nasdaq, P=Arca, D=FINRA, K=EDGX...)", file=sys.stderr)
|
|
print(f"[DEBUG] (tapes: A=NYSE-listed, B=regional, C=Nasdaq-listed)", file=sys.stderr)
|
|
|
|
print(f"Done: {total} trades, {api_calls} API calls.", file=sys.stderr)
|
|
if total == 0:
|
|
print("No trades found. Try --no-filter or different --tape/symbols/dates.", file=sys.stderr)
|
|
elif not args.all and data.get("next_page_token"):
|
|
print(f"More available; use --all to paginate.", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|