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";
+}