diff --git a/backend/src/backtest/data/alpacaLoader.ts b/backend/src/backtest/data/alpacaLoader.ts new file mode 100644 index 0000000..06fa175 --- /dev/null +++ b/backend/src/backtest/data/alpacaLoader.ts @@ -0,0 +1,145 @@ +import Alpaca from '@alpacahq/alpaca-trade-api'; +import { config } from '../../config/index.js'; +import logger from '../../utils/logger.js'; +import type { BacktestRawCandle, HistoricalDataset } from '../types.js'; +import { buildDatasetFromRows, normalizeRawCandles } from './normalize.js'; + +const FIFTEEN_MINUTES_MS = 15 * 60 * 1000; +const DEFAULT_PAGE_LIMIT = 10_000; + +export interface AlpacaLoaderInput { + symbols: string[]; + fromTs: number; + toTs: number; + /** Alpaca data feed: 'iex' (default, free) or 'sip' (paid, full market). */ + feed?: 'iex' | 'sip'; + /** Adjustment for splits/dividends. 'raw' | 'split' | 'dividend' | 'all'. */ + adjustment?: 'raw' | 'split' | 'dividend' | 'all'; + disableCache?: boolean; +} + +const symbolCache = new Map(); + +const toIso = (timestamp: number): string => new Date(timestamp).toISOString(); + +/** + * Fetch 15m bars for a single symbol over a window using Alpaca's v2 data API. + * Throws with a useful message if Alpaca isn't configured. Always emits 15m + * raw rows; the normalizer aggregates 1h/4h downstream. + * + * Free Alpaca tier serves IEX-only historical data, which has limited symbol + * coverage and starts ~2016 for major symbols. SIP feed (paid) is required + * for full pre-2017 coverage and full-market data. See + * docs/backtest/ENGINE_READINESS.md §3.4. + */ +const fetchSymbolRows = async ( + client: any, + symbol: string, + fromTs: number, + toTs: number, + feed: 'iex' | 'sip', + adjustment: 'raw' | 'split' | 'dividend' | 'all', + disableCache: boolean +): Promise => { + const cacheKey = `${symbol}|15Min|${fromTs}|${toTs}|${feed}|${adjustment}`; + if (!disableCache) { + const cached = symbolCache.get(cacheKey); + if (cached) return [...cached]; + } + const formatted = symbol.replace('/', ''); + const out: BacktestRawCandle[] = []; + const generator = client.getBarsV2(formatted, { + timeframe: '15Min', + start: toIso(fromTs), + end: toIso(toTs), + feed, + adjustment, + limit: DEFAULT_PAGE_LIMIT, + }); + for await (const bar of generator) { + const ts = new Date(bar.Timestamp).getTime(); + if (!Number.isFinite(ts)) continue; + if (ts < fromTs || ts > toTs) continue; + out.push({ + symbol, + timeframe: '15m', + timestamp: ts, + open: bar.OpenPrice, + high: bar.HighPrice, + low: bar.LowPrice, + close: bar.ClosePrice, + volume: bar.Volume, + }); + } + if (out.length === 0) { + throw new Error( + `Alpaca returned no 15Min bars for ${symbol} in window ${toIso(fromTs)} → ${toIso(toTs)} ` + + `(feed=${feed}). Free IEX feed has limited coverage; consider SIP (paid) for full history.` + ); + } + out.sort((a, b) => Number(a.timestamp) - Number(b.timestamp)); + if (!disableCache) symbolCache.set(cacheKey, [...out]); + return out; +}; + +export const loadDatasetFromAlpaca = async ({ + symbols, + fromTs, + toTs, + feed = 'iex', + adjustment = 'raw', + disableCache = false, +}: AlpacaLoaderInput): Promise => { + const apiKey = config.ALPACA_API_KEY; + const apiSecret = config.ALPACA_API_SECRET; + if (!apiKey || !apiSecret) { + throw new Error( + 'Alpaca backtest source requires ALPACA_API_KEY and ALPACA_API_SECRET. ' + + 'Free tier (IEX feed) has limited historical depth; SIP feed (paid) recommended for ' + + 'pre-2017 coverage and full-market equity data. See docs/backtest/ENGINE_READINESS.md §3.4.' + ); + } + + const uniqueSymbols = Array.from(new Set( + (symbols || []) + .map((s) => String(s || '').trim().toUpperCase()) + .filter(Boolean) + )).sort((a, b) => a.localeCompare(b)); + if (!uniqueSymbols.length) { + throw new Error('Alpaca loader requires at least one symbol.'); + } + if (!Number.isFinite(fromTs) || !Number.isFinite(toTs) || toTs < fromTs) { + throw new Error('Invalid replay window for Alpaca loader.'); + } + + const client = new (Alpaca as any)({ + keyId: apiKey, + secretKey: apiSecret, + paper: config.PAPER_TRADING, + }); + + // Warm the window with a small look-back so the strategy engine has enough + // 4h candles for its EMA/RSI windows. Mirrors krakenLoader's lookbackCandles + // pattern but expressed in time (4h × 300 ≈ 50 days). + const warmupMs = 50 * 24 * 60 * 60 * 1000; + const fetchFromTs = Math.max(0, fromTs - warmupMs); + + const allRows: BacktestRawCandle[] = []; + for (const symbol of uniqueSymbols) { + try { + const rows = await fetchSymbolRows(client, symbol, fetchFromTs, toTs, feed, adjustment, disableCache); + allRows.push(...rows); + } catch (err) { + logger.error(`[AlpacaLoader] ${symbol}: ${(err as Error).message}`); + throw err; + } + } + + const normalized = normalizeRawCandles(allRows); + return buildDatasetFromRows(normalized); +}; + +/** Test-only: clear in-memory cache. Not exported in production builds. */ +export const __clearAlpacaCacheForTests = (): void => { + symbolCache.clear(); +}; diff --git a/backend/src/backtest/data/loadHistoricalData.ts b/backend/src/backtest/data/loadHistoricalData.ts index 7ece10b..4c67272 100644 --- a/backend/src/backtest/data/loadHistoricalData.ts +++ b/backend/src/backtest/data/loadHistoricalData.ts @@ -3,6 +3,7 @@ import { InlineReplayAdapter } from './exchangeReplayAdapter.js'; import { loadDatasetFromCsv } from './csvLoader.js'; import { loadDatasetFromJsonPayload } from './jsonLoader.js'; import { loadDatasetFromKraken } from './krakenLoader.js'; +import { loadDatasetFromAlpaca } from './alpacaLoader.js'; import { selectDatasetForReplayWindow } from './normalize.js'; const parseIsoDate = (value: string, field: string): number => { @@ -40,6 +41,18 @@ const loadBaseDataset = async (request: BacktestRequest): Promise { - setSourceType(event.target.value as SourceType); - if (event.target.value === 'kraken') { + const next = event.target.value as SourceType; + setSourceType(next); + if (next === 'kraken') { setSourcePayload({ exchange: 'kraken' }); setSourceName('Kraken historical API'); + } else if (next === 'alpaca') { + setSourcePayload({ feed: 'iex', adjustment: 'raw' }); + setSourceName('Alpaca historical API (IEX, free tier)'); } else { setSourcePayload(null); setSourceName(''); @@ -320,7 +332,8 @@ const defaultTo = toDateInputValue(now); { value: 'csv', label: 'CSV' }, { value: 'json', label: 'JSON' }, { value: 'replay', label: 'Replay JSON' }, - { value: 'kraken', label: 'Kraken Historical API' }, + { value: 'kraken', label: 'Kraken (crypto)' }, + { value: 'alpaca', label: 'Alpaca (US equities)' }, ]} /> diff --git a/web/src/backtest/types.ts b/web/src/backtest/types.ts index 09949cc..9da2ce0 100644 --- a/web/src/backtest/types.ts +++ b/web/src/backtest/types.ts @@ -1,5 +1,5 @@ export type BacktestTimeframe = '1m' | '15m' | '1h' | '4h'; -export type BacktestDataSourceType = 'csv' | 'json' | 'replay' | 'kraken'; +export type BacktestDataSourceType = 'csv' | 'json' | 'replay' | 'kraken' | 'alpaca'; export type BacktestWindowClosePolicy = 'OPEN_AT_END' | 'FORCE_CLOSE'; export type BacktestIntraCandlePolicy = 'stop_loss_first' | 'take_profit_first' | 'ohlc_path'; export type BacktestTriggerTimeframe = 'off' | '1m';