Files
AITrader/app/routes/api/alpaca/quote.ts
T

114 lines
4.4 KiB
TypeScript

import alpacaService from "../../../lib/alpacaClient";
export async function loader({ request, params }: { request: Request; params: { ticker: string } }) {
const ticker = params.ticker?.toUpperCase();
const url = new URL(request.url);
const timeframe = url.searchParams.get("timeframe") || "1D";
const range = url.searchParams.get("range") || "1M"; // 1D, 1W, 1M, 3M, 1Y, 3Y, ALL
if (!ticker) {
return Response.json({ error: "Ticker is required" }, { status: 400 });
}
try {
// Normalize timeframe to Alpaca API expected values
function mapToAlpacaTimeframe(tf: string) {
switch (tf) {
case "1H":
return "1Hour";
case "1D":
return "1Day";
case "1W":
case "1M":
return "1Day"; // weekly/monthly UI ranges use daily bars
default:
return tf; // 1Min,5Min,15Min,30Min expected to be supported
}
}
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
// Get latest bar for current price (uses paper by default unless mode=live)
let price = 0;
try {
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
price = last ? (last.ClosePrice ?? last.c ?? 0) : 0;
} catch (tradeErr) {
console.error(`API quote/${ticker}: fetchLatestBar failed`, tradeErr);
}
// Calculate start date based on range
const startDate = new Date();
const isIntraday = ["1Min", "5Min", "15Min", "30Min", "1H"].includes(timeframe);
if (range === "1D") {
startDate.setDate(startDate.getDate() - 1);
} else if (range === "1W") {
startDate.setDate(startDate.getDate() - 7);
} else if (range === "1M") {
startDate.setMonth(startDate.getMonth() - 1);
} else if (range === "3M") {
startDate.setMonth(startDate.getMonth() - 3);
} else if (range === "1Y") {
startDate.setFullYear(startDate.getFullYear() - 1);
} else if (range === "3Y") {
startDate.setFullYear(startDate.getFullYear() - 3);
} else if (range === "ALL") {
startDate.setFullYear(startDate.getFullYear() - 10); // Max 10 years
} else if (isIntraday) {
startDate.setDate(startDate.getDate() - 30); // Default 30 days for intraday
}
const barsOptions: any = { limit: 1000 }; // High limit for time range
// For daily/non-intraday queries pass just the date part (YYYY-MM-DD)
if (!isIntraday) {
barsOptions.start = startDate.toISOString().split('T')[0];
} else {
// For intraday, pass full ISO start to be precise
barsOptions.start = startDate.toISOString();
}
const barsArray = await alpacaService.fetchBars(ticker, alpacaTimeframe, barsOptions);
// Transform to chart format
const transformedBars = barsArray.map((bar: any) => {
// AlpacaBarV2 transforms lowercase to capitalized: o->OpenPrice, h->HighPrice, etc.
const open = typeof bar.OpenPrice === 'number' ? bar.OpenPrice : (typeof bar.o === 'number' ? bar.o : 0);
const high = typeof bar.HighPrice === 'number' ? bar.HighPrice : (typeof bar.h === 'number' ? bar.h : 0);
const low = typeof bar.LowPrice === 'number' ? bar.LowPrice : (typeof bar.l === 'number' ? bar.l : 0);
const close = typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0);
const volume = typeof bar.Volume === 'number' ? bar.Volume : (typeof bar.v === 'number' ? bar.v : 0);
// Normalize timestamp to ISO string so client can parse reliably
const rawTs = bar.Timestamp ?? bar.t ?? bar.T ?? bar.timestamp;
let dateObj: Date | null = null;
if (rawTs != null) {
if (typeof rawTs === 'number') {
// If it's likely in seconds (< 1e12) convert to ms
const asMs = rawTs > 1e12 ? rawTs : rawTs * 1000;
dateObj = new Date(asMs);
} else {
dateObj = new Date(rawTs);
}
}
const iso = dateObj && !isNaN(dateObj.getTime()) ? dateObj.toISOString() : null;
return {
t: iso,
o: open,
h: high,
l: low,
c: close,
v: volume,
};
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0 && bar.t);
return Response.json({
ticker,
price,
bars: transformedBars,
});
} catch (error) {
console.error("Alpaca data error:", error);
return Response.json({ ticker, price: 0, bars: [] }, { status: 500 });
}
}