openpondai/agents/pair-trade
OpenTool app
typescript
import {
clampHyperliquidAbs,
fetchHyperliquidSpotTickSize,
fetchHyperliquidTickSize,
formatHyperliquidMarketablePrice,
formatHyperliquidOrderSize,
} from "opentool/adapters/hyperliquid";
import type { PairTradeConfig } from "../config";
import type { PairTradeMarketType } from "../config";
import {
resolveHyperliquidBuilderFeeRate,
resolveHyperliquidExchangeFeeRate,
resolveHyperliquidTotalFeeRate,
} from "./planner-fees";
import { buildPairTradeMetrics } from "./planner-metrics";
import type {
PairTradeExecutionPlan,
PairTradePlanInput,
ResolvedPlannedOrder,
} from "./types";
export const MIN_NOTIONAL_USD = 1;
export {
resolveHyperliquidBuilderFeeRate,
resolveHyperliquidExchangeFeeRate,
resolveHyperliquidTotalFeeRate,
} from "./planner-fees";
export { buildPairTradeMetrics } from "./planner-metrics";
export function planPairTradeCycle(
params: PairTradePlanInput,
): PairTradeExecutionPlan {
const metrics = buildPairTradeMetrics({
config: params.config,
spotPrice: params.spotPrice,
perpPrice: params.perpPrice,
fundingRateBps: params.fundingRateBps,
spotBalance: params.spotBalance,
spotEntryNtl: params.spotEntryNtl,
perpSize: params.perpSize,
perpUnrealizedPnl: params.perpUnrealizedPnl,
});
const hasSpotInventory = Math.abs(metrics.spotValueUsd) >= MIN_NOTIONAL_USD;
const hasPerpHedge = Math.abs(metrics.perpNotionalUsd) >= MIN_NOTIONAL_USD;
const targetSpotValueUsd = params.config.longTargetNotionalUsd;
const targetPerpNotionalUsd = -params.config.shortTargetNotionalUsd;
const spotDiffUsd = targetSpotValueUsd - metrics.spotValueUsd;
const perpDiffUsd = targetPerpNotionalUsd - metrics.perpNotionalUsd;
const driftUsdThreshold = params.config.rebalanceDriftUsd ?? 0;
const driftPctThreshold = params.config.rebalanceDriftPct ?? 0;
const spotDriftPct =
targetSpotValueUsd > 0 ? (Math.abs(spotDiffUsd) / targetSpotValueUsd) * 100 : 0;
const perpDriftPct =
Math.abs(targetPerpNotionalUsd) > 0
? (Math.abs(perpDiffUsd) / Math.abs(targetPerpNotionalUsd)) * 100
: 0;
const shouldRebalance =
!hasSpotInventory ||
!hasPerpHedge ||
Math.abs(spotDiffUsd) >= driftUsdThreshold ||
Math.abs(perpDiffUsd) >= driftUsdThreshold ||
spotDriftPct >= driftPctThreshold ||
perpDriftPct >= driftPctThreshold;
if (!shouldRebalance) {
return {
action: "pair-trade-hold",
metrics,
spotOrderUsd: 0,
perpOrderUsd: 0,
};
}
let spotOrderUsd = 0;
let perpOrderUsd = 0;
const action = !hasSpotInventory || !hasPerpHedge
? "pair-trade-open"
: "pair-trade-rebalance";
spotOrderUsd = clampHyperliquidAbs(spotDiffUsd, params.maxPerRunUsd);
perpOrderUsd = clampHyperliquidAbs(perpDiffUsd, params.maxPerRunUsd);
return {
action,
metrics,
spotOrderUsd,
perpOrderUsd,
};
}
export function buildResolvedOrders(params: {
slippageBps: number;
perpSymbol: string;
spotSymbol: string;
perpPrice: number;
spotPrice: number;
perpSzDecimals: number;
spotSzDecimals: number;
perpTick: ReturnType<typeof fetchHyperliquidTickSize> extends Promise<infer T> ? T : never;
spotTick: ReturnType<typeof fetchHyperliquidSpotTickSize> extends Promise<infer T> ? T : never;
perpMarketType?: PairTradeMarketType;
spotMarketType?: PairTradeMarketType;
currentSpotNotionalUsd: number;
currentPerpNotionalUsd: number;
plan: PairTradeExecutionPlan;
}): ResolvedPlannedOrder[] {
const orders: ResolvedPlannedOrder[] = [];
const perpMarketType = params.perpMarketType ?? "perp";
const spotMarketType = params.spotMarketType ?? "spot";
const addPerpOrder = (usdDelta: number) => {
if (Math.abs(usdDelta) < MIN_NOTIONAL_USD) return;
const side: "buy" | "sell" = usdDelta > 0 ? "buy" : "sell";
const size = formatHyperliquidOrderSize(
Math.abs(usdDelta) / params.perpPrice,
params.perpSzDecimals,
);
const quantity = Number.parseFloat(size);
if (!Number.isFinite(quantity) || quantity <= 0) return;
const price = formatHyperliquidMarketablePrice({
mid: params.perpPrice,
side,
slippageBps: params.slippageBps,
tick: params.perpTick,
szDecimals: params.perpSzDecimals,
marketType: perpMarketType,
});
const fillPrice = Number.parseFloat(price);
if (!Number.isFinite(fillPrice) || fillPrice <= 0) return;
const reduceOnly =
params.currentPerpNotionalUsd !== 0 &&
Math.sign(usdDelta) !== Math.sign(params.currentPerpNotionalUsd);
orders.push({
kind: "perp",
marketType: perpMarketType,
symbol: params.perpSymbol,
side,
size,
quantity,
price,
fillPrice,
referencePrice: params.perpPrice,
reduceOnly,
notionalUsd: Math.abs(quantity * fillPrice),
});
};
const addSpotOrder = (usdDelta: number) => {
if (Math.abs(usdDelta) < MIN_NOTIONAL_USD) return;
const side: "buy" | "sell" = usdDelta > 0 ? "buy" : "sell";
const size = formatHyperliquidOrderSize(
Math.abs(usdDelta) / params.spotPrice,
params.spotSzDecimals,
);
const quantity = Number.parseFloat(size);
if (!Number.isFinite(quantity) || quantity <= 0) return;
const price = formatHyperliquidMarketablePrice({
mid: params.spotPrice,
side,
slippageBps: params.slippageBps,
tick: params.spotTick,
szDecimals: params.spotSzDecimals,
marketType: spotMarketType,
});
const fillPrice = Number.parseFloat(price);
if (!Number.isFinite(fillPrice) || fillPrice <= 0) return;
const reduceOnly =
spotMarketType === "perp" &&
params.currentSpotNotionalUsd !== 0 &&
Math.sign(usdDelta) !== Math.sign(params.currentSpotNotionalUsd);
orders.push({
kind: "spot",
marketType: spotMarketType,
symbol: params.spotSymbol,
side,
size,
quantity,
price,
fillPrice,
referencePrice: params.spotPrice,
reduceOnly,
notionalUsd: Math.abs(quantity * fillPrice),
});
};
addPerpOrder(params.plan.perpOrderUsd);
addSpotOrder(params.plan.spotOrderUsd);
return orders;
}
export function resolveDecisionSignal(params: {
spotOrderUsd: number;
perpOrderUsd: number;
orders: ResolvedPlannedOrder[];
}): "buy" | "sell" | "hold" | "unknown" {
if (params.orders.length === 0) return "hold";
if (params.spotOrderUsd > 0) return "buy";
if (params.spotOrderUsd < 0) return "sell";
if (params.perpOrderUsd < 0) return "buy";
if (params.perpOrderUsd > 0) return "sell";
return "unknown";
}