diff --git a/bun.lockb b/bun.lockb index c5dd7b3..f24c59f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5506288..ee92b31 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.1.2", + "@types/ms": "^0.7.34", "axios": "^1.7.2", "boxen": "^8.0.1", "chalk": "^5.3.0", @@ -19,7 +20,10 @@ "commander": "^12.1.0", "dayjs": "^1.11.12", "dotenv": "^16.4.5", + "ink": "^5.0.1", + "ink-spinner": "^5.0.0", "inquirer": "^10.1.2", + "ms": "^2.1.3", "node-fetch": "^3.3.2", "openapi-fetch": "^0.11.1", "ora": "^8.1.0", diff --git a/src/index.ts b/src/index.ts index d888484..fbc10c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { registerContracts } from "./lib/contracts"; import { registerDev } from "./lib/dev"; import { registerInstances } from "./lib/instances"; import { registerLogin } from "./lib/login"; -import { registerOrders } from "./lib/orders"; +import { registerOrders } from "./lib/orders/index"; import { registerSell } from "./lib/sell"; import { registerSSH } from "./lib/ssh"; import { registerTokens } from "./lib/tokens"; diff --git a/src/lib/Row.tsx b/src/lib/Row.tsx new file mode 100644 index 0000000..bae607e --- /dev/null +++ b/src/lib/Row.tsx @@ -0,0 +1,16 @@ +import { Box, Text } from "ink"; + +export function Row(props: { + head: string; + value: string; + headWidth?: number; +}) { + return ( + + + {props.head} + + {props.value} + + ); +} \ No newline at end of file diff --git a/src/lib/buy.ts b/src/lib/buy.ts deleted file mode 100644 index 4c7d53a..0000000 --- a/src/lib/buy.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { confirm } from "@inquirer/prompts"; -import c from "chalk"; -import type { Command } from "commander"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; -import parseDuration from "parse-duration"; -import { apiClient } from "../apiClient"; -import { isLoggedIn } from "../helpers/config"; -import { - logAndQuit, - logLoginMessageAndQuit, - logSessionTokenExpiredAndQuit, -} from "../helpers/errors"; -import { - pricePerGPUHourToTotalPriceCents, - totalPriceToPricePerGPUHour, -} from "../helpers/price"; -import { - type Cents, - centsToDollarsFormatted, - computeApproximateDurationSeconds, - parseStartDate, - priceWholeToCents, - roundEndDate, - roundStartDate, -} from "../helpers/units"; -import { waitForOrderToNotBePending } from "../helpers/waitingForOrder"; -import type { Nullable } from "../types/empty"; -import { GPUS_PER_NODE } from "./constants"; -import { formatDuration } from "./orders"; - -dayjs.extend(relativeTime); -dayjs.extend(duration); - -interface SfBuyOptions { - type: string; - accelerators?: string; - duration: string; - price: string; - start?: string; - yes?: boolean; - quote?: boolean; - colocate?: Array; -} - -export function registerBuy(program: Command) { - program - .command("buy") - .description("Place a buy order") - .requiredOption("-t, --type ", "Specify the type of node", "h100i") - .option("-n, --accelerators ", "Specify the number of GPUs", "8") - .requiredOption("-d, --duration ", "Specify the duration", "1h") - .option("-p, --price ", "The price in dollars, per GPU hour") - .option( - "-s, --start ", - "Specify the start date. Can be a date, relative time like '+1d', or the string 'NOW'", - ) - .option("-y, --yes", "Automatically confirm the order") - .option( - "-colo, --colocate ", - "Colocate with existing contracts", - (value) => value.split(","), - [], - ) - .option("--quote", "Only provide a quote for the order") - .action(buyOrderAction); -} - -async function buyOrderAction(options: SfBuyOptions) { - const loggedIn = await isLoggedIn(); - if (!loggedIn) { - return logLoginMessageAndQuit(); - } - - // normalize inputs - - const isQuoteOnly = options.quote ?? false; - // parse duration - let durationSeconds = parseDuration(options.duration, "s"); - - if (!durationSeconds) { - return logAndQuit(`Invalid duration: ${options.duration}`); - } - - if (durationSeconds < 3600) { - return logAndQuit( - `Duration must be at least 1 hour, instead was ${durationSeconds} seconds. Try using -d '1h' instead.`, - ); - } - - const colocateWithContractIds = options.colocate ? options.colocate : []; - - // default to 1 node if not specified - const accelerators = options.accelerators ? Number(options.accelerators) : 1; - - if (accelerators % GPUS_PER_NODE !== 0) { - const exampleCommand = `sf buy -n ${GPUS_PER_NODE} -d "${options.duration}"`; - return logAndQuit( - `At the moment, only entire-nodes are available, so you must have a multiple of ${GPUS_PER_NODE} GPUs. Example command:\n\n${exampleCommand}`, - ); - } - const quantity = Math.ceil(accelerators / GPUS_PER_NODE); - - // parse price - let priceCents: Nullable = null; - if (options.price) { - const { cents: priceCentsParsed, invalid: priceInputInvalid } = - priceWholeToCents(options.price); - if (priceInputInvalid) { - return logAndQuit(`Invalid price: ${options.price}`); - } - priceCents = priceCentsParsed; - } - - // Convert the price to the total price of the contract - // (price per gpu hour * gpus per node * quantity * duration in hours) - if (priceCents) { - priceCents = pricePerGPUHourToTotalPriceCents( - priceCents, - durationSeconds, - quantity, - GPUS_PER_NODE, - ); - } - - const yesFlagOmitted = options.yes === undefined || options.yes === null; - const confirmWithUser = yesFlagOmitted || !options.yes; - - // parse starts at - let startDate: Date | "NOW"; - switch (options.start) { - case null: - case undefined: - startDate = "NOW"; - break; - default: { - const parsed = parseStartDate(options.start); - if (!parsed) { - return logAndQuit(`Invalid start date: ${options.start}`); - } - startDate = parsed; - } - } - - let endDate: Date = dayjs(startDate === "NOW" ? new Date() : startDate) - .add(durationSeconds, "s") - .toDate(); - let didQuote = false; - if (options.quote) { - const quote = await getQuote({ - instanceType: options.type, - quantity: quantity, - startsAt: startDate, - durationSeconds, - }); - - if (!quote) { - return logAndQuit("Not enough data exists to quote this order."); - } - - startDate = quote.start_at === "NOW" ? "NOW" : new Date(quote.start_at); - endDate = new Date(quote.end_at); - durationSeconds = computeApproximateDurationSeconds(startDate, endDate); - - const priceLabelUsd = c.green(centsToDollarsFormatted(quote.price)); - const priceLabelPerGPUHour = c.green( - centsToDollarsFormatted( - totalPriceToPricePerGPUHour( - quote.price, - durationSeconds, - quantity, - GPUS_PER_NODE, - ), - ), - ); - - console.log( - `Found availability from ${c.green(quote.start_at)} to ${c.green(quote.end_at)} (${c.green(formatDuration(durationSeconds * 1000))}) at ${priceLabelUsd} total (${priceLabelPerGPUHour}/GPU-hour)`, - ); - return; - } else if (!priceCents) { - const quote = await getQuote({ - instanceType: options.type, - quantity: quantity, - startsAt: startDate, - durationSeconds, - }); - - if (quote) { - startDate = quote.start_at === "NOW" ? "NOW" : new Date(quote.start_at); - endDate = new Date(quote.end_at); - durationSeconds = computeApproximateDurationSeconds(startDate, endDate); - priceCents = quote.price; - didQuote = true; - } else { - // Quote an aggressive price based on an index if the order won't fill immediately - const aggressivePricePerHour = await getAggressivePricePerHour( - options.type, - ); - if (!aggressivePricePerHour) { - return logAndQuit("Not enough data exists to quote this order."); - } - const roundedStartDate = - startDate !== "NOW" ? roundStartDate(startDate) : startDate; - - // round the end date. - const roundedEndDate = roundEndDate(endDate); - - const roundedDurationSeconds = computeApproximateDurationSeconds( - roundedStartDate, - roundedEndDate, - ); - priceCents = - aggressivePricePerHour * - GPUS_PER_NODE * - quantity * - Math.ceil(roundedDurationSeconds / 3600); - } - } - - if (!durationSeconds) { - throw new Error("unexpectly no duration provided"); - } - if (!priceCents) { - throw new Error("unexpectly no price provided"); - } - - // if we didn't quote, we need to round the start and end dates - if (!didQuote) { - // round the start date if it's not "NOW". - const roundedStartDate = - startDate !== "NOW" ? roundStartDate(startDate) : startDate; - - // round the end date. - const roundedEndDate = roundEndDate(endDate); - - // if we rounded the time, prorate the price - const roundedDurationSeconds = computeApproximateDurationSeconds( - roundedStartDate, - roundedEndDate, - ); - - const priceCentsPerSecond = priceCents / durationSeconds; - const roundedPriceCents = Math.ceil( - priceCentsPerSecond * roundedDurationSeconds, - ); - - priceCents = roundedPriceCents; - startDate = roundedStartDate; - endDate = roundedEndDate; - durationSeconds = roundedDurationSeconds; - } - - if (confirmWithUser) { - const confirmationMessage = confirmPlaceOrderMessage({ - instanceType: options.type, - priceCents, - quantity, - durationSeconds, - startsAt: startDate, - endsAt: endDate, - confirmWithUser, - quoteOnly: isQuoteOnly, - colocate_with: colocateWithContractIds, - }); - const confirmed = await confirm({ - message: confirmationMessage, - default: false, - }); - - if (!confirmed) { - logAndQuit("Order cancelled"); - } - } - - const res = await placeBuyOrder({ - instanceType: options.type, - priceCents, - quantity, - // round start date again because the user might have taken a long time to confirm - // most of the time this will do nothing, but when it does it will move the start date forwrd one minute - startsAt: startDate === "NOW" ? "NOW" : roundStartDate(startDate), - endsAt: endDate, - confirmWithUser, - quoteOnly: isQuoteOnly, - colocate_with: colocateWithContractIds, - }); - - const order = await waitForOrderToNotBePending(res.id); - if (!order) { - return; - } - - console.log("\n"); - - if (order.status === "filled") { - const now = new Date(); - const startAt = new Date(order.start_at); - const timeDiff = startAt.getTime() - now.getTime(); - const oneMinuteInMs = 60 * 1000; - - if (now >= startAt || timeDiff <= oneMinuteInMs) { - console.log(`Your nodes are currently spinning up. Once they're online, you can view them using: - - sf instances ls - -`); - } else { - const contractStartTime = dayjs(startAt); - const timeFromNow = contractStartTime.fromNow(); - console.log(`Your contract begins ${c.green(timeFromNow)}. You can view more details using: - - sf contracts ls - -`); - } - return; - } - - if (order.status === "open") { - console.log(`Your order hasn't filled yet. You can check it's status with: - - sf orders ls --only-open - -If you want to cancel the order, you can do so with: - - sf orders cancel ${order.id} - - `); - return; - } - - console.error(`Order likely did not execute. Check the status with: - - sf orders ls - - `); -} - -function confirmPlaceOrderMessage(options: BuyOptions) { - if (!options.priceCents) { - return ""; - } - - const totalNodesLabel = c.green(options.quantity); - const instanceTypeLabel = c.green(options.instanceType); - const nodesLabel = options.quantity > 1 ? "nodes" : "node"; - - const durationHumanReadable = formatDuration(options.durationSeconds * 1000); - const endsAtLabel = c.green( - dayjs(options.endsAt).format("MM/DD/YYYY hh:mm A"), - ); - const fromNowTime = dayjs( - options.startsAt === "NOW" ? new Date() : options.startsAt, - ).fromNow(); - - let timeDescription: string; - if ( - fromNowTime === "a few seconds ago" || - fromNowTime === "in a few seconds" - ) { - timeDescription = `from ${c.green("now")} until ${endsAtLabel}`; - } else { - const startAtLabel = c.green( - options.startsAt === "NOW" - ? "NOW" - : dayjs(options.startsAt).format("MM/DD/YYYY hh:mm A"), - ); - timeDescription = `from ${startAtLabel} (${c.green(fromNowTime)}) until ${endsAtLabel}`; - } - - const pricePerGPUHour = totalPriceToPricePerGPUHour( - options.priceCents, - options.durationSeconds, - options.quantity, - GPUS_PER_NODE, - ); - const pricePerHourLabel = c.green(centsToDollarsFormatted(pricePerGPUHour)); - const totalPriceLabel = c.green(centsToDollarsFormatted(options.priceCents)); - - const topLine = `${totalNodesLabel} ${instanceTypeLabel} ${nodesLabel} (${GPUS_PER_NODE * options.quantity} GPUs) at ${pricePerHourLabel} per GPU hour for ${c.green(durationHumanReadable)} ${timeDescription} for a total of ${totalPriceLabel}`; - - const dollarsLabel = c.green(centsToDollarsFormatted(pricePerGPUHour)); - - const gpusLabel = c.green(options.quantity * GPUS_PER_NODE); - - const priceLine = `\nBuy ${gpusLabel} GPUs at ${dollarsLabel} per GPU hour?`; - - return `${topLine}\n${priceLine} `; -} - -type BuyOptions = { - instanceType: string; - priceCents: number; - quantity: number; - startsAt: Date | "NOW"; - endsAt: Date; - durationSeconds: number; - confirmWithUser: boolean; - quoteOnly: boolean; - colocate_with: Array; -}; -export async function placeBuyOrder( - options: Omit, -) { - const api = await apiClient(); - const { data, error, response } = await api.POST("/v0/orders", { - body: { - side: "buy", - instance_type: options.instanceType, - quantity: options.quantity, - // round start date again because the user might take a long time to confirm - start_at: - options.startsAt === "NOW" - ? "NOW" - : roundStartDate(options.startsAt).toISOString(), - end_at: options.endsAt.toISOString(), - price: options.priceCents, - colocate_with: options.colocate_with, - }, - }); - - if (!response.ok) { - switch (response.status) { - case 400: - return logAndQuit(`Bad Request: ${error?.message}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - case 500: - return logAndQuit(`Failed to place order: ${error?.message}`); - default: - return logAndQuit(`Failed to place order: ${response.statusText}`); - } - } - - if (!data) { - return logAndQuit( - `Failed to place order: Unexpected response from server: ${response}`, - ); - } - - return data; -} - -type QuoteOptions = { - instanceType: string; - quantity: number; - startsAt: Date | "NOW"; - durationSeconds: number; -}; -export async function getQuote(options: QuoteOptions) { - const api = await apiClient(); - - const { data, error, response } = await api.GET("/v0/quote", { - params: { - query: { - side: "buy", - instance_type: options.instanceType, - quantity: options.quantity, - duration: options.durationSeconds, - min_start_date: - options.startsAt === "NOW" ? "NOW" : options.startsAt.toISOString(), - max_start_date: - options.startsAt === "NOW" ? "NOW" : options.startsAt.toISOString(), - }, - }, - }); - - if (!response.ok) { - switch (response.status) { - case 400: - console.log("Error:", error); - return logAndQuit(`Bad Request: ${error?.message}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - case 500: - return logAndQuit(`Failed to get quote: ${error?.code}`); - default: - return logAndQuit(`Failed to get quote: ${response.statusText}`); - } - } - - if (!data) { - return logAndQuit( - `Failed to get quote: Unexpected response from server: ${response}`, - ); - } - - return data.quote; -} - -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getOrder(orderId: string) { - const api = await apiClient(); - - const { data: order } = await api.GET("/v0/orders/{id}", { - params: { path: { id: orderId } }, - }); - return order; -} - -async function getMostRecentIndexAvgPrice(instanceType: string) { - const api = await apiClient(); - - const { data } = await api.GET("/v0/prices", { - params: { - query: { - instance_type: instanceType, - }, - }, - }); - - if (!data) { - return logAndQuit("Failed to get prices: Unexpected response from server"); - } - - data.data.sort((a, b) => { - return dayjs(b.period_start).diff(dayjs(a.period_start)); - }); - - return data.data[0].gpu_hour; -} - -async function getAggressivePricePerHour(instanceType: string) { - const mostRecentPrice = await getMostRecentIndexAvgPrice(instanceType); - // We'll set a floor on the recommended price here, because the index price - // will report 0 if there was no data, which might happen due to an outage. - const minimumPrice = 75; // 75 cents - - if (!mostRecentPrice) { - return minimumPrice; - } - - const recommendedIndexPrice = (mostRecentPrice.avg + mostRecentPrice.max) / 2; - if (recommendedIndexPrice < minimumPrice) { - return minimumPrice; - } - - return recommendedIndexPrice; -} diff --git a/src/lib/buy/BuyOrder.tsx b/src/lib/buy/BuyOrder.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/buy/Quote.tsx b/src/lib/buy/Quote.tsx new file mode 100644 index 0000000..306efea --- /dev/null +++ b/src/lib/buy/Quote.tsx @@ -0,0 +1,40 @@ +import { Box, Text } from "ink"; +import { Row } from "../Row"; +import dayjs from "dayjs"; +import { GPUS_PER_NODE } from "../constants"; + +type Quote = { + price: number; + quantity: number; + start_at: string; + end_at: string; + instance_type: string; +} | { + price: number; + quantity: number; + start_at: string; + end_at: string; + contract_id: string; +} | null + +export default function Quote(props: { quote: Quote }) { + if (!props.quote) { + return + No quote available for this configuration. That doesn't mean it's not available, but you'll need to give a price you're willing to pay for it. + + # Place an order with a price + sf buy --price "2.50" + + + } + + const durationSeconds = dayjs(props.quote.end_at).diff(dayjs(props.quote.start_at), 'seconds'); + const durationHours = durationSeconds / 3600; + const pricePerHour = props.quote.price / durationHours / GPUS_PER_NODE / props.quote.quantity / 100; + const priceTotal = props.quote.price / 100; + + return + + + +} diff --git a/src/lib/buy/index.tsx b/src/lib/buy/index.tsx new file mode 100644 index 0000000..f16a851 --- /dev/null +++ b/src/lib/buy/index.tsx @@ -0,0 +1,268 @@ +import type { Command } from "commander"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { apiClient } from "../../apiClient"; +import { + logAndQuit, + logSessionTokenExpiredAndQuit, +} from "../../helpers/errors"; +import { roundStartDate } from "../../helpers/units"; +import parseDurationFromLibrary from "parse-duration"; +import { render } from "ink"; +import Quote from "./Quote"; +import { parseDate } from 'chrono-node' +import { GPUS_PER_NODE } from "../constants"; + +dayjs.extend(relativeTime); +dayjs.extend(duration); + +interface SfBuyOptions { + type: string; + accelerators?: string; + duration: string; + price: string; + start?: string; + yes?: boolean; + quote?: boolean; + colocate?: Array; +} + +export function registerBuy(program: Command) { + program + .command("buy") + .description("Place a buy order") + .requiredOption("-t, --type ", "Specify the type of node", "h100i") + .option("-n, --accelerators ", "Specify the number of GPUs", "8") + .requiredOption("-d, --duration ", "Specify the duration", "1h") + .option("-p, --price ", "The price in dollars, per GPU hour") + .option( + "-s, --start ", + "Specify the start date. Can be a date, relative time like '+1d', or the string 'NOW'", + ) + .option("-y, --yes", "Automatically confirm the order") + .option( + "-colo, --colocate ", + "Colocate with existing contracts", + (value) => value.split(","), + [], + ) + .option("--quote", "Only provide a quote for the order") + .action(buyOrderAction); +} + +function parseStart(start?: string) { + if (!start) { + return "NOW"; + } + + if (start === "NOW") { + return "NOW"; + } + + const parsed = parseDate(start); + if (!parsed) { + return logAndQuit(`Invalid start date: ${start}`); + } + + return parsed; +} + +function parseAccelerators(accelerators?: string) { + if (!accelerators) { + return 1; + } + + return Number.parseInt(accelerators) / GPUS_PER_NODE; +} + +function parseDuration(duration?: string) { + if (!duration) { + return 1 * 60 * 60; // 1 hour + } + + const parsed = parseDurationFromLibrary(duration); + if (!parsed) { + return logAndQuit(`Invalid duration: ${duration}`); + } + + return parsed / 1000; +} + +async function quoteAction(options: SfBuyOptions) { + const quote = await getQuoteFromParsedSfBuyOptions(options); + render() +} + +/* +Flow is: +1. If --quote, get quote and exit +2. If -p is provided, use it as the price +3. Otherwise, get a price by quoting the market +4. If --yes isn't provided, ask for confirmation +5. Place order + */ +async function buyOrderAction(options: SfBuyOptions) { + if (options.quote) { + return quoteAction(options); + } + + +} + +type BuyOptions = { + instanceType: string; + priceCents: number; + quantity: number; + startsAt: Date | "NOW"; + endsAt: Date; + durationSeconds: number; + quoteOnly: boolean; + colocate_with: Array; +}; +export async function placeBuyOrder( + options: Omit, +) { + const api = await apiClient(); + const { data, error, response } = await api.POST("/v0/orders", { + body: { + side: "buy", + instance_type: options.instanceType, + quantity: options.quantity, + // round start date again because the user might take a long time to confirm + start_at: + options.startsAt === "NOW" + ? "NOW" + : roundStartDate(options.startsAt).toISOString(), + end_at: options.endsAt.toISOString(), + price: options.priceCents, + colocate_with: options.colocate_with, + }, + }); + + if (!response.ok) { + switch (response.status) { + case 400: + return logAndQuit(`Bad Request: ${error?.message}`); + case 401: + return await logSessionTokenExpiredAndQuit(); + case 500: + return logAndQuit(`Failed to place order: ${error?.message}`); + default: + return logAndQuit(`Failed to place order: ${response.statusText}`); + } + } + + if (!data) { + return logAndQuit( + `Failed to place order: Unexpected response from server: ${response}`, + ); + } + + return data; +} + +async function getQuoteFromParsedSfBuyOptions(options: SfBuyOptions) { + return await getQuote({ + instanceType: options.type, + quantity: parseAccelerators(options.accelerators), + startsAt: parseStart(options.start), + durationSeconds: parseDuration(options.duration), + }); +} + +type QuoteOptions = { + instanceType: string; + quantity: number; + startsAt: Date | "NOW"; + durationSeconds: number; +}; +export async function getQuote(options: QuoteOptions) { + const api = await apiClient(); + + const { data, error, response } = await api.GET("/v0/quote", { + params: { + query: { + side: "buy", + instance_type: options.instanceType, + quantity: options.quantity, + duration: options.durationSeconds, + min_start_date: + options.startsAt === "NOW" ? "NOW" : options.startsAt.toISOString(), + max_start_date: + options.startsAt === "NOW" ? "NOW" : options.startsAt.toISOString(), + }, + }, + }); + + if (!response.ok) { + switch (response.status) { + case 400: + console.log("Error:", error); + return logAndQuit(`Bad Request: ${error?.message}`); + case 401: + return await logSessionTokenExpiredAndQuit(); + case 500: + return logAndQuit(`Failed to get quote: ${error?.code}`); + default: + return logAndQuit(`Failed to get quote: ${response.statusText}`); + } + } + + if (!data) { + return logAndQuit( + `Failed to get quote: Unexpected response from server: ${response}`, + ); + } + + return data.quote; +} + +export async function getOrder(orderId: string) { + const api = await apiClient(); + + const { data: order } = await api.GET("/v0/orders/{id}", { + params: { path: { id: orderId } }, + }); + return order; +} + +export async function getMostRecentIndexAvgPrice(instanceType: string) { + const api = await apiClient(); + + const { data } = await api.GET("/v0/prices", { + params: { + query: { + instance_type: instanceType, + }, + }, + }); + + if (!data) { + return logAndQuit("Failed to get prices: Unexpected response from server"); + } + + data.data.sort((a, b) => { + return dayjs(b.period_start).diff(dayjs(a.period_start)); + }); + + return data.data[0].gpu_hour; +} + +export async function getAggressivePricePerHour(instanceType: string) { + const mostRecentPrice = await getMostRecentIndexAvgPrice(instanceType); + // We'll set a floor on the recommended price here, because the index price + // will report 0 if there was no data, which might happen due to an outage. + const minimumPrice = 75; // 75 cents + + if (!mostRecentPrice) { + return minimumPrice; + } + + const recommendedIndexPrice = (mostRecentPrice.avg + mostRecentPrice.max) / 2; + if (recommendedIndexPrice < minimumPrice) { + return minimumPrice; + } + + return recommendedIndexPrice; +} diff --git a/src/lib/contracts.ts b/src/lib/contracts.ts deleted file mode 100644 index 7bf4ceb..0000000 --- a/src/lib/contracts.ts +++ /dev/null @@ -1,130 +0,0 @@ -import Table from "cli-table3"; -import { Command } from "commander"; -import { apiClient } from "../apiClient"; -import { isLoggedIn } from "../helpers/config"; -import { - logAndQuit, - logLoginMessageAndQuit, - logSessionTokenExpiredAndQuit, -} from "../helpers/errors"; - -interface Contract { - object: string; - status: string; - id: string; - created_at: string; - instance_type: string; - shape: { - // These are date strings - intervals: string[]; - quantities: number[]; - }; - colocate_with: string[]; - cluster_id?: string; -} - -function printTable(data: Contract[]) { - if (data.length === 0) { - const table = new Table(); - table.push([ - { colSpan: 6, content: "No contracts found", hAlign: "center" }, - ]); - - console.log(table.toString()); - } - - for (const contract of data) { - // print the contract shape in a table - // if the contract is empty, will print empty shape table - const intervals: (string | number)[][] = []; - for (let i = 0; i < contract.shape.intervals.length - 1; i++) { - intervals.push([ - "-", - "-", - "-", - new Date(contract.shape.intervals[i]).toLocaleString(), - new Date(contract.shape.intervals[i + 1]).toLocaleString(), - contract.shape.quantities[i], - ]); - } - - const table = new Table({ - head: ["ID", "Status", "Instance Type", "From", "To", "Quantity"], - }); - - if (intervals.length > 0) { - intervals[0][0] = contract.id; - intervals[0][1] = contract.status; - intervals[0][2] = contract.instance_type; - } - - for (const interval of intervals) { - table.push(interval); - } - - console.log(table.toString()); - } -} - -export function registerContracts(program: Command) { - program - .command("contracts") - .description("Manage contracts") - .addCommand( - new Command("list") - .alias("ls") - .option("--json", "Output in JSON format") - .description("List all contracts") - .action(async (options) => { - if (options.json) { - console.log(await listContracts()); - } else { - const data = await listContracts(); - printTable(data); - } - process.exit(0); - }), - ); -} - -async function listContracts(): Promise { - const loggedIn = await isLoggedIn(); - if (!loggedIn) { - return logLoginMessageAndQuit(); - } - - const api = await apiClient(); - - const { data, error, response } = await api.GET("/v0/contracts"); - - if (!response.ok) { - switch (response.status) { - case 400: - return logAndQuit(`Bad Request: ${error?.message}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - default: - return logAndQuit(`Failed to get contracts: ${response.statusText}`); - } - } - - if (!data) { - return logAndQuit( - `Failed to get contracts: Unexpected response from server: ${response}`, - ); - } - - // filter out pending contracts - // we use loop instead of filter bc type - const contracts: Contract[] = []; - for (const contract of data.data) { - if (contract.status === "active") { - contracts.push({ - ...contract, - colocate_with: contract.colocate_with ?? [], - }); - } - } - - return contracts; -} diff --git a/src/lib/contracts/ContractDisplay.tsx b/src/lib/contracts/ContractDisplay.tsx new file mode 100644 index 0000000..7677c74 --- /dev/null +++ b/src/lib/contracts/ContractDisplay.tsx @@ -0,0 +1,72 @@ +import { Box, Text } from "ink"; +import type { Contract } from "./types"; +import { Row } from "../Row"; +import dayjs from "dayjs"; +import ms from 'ms' + +const STARTED = "▶" +const UPCOMING = "⏸" + +export function ContractDisplay(props: { contract: Contract }) { + if (props.contract.status === "pending") { + return null; + } + + const startsAt = new Date(props.contract.shape.intervals[0]); + const statusIcon = startsAt < new Date() ? STARTED : UPCOMING; + + return + + {statusIcon} + {props.contract.id} + + + 0 ? props.contract.colocate_with.join(", ") : "-"} /> + + + {props.contract.shape.intervals.slice(0, -1).map((interval) => { + const start = new Date(interval); + const next = new Date(props.contract.shape.intervals[props.contract.shape.intervals.indexOf(interval) + 1]); + + const duration = next.getTime() - start.getTime(); + const startString = dayjs(start).format("MMM D h:mm a").toLowerCase(); + const nextString = dayjs(next).format("MMM D h:mm a").toLowerCase(); + const durationString = ms(duration); + + const quantity = props.contract.shape.quantities[props.contract.shape.intervals.indexOf(interval)]; + + return ( + + + {quantity} x {props.contract.instance_type} + + + + {startString} + + {nextString} + + ({durationString}) + + ) + })} + + ; +} + +export function ContractList(props: { contracts: Contract[] }) { + if (props.contracts.length === 0) { + return + No contracts found. + + + # Place a buy order to get started + sf buy + + + } + + return + {props.contracts.map((contract) => )} + ; +} diff --git a/src/lib/contracts/index.tsx b/src/lib/contracts/index.tsx new file mode 100644 index 0000000..06cff20 --- /dev/null +++ b/src/lib/contracts/index.tsx @@ -0,0 +1,76 @@ +import { Command } from "commander"; +import { apiClient } from "../../apiClient"; +import { isLoggedIn } from "../../helpers/config"; +import { + logAndQuit, + logLoginMessageAndQuit, + logSessionTokenExpiredAndQuit, +} from "../../helpers/errors"; +import { ContractList } from "./ContractDisplay"; +import { render } from "ink"; +import type { Contract } from "./types"; +import dayjs from "dayjs"; + +export function registerContracts(program: Command) { + program + .command("contracts") + .description("Manage contracts") + .addCommand( + new Command("list") + .alias("ls") + .option("--json", "Output in JSON format") + .description("List all contracts") + .action(async (options) => { + if (options.json) { + console.log(await listContracts()); + } else { + const data = await listContracts(); + + render(); + } + process.exit(0); + }), + ); +} + +async function listContracts(): Promise { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { + return logLoginMessageAndQuit(); + } + + const api = await apiClient(); + + const { data, error, response } = await api.GET("/v0/contracts"); + + if (!response.ok) { + switch (response.status) { + case 400: + return logAndQuit(`Bad Request: ${error?.message}`); + case 401: + return await logSessionTokenExpiredAndQuit(); + default: + return logAndQuit(`Failed to get contracts: ${response.statusText}`); + } + } + + if (!data) { + return logAndQuit( + `Failed to get contracts: Unexpected response from server: ${response}`, + ); + } + + // filter out pending contracts + // we use loop instead of filter bc type + const contracts: Contract[] = []; + for (const contract of data.data) { + if (contract.status === "active") { + contracts.push({ + ...contract, + colocate_with: contract.colocate_with ?? [], + }); + } + } + + return contracts; +} diff --git a/src/lib/contracts/types.ts b/src/lib/contracts/types.ts new file mode 100644 index 0000000..6ba2f1f --- /dev/null +++ b/src/lib/contracts/types.ts @@ -0,0 +1,14 @@ +export interface Contract { + object: string; + status: string; + id: string; + created_at: string; + instance_type: string; + shape: { + // These are date strings + intervals: string[]; + quantities: number[]; + }; + colocate_with: string[]; + cluster_id?: string; +} diff --git a/src/lib/instances.ts b/src/lib/instances.ts deleted file mode 100644 index 4a933df..0000000 --- a/src/lib/instances.ts +++ /dev/null @@ -1,204 +0,0 @@ -import chalk, { type ChalkInstance } from "chalk"; -import Table from "cli-table3"; -import type { Command } from "commander"; -import { apiClient } from "../apiClient"; -import { isLoggedIn } from "../helpers/config"; -import { - logAndQuit, - logLoginMessageAndQuit, - logSessionTokenExpiredAndQuit, -} from "../helpers/errors"; - -export function registerInstances(program: Command) { - const instances = program - .command("instances") - .description("Manage instances you own"); - - // sf instances ls|list - instances - .command("list") - .alias("ls") - .description("List instances") - .option("-cls, --cluster ", "Specify the cluster id") - .option("--json", "Output in JSON format") - .action(async (options) => { - await listInstancesAction({ - clusterId: options.cluster, - returnJson: options.json, - }); - }); -} - -// -- - -interface ListResponseBody { - data: T[]; - object: "list"; -} -type InstanceType = "h100i" | "h100" | "a100"; -interface InstanceObject { - object: "instance"; - id: string; - type: InstanceType; - public_ip: string; - private_ip: string; - status: string; - ssh_port: number | undefined; - can_connect: boolean; -} -async function listInstancesAction({ - clusterId, - returnJson, -}: { clusterId?: string; returnJson?: boolean }) { - const instances = await getInstances({ clusterId }); - if (instances.length !== 0) { - instances.sort(sortInstancesByTypeAndIp()); - } - - if (returnJson) { - console.log(JSON.stringify(instances, null, 2)); - } else { - const tableHeaders = [ - chalk.gray("Instance Id"), - chalk.gray("Type"), - chalk.gray("IP Address"), - chalk.gray("SSH Port"), - chalk.gray("Status"), - chalk.gray("Can SSH"), - ]; - - if (instances.length === 0) { - // empty table - const table = new Table({ - head: tableHeaders, - colWidths: [20, 20, 20], - }); - table.push([ - { colSpan: 3, content: "No instances found", hAlign: "center" }, - ]); - console.log(table.toString() + "\n"); - } else { - const table = new Table({ - head: tableHeaders, - // colWidths: [32, 10, 20], - }); - - table.push( - ...instances.map((instance) => [ - instance.id, - colorInstanceType(instance.type), - instance.public_ip, - instance.ssh_port, - instance.status, - instance.can_connect ? "Yes" : "No", - ]), - ); - console.log( - table.toString() + - "\n" + - "To ssh into an instance, run `sf ssh `.\n", - ); - } - } - - process.exit(0); -} - -const InstanceTypeSortPriority: { [key in InstanceType]: number } = { - h100i: 1, - h100: 2, - a100: 3, -}; -const sortInstancesByTypeAndIp = () => { - return (a: InstanceObject, b: InstanceObject) => { - const priorityA = - InstanceTypeSortPriority[a.type] || Number.MAX_SAFE_INTEGER; - const priorityB = - InstanceTypeSortPriority[b.type] || Number.MAX_SAFE_INTEGER; - if (priorityA === priorityB) { - return compareIPs(a.public_ip, b.public_ip); // secondary sort on public ips - } - return priorityA - priorityB; - }; -}; -const compareIPs = (ip1: string, ip2: string) => { - const isIPv4 = (ip: string) => ip.split(".").length === 4; - - const ip1IsIPv4 = isIPv4(ip1); - const ip2IsIPv4 = isIPv4(ip2); - - if (ip1IsIPv4 && ip2IsIPv4) { - // Both are IPv4, proceed with numerical comparison - const segmentsA = ip1.split(".").map(Number); - const segmentsB = ip2.split(".").map(Number); - for (let i = 0; i < Math.min(segmentsA.length, segmentsB.length); i++) { - if (segmentsA[i] !== segmentsB[i]) { - return segmentsA[i] - segmentsB[i]; - } - } - - return segmentsA.length - segmentsB.length; - } else if (!ip1IsIPv4 && !ip2IsIPv4) { - // Both non-ipv4 are equal - return 0; - } else if (ip1IsIPv4 && !ip2IsIPv4) { - // ipv4 comes first (first item is sorted before second) - return -1; - } else if (!ip1IsIPv4 && ip2IsIPv4) { - // ipv4 comes first (second item is sorted before first) - return 1; - } - - return 0; // should never happen -}; - -const instanceTypeColoring: { [key in InstanceType]: ChalkInstance } = { - h100i: chalk.green, - h100: chalk.cyan, - a100: chalk.magenta, -}; -const colorInstanceType = (instanceType: InstanceType) => - (instanceTypeColoring[instanceType] || chalk.white)(instanceType); - -// -- - -export async function getInstances({ - clusterId, -}: { clusterId?: string }): Promise { - const loggedIn = await isLoggedIn(); - if (!loggedIn) { - logLoginMessageAndQuit(); - } - - const api = await apiClient(); - - const { data, error, response } = await api.GET("/v0/instances"); - - if (!response.ok) { - switch (response.status) { - case 400: - return logAndQuit(`Bad Request: ${error?.message}`); - case 401: - return await logSessionTokenExpiredAndQuit(); - default: - return logAndQuit(`Failed to get instances: ${response.statusText}`); - } - } - - if (!data) { - return logAndQuit( - `Failed to get instances: Unexpected response from server: ${response}`, - ); - } - - return data.data.map((instance) => ({ - object: instance.object, - id: instance.id, - type: instance.type as InstanceType, - public_ip: instance.public_ip, - private_ip: instance.private_ip, - status: instance.status, - ssh_port: instance.ssh_port, - can_connect: instance.can_connect, - })); -} diff --git a/src/lib/instances/InstanceDisplay.tsx b/src/lib/instances/InstanceDisplay.tsx new file mode 100644 index 0000000..1308218 --- /dev/null +++ b/src/lib/instances/InstanceDisplay.tsx @@ -0,0 +1,62 @@ +import { Box, Text } from "ink"; +import type { InstanceObject } from "./types"; +import Spinner from 'ink-spinner' +import { Row } from "../Row"; + +export function InstanceDisplay(props: { instance: InstanceObject }) { + + let status = "loading"; + if (props.instance.status === "running" && props.instance.can_connect) { + status = "ready"; + } + + return ( + + + {status === "loading" && ( + + + + )} + {status === "ready" && ( + + )} + {props.instance.id} + ({props.instance.status}) + + + + + + + + + + + + ); +} + +export function InstanceList(props: { instances: InstanceObject[] }) { + if (props.instances.length === 0) { + return + No instances found, you either haven't bought any, or they haven't started yet. + + + # List contracts you've bought to see when instances will start + sf contracts list + + + + # If you don't have a contract, you can buy one + sf buy + + + } + + return + {props.instances.map((instance) => ( + + ))} + +} \ No newline at end of file diff --git a/src/lib/instances/index.tsx b/src/lib/instances/index.tsx new file mode 100644 index 0000000..a5f99e3 --- /dev/null +++ b/src/lib/instances/index.tsx @@ -0,0 +1,144 @@ +import type { Command } from "commander"; +import { apiClient } from "../../apiClient"; +import { isLoggedIn } from "../../helpers/config"; +import { + logAndQuit, + logLoginMessageAndQuit, + logSessionTokenExpiredAndQuit, +} from "../../helpers/errors"; +import type { InstanceObject, InstanceType } from "./types"; +import { render } from "ink"; +import { InstanceDisplay, InstanceList } from "./InstanceDisplay"; + +export function registerInstances(program: Command) { + const instances = program + .command("instances") + .alias("i") + .alias("instance") + .description("Manage instances you own"); + + // sf instances ls|list + instances + .command("list") + .alias("ls") + .description("List instances") + .option("-cls, --cluster ", "Specify the cluster id") + .option("--json", "Output in JSON format") + .action(async (options) => { + await listInstancesAction({ + clusterId: options.cluster, + returnJson: options.json, + }); + }); +} + +// -- + +async function listInstancesAction({ + clusterId, + returnJson, +}: { clusterId?: string; returnJson?: boolean }) { + const instances = await getInstances({ clusterId }); + if (instances.length !== 0) { + instances.sort(sortInstancesByTypeAndIp()); + } + + if (returnJson) { + console.log(JSON.stringify(instances, null, 2)); + } else { + render(); + } + + process.exit(0); +} + +const InstanceTypeSortPriority: { [key in InstanceType]: number } = { + h100i: 1, + h100: 2, + a100: 3, +}; +const sortInstancesByTypeAndIp = () => { + return (a: InstanceObject, b: InstanceObject) => { + const priorityA = + InstanceTypeSortPriority[a.type] || Number.MAX_SAFE_INTEGER; + const priorityB = + InstanceTypeSortPriority[b.type] || Number.MAX_SAFE_INTEGER; + if (priorityA === priorityB) { + return compareIPs(a.public_ip, b.public_ip); // secondary sort on public ips + } + return priorityA - priorityB; + }; +}; +const compareIPs = (ip1: string, ip2: string) => { + const isIPv4 = (ip: string) => ip.split(".").length === 4; + + const ip1IsIPv4 = isIPv4(ip1); + const ip2IsIPv4 = isIPv4(ip2); + + if (ip1IsIPv4 && ip2IsIPv4) { + // Both are IPv4, proceed with numerical comparison + const segmentsA = ip1.split(".").map(Number); + const segmentsB = ip2.split(".").map(Number); + for (let i = 0; i < Math.min(segmentsA.length, segmentsB.length); i++) { + if (segmentsA[i] !== segmentsB[i]) { + return segmentsA[i] - segmentsB[i]; + } + } + + return segmentsA.length - segmentsB.length; + } else if (!ip1IsIPv4 && !ip2IsIPv4) { + // Both non-ipv4 are equal + return 0; + } else if (ip1IsIPv4 && !ip2IsIPv4) { + // ipv4 comes first (first item is sorted before second) + return -1; + } else if (!ip1IsIPv4 && ip2IsIPv4) { + // ipv4 comes first (second item is sorted before first) + return 1; + } + + return 0; // should never happen +}; + +// -- + +export async function getInstances({ + clusterId, +}: { clusterId?: string }): Promise { + const loggedIn = await isLoggedIn(); + if (!loggedIn) { + logLoginMessageAndQuit(); + } + + const api = await apiClient(); + + const { data, error, response } = await api.GET("/v0/instances"); + + if (!response.ok) { + switch (response.status) { + case 400: + return logAndQuit(`Bad Request: ${error?.message}`); + case 401: + return await logSessionTokenExpiredAndQuit(); + default: + return logAndQuit(`Failed to get instances: ${response.statusText}`); + } + } + + if (!data) { + return logAndQuit( + `Failed to get instances: Unexpected response from server: ${response}`, + ); + } + + return data.data.map((instance) => ({ + object: instance.object, + id: instance.id, + type: instance.type as InstanceType, + public_ip: instance.public_ip, + private_ip: instance.private_ip, + status: instance.status, + ssh_port: instance.ssh_port, + can_connect: instance.can_connect, + })); +} diff --git a/src/lib/instances/types.ts b/src/lib/instances/types.ts new file mode 100644 index 0000000..af951ca --- /dev/null +++ b/src/lib/instances/types.ts @@ -0,0 +1,17 @@ +export interface ListResponseBody { + data: T[]; + object: "list"; +} + +export type InstanceType = "h100i" | "h100" | "a100"; + +export interface InstanceObject { + object: "instance"; + id: string; + type: InstanceType; + public_ip: string; + private_ip: string; + status: string; + ssh_port: number | undefined; + can_connect?: boolean; +} diff --git a/src/lib/orders/OrderDisplay.tsx b/src/lib/orders/OrderDisplay.tsx new file mode 100644 index 0000000..c6fadeb --- /dev/null +++ b/src/lib/orders/OrderDisplay.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from "ink"; +import type { HydratedOrder } from "./types"; +import { GPUS_PER_NODE } from "../constants"; +import dayjs from "dayjs"; +import { formatDuration } from "."; +import { Row } from "../Row"; + + +function Order(props: { order: HydratedOrder }) { + const duration = dayjs(props.order.end_at).diff(props.order.start_at); + const durationInHours = duration === 0 ? 1 : duration / 1000 / 60 / 60; + const pricePerGPUHour = props.order.price * props.order.quantity / GPUS_PER_NODE / durationInHours / 100; + const durationFormatted = formatDuration(duration); + + return ( + + + {props.order.side === "buy" ? "↑" : "↓"} + {props.order.side} + {props.order.id} + ({props.order.status}) + + + + + + + + + ); +} + +export function OrderDisplay(props: { orders: HydratedOrder[] }) { + if (props.orders.length === 0) { + return + No orders found. + + + # View all public standing orders + sf orders list --public + + + + # Place an order to buy compute + sf buy + + + } + + return props.orders.map((order) => { + return ; + }); +} diff --git a/src/lib/orders.ts b/src/lib/orders/index.tsx similarity index 73% rename from src/lib/orders.ts rename to src/lib/orders/index.tsx index 0727fe9..27bbe47 100644 --- a/src/lib/orders.ts +++ b/src/lib/orders/index.tsx @@ -1,16 +1,19 @@ -import Table from "cli-table3"; + import type { Command } from "commander"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import relativeTime from "dayjs/plugin/relativeTime"; -import { getAuthToken, isLoggedIn } from "../helpers/config"; +import { getAuthToken, isLoggedIn } from "../../helpers/config"; import { logAndQuit, logLoginMessageAndQuit, logSessionTokenExpiredAndQuit, -} from "../helpers/errors"; -import { fetchAndHandleErrors } from "../helpers/fetch"; -import { getApiUrl } from "../helpers/urls"; +} from "../../helpers/errors"; +import { fetchAndHandleErrors } from "../../helpers/fetch"; +import { getApiUrl } from "../../helpers/urls"; +import { render, Text } from "ink"; +import { OrderDisplay } from "./OrderDisplay"; +import type { HydratedOrder, ListResponseBody } from "./types"; const usdFormatter = new Intl.NumberFormat("en-US", { style: "currency", @@ -44,115 +47,9 @@ export function formatDuration(ms: number) { return result || "0ms"; } -export type OrderType = "buy" | "sell"; -export enum OrderStatus { - Pending = "pending", - Rejected = "rejected", - Open = "open", - Cancelled = "cancelled", - Filled = "filled", - Expired = "expired", -} -export interface OrderFlags { - market: boolean; - post_only: boolean; - ioc: boolean; - prorate: boolean; -} - -export interface HydratedOrder { - object: "order"; - id: string; - side: OrderType; - instance_type: string; - price: number; - start_at: string; - end_at: string; - quantity: number; - flags: OrderFlags; - created_at: string; - executed: boolean; - execution_price?: number; - cancelled: boolean; - status: OrderStatus; -} - -export type PlaceSellOrderParameters = { - side: "sell"; - quantity: number; - price: number; - start_at: string; - end_at: string; - contract_id: string; -}; - -export type PlaceOrderParameters = { - side: "buy"; - quantity: number; - price: number; - instance_type: string; - duration: number; - start_at: string; -}; - -interface ListResponseBody { - data: T[]; - object: "list"; -} - -function printAsTable(orders: Array) { - orders.sort((a, b) => a.start_at.localeCompare(b.start_at)); - const table = new Table({ - head: [ - "ID", - "Side", - "Type", - "Price", - "Quantity", - "Duration", - "Start", - "Status", - "Execution Price", - ], - }); - for (const order of orders) { - if (order.status === "pending") { - table.push([order.id, "-", "-", "-", "-", "-", "-", order.status]); - } else { - let status: string; - let executionPrice: number | undefined; - if (order.cancelled) { - status = "cancelled"; - } else if (order.executed) { - status = "filled"; - executionPrice = order.execution_price; - } else { - status = order.status; - } - - const startDate = new Date(order.start_at); - const duration = formatDuration( - dayjs(order.end_at).diff(dayjs(startDate), "ms"), - ); - table.push([ - order.id, - order.side, - order.instance_type, - usdFormatter.format(order.price / 100), - order.quantity.toString(), - duration, - startDate.toLocaleString(), - status, - executionPrice ? usdFormatter.format(executionPrice / 100) : "-", - ]); - } - } - - console.log(table.toString() + "\n"); -} export function registerOrders(program: Command) { - const ordersCommand = program.command("orders").description("Manage orders"); + const ordersCommand = program.command("orders").alias("o").alias("order").description("Manage orders"); ordersCommand .command("ls") @@ -204,7 +101,7 @@ export function registerOrders(program: Command) { "--max-fill-price ", "Filter by maximum fill price (in cents)", ) - .option("--exclude-cancelled", "Exclude cancelled orders") + .option("--include-cancelled", "Include cancelled orders") .option("--only-cancelled", "Show only cancelled orders") .option( "--min-cancelled-at ", @@ -252,7 +149,7 @@ export function registerOrders(program: Command) { min_fill_price: options.minFillPrice, max_fill_price: options.maxFillPrice, - exclude_cancelled: options.excludeCancelled, + exclude_cancelled: !options.includeCancelled, only_cancelled: options.onlyCancelled, min_cancelled_at: options.minCancelledAt, max_cancelled_at: options.maxCancelledAt, @@ -267,7 +164,7 @@ export function registerOrders(program: Command) { if (options.json) { console.log(JSON.stringify(orders, null, 2)); } else { - printAsTable(orders); + render(); } process.exit(0); @@ -348,7 +245,7 @@ export async function getOrders(props: { export async function submitOrderCancellationByIdAction( orderId: string, -): Promise { +): Promise { const loggedIn = await isLoggedIn(); if (!loggedIn) { logLoginMessageAndQuit(); diff --git a/src/lib/orders/types.ts b/src/lib/orders/types.ts new file mode 100644 index 0000000..6171deb --- /dev/null +++ b/src/lib/orders/types.ts @@ -0,0 +1,55 @@ +export type OrderType = "buy" | "sell"; +export enum OrderStatus { + Pending = "pending", + Rejected = "rejected", + Open = "open", + Cancelled = "cancelled", + Filled = "filled", + Expired = "expired", +} +export interface OrderFlags { + market: boolean; + post_only: boolean; + ioc: boolean; + prorate: boolean; +} + +export interface HydratedOrder { + object: "order"; + id: string; + side: OrderType; + instance_type: string; + price: number; + start_at: string; + end_at: string; + quantity: number; + flags: OrderFlags; + created_at: string; + executed: boolean; + execution_price?: number; + cancelled: boolean; + status: OrderStatus; +} + +export type PlaceSellOrderParameters = { + side: "sell"; + quantity: number; + price: number; + start_at: string; + end_at: string; + contract_id: string; +}; + +export type PlaceOrderParameters = { + side: "buy"; + quantity: number; + price: number; + instance_type: string; + duration: number; + start_at: string; +}; + +export interface ListResponseBody { + data: T[]; + object: "list"; +}