robinhood-nodets
Version:
Comprehensive TypeScript API wrapper for the Robinhood private API
486 lines (436 loc) • 14 kB
text/typescript
import fetch from "node-fetch";
import {
robinhoodApiBaseUrl,
cryptoApiBaseUrl,
endpoints,
clientId,
} from "./constants.js";
import {
RobinhoodEarnings,
RobinhoodOrder,
RobinhoodOrdersOptions,
RobinhoodResultResponse,
RobinhoodUser,
RobinhoodAccount,
RobinhoodDividend,
RobinhoodEarningsOptions,
RobinhoodPosition,
RobinhoodOrderOptions,
RobinhoodFundamentals,
RobinhoodPopularity,
RobinhoodQuoteData,
RobinhoodInvestmentProfile,
RobinhoodInstrument,
RobinhoodWatchlist,
RobinhoodCryptoHolding,
} from "./types.js";
export default class RobinhoodApi {
private authToken: string;
private headers: Record<string, string>;
private refreshToken?: string;
private account?: string;
constructor(authToken: string) {
this.authToken = authToken;
this.headers = {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json;charset=UTF-8",
};
}
async accounts(): Promise<RobinhoodAccount[]> {
const url = robinhoodApiBaseUrl + endpoints.accounts;
const params = new URLSearchParams();
return this._fetchList(url, params);
}
async user(): Promise<RobinhoodUser> {
const url = robinhoodApiBaseUrl + endpoints.user;
const params = new URLSearchParams();
return this._fetch<RobinhoodUser>(url, params);
}
async dividends(): Promise<RobinhoodDividend[]> {
const url = robinhoodApiBaseUrl + endpoints.dividends;
const params = new URLSearchParams();
return this._fetchList(url, params);
}
async earnings(
options: RobinhoodEarningsOptions
): Promise<RobinhoodEarnings[]> {
let url = robinhoodApiBaseUrl + endpoints.earnings;
if (options.instrument) {
url += "?instrument=" + options.instrument;
} else if (options.symbol) {
url += "?symbol=" + options.symbol;
} else {
url += "?range=" + (options.range || 1) + "day";
}
return await this._fetchList<RobinhoodEarnings>(url, new URLSearchParams());
}
async orders(options: RobinhoodOrdersOptions): Promise<RobinhoodOrder[]> {
const url = robinhoodApiBaseUrl + endpoints.orders;
const params = new URLSearchParams();
if (options) {
if (options.fetchAfter) {
params.set("updated_at[gte]", options.fetchAfter);
}
}
return this._fetchList(url, params, options.fetchMaxPages);
}
async positions(): Promise<RobinhoodPosition[]> {
const url = robinhoodApiBaseUrl + endpoints.positions;
const params = new URLSearchParams();
return this._fetchList(url, params);
}
async nonzero_positions(): Promise<RobinhoodPosition[]> {
const url = robinhoodApiBaseUrl + endpoints.positions;
const params = new URLSearchParams();
params.set("nonzero", "true");
return this._fetchList(url, params);
}
async crypto_holdings(): Promise<RobinhoodCryptoHolding[]> {
const url = cryptoApiBaseUrl + endpoints.crypto_holdings;
const params = new URLSearchParams();
return this._fetchList(url, params);
}
async place_buy_order(
options: RobinhoodOrderOptions
): Promise<RobinhoodOrder> {
options.side = "buy";
return this._place_order(options);
}
async place_sell_order(
options: RobinhoodOrderOptions
): Promise<RobinhoodOrder> {
options.side = "sell";
return this._place_order(options);
}
async _place_order(options: RobinhoodOrderOptions): Promise<RobinhoodOrder> {
const response = await fetch(robinhoodApiBaseUrl + endpoints.orders, {
method: "POST",
headers: this.headers,
body: JSON.stringify({
account: this.account,
instrument: options.instrument.url,
price: options.bid_price,
stop_price: options.stop_price,
quantity: options.quantity,
side: options.side,
symbol: options.instrument.symbol.toUpperCase(),
time_in_force: options.time || "gfd",
trigger: options.trigger || "immediate",
type: options.type || "market",
}),
});
if (!response.ok) {
throw new Error(
"Failed to place order: " + JSON.stringify(response.json())
);
}
return (await response.json()) as RobinhoodOrder;
}
async fundamentals(ticker: string): Promise<RobinhoodFundamentals> {
return this._fetch<RobinhoodFundamentals>(
robinhoodApiBaseUrl +
endpoints.fundamentals +
String(ticker).toUpperCase() +
"/"
);
}
async popularity(symbol: string): Promise<RobinhoodPopularity> {
const quote = await this.quote_data(symbol);
const symbol_uuid = quote[0].instrument.split("/")[4];
return this._fetch<RobinhoodPopularity>(
robinhoodApiBaseUrl + endpoints.instruments + symbol_uuid + "/popularity/"
);
}
async quote_data(symbol: string): Promise<RobinhoodQuoteData[]> {
const symbols = Array.isArray(symbol) ? symbol.join(",") : symbol;
return this._fetchList<RobinhoodQuoteData>(
robinhoodApiBaseUrl +
endpoints.quotes +
`?symbols=${symbols.toUpperCase()}`
);
}
async investment_profile(): Promise<RobinhoodInvestmentProfile> {
return this._fetch<RobinhoodInvestmentProfile>(
robinhoodApiBaseUrl + endpoints.investment_profile
);
}
async instruments_by_id(id: string): Promise<RobinhoodInstrument> {
return this._fetch<RobinhoodInstrument>(
robinhoodApiBaseUrl + endpoints.instruments + id + "/"
);
}
async instruments(symbol: string): Promise<RobinhoodInstrument[]> {
return this._fetchList<RobinhoodInstrument>(
robinhoodApiBaseUrl +
endpoints.instruments +
`?query=${symbol.toUpperCase()}`
);
}
async cancel_order(order: string | RobinhoodOrder): Promise<boolean> {
let cancelUrl;
if (typeof order === "string") {
cancelUrl =
robinhoodApiBaseUrl + endpoints.cancel_order + order + "/cancel/";
} else if (order.cancel) {
cancelUrl = order.cancel;
}
if (cancelUrl) {
try {
await this._post<null, {}>(cancelUrl, null);
return true;
} catch (error) {
return false;
}
} else {
if (typeof order === "string") {
throw new Error("Order cannot be cancelled.");
} else {
if (order.state === "cancelled") {
throw new Error("Order already cancelled.");
} else {
throw new Error("Order cannot be cancelled.");
}
}
}
}
async watchlists(): Promise<RobinhoodWatchlist[]> {
return this._fetchList<RobinhoodWatchlist>(
robinhoodApiBaseUrl + endpoints.watchlists
);
}
async create_watch_list(name: string): Promise<RobinhoodWatchlist> {
return this._post<{ name: string }, RobinhoodWatchlist>(
robinhoodApiBaseUrl + endpoints.watchlists,
{ name }
);
}
async sp500_up(): Promise<any> {
return this._fetchList<any>(robinhoodApiBaseUrl + endpoints.sp500_up);
}
async sp500_down(): Promise<any> {
return this._fetchList<any>(robinhoodApiBaseUrl + endpoints.sp500_down);
}
async splits(instrument: string): Promise<any> {
return this._fetch<any>(
robinhoodApiBaseUrl +
endpoints.instruments +
"/" +
instrument +
"/splits/"
);
}
async historicals(symbol: string, intv: string, span: string): Promise<any> {
return this._fetch<any>(
robinhoodApiBaseUrl +
endpoints.quotes +
"historicals/" +
symbol +
"/?interval=" +
intv +
"&span=" +
span
);
}
async url(url: string): Promise<any> {
const response = await fetch(url, {
headers: this.headers,
});
if (!response.ok) {
throw new Error("Failed to fetch URL: " + JSON.stringify(response));
}
return response.json();
}
async news(symbol: string): Promise<any> {
return this._fetch<any>(
robinhoodApiBaseUrl + endpoints.news + "/" + symbol
);
}
async tag(tag: string): Promise<any> {
return this._fetch<any>(robinhoodApiBaseUrl + endpoints.tag + tag);
}
async get_currency_pairs(): Promise<any> {
return this._fetch<any>(cryptoApiBaseUrl + endpoints.currency_pairs);
}
async get_crypto(symbol: string): Promise<any> {
const currencyPairs = await this.get_currency_pairs();
const assets = currencyPairs.results;
const asset = assets.find(
(a: any) => a.asset_currency.code.toLowerCase() === symbol.toLowerCase()
);
if (!asset) {
const codes = assets.map((a: any) => a.asset_currency.code);
throw new Error(
"Symbol not found. Only these codes are allowed: " +
JSON.stringify(codes)
);
}
const response = await fetch(
robinhoodApiBaseUrl + endpoints.crypto + asset.id + "/",
{
headers: this.headers,
}
);
if (!response.ok) {
throw new Error(
"Failed to fetch crypto data: " + JSON.stringify(response)
);
}
return response.json();
}
async options_positions(): Promise<any> {
return this._fetch<any>(robinhoodApiBaseUrl + endpoints.options_positions);
}
async options_orders(): Promise<any> {
return this._fetch<any>(robinhoodApiBaseUrl + endpoints.options_orders);
}
async options_dates(symbol: string): Promise<any> {
const instruments = await this.instruments(symbol);
const tradable_chain_id = instruments[0].tradable_chain_id;
return this._fetch<any>(
robinhoodApiBaseUrl + endpoints.options_chains + "/" + tradable_chain_id
);
}
async options_available(
chain_id: string,
expiration_date: string,
type = "put"
): Promise<any> {
return this._fetch<any>(
robinhoodApiBaseUrl +
endpoints.options_instruments +
`?chain_id=${chain_id}&type=${type}&expiration_date=${expiration_date}&state=active&tradability=tradable`
);
}
async expire_token(): Promise<any> {
const response = await fetch(robinhoodApiBaseUrl + endpoints.logout, {
method: "POST",
headers: this.headers,
body: JSON.stringify({
client_id: clientId,
token: this.refreshToken,
}),
});
if (!response.ok) {
throw new Error("Failed to expire token: " + JSON.stringify(response));
}
return response.json();
}
async set_account(): Promise<void> {
const accounts = await this.accounts();
if (accounts.length > 0) {
this.account = accounts[0].url;
}
}
async refresh_token({
refreshToken,
deviceToken,
}: {
refreshToken: string;
deviceToken: string;
}): Promise<any> {
// Remove any existing Authorization header as shown in the Python code
const headers = {
"Content-Type": "application/x-www-form-urlencoded", // Changed to form-urlencoded
};
// Convert payload to URLSearchParams as it should be form data, not JSON
const payload = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
scope: "internal",
expires_in: "86400",
device_token: deviceToken,
});
try {
const response = await fetch(robinhoodApiBaseUrl + endpoints.login, {
method: "POST",
headers: headers,
body: payload,
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Failed to refresh token: ${response.status} ${response.statusText}, Body: ${body}`
);
}
const data = await response.json();
return { device_token: deviceToken, ...(data as any) };
} catch (error) {
console.error("Error refreshing token:", error);
throw error;
}
}
private async _post<I, R>(url: string, body: I): Promise<R> {
const request: any = {
method: "POST",
headers: this.headers,
};
if (body != null && Object.keys(body).length > 0) {
request.body = JSON.stringify(body);
}
const response = await fetch(url, request);
if (!response.ok) {
throw new Error(
`Failed to post data: ${response.status} ${
response.statusText
}, Body: ${JSON.stringify(await response.text())}`
);
}
return (await response.json()) as R;
}
/**
* Fetches data from the API.
* @param url The URL to fetch the data from.
* @param params The parameters to pass to the API.
* @returns A promise that resolves to the data.
*/
private async _fetch<T>(url: string, params?: URLSearchParams): Promise<T> {
let urlWithParams = url;
const paramsString = params?.toString();
if (paramsString) {
urlWithParams += `?${paramsString}`;
}
const response = await fetch(urlWithParams, {
method: "GET",
headers: this.headers,
});
if (!response.ok) {
throw new Error(
`Failed to fetch data: ${response.status} ${
response.statusText
}, Body: ${JSON.stringify(await response.text())}`
);
}
return (await response.json()) as T;
}
/**
* Fetches a list of items from the API.
* @param url The URL to fetch the list from.
* @param params The parameters to pass to the API.
* @param fetchMaxPages The maximum number of pages to fetch. If not provided, only the first page will be fetched.
* @returns A promise that resolves to an array of items.
*/
private async _fetchList<T>(
url: string,
params?: URLSearchParams,
fetchMaxPages?: number
): Promise<T[]> {
const response = await this._fetch<RobinhoodResultResponse<T>>(url, params);
let data = response.results;
let nextPage = response.next;
let pagesFetched = 0;
if (fetchMaxPages === undefined) {
return data;
}
while (nextPage && pagesFetched < fetchMaxPages) {
const nextResponse = await this._fetch<RobinhoodResultResponse<T>>(
nextPage,
new URLSearchParams()
);
data = data.concat(nextResponse.results);
nextPage = nextResponse.next;
pagesFetched++;
}
return data;
}
}