Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redo buy and sell, add up and down #24

Merged
merged 12 commits into from
Sep 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"inquirer": "^10.1.2",
"node-fetch": "^3.3.2",
"openapi-fetch": "^0.11.1",
"ora": "^8.0.1",
"ora": "^8.1.0",
"parse-duration": "^1.1.0"
},
"devDependencies": {
Expand Down
32 changes: 32 additions & 0 deletions src/helpers/fetchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { apiClient } from "../apiClient";
import { logAndQuit } from "./errors";

export async function getContract(contractId: string) {
const api = await apiClient();
const { data, response } = await api.GET("/v0/contracts/{id}", {
params: {
path: { id: contractId },
},
});
if (!response.ok) {
return logAndQuit(`Failed to get contract: ${response.statusText}`);
}
return data;
}

export async function getOrder(orderId: string) {
const api = await apiClient();
const { data, response, error } = await api.GET("/v0/orders/{id}", {
params: {
path: { id: orderId },
},
});
if (!response.ok) {
// @ts-ignore
if (error?.code === "order.not_found") {
return null;
}
return logAndQuit(`Failed to get order: ${response.statusText}`);
}
return data;
}
7 changes: 7 additions & 0 deletions src/helpers/price.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function pricePerGPUHourToTotalPrice(pricePerGPUHourInCenticents: number, durationSeconds: number, nodes: number, gpusPerNode: number) {
return Math.ceil(pricePerGPUHourInCenticents * durationSeconds / 3600 * nodes * gpusPerNode);
}

export function totalPriceToPricePerGPUHour(totalPriceInCenticents: number, durationSeconds: number, nodes: number, gpusPerNode: number) {
return totalPriceInCenticents / nodes / gpusPerNode / (durationSeconds / 3600);
}
23 changes: 23 additions & 0 deletions src/helpers/waitingForOrder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import ora from "ora";
import chalk from "chalk";
import { getOrder } from "./fetchers";
import { logAndQuit } from "./errors";

export async function waitForOrderToNotBePending(orderId: string) {
const spinner = ora(`Order ${orderId} - pending (this can take a moment)`).start();
const maxTries = 25;
for (let i = 0; i < maxTries; i++) {
const order = await getOrder(orderId);

if (order && order?.status !== "pending") {
spinner.text = `Order ${orderId} - ${order?.status}`;
spinner.succeed();
console.log(chalk.green("Order placed successfully"));
return order;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}

spinner.fail();
logAndQuit(`Order ${orderId} - possibly failed`);
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerOrders } from "./lib/orders";
import { registerSell } from "./lib/sell";
import { registerSSH } from "./lib/ssh";
import { registerTokens } from "./lib/tokens";
import { registerDown, registerUp } from "./lib/updown";
import { registerUpgrade } from "./lib/upgrade";

const program = new Command();
Expand All @@ -32,6 +33,8 @@ registerSell(program);
registerBalance(program);
registerTokens(program);
registerUpgrade(program);
registerUp(program);
registerDown(program);

// (development commands)
registerDev(program);
Expand Down
72 changes: 50 additions & 22 deletions src/lib/buy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ import {
} from "../helpers/units";
import type { Nullable } from "../types/empty";
import { formatDuration } from "./orders";
import { pricePerGPUHourToTotalPrice, totalPriceToPricePerGPUHour } from "../helpers/price";
import { GPUS_PER_NODE } from "./constants";
import { waitForOrderToNotBePending } from "../helpers/waitingForOrder";

dayjs.extend(relativeTime);
dayjs.extend(duration);

interface SfBuyOptions {
type: string;
nodes?: string;
accelerators?: string;
duration: string;
price: string;
start?: string;
Expand All @@ -40,10 +43,10 @@ export function registerBuy(program: Command) {
program
.command("buy")
.description("Place a buy order")
.requiredOption("-t, --type <type>", "Specify the type of node")
.option("-n, --nodes <quantity>", "Specify the number of nodes")
.requiredOption("-t, --type <type>", "Specify the type of node", "h100i")
.option("-n, --accelerators <quantity>", "Specify the number of GPUs", "8")
.requiredOption("-d, --duration <duration>", "Specify the duration", "1h")
.option("-p, --price <price>", "Specify the price")
.option("-p, --price <price>", "The price in dollars, per GPU hour")
.option("-s, --start <start>", "Specify the start date")
.option("-y, --yes", "Automatically confirm the order")
.option("--quote", "Only provide a quote for the order")
Expand All @@ -66,6 +69,15 @@ async function buyOrderAction(options: SfBuyOptions) {
return logAndQuit(`Invalid duration: ${options.duration}`);
}

// 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 priceCenticents: Nullable<Centicents> = null;
if (options.price) {
Expand All @@ -77,6 +89,12 @@ async function buyOrderAction(options: SfBuyOptions) {
priceCenticents = priceParsed;
}

// Convert the price to the total price of the contract
// (price per gpu hour * gpus per node * quantity * duration in hours)
if (priceCenticents) {
priceCenticents = pricePerGPUHourToTotalPrice(priceCenticents, durationSeconds, quantity, GPUS_PER_NODE);
}

const yesFlagOmitted = options.yes === undefined || options.yes === null;
const confirmWithUser = yesFlagOmitted || !options.yes;

Expand All @@ -86,38 +104,30 @@ async function buyOrderAction(options: SfBuyOptions) {
return logAndQuit("Invalid start date");
}

// default to 1 node if not specified
const quantity = options.nodes ? Number(options.nodes) : 1;

if (options.quote) {
const quote = await getQuote({
instanceType: options.type,
priceCenticents,
quantity: quantity,
startsAt: startDate,
durationSeconds,
confirmWithUser,
quoteOnly: isQuoteOnly,
});

if (!quote) {
return logAndQuit("Not enough data exists to quote this order.");
}

const priceLabelUsd = c.green(centicentsToDollarsFormatted(quote.price));
const priceLabelPerGPUHour = c.green(centicentsToDollarsFormatted(totalPriceToPricePerGPUHour(quote.price, durationSeconds, quantity, GPUS_PER_NODE)));

console.log(`This order is projected to cost ${priceLabelUsd}`);
console.log(`This order is projected to cost ${priceLabelUsd} total or ${priceLabelPerGPUHour} per GPU hour`);
} else {
// quote if no price was provided
if (!priceCenticents) {
const quote = await getQuote({
instanceType: options.type,
priceCenticents,
quantity: quantity,
startsAt: startDate,
durationSeconds,
confirmWithUser,
quoteOnly: isQuoteOnly,
});

if (!quote) {
Expand All @@ -142,6 +152,14 @@ async function buyOrderAction(options: SfBuyOptions) {
startDate = new Date(quote.start_at);
}

if (!durationSeconds) {
throw new Error("unexpectly no duration provided");
}

if (!priceCenticents) {
throw new Error("unexpectly no price provided");
}

// round the start and end dates. If we came from a quote, they should already be rounded,
// however, there may have been a delay between the quote and now, so we may need to move the start time up to the next minute
startDate = roundStartDate(startDate);
Expand Down Expand Up @@ -180,7 +198,12 @@ async function buyOrderAction(options: SfBuyOptions) {
quoteOnly: isQuoteOnly,
});

switch (res.status) {
const order = await waitForOrderToNotBePending(res.id);
if (!order) {
return;
}

switch (order.status) {
case "pending": {
const orderId = res.id;
const printOrderNumber = (status: string) =>
Expand Down Expand Up @@ -269,13 +292,21 @@ function confirmPlaceOrderMessage(options: BuyOptions) {
timeDescription = `from ${startAtLabel} (${c.green(fromNowTime)}) until ${endsAtLabel}`;
}

const topLine = `${totalNodesLabel} ${instanceTypeLabel} ${nodesLabel} for ${c.green(durationHumanReadable)} ${timeDescription}`;
const durationInSeconds = options.endsAt.getTime() / 1000 - options.startsAt.getTime() / 1000;
const pricePerGPUHour = totalPriceToPricePerGPUHour(options.priceCenticents, durationInSeconds, options.quantity, GPUS_PER_NODE);
const pricePerHourLabel = c.green(centicentsToDollarsFormatted(pricePerGPUHour));

const topLine = `${totalNodesLabel} ${instanceTypeLabel} ${nodesLabel} (${GPUS_PER_NODE * options.quantity} GPUs) at ${pricePerHourLabel} per GPU hour for ${c.green(durationHumanReadable)} ${timeDescription}`;

const dollarsLabel = c.green(
centicentsToDollarsFormatted(options.priceCenticents),
centicentsToDollarsFormatted(
pricePerGPUHour,
),
);

const priceLine = `\nBuy for ${dollarsLabel}?`;
const gpusLabel = c.green(options.quantity * GPUS_PER_NODE);

const priceLine = `\nBuy ${gpusLabel} GPUs at ${dollarsLabel} per GPU hour?`;

return `${topLine}\n${priceLine} `;
}
Expand Down Expand Up @@ -327,14 +358,11 @@ export async function placeBuyOrder(options: BuyOptions) {

type QuoteOptions = {
instanceType: string;
priceCenticents: Nullable<number>;
quantity: number;
startsAt: Date;
durationSeconds: number;
confirmWithUser: boolean;
quoteOnly: boolean;
};
async function getQuote(options: QuoteOptions) {
export async function getQuote(options: QuoteOptions) {
const api = await apiClient();

const { data, error, response } = await api.GET("/v0/quote", {
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GPUS_PER_NODE = 8;
Loading
Loading