From d0938e9598131b9c28ab3c1b479274d9981e1221 Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Tue, 24 Mar 2026 11:02:56 +0800 Subject: [PATCH 1/6] feat: add `predictions markets` cmd --- src/clients/PredictionsClient.ts | 113 ++++++++++++++++++++++++ src/commands/PredictionsCommand.ts | 136 +++++++++++++++++++++++++++++ src/index.ts | 2 + 3 files changed, 251 insertions(+) create mode 100644 src/clients/PredictionsClient.ts create mode 100644 src/commands/PredictionsCommand.ts diff --git a/src/clients/PredictionsClient.ts b/src/clients/PredictionsClient.ts new file mode 100644 index 0000000..67f8f56 --- /dev/null +++ b/src/clients/PredictionsClient.ts @@ -0,0 +1,113 @@ +import ky from "ky"; + +import { ClientConfig } from "./ClientConfig.ts"; + +export type EventMetadata = { + eventId: string; + title: string; + subtitle: string; + slug: string; + series: string; + closeTime: string; + imageUrl: string; + isLive: boolean; +}; + +export type MarketPricing = { + buyYesPriceUsd: number | null; + buyNoPriceUsd: number | null; + sellYesPriceUsd: number | null; + sellNoPriceUsd: number | null; + volume: number; +}; + +export type MarketMetadata = { + marketId: string; + title: string; + status: string; + result: string; + closeTime: number; + openTime: number; + isTeamMarket: boolean; + rulesPrimary: string; + rulesSecondary: string; +}; + +export type Market = { + marketId: string; + status: "open" | "closed" | "cancelled"; + result: "yes" | "no" | null; + openTime: number; + closeTime: number; + resolveAt: number | null; + marketResultPubkey: string | null; + imageUrl: string | null; + metadata: MarketMetadata; + pricing: MarketPricing; +}; + +export type PredictionEvent = { + eventId: string; + isActive: boolean; + isLive: boolean; + category: string; + subcategory: string; + tags: string[]; + metadata: EventMetadata; + markets: Market[]; + volumeUsd: string; + closeCondition: string; + beginAt: string | null; + rulesPdf: string; +}; + +export type Pagination = { + start: number; + end: number; + total: number; + hasNext: boolean; +}; + +export type GetEventsResponse = { + data: PredictionEvent[]; + pagination: Pagination; +}; + +export class PredictionsClient { + static readonly #ky = ky.create({ + prefixUrl: `${ClientConfig.host}/prediction/v1`, + headers: ClientConfig.headers, + }); + + public static async getEvents(params: { + filter?: string; + sortBy?: string; + sortDirection?: string; + category?: string; + start?: number; + end?: number; + }): Promise { + const searchParams: Record = { + includeMarkets: true, + }; + if (params.filter) { + searchParams.filter = params.filter; + } + if (params.sortBy) { + searchParams.sortBy = params.sortBy; + } + if (params.sortDirection) { + searchParams.sortDirection = params.sortDirection; + } + if (params.category) { + searchParams.category = params.category; + } + if (params.start !== undefined) { + searchParams.start = params.start; + } + if (params.end !== undefined) { + searchParams.end = params.end; + } + return this.#ky.get("events", { searchParams }).json(); + } +} diff --git a/src/commands/PredictionsCommand.ts b/src/commands/PredictionsCommand.ts new file mode 100644 index 0000000..c8166dd --- /dev/null +++ b/src/commands/PredictionsCommand.ts @@ -0,0 +1,136 @@ +import chalk from "chalk"; +import type { Command } from "commander"; + +import { + PredictionsClient, + type PredictionEvent, +} from "../clients/PredictionsClient.ts"; +import { Output } from "../lib/Output.ts"; + +export class PredictionsCommand { + public static register(program: Command): void { + const predictions = program + .command("predictions") + .description("Prediction markets"); + predictions + .command("events") + .description("Browse prediction events") + .option("--filter ", "Filter: new, live, trending") + .option("--sort ", "Sort by: volume, recent", "volume") + .option( + "--category ", + "Category: all, crypto, sports, politics, esports, culture, economics, tech", + "all" + ) + .option("--offset ", "Pagination offset", "0") + .option("--limit ", "Max results", "10") + .action((opts) => this.events(opts)); + } + + private static async events(opts: { + filter?: string; + sort: string; + category: string; + offset: string; + limit: string; + }): Promise { + const start = Number(opts.offset); + const limit = Number(opts.limit); + const end = start + limit; + + const sortBy = opts.sort === "recent" ? "beginAt" : "volume24hr"; + const sortDirection = opts.sort === "recent" ? "desc" : undefined; + + const res = await PredictionsClient.getEvents({ + filter: opts.filter, + sortBy, + sortDirection, + category: opts.category, + start, + end, + }); + + const events = res.data.map((e) => ({ + id: e.eventId, + title: e.metadata.title, + category: e.category, + isLive: e.isLive, + volumeUsd: Number(e.volumeUsd) / 1e6, + startsAt: e.beginAt + ? new Date(Number(e.beginAt) * 1000).toISOString() + : null, + endsAt: e.metadata.closeTime + ? new Date(e.metadata.closeTime).toISOString() + : null, + markets: (e.markets ?? []).map((m) => ({ + id: m.marketId, + title: m.metadata.title, + status: m.status, + yesPriceUsd: m.pricing.buyYesPriceUsd + ? m.pricing.buyYesPriceUsd / 1e6 + : null, + noPriceUsd: m.pricing.buyNoPriceUsd + ? m.pricing.buyNoPriceUsd / 1e6 + : null, + result: m.result, + })), + })); + + if (Output.isJson()) { + const json: Record = { events }; + if (res.pagination.hasNext) { + json.next = res.pagination.end; + } + Output.json(json); + return; + } + + if (events.length === 0) { + console.log("No events found."); + return; + } + + for (const event of events) { + const startsAt = event.startsAt ? this.formatDate(event.startsAt) : "???"; + const endsAt = event.endsAt ? this.formatDate(event.endsAt) : "???"; + const dateRange = ` (${startsAt} — ${endsAt})`; + console.log(chalk.bold(event.title) + chalk.gray(dateRange)); + console.log(`Vol: ${Output.formatDollar(event.volumeUsd)}`); + + if (event.markets.length > 0) { + Output.table({ + type: "horizontal", + headers: { + title: "Market", + yes: "Yes", + no: "No", + id: "ID", + }, + rows: event.markets.map((m) => ({ + title: m.title, + yes: this.formatPricePct(m.yesPriceUsd), + no: this.formatPricePct(m.noPriceUsd), + id: m.id, + })), + }); + } + + console.log(); + } + + if (res.pagination.hasNext) { + console.log("Next offset:", res.pagination.end); + } + } + + private static formatPricePct(price: number | null): string { + if (price === null || price === undefined) { + return chalk.gray("\u2014"); + } + return `${Math.round(price * 100)}%`; + } + + private static formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(); + } +} diff --git a/src/index.ts b/src/index.ts index 7f79d63..69b497f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ConfigCommand } from "./commands/ConfigCommand.ts"; import { KeysCommand } from "./commands/KeysCommand.ts"; import { LendCommand } from "./commands/LendCommand.ts"; import { PerpsCommand } from "./commands/PerpsCommand.ts"; +import { PredictionsCommand } from "./commands/PredictionsCommand.ts"; import { SpotCommand } from "./commands/SpotCommand.ts"; import { UpdateCommand } from "./commands/UpdateCommand.ts"; @@ -34,6 +35,7 @@ ConfigCommand.register(program); KeysCommand.register(program); LendCommand.register(program); PerpsCommand.register(program); +PredictionsCommand.register(program); SpotCommand.register(program); UpdateCommand.register(program); From 60f273fb4d547f9eea018327536dfb2e21058515 Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Tue, 24 Mar 2026 16:18:15 +0800 Subject: [PATCH 2/6] feat: add predictions positions and open cmd --- src/clients/PerpsClient.ts | 11 +- src/clients/PredictionsClient.ts | 94 +++++++++++++ src/commands/PerpsCommand.ts | 94 +++++++------ src/commands/PredictionsCommand.ts | 206 ++++++++++++++++++++++++++++- src/commands/SpotCommand.ts | 20 +-- src/lib/Asset.ts | 40 +++++- src/lib/NumberConverter.ts | 7 + 7 files changed, 389 insertions(+), 83 deletions(-) diff --git a/src/clients/PerpsClient.ts b/src/clients/PerpsClient.ts index aafe771..e7fbf6c 100644 --- a/src/clients/PerpsClient.ts +++ b/src/clients/PerpsClient.ts @@ -1,7 +1,6 @@ import ky from "ky"; -import { Asset, resolveAsset } from "../lib/Asset.ts"; -import { NumberConverter } from "../lib/NumberConverter.ts"; +import { resolveAsset } from "../lib/Asset.ts"; import { ClientConfig } from "./ClientConfig.ts"; export type MarketStatsResponse = { @@ -238,14 +237,6 @@ export class PerpsClient { headers: ClientConfig.headers, }); - public static toUsdRaw(amount: string): string { - return NumberConverter.toChainAmount(amount, Asset.USDC.decimals); - } - - public static fromUsdRaw(amount: string): string { - return NumberConverter.fromChainAmount(amount, Asset.USDC.decimals); - } - public static async getMarkets(): Promise< ({ asset: string } & MarketStatsResponse)[] > { diff --git a/src/clients/PredictionsClient.ts b/src/clients/PredictionsClient.ts index 67f8f56..2bd7ece 100644 --- a/src/clients/PredictionsClient.ts +++ b/src/clients/PredictionsClient.ts @@ -73,6 +73,69 @@ export type GetEventsResponse = { pagination: Pagination; }; +export type PredictionPosition = { + pubkey: string; + ownerPubkey: string; + marketId: string; + isYes: boolean; + contracts: string; + totalCostUsd: number; + valueUsd: number; + pnlUsd: number; + pnlUsdPercent: number; + claimed: boolean; + claimedUsd: number; + openedAt: string; + updatedAt: string; + eventMetadata: { title: string }; + marketMetadata: { + title: string; + status: string; + result: "yes" | "no" | null; + }; +}; + +export type GetPositionsResponse = { + data: PredictionPosition[]; + pagination: Pagination; +}; + +export type OrderDetails = { + orderPubkey: string; + orderAtaPubkey: string; + userPubkey: string; + marketId: string; + marketIdHash: string; + positionPubkey: string; + isBuy: boolean; + isYes: boolean; + contracts: string; + newContracts: string; + maxBuyPriceUsd: string | null; + minSellPriceUsd: string | null; + externalOrderId: string; + orderCostUsd: string; + newAvgPriceUsd: string; + newSizeUsd: string; + newPayoutUsd: string; + estimatedProtocolFeeUsd: string; + estimatedVenueFeeUsd: string; + estimatedIntegratorFeeUsd: string; + estimatedTotalFeeUsd: string; +}; + +export type CreateOrderResponse = { + transaction: string; + txMeta: { blockhash: string; lastValidBlockHeight: string }; + externalOrderId: string; + requiredSigners: string[]; + order: OrderDetails; +}; + +export type ExecuteOrderResponse = { + signature: string; +}; + export class PredictionsClient { static readonly #ky = ky.create({ prefixUrl: `${ClientConfig.host}/prediction/v1`, @@ -110,4 +173,35 @@ export class PredictionsClient { } return this.#ky.get("events", { searchParams }).json(); } + + public static async getPositions( + ownerPubkey: string + ): Promise { + return this.#ky.get("positions", { searchParams: { ownerPubkey } }).json(); + } + + public static async getPosition( + positionPubkey: string + ): Promise { + return this.#ky.get(`positions/${positionPubkey}`).json(); + } + + public static async postOrder(req: { + isBuy: boolean; + ownerPubkey: string; + marketId: string; + isYes: boolean; + depositAmount: string; + depositMint?: string; + }): Promise { + return this.#ky + .post("orders", { json: { ...req, skipSigning: true } }) + .json(); + } + + public static async postExecute(req: { + signedTransaction: string; + }): Promise { + return this.#ky.post("orders/execute", { json: req }).json(); + } } diff --git a/src/commands/PerpsCommand.ts b/src/commands/PerpsCommand.ts index cd7f4e3..40ecafe 100644 --- a/src/commands/PerpsCommand.ts +++ b/src/commands/PerpsCommand.ts @@ -133,18 +133,18 @@ export class PerpsCommand { asset: p.asset, side: p.side, leverage: Number(p.leverage), - sizeUsd: Number(PerpsClient.fromUsdRaw(p.sizeUsd)), - entryPriceUsd: Number(PerpsClient.fromUsdRaw(p.entryPriceUsd)), - markPriceUsd: Number(PerpsClient.fromUsdRaw(p.markPriceUsd)), + sizeUsd: NumberConverter.fromMicroUsd(p.sizeUsd), + entryPriceUsd: NumberConverter.fromMicroUsd(p.entryPriceUsd), + markPriceUsd: NumberConverter.fromMicroUsd(p.markPriceUsd), pnlPct: Number(p.pnlAfterFeesPct), - liquidationPriceUsd: Number( - PerpsClient.fromUsdRaw(p.liquidationPriceUsd) + liquidationPriceUsd: NumberConverter.fromMicroUsd( + p.liquidationPriceUsd ), tpsl: p.tpslRequests.map((t) => ({ pubkey: t.positionRequestPubkey, type: t.requestType, triggerPriceUsd: t.triggerPriceUsd - ? Number(PerpsClient.fromUsdRaw(t.triggerPriceUsd)) + ? NumberConverter.fromMicroUsd(t.triggerPriceUsd) : null, })), })), @@ -152,9 +152,9 @@ export class PerpsCommand { orderPubkey: o.positionRequestPubkey, asset: mintToName.get(o.marketMint) ?? o.marketMint, side: o.side, - sizeUsd: Number(PerpsClient.fromUsdRaw(o.sizeUsdDelta)), + sizeUsd: NumberConverter.fromMicroUsd(o.sizeUsdDelta), triggerPriceUsd: o.triggerPrice - ? Number(PerpsClient.fromUsdRaw(o.triggerPrice)) + ? NumberConverter.fromMicroUsd(o.triggerPrice) : null, })), }); @@ -174,20 +174,18 @@ export class PerpsCommand { }, { label: "Size", - value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(p.sizeUsd)) - ), + value: Output.formatDollar(NumberConverter.fromMicroUsd(p.sizeUsd)), }, { label: "Entry Price", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(p.entryPriceUsd)) + NumberConverter.fromMicroUsd(p.entryPriceUsd) ), }, { label: "Mark Price", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(p.markPriceUsd)) + NumberConverter.fromMicroUsd(p.markPriceUsd) ), }, { @@ -197,19 +195,19 @@ export class PerpsCommand { { label: "Liq. Price", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(p.liquidationPriceUsd)) + NumberConverter.fromMicroUsd(p.liquidationPriceUsd) ), }, { label: "TP", value: tp - ? `${Output.formatDollar(tp.triggerPriceUsd ? Number(PerpsClient.fromUsdRaw(tp.triggerPriceUsd)) : undefined)} ${chalk.gray(`(${tp.positionRequestPubkey})`)}` + ? `${Output.formatDollar(tp.triggerPriceUsd ? NumberConverter.fromMicroUsd(tp.triggerPriceUsd) : undefined)} ${chalk.gray(`(${tp.positionRequestPubkey})`)}` : Output.formatDollar(undefined), }, { label: "SL", value: sl - ? `${Output.formatDollar(sl.triggerPriceUsd ? Number(PerpsClient.fromUsdRaw(sl.triggerPriceUsd)) : undefined)} ${chalk.gray(`(${sl.positionRequestPubkey})`)}` + ? `${Output.formatDollar(sl.triggerPriceUsd ? NumberConverter.fromMicroUsd(sl.triggerPriceUsd) : undefined)} ${chalk.gray(`(${sl.positionRequestPubkey})`)}` : Output.formatDollar(undefined), }, ]; @@ -237,11 +235,11 @@ export class PerpsCommand { mintToName.get(o.marketMint) ?? o.marketMint.slice(0, 8) + "...", side: o.side, size: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(o.sizeUsdDelta)) + NumberConverter.fromMicroUsd(o.sizeUsdDelta) ), trigger: Output.formatDollar( o.triggerPrice - ? Number(PerpsClient.fromUsdRaw(o.triggerPrice)) + ? NumberConverter.fromMicroUsd(o.triggerPrice) : undefined ), pubkey: o.positionRequestPubkey, @@ -328,12 +326,12 @@ export class PerpsCommand { inputDecimals ); const sizeUsdDelta = opts.size - ? PerpsClient.toUsdRaw(opts.size) + ? NumberConverter.toMicroUsd(opts.size) : undefined; if (opts.limit) { // Limit order - const triggerPrice = PerpsClient.toUsdRaw(opts.limit); + const triggerPrice = NumberConverter.toMicroUsd(opts.limit); const res = await PerpsClient.postLimitOrder({ asset, inputToken, @@ -361,7 +359,7 @@ export class PerpsCommand { asset, side, triggerPriceUsd: Number(opts.limit), - sizeUsd: Number(PerpsClient.fromUsdRaw(res.quote.sizeUsdDelta)), + sizeUsd: NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta), leverage: Number(res.quote.leverage), signature: result.txid, }); @@ -381,7 +379,7 @@ export class PerpsCommand { { label: "Size", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.sizeUsdDelta)) + NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta) ), }, { label: "Leverage", value: `${res.quote.leverage}x` }, @@ -398,14 +396,14 @@ export class PerpsCommand { if (opts.tp) { tpsl.push({ receiveToken: inputToken, - triggerPrice: PerpsClient.toUsdRaw(opts.tp), + triggerPrice: NumberConverter.toMicroUsd(opts.tp), requestType: "tp", }); } if (opts.sl) { tpsl.push({ receiveToken: inputToken, - triggerPrice: PerpsClient.toUsdRaw(opts.sl), + triggerPrice: NumberConverter.toMicroUsd(opts.sl), requestType: "sl", }); } @@ -434,15 +432,15 @@ export class PerpsCommand { positionPubkey: res.positionPubkey, asset, side, - entryPriceUsd: Number( - PerpsClient.fromUsdRaw(res.quote.averagePriceUsd) + entryPriceUsd: NumberConverter.fromMicroUsd( + res.quote.averagePriceUsd ), - sizeUsd: Number(PerpsClient.fromUsdRaw(res.quote.sizeUsdDelta)), + sizeUsd: NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta), leverage: Number(res.quote.leverage), - liquidationPriceUsd: Number( - PerpsClient.fromUsdRaw(res.quote.liquidationPriceUsd) + liquidationPriceUsd: NumberConverter.fromMicroUsd( + res.quote.liquidationPriceUsd ), - openFeeUsd: Number(PerpsClient.fromUsdRaw(res.quote.openFeeUsd)), + openFeeUsd: NumberConverter.fromMicroUsd(res.quote.openFeeUsd), signature: result.txid, }); return; @@ -457,26 +455,26 @@ export class PerpsCommand { { label: "Entry Price", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.averagePriceUsd)) + NumberConverter.fromMicroUsd(res.quote.averagePriceUsd) ), }, { label: "Size", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.sizeUsdDelta)) + NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta) ), }, { label: "Leverage", value: `${res.quote.leverage}x` }, { label: "Liq. Price", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.liquidationPriceUsd)) + NumberConverter.fromMicroUsd(res.quote.liquidationPriceUsd) ), }, { label: "Open Fee", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.openFeeUsd)) + NumberConverter.fromMicroUsd(res.quote.openFeeUsd) ), }, { label: "Tx Signature", value: result.txid }, @@ -513,7 +511,7 @@ export class PerpsCommand { // Update limit order trigger price const res = await PerpsClient.patchLimitOrder({ positionRequestPubkey: opts.order, - triggerPrice: PerpsClient.toUsdRaw(opts.limit!), + triggerPrice: NumberConverter.toMicroUsd(opts.limit!), }); if (!res.serializedTxBase64) { throw new Error("API returned no transaction for limit order update."); @@ -581,7 +579,7 @@ export class PerpsCommand { // Update existing const res = await PerpsClient.patchTpsl({ positionRequestPubkey: existing.positionRequestPubkey, - triggerPrice: PerpsClient.toUsdRaw(price), + triggerPrice: NumberConverter.toMicroUsd(price), }); const result = await this.signAndExecute( signer, @@ -602,7 +600,7 @@ export class PerpsCommand { tpsl: [ { receiveToken: position.collateralToken, - triggerPrice: PerpsClient.toUsdRaw(price), + triggerPrice: NumberConverter.toMicroUsd(price), requestType: type, entirePosition: true, }, @@ -739,7 +737,9 @@ export class PerpsCommand { const res = await PerpsClient.postDecreasePosition({ positionPubkey: opts.position!, receiveToken, - sizeUsdDelta: opts.size ? PerpsClient.toUsdRaw(opts.size) : undefined, + sizeUsdDelta: opts.size + ? NumberConverter.toMicroUsd(opts.size) + : undefined, entirePosition: entirePosition || undefined, maxSlippageBps: opts.slippage, }); @@ -760,14 +760,12 @@ export class PerpsCommand { Output.json({ action: entirePosition ? "close-position" : "decrease-position", positionPubkey: res.positionPubkey, - sizeReducedUsd: Number(PerpsClient.fromUsdRaw(res.quote.sizeUsdDelta)), - pnlUsd: Number(PerpsClient.fromUsdRaw(res.quote.pnlAfterFeesUsd)), + sizeReducedUsd: NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta), + pnlUsd: NumberConverter.fromMicroUsd(res.quote.pnlAfterFeesUsd), pnlPct: Number(res.quote.pnlAfterFeesPercent), received: `${receivedAmount} ${receiveToken}`, - receivedUsd: Number( - PerpsClient.fromUsdRaw(res.quote.transferAmountUsd) - ), - feesUsd: Number(PerpsClient.fromUsdRaw(res.quote.totalFeeUsd)), + receivedUsd: NumberConverter.fromMicroUsd(res.quote.transferAmountUsd), + feesUsd: NumberConverter.fromMicroUsd(res.quote.totalFeeUsd), signature: result.txid, }); return; @@ -783,21 +781,21 @@ export class PerpsCommand { { label: "Size Reduced", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.sizeUsdDelta)) + NumberConverter.fromMicroUsd(res.quote.sizeUsdDelta) ), }, { label: "PnL", - value: `${Output.formatDollar(Number(PerpsClient.fromUsdRaw(res.quote.pnlAfterFeesUsd)))} (${Output.formatPercentageChange(Number(res.quote.pnlAfterFeesPercent))})`, + value: `${Output.formatDollar(NumberConverter.fromMicroUsd(res.quote.pnlAfterFeesUsd))} (${Output.formatPercentageChange(Number(res.quote.pnlAfterFeesPercent))})`, }, { label: "Received", - value: `${receivedAmount} ${receiveToken} (${Output.formatDollar(Number(PerpsClient.fromUsdRaw(res.quote.transferAmountUsd)))})`, + value: `${receivedAmount} ${receiveToken} (${Output.formatDollar(NumberConverter.fromMicroUsd(res.quote.transferAmountUsd))})`, }, { label: "Fees", value: Output.formatDollar( - Number(PerpsClient.fromUsdRaw(res.quote.totalFeeUsd)) + NumberConverter.fromMicroUsd(res.quote.totalFeeUsd) ), }, { label: "Tx Signature", value: result.txid }, diff --git a/src/commands/PredictionsCommand.ts b/src/commands/PredictionsCommand.ts index c8166dd..3cfd41f 100644 --- a/src/commands/PredictionsCommand.ts +++ b/src/commands/PredictionsCommand.ts @@ -1,11 +1,13 @@ +import type { Base64EncodedBytes } from "@solana/kit"; import chalk from "chalk"; import type { Command } from "commander"; -import { - PredictionsClient, - type PredictionEvent, -} from "../clients/PredictionsClient.ts"; +import { PredictionsClient } from "../clients/PredictionsClient.ts"; +import { resolveWalletAsset } from "../lib/Asset.ts"; +import { Config } from "../lib/Config.ts"; +import { NumberConverter } from "../lib/NumberConverter.ts"; import { Output } from "../lib/Output.ts"; +import { Signer } from "../lib/Signer.ts"; export class PredictionsCommand { public static register(program: Command): void { @@ -25,6 +27,28 @@ export class PredictionsCommand { .option("--offset ", "Pagination offset", "0") .option("--limit ", "Max results", "10") .action((opts) => this.events(opts)); + predictions + .command("positions") + .description("View open prediction positions") + .option("--position ", "Look up a single position by pubkey") + .option("--key ", "Key to use (overrides active key)") + .option("--address
", "Wallet address to look up") + .action((opts) => this.positions(opts)); + predictions + .command("open") + .description("Open a prediction position") + .requiredOption( + "--market ", + "Market ID from predictions events" + ) + .requiredOption("--side ", "Side: yes, no, y, n") + .requiredOption( + "--amount ", + "Input token amount (human-readable)" + ) + .option("--input ", "Input token (symbol or mint)", "USDC") + .option("--key ", "Key to use for signing") + .action((opts) => this.open(opts)); } private static async events(opts: { @@ -55,7 +79,7 @@ export class PredictionsCommand { title: e.metadata.title, category: e.category, isLive: e.isLive, - volumeUsd: Number(e.volumeUsd) / 1e6, + volumeUsd: NumberConverter.fromMicroUsd(e.volumeUsd), startsAt: e.beginAt ? new Date(Number(e.beginAt) * 1000).toISOString() : null, @@ -67,10 +91,10 @@ export class PredictionsCommand { title: m.metadata.title, status: m.status, yesPriceUsd: m.pricing.buyYesPriceUsd - ? m.pricing.buyYesPriceUsd / 1e6 + ? NumberConverter.fromMicroUsd(m.pricing.buyYesPriceUsd) : null, noPriceUsd: m.pricing.buyNoPriceUsd - ? m.pricing.buyNoPriceUsd / 1e6 + ? NumberConverter.fromMicroUsd(m.pricing.buyNoPriceUsd) : null, result: m.result, })), @@ -123,6 +147,174 @@ export class PredictionsCommand { } } + private static normalizeSide(side: string): "yes" | "no" { + const s = side.toLowerCase(); + if (s === "yes" || s === "y") { + return "yes"; + } + if (s === "no" || s === "n") { + return "no"; + } + throw new Error("Invalid --side. Must be yes, no, y, or n."); + } + + private static async positions(opts: { + position?: string; + key?: string; + address?: string; + }): Promise { + if (opts.position && (opts.key || opts.address)) { + throw new Error("--position cannot be combined with --key or --address."); + } + if (opts.address && opts.key) { + throw new Error("Only one of --address or --key can be provided."); + } + + let data; + if (opts.position) { + const p = await PredictionsClient.getPosition(opts.position); + data = [p]; + } else { + const address = + opts.address ?? + (await Signer.load(opts.key ?? Config.load().activeKey)).address; + const res = await PredictionsClient.getPositions(address); + data = res.data; + } + + const positions = data.map((p) => ({ + positionPubkey: p.pubkey, + event: p.eventMetadata.title, + market: p.marketMetadata.title, + side: p.isYes ? "yes" : "no", + contracts: Number(p.contracts), + costUsd: NumberConverter.fromMicroUsd(p.totalCostUsd), + valueUsd: NumberConverter.fromMicroUsd(p.valueUsd), + pnlUsd: NumberConverter.fromMicroUsd(p.pnlUsd), + pnlPct: p.pnlUsdPercent, + claimable: + !p.claimed && + p.marketMetadata.result !== null && + p.marketMetadata.result === (p.isYes ? "yes" : "no"), + })); + + if (Output.isJson()) { + Output.json({ count: positions.length, positions }); + return; + } + + if (positions.length === 0) { + console.log("No open positions."); + return; + } + + Output.table({ + type: "horizontal", + headers: { + event: "Event", + market: "Market", + side: "Side", + costUsd: "Cost", + valueUsd: "Value", + pnl: "PnL", + claimable: "Claimable", + positionPubkey: "Position", + }, + rows: positions.map((p) => { + const sideColor = p.side === "yes" ? chalk.green : chalk.red; + return { + event: p.event, + market: p.market, + side: sideColor(p.side), + costUsd: Output.formatDollar(p.costUsd), + valueUsd: Output.formatDollar(p.valueUsd), + pnl: `${Output.formatDollarChange(p.pnlUsd)} (${Output.formatPercentageChange(p.pnlPct)})`, + claimable: Output.formatBoolean(p.claimable), + positionPubkey: p.positionPubkey, + }; + }), + }); + } + + private static async open(opts: { + market: string; + side: string; + amount: string; + input: string; + key?: string; + }): Promise { + const side = this.normalizeSide(opts.side); + const isYes = side === "yes"; + const signer = await Signer.load(opts.key ?? Config.load().activeKey); + const token = await resolveWalletAsset(signer.address, opts.input); + const depositAmount = NumberConverter.toChainAmount( + opts.amount, + token.decimals + ); + + const res = await PredictionsClient.postOrder({ + isBuy: true, + ownerPubkey: signer.address, + marketId: opts.market, + isYes, + depositAmount, + depositMint: token.id, + }); + + const signedTx = await signer.signTransaction( + res.transaction as Base64EncodedBytes + ); + const result = await PredictionsClient.postExecute({ + signedTransaction: signedTx, + }); + + const { order } = res; + const contracts = Number(order.contracts); + const costUsd = NumberConverter.fromMicroUsd(order.orderCostUsd); + const feeUsd = NumberConverter.fromMicroUsd(order.estimatedTotalFeeUsd); + const positionAvgPriceUsd = NumberConverter.fromMicroUsd( + order.newAvgPriceUsd + ); + const positionPayoutUsd = NumberConverter.fromMicroUsd(order.newPayoutUsd); + + if (Output.isJson()) { + Output.json({ + action: "open", + marketId: opts.market, + side, + contracts, + costUsd, + feeUsd, + positionAvgPriceUsd, + positionPayoutUsd, + positionPubkey: order.positionPubkey, + signature: result.signature, + }); + return; + } + + Output.table({ + type: "vertical", + rows: [ + { label: "Action", value: "Open Position" }, + { label: "Market", value: opts.market }, + { label: "Side", value: side }, + { label: "Cost", value: Output.formatDollar(costUsd) }, + { label: "Fee", value: Output.formatDollar(feeUsd) }, + { + label: "Position Avg Price", + value: Output.formatDollar(positionAvgPriceUsd), + }, + { + label: "Position Payout", + value: Output.formatDollar(positionPayoutUsd), + }, + { label: "Position", value: order.positionPubkey }, + { label: "Tx Signature", value: result.signature }, + ], + }); + } + private static formatPricePct(price: number | null): string { if (price === null || price === undefined) { return chalk.gray("\u2014"); diff --git a/src/commands/SpotCommand.ts b/src/commands/SpotCommand.ts index e724609..1394762 100644 --- a/src/commands/SpotCommand.ts +++ b/src/commands/SpotCommand.ts @@ -13,7 +13,7 @@ import { type GetHoldingsResponse, type HoldingsTokenAccount, } from "../clients/UltraClient.ts"; -import { Asset } from "../lib/Asset.ts"; +import { Asset, resolveWalletAsset } from "../lib/Asset.ts"; import { Config } from "../lib/Config.ts"; import { NumberConverter } from "../lib/NumberConverter.ts"; import { Output } from "../lib/Output.ts"; @@ -234,7 +234,7 @@ export class SpotCommand { const settings = Config.load(); const signer = await Signer.load(opts.key ?? settings.activeKey); const [inputToken, outputToken] = await Promise.all([ - this.resolveWalletToken(opts.from, signer.address), + resolveWalletAsset(signer.address, opts.from), DatapiClient.resolveToken(opts.to), ]); @@ -450,7 +450,7 @@ export class SpotCommand { const settings = Config.load(); const signer = await Signer.load(opts.key ?? settings.activeKey); - const token = await this.resolveWalletToken(opts.token, signer.address); + const token = await resolveWalletAsset(signer.address, opts.token); const multiplier = Swap.getScaledUiMultiplier(token); const chainAmount = opts.rawAmount ?? @@ -801,20 +801,6 @@ export class SpotCommand { }); } - private static async resolveWalletToken( - input: string, - walletAddress: string - ): Promise { - if (isAddress(input)) { - return DatapiClient.resolveToken(input); - } - const holdings = await UltraClient.getHoldings(walletAddress); - return this.resolveTokenFromHoldings( - input, - this.getHoldingsMints(holdings) - ); - } - private static async resolveTokenFromHoldings( input: string, holdingsMints: string[] diff --git a/src/lib/Asset.ts b/src/lib/Asset.ts index b5871cd..dceb3fd 100644 --- a/src/lib/Asset.ts +++ b/src/lib/Asset.ts @@ -1,4 +1,7 @@ -import { address } from "@solana/kit"; +import { address, isAddress } from "@solana/kit"; + +import { DatapiClient, type Token } from "../clients/DatapiClient.ts"; +import { UltraClient } from "../clients/UltraClient.ts"; type AssetInfo = { readonly id: ReturnType; @@ -34,3 +37,38 @@ export function resolveAsset(name: string): (typeof Asset)[keyof typeof Asset] { } return asset; } + +export async function resolveWalletAsset( + walletAddress: string, + asset: string +): Promise { + if (isAddress(asset)) { + return DatapiClient.resolveToken(asset); + } + + const key = asset.toUpperCase(); + // Prevent full holdings lookup for well-known tokens + if (key in Asset) { + return DatapiClient.resolveToken(Asset[key as keyof typeof Asset].id); + } + + const holdings = await UltraClient.getHoldings(walletAddress); + const mints = Object.keys(holdings.tokens); + if (BigInt(holdings.amount) > 0n && !mints.includes(Asset.SOL.id)) { + mints.push(Asset.SOL.id); + } + const tokens = await DatapiClient.getTokensByMints(mints); + const query = asset.toLowerCase(); + const matches = tokens.filter((t) => t.symbol.toLowerCase() === query); + + if (matches.length === 0) { + throw new Error(`Token "${asset}" not found in wallet.`); + } + if (matches.length === 1) { + return matches[0]!; + } + const options = matches.map((t) => ` - ${t.symbol} (${t.id})`).join("\n"); + throw new Error( + `Multiple tokens matching "${asset}" found in wallet. Use the mint address instead:\n${options}` + ); +} diff --git a/src/lib/NumberConverter.ts b/src/lib/NumberConverter.ts index de96516..3232c81 100644 --- a/src/lib/NumberConverter.ts +++ b/src/lib/NumberConverter.ts @@ -81,4 +81,11 @@ export class NumberConverter { ); } + public static fromMicroUsd(amount: string | number): number { + return Number(this.fromChainAmount(amount.toString(), 6)); + } + + public static toMicroUsd(amount: string): string { + return this.toChainAmount(amount, 6); + } } From 05ba6a00c31820cc71c7754eca61579fc8601f80 Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Tue, 24 Mar 2026 17:13:59 +0800 Subject: [PATCH 3/6] feat: add predictions close and history cmd --- src/clients/PredictionsClient.ts | 101 ++++++++++ src/commands/PerpsCommand.ts | 8 +- src/commands/PredictionsCommand.ts | 284 +++++++++++++++++++++++++++-- 3 files changed, 379 insertions(+), 14 deletions(-) diff --git a/src/clients/PredictionsClient.ts b/src/clients/PredictionsClient.ts index 2bd7ece..c1dfecc 100644 --- a/src/clients/PredictionsClient.ts +++ b/src/clients/PredictionsClient.ts @@ -136,6 +136,62 @@ export type ExecuteOrderResponse = { signature: string; }; +export type ClaimPositionResponse = { + transaction: string; + txMeta: { blockhash: string; lastValidBlockHeight: string }; + position: { + positionPubkey: string; + marketPubkey: string; + userPubkey: string; + ownerPubkey: string; + isYes: boolean; + contracts: string; + payoutAmountUsd: string; + }; +}; + +export type CloseAllPositionsResponse = { + data: (CreateOrderResponse | ClaimPositionResponse)[]; +}; + +export type HistoryEvent = { + id: number; + eventType: string; + signature: string; + slot: string; + timestamp: number; + orderPubkey: string; + positionPubkey: string; + marketId: string; + ownerPubkey: string; + keeperPubkey: string; + isBuy: boolean; + isYes: boolean; + contracts: string; + filledContracts: string; + maxFillPriceUsd: string; + avgFillPriceUsd: string; + realizedPnl: string | null; + payoutAmountUsd: string; + marketMetadata: { + title: string; + status: string; + result: string | null; + }; + eventMetadata: { + title: string; + subtitle: string; + slug: string; + imageUrl: string; + isLive: boolean; + }; +}; + +export type GetHistoryResponse = { + data: HistoryEvent[]; + pagination: Pagination; +}; + export class PredictionsClient { static readonly #ky = ky.create({ prefixUrl: `${ClientConfig.host}/prediction/v1`, @@ -204,4 +260,49 @@ export class PredictionsClient { }): Promise { return this.#ky.post("orders/execute", { json: req }).json(); } + + public static async closePosition( + positionPubkey: string, + ownerPubkey: string + ): Promise { + return this.#ky + .delete(`positions/${positionPubkey}`, { json: { ownerPubkey } }) + .json(); + } + + public static async closeAllPositions( + ownerPubkey: string + ): Promise { + return this.#ky + .delete("positions", { + json: { ownerPubkey, minSellPriceSlippageBps: 200 }, + }) + .json(); + } + + public static async getHistory(params: { + ownerPubkey: string; + start?: number; + end?: number; + }): Promise { + const searchParams: Record = { + ownerPubkey: params.ownerPubkey, + }; + if (params.start !== undefined) { + searchParams.start = params.start; + } + if (params.end !== undefined) { + searchParams.end = params.end; + } + return this.#ky.get("history", { searchParams }).json(); + } + + public static async claimPosition( + positionPubkey: string, + ownerPubkey: string + ): Promise { + return this.#ky + .post(`positions/${positionPubkey}/claim`, { json: { ownerPubkey } }) + .json(); + } } diff --git a/src/commands/PerpsCommand.ts b/src/commands/PerpsCommand.ts index 40ecafe..30780c1 100644 --- a/src/commands/PerpsCommand.ts +++ b/src/commands/PerpsCommand.ts @@ -714,9 +714,11 @@ export class PerpsCommand { return; } - console.log( - `Closed ${sigs.length} position(s):\n${sigs.map((t) => ` ${t}`).join("\n")}` - ); + Output.table({ + type: "horizontal", + headers: { signature: "Tx Signature" }, + rows: sigs.map((s) => ({ signature: s })), + }); return; } diff --git a/src/commands/PredictionsCommand.ts b/src/commands/PredictionsCommand.ts index 3cfd41f..103c6be 100644 --- a/src/commands/PredictionsCommand.ts +++ b/src/commands/PredictionsCommand.ts @@ -2,7 +2,11 @@ import type { Base64EncodedBytes } from "@solana/kit"; import chalk from "chalk"; import type { Command } from "commander"; -import { PredictionsClient } from "../clients/PredictionsClient.ts"; +import { + PredictionsClient, + type ClaimPositionResponse, + type CreateOrderResponse, +} from "../clients/PredictionsClient.ts"; import { resolveWalletAsset } from "../lib/Asset.ts"; import { Config } from "../lib/Config.ts"; import { NumberConverter } from "../lib/NumberConverter.ts"; @@ -49,6 +53,23 @@ export class PredictionsCommand { .option("--input ", "Input token (symbol or mint)", "USDC") .option("--key ", "Key to use for signing") .action((opts) => this.open(opts)); + predictions + .command("close") + .description("Close or claim a prediction position") + .requiredOption( + "--position ", + "Position pubkey to close, or 'all'" + ) + .option("--key ", "Key to use for signing") + .action((opts) => this.close(opts)); + predictions + .command("history") + .description("View prediction trading history") + .option("--key ", "Key to use (overrides active key)") + .option("--address
", "Wallet address to look up") + .option("--limit ", "Max results", "10") + .option("--offset ", "Pagination offset", "0") + .action((opts) => this.history(opts)); } private static async events(opts: { @@ -192,10 +213,7 @@ export class PredictionsCommand { valueUsd: NumberConverter.fromMicroUsd(p.valueUsd), pnlUsd: NumberConverter.fromMicroUsd(p.pnlUsd), pnlPct: p.pnlUsdPercent, - claimable: - !p.claimed && - p.marketMetadata.result !== null && - p.marketMetadata.result === (p.isYes ? "yes" : "no"), + claimable: this.isClaimable(p), })); if (Output.isJson()) { @@ -261,12 +279,7 @@ export class PredictionsCommand { depositMint: token.id, }); - const signedTx = await signer.signTransaction( - res.transaction as Base64EncodedBytes - ); - const result = await PredictionsClient.postExecute({ - signedTransaction: signedTx, - }); + const result = await this.signAndExecute(signer, res.transaction); const { order } = res; const contracts = Number(order.contracts); @@ -315,6 +328,255 @@ export class PredictionsCommand { }); } + private static async history(opts: { + key?: string; + address?: string; + limit: string; + offset: string; + }): Promise { + if (opts.address && opts.key) { + throw new Error("Only one of --address or --key can be provided."); + } + + const start = Number(opts.offset); + const limit = Number(opts.limit); + const end = start + limit; + + const address = + opts.address ?? + (await Signer.load(opts.key ?? Config.load().activeKey)).address; + + const res = await PredictionsClient.getHistory({ + ownerPubkey: address, + start, + end, + }); + + const history = res.data.map((h) => ({ + time: new Date(h.timestamp * 1000).toISOString(), + event: h.eventMetadata.title, + market: h.marketMetadata.title, + type: h.eventType, + side: h.isYes ? "yes" : "no", + action: h.isBuy ? "buy" : "sell", + contracts: Number(h.filledContracts), + avgPriceUsd: NumberConverter.fromMicroUsd(h.avgFillPriceUsd), + pnlUsd: h.realizedPnl + ? NumberConverter.fromMicroUsd(h.realizedPnl) + : null, + payoutUsd: NumberConverter.fromMicroUsd(h.payoutAmountUsd), + positionPubkey: h.positionPubkey, + signature: h.signature, + })); + + if (Output.isJson()) { + const json: Record = { count: history.length, history }; + if (res.pagination.hasNext) { + json.next = res.pagination.end; + } + Output.json(json); + return; + } + + if (history.length === 0) { + console.log("No history found."); + return; + } + + Output.table({ + type: "horizontal", + headers: { + time: "Time", + event: "Event / Market", + side: "Side", + action: "Action", + avgPrice: "Avg Price", + pnl: "PnL", + signature: "Tx Signature", + }, + rows: history.map((h) => { + const sideColor = h.side === "yes" ? chalk.green : chalk.red; + return { + time: this.formatDate(h.time), + event: h.event + chalk.gray("\n└─► ") + h.market, + side: sideColor(h.side), + action: h.action, + avgPrice: Output.formatDollar(h.avgPriceUsd), + pnl: h.pnlUsd + ? Output.formatDollarChange(h.pnlUsd) + : chalk.gray("\u2014"), + signature: h.signature, + }; + }), + }); + + if (res.pagination.hasNext) { + console.log("\nNext offset:", res.pagination.end); + } + } + + private static isClaimable(p: { + claimed: boolean; + isYes: boolean; + marketMetadata: { result: "yes" | "no" | null }; + }): boolean { + return ( + !p.claimed && + p.marketMetadata.result !== null && + p.marketMetadata.result === (p.isYes ? "yes" : "no") + ); + } + + private static async signAndExecute( + signer: Signer, + transaction: string + ): Promise<{ signature: string }> { + const signedTx = await signer.signTransaction( + transaction as Base64EncodedBytes + ); + return PredictionsClient.postExecute({ signedTransaction: signedTx }); + } + + private static async close(opts: { + position: string; + key?: string; + }): Promise { + const signer = await Signer.load(opts.key ?? Config.load().activeKey); + + if (opts.position === "all") { + const res = await PredictionsClient.closeAllPositions(signer.address); + + if (res.data.length === 0) { + throw new Error("No open positions to close."); + } + + const results: { + action: string; + positionPubkey: string; + signature: string; + }[] = []; + + for (const item of res.data) { + const execResult = await this.signAndExecute(signer, item.transaction); + const isClaim = "position" in item; + const pubkey = isClaim + ? (item as ClaimPositionResponse).position.positionPubkey + : (item as CreateOrderResponse).order.positionPubkey; + results.push({ + action: isClaim ? "claim" : "close", + positionPubkey: pubkey, + signature: execResult.signature, + }); + } + + if (Output.isJson()) { + Output.json({ action: "close-all", results }); + return; + } + + Output.table({ + type: "horizontal", + headers: { + action: "Action", + positionPubkey: "Position", + signature: "Tx Signature", + }, + rows: results, + }); + return; + } + + // Single position + const position = await PredictionsClient.getPosition(opts.position); + const claimable = this.isClaimable(position); + + if (claimable) { + const res = await PredictionsClient.claimPosition( + opts.position, + signer.address + ); + const result = await this.signAndExecute(signer, res.transaction); + + const contracts = Number(res.position.contracts); + const payoutUsd = NumberConverter.fromMicroUsd( + res.position.payoutAmountUsd + ); + + if (Output.isJson()) { + Output.json({ + action: "claim", + event: position.eventMetadata.title, + market: position.marketMetadata.title, + side: position.isYes ? "yes" : "no", + positionPubkey: opts.position, + contracts, + payoutUsd, + signature: result.signature, + }); + return; + } + + Output.table({ + type: "vertical", + rows: [ + { label: "Action", value: "Claim Position" }, + { label: "Event", value: position.eventMetadata.title }, + { label: "Market", value: position.marketMetadata.title }, + { + label: "Side", + value: position.isYes ? chalk.green("yes") : chalk.red("no"), + }, + { label: "Payout", value: Output.formatDollar(payoutUsd) }, + { label: "Position", value: opts.position }, + { label: "Tx Signature", value: result.signature }, + ], + }); + } else { + const res = await PredictionsClient.closePosition( + opts.position, + signer.address + ); + const result = await this.signAndExecute(signer, res.transaction); + + const { order } = res; + const contracts = Number(order.contracts); + const costUsd = NumberConverter.fromMicroUsd(order.orderCostUsd); + const feeUsd = NumberConverter.fromMicroUsd(order.estimatedTotalFeeUsd); + + if (Output.isJson()) { + Output.json({ + action: "close", + event: position.eventMetadata.title, + market: position.marketMetadata.title, + side: position.isYes ? "yes" : "no", + positionPubkey: opts.position, + contracts, + costUsd, + feeUsd, + signature: result.signature, + }); + return; + } + + Output.table({ + type: "vertical", + rows: [ + { label: "Action", value: "Close Position" }, + { label: "Event", value: position.eventMetadata.title }, + { label: "Market", value: position.marketMetadata.title }, + { + label: "Side", + value: position.isYes ? chalk.green("yes") : chalk.red("no"), + }, + { label: "Cost", value: Output.formatDollar(costUsd) }, + { label: "Fee", value: Output.formatDollar(feeUsd) }, + { label: "Position", value: opts.position }, + { label: "Tx Signature", value: result.signature }, + ], + }); + } + } + private static formatPricePct(price: number | null): string { if (price === null || price === undefined) { return chalk.gray("\u2014"); From 1e69a5f3bb22b66928b50068d3c433c995f97bc4 Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Tue, 24 Mar 2026 20:58:54 +0800 Subject: [PATCH 4/6] feat: add `id` and `search` params to `events` --- src/clients/PredictionsClient.ts | 26 +++++++++- src/commands/PredictionsCommand.ts | 80 +++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/clients/PredictionsClient.ts b/src/clients/PredictionsClient.ts index c1dfecc..73b5c99 100644 --- a/src/clients/PredictionsClient.ts +++ b/src/clients/PredictionsClient.ts @@ -1,4 +1,4 @@ -import ky from "ky"; +import ky, { type SearchParamsOption } from "ky"; import { ClientConfig } from "./ClientConfig.ts"; @@ -198,6 +198,30 @@ export class PredictionsClient { headers: ClientConfig.headers, }); + public static async searchEvents(params: { + query: string; + start?: number; + end?: number; + }): Promise<{ data: PredictionEvent[] }> { + const searchParams: SearchParamsOption = { + query: params.query, + includeMarkets: true, + }; + if (params.start !== undefined) { + searchParams.start = params.start; + } + if (params.end !== undefined) { + searchParams.end = params.end; + } + return this.#ky.get("events/search", { searchParams }).json(); + } + + public static async getEvent(eventId: string): Promise { + return this.#ky + .get(`events/${eventId}`, { searchParams: { includeMarkets: true } }) + .json(); + } + public static async getEvents(params: { filter?: string; sortBy?: string; diff --git a/src/commands/PredictionsCommand.ts b/src/commands/PredictionsCommand.ts index 103c6be..50d6cf4 100644 --- a/src/commands/PredictionsCommand.ts +++ b/src/commands/PredictionsCommand.ts @@ -6,6 +6,7 @@ import { PredictionsClient, type ClaimPositionResponse, type CreateOrderResponse, + type PredictionEvent, } from "../clients/PredictionsClient.ts"; import { resolveWalletAsset } from "../lib/Asset.ts"; import { Config } from "../lib/Config.ts"; @@ -21,6 +22,8 @@ export class PredictionsCommand { predictions .command("events") .description("Browse prediction events") + .option("--id ", "Look up a single event by ID") + .option("--search ", "Search events by title") .option("--filter ", "Filter: new, live, trending") .option("--sort ", "Sort by: volume, recent", "volume") .option( @@ -73,29 +76,68 @@ export class PredictionsCommand { } private static async events(opts: { + id?: string; + search?: string; filter?: string; sort: string; category: string; offset: string; limit: string; }): Promise { - const start = Number(opts.offset); - const limit = Number(opts.limit); - const end = start + limit; - - const sortBy = opts.sort === "recent" ? "beginAt" : "volume24hr"; - const sortDirection = opts.sort === "recent" ? "desc" : undefined; + const hasListingOpts = + opts.filter || + opts.sort !== "volume" || + opts.category !== "all" || + opts.offset !== "0"; + if (opts.id && (opts.search || hasListingOpts)) { + throw new Error( + "--id cannot be combined with --search, --filter, --sort, --category, or --offset." + ); + } + if (opts.search && hasListingOpts) { + throw new Error( + "--search cannot be combined with --filter, --sort, --category, or --offset." + ); + } - const res = await PredictionsClient.getEvents({ - filter: opts.filter, - sortBy, - sortDirection, - category: opts.category, - start, - end, - }); + let data: PredictionEvent[]; + let hasNext = false; + let nextOffset: number | undefined; + + if (opts.id) { + const event = await PredictionsClient.getEvent(opts.id); + data = [event]; + } else if (opts.search) { + const start = Number(opts.offset); + const end = start + Number(opts.limit); + const res = await PredictionsClient.searchEvents({ + query: opts.search, + start, + end, + }); + data = res.data; + } else { + const start = Number(opts.offset); + const limit = Number(opts.limit); + const end = start + limit; + + const sortBy = opts.sort === "recent" ? "beginAt" : "volume24hr"; + const sortDirection = opts.sort === "recent" ? "desc" : undefined; + + const res = await PredictionsClient.getEvents({ + filter: opts.filter, + sortBy, + sortDirection, + category: opts.category, + start, + end, + }); + data = res.data; + hasNext = res.pagination.hasNext; + nextOffset = res.pagination.end; + } - const events = res.data.map((e) => ({ + const events = data.map((e) => ({ id: e.eventId, title: e.metadata.title, category: e.category, @@ -123,8 +165,8 @@ export class PredictionsCommand { if (Output.isJson()) { const json: Record = { events }; - if (res.pagination.hasNext) { - json.next = res.pagination.end; + if (hasNext) { + json.next = nextOffset; } Output.json(json); return; @@ -163,8 +205,8 @@ export class PredictionsCommand { console.log(); } - if (res.pagination.hasNext) { - console.log("Next offset:", res.pagination.end); + if (hasNext) { + console.log("Next offset:", nextOffset); } } From 9974dae57677f82b54f254ff2e95580ce88cd67f Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Wed, 25 Mar 2026 11:53:18 +0800 Subject: [PATCH 5/6] feat: prep for release --- CLAUDE.md | 8 +- README.md | 8 + docs/predictions.md | 234 +++++++++++++++++++++++++++++ llms.txt | 2 +- src/commands/PredictionsCommand.ts | 8 +- 5 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 docs/predictions.md diff --git a/CLAUDE.md b/CLAUDE.md index 71396d4..5c746d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ bun run ci ## Architecture -**Entry point:** `src/index.ts` — initializes config, registers 5 command groups with Commander. +**Entry point:** `src/index.ts` — initializes config, registers 6 command groups with Commander. **Commands** (`src/commands/`): Static classes that register subcommands. Each delegates to library modules. @@ -32,6 +32,7 @@ bun run ci - `KeysCommand` — `keys list/add/delete/edit/use/solana-import` - `LendCommand` — `lend earn tokens/positions/deposit/withdraw` - `PerpsCommand` — `perps positions/markets/open/set/close` +- `PredictionsCommand` — `predictions events/positions/open/close/history` - `SpotCommand` — `spot tokens/quote/swap/portfolio/transfer/reclaim` - `UpdateCommand` — `update` (self-update CLI to latest version) @@ -51,6 +52,7 @@ bun run ci - `UltraClient` — Jupiter Ultra swap API (quote + execute) - `PerpsClient` — Jupiter Perps API v2 (positions, orders, TP/SL) - `LendClient` — Jupiter Lend API (earn tokens, positions, earnings) +- `PredictionsClient` — Jupiter Predictions API v1 (events, positions, orders, history) **Spot swap flow:** token search → Swap.execute → UltraClient.getOrder → Signer.signTransaction → UltraClient.postExecute @@ -58,6 +60,8 @@ bun run ci **Lend deposit/withdraw flow:** LendClient.getTokens → resolve jlToken → Swap.execute → LendClient.getPositions (updated state) +**Predictions flow:** PredictionsClient.postOrder → Signer.signTransaction → PredictionsClient.postExecute + ## CLI Conventions When adding new commands, both input (options/arguments) and output (JSON/table) must be consistent with existing commands so that both humans and AI agents can reliably use and parse them. Check `docs/` for the canonical command shapes. @@ -71,7 +75,7 @@ When adding new commands, both input (options/arguments) and output (JSON/table) ### Output -- **Field naming:** Reuse established key names — e.g. `sizeUsd`, `priceUsd`, `pnlUsd`, `pnlPct`, `feeUsd`, `signature`, `positionPubkey`, `side`, `asset`, `leverage`. Check `docs/perps.md` and `docs/spot.md` for the canonical JSON shapes. +- **Field naming:** Reuse established key names — e.g. `sizeUsd`, `priceUsd`, `pnlUsd`, `pnlPct`, `feeUsd`, `signature`, `positionPubkey`, `side`, `asset`, `leverage`. Check `docs/perps.md`, `docs/spot.md`, and `docs/predictions.md` for the canonical JSON shapes. - **Value types:** Dollar amounts are `number`, percentages are `number` (e.g. `5.97` means +5.97%), nullable fields use `null` (not `0` or `""`), transaction hashes use `signature`. - **Table headers:** Match the JSON key semantics — e.g. a JSON field `signature` maps to table header "Tx Signature", `pnlUsd` maps to "PnL". - **Formatters:** Use `Output.formatDollar()`, `Output.formatDollarChange()`, `Output.formatPercentageChange()` for consistent styling. Pass `{ decimals: N }` to `formatDollar` when explicit precision is needed. diff --git a/README.md b/README.md index c4bad51..79faaeb 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ jup lend earn tokens jup lend earn deposit --token USDC --amount 100 # View your lending positions jup lend earn positions + +# Browse prediction markets +jup predictions events --category crypto +# Open a prediction position +jup predictions open --market --side yes --amount 10 +# View your prediction positions +jup predictions positions ``` ## Docs @@ -65,6 +72,7 @@ jup lend earn positions - [Spot](docs/spot.md): Spot trading, transfers, token search and portfolio data - [Perps](docs/perps.md): Perps trading (leveraged longs/shorts) - [Lend](docs/lend.md): Lending and yield farming +- [Predictions](docs/predictions.md): Prediction markets ## Changelog diff --git a/docs/predictions.md b/docs/predictions.md new file mode 100644 index 0000000..389a23c --- /dev/null +++ b/docs/predictions.md @@ -0,0 +1,234 @@ +# Prediction Markets + +Requires: an active key for `open` and `close` commands. See [setup](setup.md). + +## Commands + +### Browse events + +```bash +jup predictions events +jup predictions events --filter trending +jup predictions events --category crypto --sort volume +jup predictions events --category sports --sort recent +jup predictions events --search "bitcoin" +jup predictions events --id +jup predictions events --limit 5 --offset 10 +``` + +- `--filter`: `new`, `live`, `trending` +- `--sort`: `volume` (default), `recent` +- `--category`: `all` (default), `crypto`, `sports`, `politics`, `esports`, `culture`, `economics`, `tech` +- `--id` cannot be combined with `--search`, `--filter`, `--sort`, `--category`, or `--offset` +- `--search` cannot be combined with `--filter`, `--sort`, `--category`, or `--offset` + +```js +// Example JSON response: +{ + "events": [ + { + "eventId": "abc123", + "title": "Will BTC hit $200k by end of 2026?", + "category": "crypto", + "isLive": true, + "volumeUsd": 125000.50, + "startsAt": "2026-01-01T00:00:00.000Z", + "endsAt": "2026-12-31T23:59:59.000Z", + "markets": [ + { + "marketId": "mkt456", + "title": "Yes / No", + "status": "open", + "yesPriceUsd": 0.65, // 65% implied probability + "noPriceUsd": 0.35, + "result": null // "yes" or "no" when resolved + } + ] + } + ], + "next": 10 // pagination offset for next page; omitted when no more results +} +``` + +### View positions + +```bash +jup predictions positions +jup predictions positions --key mykey +jup predictions positions --address +jup predictions positions --position +``` + +- With no options, uses the active key's wallet +- `--position` looks up a single position by pubkey; cannot be combined with `--key` or `--address` + +```js +// Example JSON response: +{ + "count": 2, + "positions": [ + { + "positionPubkey": "3qMZ...83tz", + "event": "Will BTC hit $200k by end of 2026?", + "market": "Yes / No", + "side": "yes", + "contracts": 10, + "costUsd": 6.50, + "valueUsd": 7.20, + "pnlUsd": 0.70, + "pnlPct": 10.77, // percentage; 10.77 means +10.77% + "claimable": false // true when market resolved in your favor + } + ] +} +``` + +### Open a position + +```bash +jup predictions open --market --side yes --amount 10 +jup predictions open --market --side no --amount 5 --input USDC +jup predictions open --market --side y --amount 10 --key mykey +``` + +- `--market`: market ID from `jup predictions events` +- `--side`: `yes`, `no`, `y`, `n` +- `--amount`: input token amount (human-readable) +- `--input`: input token symbol or mint (default: `USDC`) + +```js +// Example JSON response: +{ + "action": "open", + "marketId": "mkt456", + "side": "yes", + "contracts": 10, + "costUsd": 6.50, + "feeUsd": 0.07, + "positionAvgPriceUsd": 0.65, + "positionPayoutUsd": 10.00, + "positionPubkey": "3qMZ...83tz", + "signature": "2Goj...diEc" +} +``` + +### Close or claim a position + +```bash +# Close a single position (sell back) +jup predictions close --position + +# Claim a resolved position (market settled in your favor) +jup predictions close --position + +# Close all positions +jup predictions close --position all +``` + +- The CLI auto-detects whether to sell or claim based on the market result +- Claimable positions (market resolved in your favor) are claimed for the full payout +- Open positions on live markets are sold at the current market price + +```js +// Example JSON response (close): +{ + "action": "close", + "event": "Will BTC hit $200k by end of 2026?", + "market": "Yes / No", + "side": "yes", + "positionPubkey": "3qMZ...83tz", + "contracts": 10, + "costUsd": 6.50, + "feeUsd": 0.07, + "signature": "5YhT...9AKU" +} + +// Example JSON response (claim): +{ + "action": "claim", + "event": "Will BTC hit $200k by end of 2026?", + "market": "Yes / No", + "side": "yes", + "positionPubkey": "3qMZ...83tz", + "contracts": 10, + "payoutUsd": 10.00, + "signature": "4xK2...9zH1" +} + +// Example JSON response (close all): +{ + "action": "close-all", + "results": [ + { + "action": "close", // or "claim" + "positionPubkey": "3qMZ...83tz", + "signature": "5YhT...9AKU" + } + ] +} +``` + +### View trade history + +```bash +jup predictions history +jup predictions history --key mykey +jup predictions history --address +jup predictions history --limit 5 --offset 10 +``` + +- With no `--address` or `--key`, uses the active key's wallet +- `--limit` defaults to 10 +- `--offset` is used for pagination; use the `next` value from the previous response to fetch the next page + +```js +// Example JSON response: +{ + "count": 15, + "history": [ + { + "time": "2026-03-15T10:30:00.000Z", + "event": "Will BTC hit $200k by end of 2026?", + "market": "Yes / No", + "type": "OrderFilled", // event type + "side": "yes", + "action": "buy", // or "sell" + "contracts": 10, + "avgPriceUsd": 0.65, + "pnlUsd": 0.70, // null for buys + "payoutUsd": 0, + "positionPubkey": "3qMZ...83tz", + "signature": "2Goj...diEc" + } + ], + "next": 10 // pagination offset for next page; omitted when no more results +} +``` + +## Workflows + +### Browse events then open a position + +```bash +jup predictions events --category crypto +# Find the market ID from the output +jup predictions open --market --side yes --amount 10 +``` + +### Check positions then close + +```bash +jup predictions positions +# Copy the positionPubkey +jup predictions close --position +``` + +### Claim resolved positions + +```bash +jup predictions positions +# Positions with "claimable: true" can be claimed +jup predictions close --position +# Or claim all at once +jup predictions close --position all +``` diff --git a/llms.txt b/llms.txt index 031226f..313552f 100644 --- a/llms.txt +++ b/llms.txt @@ -14,4 +14,4 @@ On failure, commands exit with non-zero code with an error message. In JSON mode - [Spot](docs/spot.md): Spot trading, transfers, reclaim rent, token and portfolio data - [Perps](docs/perps.md): Perps trading (leveraged longs/shorts) - [Lend](docs/lend.md): Lending and yield farming -- Predictions: Create and trade prediction markets (coming soon) +- [Predictions](docs/predictions.md): Prediction markets diff --git a/src/commands/PredictionsCommand.ts b/src/commands/PredictionsCommand.ts index 50d6cf4..9de2988 100644 --- a/src/commands/PredictionsCommand.ts +++ b/src/commands/PredictionsCommand.ts @@ -138,7 +138,7 @@ export class PredictionsCommand { } const events = data.map((e) => ({ - id: e.eventId, + eventId: e.eventId, title: e.metadata.title, category: e.category, isLive: e.isLive, @@ -150,7 +150,7 @@ export class PredictionsCommand { ? new Date(e.metadata.closeTime).toISOString() : null, markets: (e.markets ?? []).map((m) => ({ - id: m.marketId, + marketId: m.marketId, title: m.metadata.title, status: m.status, yesPriceUsd: m.pricing.buyYesPriceUsd @@ -191,13 +191,13 @@ export class PredictionsCommand { title: "Market", yes: "Yes", no: "No", - id: "ID", + marketId: "Market ID", }, rows: event.markets.map((m) => ({ title: m.title, yes: this.formatPricePct(m.yesPriceUsd), no: this.formatPricePct(m.noPriceUsd), - id: m.id, + marketId: m.marketId, })), }); } From 8e0dbb53d79097258240253a8e859a8daf892d65 Mon Sep 17 00:00:00 2001 From: Aaron Choo Date: Wed, 25 Mar 2026 11:54:22 +0800 Subject: [PATCH 6/6] docs: update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79faaeb..f88b997 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ jup spot swap --from SOL --to USDC --amount 1 # Reclaim rent from empty token accounts jup spot reclaim -# Open a 3x long SOL position with $10 USDC +# Open a 3x long SOL position with 10 USDC jup perps open --asset SOL --side long --amount 10 --input USDC --leverage 3 # View your perps positions jup perps positions @@ -52,7 +52,7 @@ jup lend earn positions # Browse prediction markets jup predictions events --category crypto -# Open a prediction position +# Open a prediction position with 10 USDC jup predictions open --market --side yes --amount 10 # View your prediction positions jup predictions positions