openpondai/agents/pair-trade
OpenTool app
typescript
import { wallet } from "opentool/wallet";
import type { WalletFullContext } from "opentool/wallet";
import {
extractHyperliquidOrderIds,
fetchHyperliquidClearinghouseState,
fetchHyperliquidSpotClearinghouseState,
fetchHyperliquidSpotMarketInfo,
fetchHyperliquidSpotTickSize,
fetchHyperliquidTickSize,
placeHyperliquidOrder,
readHyperliquidPerpPosition,
readHyperliquidSpotBalance,
resolveHyperliquidChainConfig,
resolveHyperliquidErrorDetail,
resolveHyperliquidSpotSymbol,
updateHyperliquidLeverage,
type HyperliquidEnvironment,
} from "opentool/adapters/hyperliquid";
import type { PairTradeConfig } from "../config";
import {
fetchPairTradePerpMarketInfo,
resolvePairTradePerpLeverageMode,
resolvePairTradePerpSymbol,
} from "./market";
import { buildResolvedOrders, planPairTradeCycle } from "./planner";
import { resolvePairTradeLegMarkets } from "./schedule";
import type { PairTradeMetrics, PairTradeResult, PlannedOrder } from "./types";
function buildEmptyMetrics(): PairTradeMetrics {
return {
spotValueUsd: 0,
perpNotionalUsd: 0,
deltaUsd: 0,
deltaPct: null,
basisBps: null,
fundingRateBps: null,
spotBalance: 0,
perpSize: 0,
spotEntryNtl: null,
perpUnrealizedPnl: null,
spotUnrealizedPnl: null,
};
}
function resolvePositiveLeverage(value: number) {
return Number.isFinite(value) && value > 0 ? Math.max(1, Math.round(value)) : 1;
}
async function resolveMarketLeg(params: {
environment: HyperliquidEnvironment;
asset: string;
marketType: "spot" | "perp";
}) {
if (params.marketType === "spot") {
const spot = resolveHyperliquidSpotSymbol(params.asset);
const market = await fetchHyperliquidSpotMarketInfo({
environment: params.environment,
base: spot.base,
quote: spot.quote,
});
const tick = await fetchHyperliquidSpotTickSize({
environment: params.environment,
marketIndex: market.marketIndex,
});
return {
asset: params.asset,
marketType: params.marketType,
symbol: spot.symbol,
base: spot.base,
price: market.price,
szDecimals: market.szDecimals,
tick,
fundingRateBps: null,
};
}
const symbol = resolvePairTradePerpSymbol(params.asset);
const [market, tick] = await Promise.all([
fetchPairTradePerpMarketInfo({ environment: params.environment, symbol }),
fetchHyperliquidTickSize({ environment: params.environment, symbol }),
]);
return {
asset: params.asset,
marketType: params.marketType,
symbol,
base: symbol,
price: market.price,
szDecimals: market.szDecimals,
tick,
fundingRateBps:
market.fundingRate != null ? market.fundingRate * 10_000 : null,
};
}
export async function runPairTrade(config: PairTradeConfig): Promise<PairTradeResult> {
const environment = (config.environment ?? "mainnet") as HyperliquidEnvironment;
const { longAsset, shortAsset, longMarketType, shortMarketType } =
resolvePairTradeLegMarkets(config);
const longSymbol =
longMarketType === "spot"
? resolveHyperliquidSpotSymbol(longAsset).symbol
: resolvePairTradePerpSymbol(longAsset);
const shortSymbol =
shortMarketType === "spot"
? resolveHyperliquidSpotSymbol(shortAsset).symbol
: resolvePairTradePerpSymbol(shortAsset);
const perpSymbol = shortSymbol;
const spotSymbol = longSymbol;
const legAPerpSymbol =
config.legAMarketType === "perp"
? resolvePairTradePerpSymbol(config.legAAsset)
: null;
const legBPerpSymbol =
config.legBMarketType === "perp"
? resolvePairTradePerpSymbol(config.legBAsset)
: null;
const leverageByPerpSymbol = new Map<
string,
{ leverage: number; leverageMode: "cross" | "isolated" }
>();
if (legAPerpSymbol) {
leverageByPerpSymbol.set(legAPerpSymbol, {
leverage: resolvePositiveLeverage(config.legALeverage),
leverageMode: resolvePairTradePerpLeverageMode(
legAPerpSymbol,
config.legALeverageMode,
),
});
}
if (legBPerpSymbol) {
leverageByPerpSymbol.set(legBPerpSymbol, {
leverage: resolvePositiveLeverage(config.legBLeverage),
leverageMode: resolvePairTradePerpLeverageMode(
legBPerpSymbol,
config.legBLeverageMode,
),
});
}
const slippageBps = Math.max(0, config.slippageBps);
const maxPerRunUsd = Math.max(0, config.maxPerRunUsd);
const chain = resolveHyperliquidChainConfig(environment).chain;
const ctx = await wallet({ chain });
const walletAddress = ctx.address as string;
if (shortMarketType === "spot") {
return {
ok: false,
action: "pair-trade-hold",
environment,
walletAddress,
asset: longAsset,
longAsset,
shortAsset,
longMarketType,
shortMarketType,
perpSymbol,
spotSymbol,
metrics: buildEmptyMetrics(),
plannedOrders: [],
executedOrders: [],
orderResponses: [],
error: "Pair-trade does not support a spot short leg.",
};
}
const [longLeg, shortLeg, perpClearing, spotClearing] = await Promise.all([
resolveMarketLeg({ environment, asset: longAsset, marketType: longMarketType }),
resolveMarketLeg({ environment, asset: shortAsset, marketType: shortMarketType }),
fetchHyperliquidClearinghouseState({
environment,
walletAddress: ctx.address as `0x${string}`,
}),
longMarketType === "spot"
? fetchHyperliquidSpotClearinghouseState({
environment,
user: ctx.address as `0x${string}`,
})
: Promise.resolve(null),
]);
if (!perpClearing.ok || !perpClearing.data) {
return {
ok: false,
action: "pair-trade-hold",
environment,
walletAddress,
asset: longAsset,
longAsset,
shortAsset,
longMarketType,
shortMarketType,
perpSymbol,
spotSymbol,
metrics: buildEmptyMetrics(),
plannedOrders: [],
executedOrders: [],
orderResponses: [],
error: "Failed to load Hyperliquid perp state.",
errorDetail: perpClearing.data,
};
}
const longSpotBalance =
longLeg.marketType === "spot"
? readHyperliquidSpotBalance(spotClearing, longLeg.base)
: null;
const longPerpPosition =
longLeg.marketType === "perp"
? readHyperliquidPerpPosition(perpClearing.data, longLeg.symbol)
: null;
const longPosition =
longLeg.marketType === "spot"
? {
size: longSpotBalance?.total ?? 0,
entryNtl: longSpotBalance?.entryNtl ?? null,
unrealizedPnl: null,
}
: {
size: longPerpPosition?.size ?? 0,
entryNtl: null,
unrealizedPnl: longPerpPosition?.unrealizedPnl ?? null,
};
const shortPosition = readHyperliquidPerpPosition(perpClearing.data, shortLeg.symbol);
const plan = planPairTradeCycle({
config,
spotPrice: longLeg.price,
perpPrice: shortLeg.price,
fundingRateBps:
shortLeg.fundingRateBps ?? longLeg.fundingRateBps ?? null,
spotBalance: longPosition.size,
spotEntryNtl: longPosition.entryNtl,
perpSize: shortPosition.size,
perpUnrealizedPnl: shortPosition.unrealizedPnl,
maxPerRunUsd,
});
const plannedOrders = buildResolvedOrders({
slippageBps,
perpSymbol: shortLeg.symbol,
spotSymbol: longLeg.symbol,
perpPrice: shortLeg.price,
spotPrice: longLeg.price,
perpSzDecimals: shortLeg.szDecimals,
spotSzDecimals: longLeg.szDecimals,
perpTick: shortLeg.tick,
spotTick: longLeg.tick,
perpMarketType: shortLeg.marketType,
spotMarketType: longLeg.marketType,
currentSpotNotionalUsd: plan.metrics.spotValueUsd,
currentPerpNotionalUsd: plan.metrics.perpNotionalUsd,
plan,
}).map((order) => ({
kind: order.kind,
marketType: order.marketType,
symbol: order.symbol,
side: order.side,
size: order.size,
price: order.price,
reduceOnly: order.reduceOnly,
notionalUsd: order.notionalUsd,
})) satisfies PlannedOrder[];
if (plannedOrders.length === 0) {
return {
ok: true,
action: "pair-trade-hold",
environment,
walletAddress,
asset: longAsset,
longAsset,
shortAsset,
longMarketType,
shortMarketType,
perpSymbol,
spotSymbol,
metrics: plan.metrics,
plannedOrders,
executedOrders: [],
orderResponses: [],
...(plan.reason ? { skipped: true, reason: plan.reason } : {}),
...(!plan.reason ? { skipped: true, reason: "order-too-small" } : {}),
};
}
const executionOrder =
plan.action === "pair-trade-open" ? ["spot", "perp"] : ["perp", "spot"];
const executedOrders: PlannedOrder[] = [];
const orderResponses: PairTradeResult["orderResponses"] = [];
try {
const perpSymbols = Array.from(
new Set(
plannedOrders
.filter((entry) => entry.marketType === "perp")
.map((entry) => entry.symbol),
),
);
for (const symbol of perpSymbols) {
const leverageConfig = leverageByPerpSymbol.get(symbol) ?? {
leverage: 1,
leverageMode: resolvePairTradePerpLeverageMode(symbol, "cross"),
};
await updateHyperliquidLeverage({
wallet: ctx as WalletFullContext,
environment,
input: {
symbol,
leverageMode: leverageConfig.leverageMode,
leverage: leverageConfig.leverage,
},
});
}
for (const kind of executionOrder) {
const order = plannedOrders.find((entry) => entry.kind === kind);
if (!order) continue;
const response = await placeHyperliquidOrder({
wallet: ctx as WalletFullContext,
environment,
orders: [
{
symbol: order.symbol,
side: order.side,
price: order.price,
size: order.size,
tif: "FrontendMarket",
reduceOnly: order.reduceOnly,
},
],
});
orderResponses.push(response);
executedOrders.push(order);
}
} catch (error) {
return {
ok: false,
action: plan.action,
environment,
walletAddress,
asset: longAsset,
longAsset,
shortAsset,
longMarketType,
shortMarketType,
perpSymbol,
spotSymbol,
metrics: plan.metrics,
plannedOrders,
executedOrders,
orderResponses,
error: error instanceof Error ? error.message : "Order submission failed",
errorDetail: resolveHyperliquidErrorDetail(error),
};
}
const orderIds = extractHyperliquidOrderIds(
orderResponses as unknown as Array<{
response?: { data?: { statuses?: Array<Record<string, unknown>> } };
}>,
);
return {
ok: true,
action: plan.action,
environment,
walletAddress,
asset: longAsset,
longAsset,
shortAsset,
longMarketType,
shortMarketType,
perpSymbol,
spotSymbol,
metrics: plan.metrics,
plannedOrders,
executedOrders,
orderResponses,
...(orderIds.cloids.length || orderIds.oids.length ? { orderIds } : {}),
};
}